diff options
3 files changed, 166 insertions, 46 deletions
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt index 8552aaf0ebff..7c4fd5a32596 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt @@ -40,12 +40,15 @@ import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.ObserverModifierNode import androidx.compose.ui.node.PointerInputModifierNode import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.observeReads import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.Velocity import androidx.compose.ui.util.fastForEach +import kotlin.math.sign /** * Make an element draggable in the given [orientation]. @@ -117,10 +120,15 @@ internal class MultiPointerDraggableNode( var onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> Unit, var onDragDelta: (Float) -> Unit, var onDragStopped: (velocity: Float) -> Unit, -) : PointerInputModifierNode, DelegatingNode(), CompositionLocalConsumerModifierNode { +) : + PointerInputModifierNode, + DelegatingNode(), + CompositionLocalConsumerModifierNode, + ObserverModifierNode { private val pointerInputHandler: suspend PointerInputScope.() -> Unit = { pointerInput() } private val delegate = delegate(SuspendingPointerInputModifierNode(pointerInputHandler)) private val velocityTracker = VelocityTracker() + private var previousEnabled: Boolean = false var enabled: () -> Boolean = enabled set(value) { @@ -140,6 +148,21 @@ internal class MultiPointerDraggableNode( } } + override fun onAttach() { + previousEnabled = enabled() + onObservedReadsChanged() + } + + override fun onObservedReadsChanged() { + observeReads { + val newEnabled = enabled() + if (newEnabled != previousEnabled) { + delegate.resetPointerInputHandler() + } + previousEnabled = newEnabled + } + } + override fun onCancelPointerInput() = delegate.onCancelPointerInput() override fun onPointerEvent( @@ -223,12 +246,31 @@ private suspend fun PointerInputScope.detectDragGestures( // TODO(b/291055080): Replace by await[Orientation]PointerSlopOrCancellation once // it is public. - when (orientation) { - Orientation.Horizontal -> - awaitHorizontalTouchSlopOrCancellation(down.id, onSlopReached) - Orientation.Vertical -> - awaitVerticalTouchSlopOrCancellation(down.id, onSlopReached) + val drag = + when (orientation) { + Orientation.Horizontal -> + awaitHorizontalTouchSlopOrCancellation(down.id, onSlopReached) + Orientation.Vertical -> + awaitVerticalTouchSlopOrCancellation(down.id, onSlopReached) + } + + // Make sure that overSlop is not 0f. This can happen when the user drags by exactly + // the touch slop. However, the overSlop we pass to onDragStarted() is used to + // compute the direction we are dragging in, so overSlop should never be 0f unless + // we intercept an ongoing swipe transition (i.e. startDragImmediately() returned + // true). + if (drag != null && overSlop == 0f) { + val deltaOffset = drag.position - initialDown.position + val delta = + when (orientation) { + Orientation.Horizontal -> deltaOffset.y + Orientation.Vertical -> deltaOffset.y + } + check(delta != 0f) + overSlop = delta.sign } + + drag } if (drag != null) { diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt index 88363ad24d9a..a1f40077ecc2 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.Text import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.Velocity @@ -118,9 +117,6 @@ class SceneGestureHandlerTest { fun up(fractionOfScreen: Float) = if (fractionOfScreen < 0f) error("use down()") else -down(fractionOfScreen) - // Float tolerance for comparisons - val tolerance = 0.00001f - // Offset y: 10% of the screen val offsetY10 = Offset(x = 0f, y = down(0.1f)) @@ -169,12 +165,11 @@ class SceneGestureHandlerTest { if (progress != null) assertWithMessage("progress does not match") .that((transitionState as? Transition)?.progress) - .isWithin(tolerance) + .isWithin(0f) // returns true when comparing 0.0f with -0.0f .of(progress) } } - @OptIn(ExperimentalTestApi::class) private fun runGestureTest(block: suspend TestGestureScope.() -> Unit) { runMonotonicClockTest { TestGestureScope(coroutineScope = this).block() } } @@ -248,7 +243,7 @@ class SceneGestureHandlerTest { @Test fun onDragReversedDirection_changeToScene() = runGestureTest { // Drag A -> B with progress 0.6 - draggable.onDragStarted(overSlop = up(0.6f)) + draggable.onDragStarted(overSlop = -60f) assertTransition( currentScene = SceneA, fromScene = SceneA, @@ -257,7 +252,7 @@ class SceneGestureHandlerTest { ) // Reverse direction such that A -> C now with 0.4 - draggable.onDelta(down(1f)) + draggable.onDelta(100f) assertTransition( currentScene = SceneA, fromScene = SceneA, @@ -287,7 +282,7 @@ class SceneGestureHandlerTest { navigateToSceneC() // We are on SceneC which has no action in Down direction - draggable.onDragStarted(down(0.1f)) + draggable.onDragStarted(10f) assertTransition( currentScene = SceneC, fromScene = SceneC, @@ -296,7 +291,7 @@ class SceneGestureHandlerTest { ) // Reverse drag direction, it will consume the previous drag - draggable.onDelta(up(0.1f)) + draggable.onDelta(-10f) assertTransition( currentScene = SceneC, fromScene = SceneC, @@ -305,7 +300,7 @@ class SceneGestureHandlerTest { ) // Continue reverse drag direction, it should record progress to Scene B - draggable.onDelta(up(0.1f)) + draggable.onDelta(-10f) assertTransition( currentScene = SceneC, fromScene = SceneC, @@ -557,51 +552,51 @@ class SceneGestureHandlerTest { ) { val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithPreview) // start scene transition - nestedScroll.scroll(available = Offset(0f, SCREEN_SIZE * firstScroll)) + nestedScroll.scroll(available = Offset(0f, firstScroll)) // stop scene transition (start the "stop animation") nestedScroll.onPreFling(available = Velocity.Zero) // a pre scroll event, that could be intercepted by SceneGestureHandler - nestedScroll.onPreScroll(Offset(0f, SCREEN_SIZE * secondScroll), NestedScrollSource.Drag) + nestedScroll.onPreScroll(Offset(0f, secondScroll), NestedScrollSource.Drag) } @Test fun scrollAndFling_scrollLessThanInterceptable_goToIdleOnCurrentScene() = runGestureTest { - val first = transitionInterceptionThreshold - tolerance - val second = 0.01f + val firstScroll = (transitionInterceptionThreshold - 0.0001f) * SCREEN_SIZE + val secondScroll = 1f - preScrollAfterSceneTransition(firstScroll = first, secondScroll = second) + preScrollAfterSceneTransition(firstScroll = firstScroll, secondScroll = secondScroll) assertIdle(SceneA) } @Test fun scrollAndFling_scrollMinInterceptable_interceptPreScrollEvents() = runGestureTest { - val first = transitionInterceptionThreshold + tolerance - val second = 0.01f + val firstScroll = (transitionInterceptionThreshold + 0.0001f) * SCREEN_SIZE + val secondScroll = 1f - preScrollAfterSceneTransition(firstScroll = first, secondScroll = second) + preScrollAfterSceneTransition(firstScroll = firstScroll, secondScroll = secondScroll) - assertTransition(progress = first + second) + assertTransition(progress = (firstScroll + secondScroll) / SCREEN_SIZE) } @Test fun scrollAndFling_scrollMaxInterceptable_interceptPreScrollEvents() = runGestureTest { - val first = 1f - transitionInterceptionThreshold - tolerance - val second = 0.01f + val firstScroll = (1f - transitionInterceptionThreshold - 0.0001f) * SCREEN_SIZE + val secondScroll = 1f - preScrollAfterSceneTransition(firstScroll = first, secondScroll = second) + preScrollAfterSceneTransition(firstScroll = firstScroll, secondScroll = secondScroll) - assertTransition(progress = first + second) + assertTransition(progress = (firstScroll + secondScroll) / SCREEN_SIZE) } @Test fun scrollAndFling_scrollMoreThanInterceptable_goToIdleOnNextScene() = runGestureTest { - val first = 1f - transitionInterceptionThreshold + tolerance - val second = 0.01f + val firstScroll = (1f - transitionInterceptionThreshold + 0.0001f) * SCREEN_SIZE + val secondScroll = 0.01f - preScrollAfterSceneTransition(firstScroll = first, secondScroll = second) + preScrollAfterSceneTransition(firstScroll = firstScroll, secondScroll = secondScroll) assertIdle(SceneC) } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt index 940335853221..d81631aad13a 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt @@ -21,6 +21,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalViewConfiguration @@ -63,7 +66,10 @@ class SwipeToSceneTest { /** The content under test. */ @Composable - private fun TestContent(layoutState: SceneTransitionLayoutState) { + private fun TestContent( + layoutState: SceneTransitionLayoutState, + swipesEnabled: () -> Boolean = { true }, + ) { SceneTransitionLayout( state = layoutState, modifier = Modifier.size(LayoutWidth, LayoutHeight).testTag(TestElements.Foo.debugName), @@ -71,28 +77,35 @@ class SwipeToSceneTest { scene( TestScenes.SceneA, userActions = - mapOf( - Swipe.Left to TestScenes.SceneB, - Swipe.Down to TestScenes.SceneC, - ), + if (swipesEnabled()) + mapOf( + Swipe.Left to TestScenes.SceneB, + Swipe.Down to TestScenes.SceneC, + Swipe.Up to TestScenes.SceneB, + ) + else emptyMap(), ) { Box(Modifier.fillMaxSize()) } scene( TestScenes.SceneB, - userActions = mapOf(Swipe.Right to TestScenes.SceneA), + userActions = + if (swipesEnabled()) mapOf(Swipe.Right to TestScenes.SceneA) else emptyMap(), ) { Box(Modifier.fillMaxSize()) } scene( TestScenes.SceneC, userActions = - mapOf( - Swipe.Down to TestScenes.SceneA, - Swipe(SwipeDirection.Down, pointerCount = 2) to TestScenes.SceneB, - Swipe(SwipeDirection.Right, fromSource = Edge.Left) to TestScenes.SceneB, - Swipe(SwipeDirection.Down, fromSource = Edge.Top) to TestScenes.SceneB, - ), + if (swipesEnabled()) + mapOf( + Swipe.Down to TestScenes.SceneA, + Swipe(SwipeDirection.Down, pointerCount = 2) to TestScenes.SceneB, + Swipe(SwipeDirection.Right, fromSource = Edge.Left) to + TestScenes.SceneB, + Swipe(SwipeDirection.Down, fromSource = Edge.Top) to TestScenes.SceneB, + ) + else emptyMap(), ) { Box(Modifier.fillMaxSize()) } @@ -357,7 +370,7 @@ class SwipeToSceneTest { // detected as a drag event. var touchSlop = 0f - val layoutState = MutableSceneTransitionLayoutState(TestScenes.SceneA) + val layoutState = layoutState() val verticalSwipeDistance = 50.dp assertThat(verticalSwipeDistance).isNotEqualTo(LayoutHeight) @@ -392,4 +405,74 @@ class SwipeToSceneTest { assertThat(transition).isNotNull() assertThat(transition!!.progress).isEqualTo(0.5f) } + + @Test + fun swipeByTouchSlop() { + val layoutState = layoutState() + var touchSlop = 0f + rule.setContent { + touchSlop = LocalViewConfiguration.current.touchSlop + TestContent(layoutState) + } + + // Swipe down by exactly touchSlop, so that the drag overSlop is 0f. + rule.onRoot().performTouchInput { + down(middle) + moveBy(Offset(0f, touchSlop), delayMillis = 1_000) + } + + // We should still correctly compute that we are swiping down to scene C. + var transition = layoutState.currentTransition + assertThat(transition).isNotNull() + assertThat(transition?.toScene).isEqualTo(TestScenes.SceneC) + + // Release the finger, animating back to scene A. + rule.onRoot().performTouchInput { up() } + rule.waitForIdle() + assertThat(layoutState.currentTransition).isNull() + assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA) + + // Swipe up by exactly touchSlop, so that the drag overSlop is 0f. + rule.onRoot().performTouchInput { + down(middle) + moveBy(Offset(0f, -touchSlop), delayMillis = 1_000) + } + + // We should still correctly compute that we are swiping up to scene B. + transition = layoutState.currentTransition + assertThat(transition).isNotNull() + assertThat(transition?.toScene).isEqualTo(TestScenes.SceneB) + } + + @Test + fun swipeEnabledLater() { + val layoutState = MutableSceneTransitionLayoutState(TestScenes.SceneA) + var swipesEnabled by mutableStateOf(false) + var touchSlop = 0f + rule.setContent { + touchSlop = LocalViewConfiguration.current.touchSlop + TestContent(layoutState, swipesEnabled = { swipesEnabled }) + } + + // Drag down from the middle. This should not do anything, because swipes are disabled. + rule.onRoot().performTouchInput { + down(middle) + moveBy(Offset(0f, touchSlop), delayMillis = 1_000) + } + assertThat(layoutState.currentTransition).isNull() + + // Release finger. + rule.onRoot().performTouchInput { up() } + + // Enable swipes. + swipesEnabled = true + rule.waitForIdle() + + // Drag down from the middle. Now it should start a transition. + rule.onRoot().performTouchInput { + down(middle) + moveBy(Offset(0f, touchSlop), delayMillis = 1_000) + } + assertThat(layoutState.currentTransition).isNotNull() + } } |