summaryrefslogtreecommitdiff
path: root/java/src
diff options
context:
space:
mode:
author Govinda Wasserman <gwasserman@google.com> 2025-02-28 10:45:23 -0500
committer Govinda Wasserman <gwasserman@google.com> 2025-03-03 08:39:40 -0500
commit6677d3a4e0cd91da6b8046c4084a82c4697b7365 (patch)
treecaeb76f78b1414260e1cb3c076329d8b1f9469a4 /java/src
parentcb2a5be13e6e50d1095e974cde10519e0a480903 (diff)
Improve tap to scroll support to be ready for rollout
- Moves tap to scroll detection to the scrollable container instead of the children of the container, reducing the amount of jank from calculations required for each tap, as well as the code complexity. - Encapsulates the behavior in a single Modifier extension for ease of code readability and usability. - Generifies the solution to work with any arbitrary scrollable container. Test: Manual test with Share Test BUG: 384656926 Flag: com.android.intentresolver.shareousel_tap_to_scroll_support Change-Id: Ic40e040068b2a8ce6038d2e9d7d2bca6f0a88670
Diffstat (limited to 'java/src')
-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,
)
}
}