summaryrefslogtreecommitdiff
path: root/java
diff options
context:
space:
mode:
author Govinda Wasserman <gwasserman@google.com> 2025-03-03 07:16:58 -0800
committer Android (Google) Code Review <android-gerrit@google.com> 2025-03-03 07:16:58 -0800
commit5c59d1a3940c998919a5bc39ef163eb65412431b (patch)
treecebc5a1f8b5938b8fdb5fe3e44d7a738bbe27334 /java
parenta70872698c2f821ea214862e1e544a3e950bd136 (diff)
parent6677d3a4e0cd91da6b8046c4084a82c4697b7365 (diff)
Merge "Improve tap to scroll support to be ready for rollout" into main
Diffstat (limited to 'java')
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ModifierExt.kt118
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt79
2 files changed, 125 insertions, 72 deletions
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ModifierExt.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ModifierExt.kt
new file mode 100644
index 00000000..a9d8b9dc
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ModifierExt.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable
+
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.waitForUpOrCancellation
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+
+/** Calls [whenTrue] on this [Modifier] if [condition] is true. */
+@Composable
+inline fun Modifier.conditional(
+ condition: Boolean,
+ crossinline whenTrue: @Composable Modifier.() -> Modifier,
+): Modifier = if (condition) this.whenTrue() else this
+
+/**
+ * Overlays tap regions at the beginning and end of the scrollable region.
+ *
+ * When tap regions are tapped, [scrollableState] will be scrolled by the size of the modified
+ * scrollable multiplied by the [scrollRatio].
+ *
+ * Note: [scrollableState] must be shared with the scrollable being modified and [vertical] must be
+ * true only if the modified scrollable scrolls vertically.
+ */
+@Composable
+fun Modifier.tapToScroll(
+ scrollableState: ScrollableState,
+ vertical: Boolean = false,
+ tapRegionSize: Dp = 48.dp,
+ scrollRatio: Float = 0.5f,
+): Modifier {
+ val scope = rememberCoroutineScope()
+ var viewSize by remember { mutableStateOf(0) }
+ val isLtrLayoutDirection = LocalLayoutDirection.current == LayoutDirection.Ltr
+ val normalizedScrollVector = remember {
+ derivedStateOf {
+ if (vertical || isLtrLayoutDirection) {
+ viewSize * scrollRatio
+ } else {
+ -viewSize * scrollRatio
+ }
+ }
+ }
+ return onGloballyPositioned { viewSize = if (vertical) it.size.height else it.size.width }
+ .pointerInput(Unit) {
+ val tapRegionSizePx = tapRegionSize.roundToPx()
+
+ awaitEachGesture {
+ // Tap to scroll is disabled if the modified composable is not large enough to fit
+ // both tap regions.
+ if (viewSize < tapRegionSizePx * 2) return@awaitEachGesture
+
+ val down = awaitFirstDown(pass = PointerEventPass.Initial)
+ if (down.isConsumed) return@awaitEachGesture
+
+ val downPosition = if (vertical) down.position.y else down.position.x
+ val scrollVector =
+ when {
+ // Start taps scroll toward start
+ downPosition <= tapRegionSizePx -> -normalizedScrollVector.value
+
+ // End taps scroll toward end
+ downPosition >= viewSize - tapRegionSizePx -> normalizedScrollVector.value
+
+ // Middle taps are ignored
+ else -> return@awaitEachGesture
+ }
+
+ val up =
+ waitForUpOrCancellation(pass = PointerEventPass.Initial)
+ ?: return@awaitEachGesture
+
+ // Long presses are ignored
+ if (up.uptimeMillis - down.uptimeMillis >= viewConfiguration.longPressTimeoutMillis)
+ return@awaitEachGesture
+
+ // Swipes are ignored
+ if ((up.position - down.position).getDistance() >= viewConfiguration.touchSlop)
+ return@awaitEachGesture
+
+ down.consume()
+ up.consume()
+ scope.launch { scrollableState.animateScrollBy(scrollVector) }
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt
index 9a9a0821..015a0490 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt
@@ -22,8 +22,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.foundation.gestures.animateScrollBy
-import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -61,7 +60,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
-import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.Placeable
@@ -77,7 +75,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.intentresolver.Flags.announceShareouselItemListPosition
import com.android.intentresolver.Flags.shareouselScrollOffscreenSelections
import com.android.intentresolver.Flags.shareouselSelectionShrink
-import com.android.intentresolver.Flags.shareouselTapToScroll
+import com.android.intentresolver.Flags.shareouselTapToScrollSupport
import com.android.intentresolver.Flags.unselectFinalItem
import com.android.intentresolver.R
import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
@@ -178,7 +176,10 @@ private fun PreviewCarouselItems(
start = measurements.horizontalPaddingDp,
end = measurements.horizontalPaddingDp,
),
- modifier = Modifier.fillMaxSize(),
+ modifier =
+ Modifier.fillMaxSize().conditional(shareouselTapToScrollSupport()) {
+ tapToScroll(scrollableState = state)
+ },
) {
itemsIndexed(
items = previews.previewModels,
@@ -226,9 +227,6 @@ private fun PreviewCarouselItems(
}
ShareouselCard(
- carouselState = state,
- measurements = measurements,
- index = index,
viewModel = previewModel,
aspectRatio = measurements.coerceAspectRatio(previewModel.aspectRatio),
annotateWithPosition = previews.previewModels.size > 1,
@@ -285,9 +283,6 @@ private fun ScrollOffscreenSelectionsEffect(
@Composable
private fun ShareouselCard(
- carouselState: LazyListState,
- measurements: PreviewCarouselMeasurements,
- index: Int,
viewModel: ShareouselPreviewViewModel,
aspectRatio: Float,
annotateWithPosition: Boolean,
@@ -307,13 +302,7 @@ private fun ShareouselCard(
modifier =
Modifier.semantics { this.contentDescription = contentDescription }
.testTag(viewModel.testTag)
- .clickableWithTapToScrollSupport(
- state = carouselState,
- index = index,
- measurements = measurements,
- ) {
- scope.launch { viewModel.setSelected(!selected) }
- }
+ .clickable { scope.launch { viewModel.setSelected(!selected) } }
.conditional(shareouselSelectionShrink()) {
val selectionScale by animateFloatAsState(if (selected) 0.95f else 1f)
scale(selectionScale)
@@ -335,50 +324,6 @@ private fun ShareouselCard(
}
@Composable
-private fun Modifier.clickableWithTapToScrollSupport(
- state: LazyListState,
- index: Int,
- measurements: PreviewCarouselMeasurements,
- onClick: () -> Unit,
-): Modifier {
- val scope = rememberCoroutineScope()
- return pointerInput(Unit) {
- detectTapGestures { offset ->
- with(state.layoutInfo) {
- val item = visibleItemsInfo.firstOrNull { it.index == index }
- when {
- // If the item is not visible, then this was likely an accidental click event
- // while flinging so ignore it.
- item == null -> {}
-
- // If tap to scroll flag is off, do a normal click
- !shareouselTapToScroll() -> onClick()
-
- // If click is in the start tap to scroll region
- (item.offset + offset.x) - viewportStartOffset <
- measurements.scrollByTapWidthPx ->
- // Scroll towards the start
- scope.launch {
- state.animateScrollBy(-measurements.viewportCenterPx.toFloat())
- }
-
- // If click is in the end tap to scroll region
- viewportEndOffset - (item.offset + offset.x) <
- measurements.scrollByTapWidthPx ->
- // Scroll towards the end
- scope.launch {
- state.animateScrollBy(measurements.viewportCenterPx.toFloat())
- }
-
- // If click is between the tap to scroll regions, do a normal click
- else -> onClick()
- }
- }
- }
- }
-}
-
-@Composable
private fun buildContentDescription(
annotateWithPosition: Boolean,
viewModel: ShareouselPreviewViewModel,
@@ -520,12 +465,6 @@ private fun ShareouselAction(
)
}
-@Composable
-private inline fun Modifier.conditional(
- condition: Boolean,
- crossinline whenTrue: @Composable Modifier.() -> Modifier,
-): Modifier = if (condition) this.whenTrue() else this
-
private data class PreviewCarouselMeasurements(
val viewportHeightPx: Int,
val viewportWidthPx: Int,
@@ -533,7 +472,6 @@ private data class PreviewCarouselMeasurements(
val maxAspectRatio: Float,
val horizontalPaddingPx: Int,
val horizontalPaddingDp: Dp,
- val scrollByTapWidthPx: Int,
) {
constructor(
placeable: Placeable,
@@ -551,7 +489,6 @@ private data class PreviewCarouselMeasurements(
},
horizontalPaddingPx = horizontalPadding.roundToInt(),
horizontalPaddingDp = with(measureScope) { horizontalPadding.toDp() },
- scrollByTapWidthPx = with(measureScope) { SCROLL_BY_TAP_WIDTH.roundToPx() },
)
fun coerceAspectRatio(ratio: Float): Float = ratio.coerceIn(MIN_ASPECT_RATIO, maxAspectRatio)
@@ -572,7 +509,6 @@ private data class PreviewCarouselMeasurements(
private const val MIN_ASPECT_RATIO = 0.4f
private const val MAX_ASPECT_RATIO = 2.5f
- val SCROLL_BY_TAP_WIDTH = 48.dp
val UNMEASURED =
PreviewCarouselMeasurements(
viewportHeightPx = 0,
@@ -580,7 +516,6 @@ private data class PreviewCarouselMeasurements(
maxAspectRatio = 0f,
horizontalPaddingPx = 0,
horizontalPaddingDp = 0.dp,
- scrollByTapWidthPx = 0,
)
}
}