diff options
| author | 2024-06-13 13:50:55 +0000 | |
|---|---|---|
| committer | 2024-06-18 10:01:41 +0000 | |
| commit | 9a42e828b01f90d84c5a99daf89bd71b955621f6 (patch) | |
| tree | 0a42555c8b88f1139892e3e3c1c531e74836dd6d | |
| parent | 6d28c9007ed4efecefc36629e0cd5435fdf3ab5c (diff) | |
Expose startedPosition and pointersDown to NestedScrollHandler
Until now, we have handled scrolls as gestures without a specific
starting position (using null) and have only considered one finger on
the screen.
Moving forward, we aim to accurately expose this information and we can
achieve this by leveraging the PointerInput API.
The NestedScrollToSceneNode now employs a PointerInputHandler to
determine the number of pointers and obtain the starting position during
the nested scroll.
The PointerInputHandler awaits the creation of the
AwaitPointerEventScope to register the nestedScrollHandler.
This approach ensures that touch events are always received by the
PointerInputHandler first, in the Initial step, and later these events
are consumed by our descendant scrollable in the Main step.
Test: atest ElementTest
Bug: 330200163
Flag: com.android.systemui.scene_container
Change-Id: Ib912aec5af40145af7a0d580aa81b3098d0ad092
4 files changed, 156 insertions, 36 deletions
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt index e9633c2f6603..f551207485f6 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt @@ -889,6 +889,7 @@ internal class NestedScrollHandlerImpl( private val topOrLeftBehavior: NestedScrollBehavior, private val bottomOrRightBehavior: NestedScrollBehavior, private val isExternalOverscrollGesture: () -> Boolean, + private val pointersInfo: () -> PointersInfo, ) { private val layoutState = layoutImpl.state private val draggableHandler = layoutImpl.draggableHandler(orientation) @@ -900,34 +901,36 @@ internal class NestedScrollHandlerImpl( // moving on to the next scene. var canChangeScene = false - val actionUpOrLeft = - Swipe( - direction = - when (orientation) { - Orientation.Horizontal -> SwipeDirection.Left - Orientation.Vertical -> SwipeDirection.Up - }, - pointerCount = 1, - ) - - val actionDownOrRight = - Swipe( - direction = - when (orientation) { - Orientation.Horizontal -> SwipeDirection.Right - Orientation.Vertical -> SwipeDirection.Down - }, - pointerCount = 1, - ) - fun hasNextScene(amount: Float): Boolean { val transitionState = layoutState.transitionState val scene = transitionState.currentScene val fromScene = layoutImpl.scene(scene) val nextScene = when { - amount < 0f -> fromScene.userActions[actionUpOrLeft] - amount > 0f -> fromScene.userActions[actionDownOrRight] + amount < 0f -> { + val actionUpOrLeft = + Swipe( + direction = + when (orientation) { + Orientation.Horizontal -> SwipeDirection.Left + Orientation.Vertical -> SwipeDirection.Up + }, + pointerCount = pointersInfo().pointersDown, + ) + fromScene.userActions[actionUpOrLeft] + } + amount > 0f -> { + val actionDownOrRight = + Swipe( + direction = + when (orientation) { + Orientation.Horizontal -> SwipeDirection.Right + Orientation.Vertical -> SwipeDirection.Down + }, + pointerCount = pointersInfo().pointersDown, + ) + fromScene.userActions[actionDownOrRight] + } else -> null } if (nextScene != null) return true @@ -1025,10 +1028,11 @@ internal class NestedScrollHandlerImpl( canContinueScroll = { true }, canScrollOnFling = false, onStart = { offsetAvailable -> + val pointers = pointersInfo() dragController = draggableHandler.onDragStarted( - pointersDown = 1, - startedPosition = null, + pointersDown = pointers.pointersDown, + startedPosition = pointers.startedPosition, overSlop = if (isIntercepting) 0f else offsetAvailable, ) }, diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt index 1fa6b3f7d6c0..dd795cd9ddfe 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt @@ -18,12 +18,21 @@ package com.android.compose.animation.scene import androidx.compose.foundation.gestures.Orientation import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.util.fastMap +import androidx.compose.ui.util.fastReduce import com.android.compose.nestedscroll.PriorityNestedScrollConnection +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive /** * Defines the behavior of the [SceneTransitionLayout] when a scrollable component is scrolled. @@ -121,6 +130,11 @@ private data class NestedScrollToSceneElement( } } +internal data class PointersInfo( + val pointersDown: Int, + val startedPosition: Offset, +) + private class NestedScrollToSceneNode( layoutImpl: SceneTransitionLayoutImpl, orientation: Orientation, @@ -135,7 +149,42 @@ private class NestedScrollToSceneNode( topOrLeftBehavior = topOrLeftBehavior, bottomOrRightBehavior = bottomOrRightBehavior, isExternalOverscrollGesture = isExternalOverscrollGesture, + pointersInfo = pointerInfo() + ) + + private var lastPointers: List<PointerInputChange>? = null + + private fun pointerInfo(): () -> PointersInfo = { + val pointers = + requireNotNull(lastPointers) { "NestedScroll API was called before PointerInput API" } + PointersInfo( + pointersDown = pointers.size, + startedPosition = pointers.fastMap { it.position }.fastReduce { a, b -> (a + b) / 2f }, ) + } + + private val pointerInputHandler: suspend PointerInputScope.() -> Unit = { + coroutineScope { + awaitPointerEventScope { + // Await this scope to guarantee that the PointerInput API receives touch events + // before the NestedScroll API. + delegate(nestedScrollNode) + + try { + while (isActive) { + // During the initial phase, we receive the event after our ancestors. + lastPointers = awaitPointerEvent(PointerEventPass.Initial).changes + } + } finally { + // Clean up the nested scroll connection + priorityNestedScrollConnection.reset() + undelegate(nestedScrollNode) + } + } + } + } + + private val pointerInputNode = delegate(SuspendingPointerInputModifierNode(pointerInputHandler)) private var nestedScrollNode: DelegatableNode = nestedScrollModifierNode( @@ -143,15 +192,6 @@ private class NestedScrollToSceneNode( dispatcher = null, ) - override fun onAttach() { - delegate(nestedScrollNode) - } - - override fun onDetach() { - // Make sure we reset the scroll connection when this modifier is removed from composition - priorityNestedScrollConnection.reset() - } - fun update( layoutImpl: SceneTransitionLayoutImpl, orientation: Orientation, @@ -161,7 +201,7 @@ private class NestedScrollToSceneNode( ) { // Clean up the old nested scroll connection priorityNestedScrollConnection.reset() - undelegate(nestedScrollNode) + pointerInputNode.resetPointerInputHandler() // Create a new nested scroll connection priorityNestedScrollConnection = @@ -171,13 +211,13 @@ private class NestedScrollToSceneNode( topOrLeftBehavior = topOrLeftBehavior, bottomOrRightBehavior = bottomOrRightBehavior, isExternalOverscrollGesture = isExternalOverscrollGesture, + pointersInfo = pointerInfo(), ) nestedScrollNode = nestedScrollModifierNode( connection = priorityNestedScrollConnection, dispatcher = null, ) - delegate(nestedScrollNode) } } @@ -187,6 +227,7 @@ private fun scenePriorityNestedScrollConnection( topOrLeftBehavior: NestedScrollBehavior, bottomOrRightBehavior: NestedScrollBehavior, isExternalOverscrollGesture: () -> Boolean, + pointersInfo: () -> PointersInfo, ) = NestedScrollHandlerImpl( layoutImpl = layoutImpl, @@ -194,5 +235,6 @@ private fun scenePriorityNestedScrollConnection( topOrLeftBehavior = topOrLeftBehavior, bottomOrRightBehavior = bottomOrRightBehavior, isExternalOverscrollGesture = isExternalOverscrollGesture, + pointersInfo = pointersInfo, ) .connection diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt index 8625482d5f71..7f3b7a1fae1a 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt @@ -111,7 +111,8 @@ class DraggableHandlerTest { orientation = draggableHandler.orientation, topOrLeftBehavior = nestedScrollBehavior, bottomOrRightBehavior = nestedScrollBehavior, - isExternalOverscrollGesture = { isExternalOverscrollGesture } + isExternalOverscrollGesture = { isExternalOverscrollGesture }, + pointersInfo = { PointersInfo(pointersDown = 1, startedPosition = Offset.Zero) } ) .connection diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt index beb74bc9bb36..772a4dac4540 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt @@ -837,6 +837,79 @@ class ElementTest { } @Test + fun elementTransitionDuringNestedScrollWith2Pointers() { + // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is + // detected as a drag event. + var touchSlop = 0f + val translateY = 10.dp + val layoutWidth = 200.dp + val layoutHeight = 400.dp + + val state = + rule.runOnUiThread { + MutableSceneTransitionLayoutState( + initialScene = SceneA, + transitions = transitions { + from(SceneA, to = SceneB) { + translate(TestElements.Foo, y = translateY) + } + }, + ) + as MutableSceneTransitionLayoutStateImpl + } + + rule.setContent { + touchSlop = LocalViewConfiguration.current.touchSlop + SceneTransitionLayout( + state = state, + modifier = Modifier.size(layoutWidth, layoutHeight) + ) { + scene( + SceneA, + userActions = mapOf(Swipe(SwipeDirection.Down, pointerCount = 2) to SceneB) + ) { + Box( + Modifier + // Unconsumed scroll gesture will be intercepted by STL + .verticalNestedScrollToScene() + // A scrollable that does not consume the scroll gesture + .scrollable( + rememberScrollableState(consumeScrollDelta = { 0f }), + Orientation.Vertical + ) + .fillMaxSize() + ) { + Spacer(Modifier.element(TestElements.Foo).fillMaxSize()) + } + } + scene(SceneB) { Spacer(Modifier.fillMaxSize()) } + } + } + + assertThat(state.transitionState).isIdle() + val fooElement = rule.onNodeWithTag(TestElements.Foo.testTag) + fooElement.assertTopPositionInRootIsEqualTo(0.dp) + + // Swipe down with 2 pointers by half of verticalSwipeDistance. + rule.onRoot().performTouchInput { + val middleTop = Offset((layoutWidth / 2).toPx(), 0f) + repeat(2) { i -> down(pointerId = i, middleTop) } + repeat(2) { i -> + // Scroll 50% + moveBy( + pointerId = i, + delta = Offset(0f, touchSlop + layoutHeight.toPx() * 0.5f), + delayMillis = 1_000, + ) + } + } + + val transition = assertThat(state.transitionState).isTransition() + assertThat(transition).hasProgress(0.5f) + fooElement.assertTopPositionInRootIsEqualTo(translateY * 0.5f) + } + + @Test fun elementTransitionWithDistanceDuringOverscroll() { val layoutWidth = 200.dp val layoutHeight = 400.dp |