diff options
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 |