diff options
author | 2025-03-03 07:16:58 -0800 | |
---|---|---|
committer | 2025-03-03 07:16:58 -0800 | |
commit | 5c59d1a3940c998919a5bc39ef163eb65412431b (patch) | |
tree | cebc5a1f8b5938b8fdb5fe3e44d7a738bbe27334 /java | |
parent | a70872698c2f821ea214862e1e544a3e950bd136 (diff) | |
parent | 6677d3a4e0cd91da6b8046c4084a82c4697b7365 (diff) |
Merge "Improve tap to scroll support to be ready for rollout" into main
Diffstat (limited to 'java')
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, ) } } |