Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 8f453fe0 authored by omarmt's avatar omarmt
Browse files

Expose startedPosition and pointersDown to STL descendants, if available

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 in the
MultiPointerDraggableNode.

New behaviors:
- MultiPointerDraggableNode now is a PointersInfoOwner and tracks the
  pointers events.
- We can access to the nearest ancestor PointersInfoOwner by using
  findAncestorPointersInfoOwner().
- The NestedScrollToSceneNode can determine the number of pointers and
  obtain the starting position during the nested scroll.

Test: atest ElementTest
Test: Tested on Flexiglass, the scroll between scenes still works. This
will enable two finger gestures on scrollable components.
Bug: 330200163
Flag: com.android.systemui.scene_container

Change-Id: Idf43855e0ee094a9e726a77cfc9b411c7e844dff
parent 6988f849
Loading
Loading
Loading
Loading
+40 −23
Original line number Diff line number Diff line
@@ -913,6 +913,7 @@ internal class NestedScrollHandlerImpl(
    private val topOrLeftBehavior: NestedScrollBehavior,
    private val bottomOrRightBehavior: NestedScrollBehavior,
    private val isExternalOverscrollGesture: () -> Boolean,
    private val pointersInfoOwner: PointersInfoOwner,
) {
    private val layoutState = layoutImpl.state
    private val draggableHandler = layoutImpl.draggableHandler(orientation)
@@ -924,6 +925,20 @@ internal class NestedScrollHandlerImpl(
        // moving on to the next scene.
        var canChangeScene = false

        var _lastPointersInfo: PointersInfo? = null
        fun pointersInfo(): PointersInfo {
            return checkNotNull(_lastPointersInfo) {
                "PointersInfo should be initialized before the transition begins."
            }
        }

        fun hasNextScene(amount: Float): Boolean {
            val transitionState = layoutState.transitionState
            val scene = transitionState.currentScene
            val fromScene = layoutImpl.scene(scene)
            val nextScene =
                when {
                    amount < 0f -> {
                        val actionUpOrLeft =
                            Swipe(
                                direction =
@@ -931,9 +946,11 @@ internal class NestedScrollHandlerImpl(
                                        Orientation.Horizontal -> SwipeDirection.Left
                                        Orientation.Vertical -> SwipeDirection.Up
                                    },
                pointerCount = 1,
                                pointerCount = pointersInfo().pointersDown,
                            )

                        fromScene.userActions[actionUpOrLeft]
                    }
                    amount > 0f -> {
                        val actionDownOrRight =
                            Swipe(
                                direction =
@@ -941,17 +958,10 @@ internal class NestedScrollHandlerImpl(
                                        Orientation.Horizontal -> SwipeDirection.Right
                                        Orientation.Vertical -> SwipeDirection.Down
                                    },
                pointerCount = 1,
                                pointerCount = pointersInfo().pointersDown,
                            )

        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]
                        fromScene.userActions[actionDownOrRight]
                    }
                    else -> null
                }
            if (nextScene != null) return true
@@ -985,6 +995,8 @@ internal class NestedScrollHandlerImpl(
                    return@PriorityNestedScrollConnection false
                }

                _lastPointersInfo = pointersInfoOwner.pointersInfo()

                // If the current swipe transition is *not* closed to 0f or 1f, then we want the
                // scroll events to intercept the current transition to continue the scene
                // transition.
@@ -1002,6 +1014,8 @@ internal class NestedScrollHandlerImpl(
                val isZeroOffset =
                    if (isExternalOverscrollGesture()) false else offsetBeforeStart == 0f

                _lastPointersInfo = pointersInfoOwner.pointersInfo()

                val canStart =
                    when (behavior) {
                        NestedScrollBehavior.DuringTransitionBetweenScenes -> {
@@ -1039,6 +1053,8 @@ internal class NestedScrollHandlerImpl(
                // We could start an overscroll animation
                canChangeScene = false

                _lastPointersInfo = pointersInfoOwner.pointersInfo()

                val canStart = behavior.canStartOnPostFling && hasNextScene(velocityAvailable)
                if (canStart) {
                    isIntercepting = false
@@ -1049,10 +1065,11 @@ internal class NestedScrollHandlerImpl(
            canContinueScroll = { true },
            canScrollOnFling = false,
            onStart = { offsetAvailable ->
                val pointersInfo = pointersInfo()
                dragController =
                    draggableHandler.onDragStarted(
                        pointersDown = 1,
                        startedPosition = null,
                        pointersDown = pointersInfo.pointersDown,
                        startedPosition = pointersInfo.startedPosition,
                        overSlop = if (isIntercepting) 0f else offsetAvailable,
                    )
            },
+73 −11
Original line number Diff line number Diff line
@@ -36,11 +36,14 @@ import androidx.compose.ui.input.pointer.positionChangeIgnoreConsumed
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DelegatableNode
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.TraversableNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.findNearestAncestor
import androidx.compose.ui.node.observeReads
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.IntSize
@@ -48,11 +51,12 @@ import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastFirstOrNull
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastSumBy
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.sign
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

/**
 * Make an element draggable in the given [orientation].
@@ -112,6 +116,18 @@ private data class MultiPointerDraggableElement(
    }
}

private val TRAVERSE_KEY = Any()

/** Find the nearest [PointersInfoOwner] ancestor or throw. */
internal fun DelegatableNode.requireAncestorPointersInfoOwner(): PointersInfoOwner {
    val ancestorNode =
        checkNotNull(findNearestAncestor(TRAVERSE_KEY)) {
            "This should never happen! Couldn't find a MultiPointerDraggableNode. " +
                "Are we inside an SceneTransitionLayout?"
        }
    return ancestorNode as PointersInfoOwner
}

internal class MultiPointerDraggableNode(
    orientation: Orientation,
    enabled: () -> Boolean,
@@ -120,15 +136,19 @@ internal class MultiPointerDraggableNode(
        (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController,
    var swipeDetector: SwipeDetector = DefaultSwipeDetector,
) :
    PointerInputModifierNode,
    DelegatingNode(),
    PointerInputModifierNode,
    CompositionLocalConsumerModifierNode,
    TraversableNode,
    PointersInfoOwner,
    ObserverModifierNode {
    private val pointerInputHandler: suspend PointerInputScope.() -> Unit = { pointerInput() }
    private val delegate = delegate(SuspendingPointerInputModifierNode(pointerInputHandler))
    private val velocityTracker = VelocityTracker()
    private var previousEnabled: Boolean = false

    override val traverseKey: Any = TRAVERSE_KEY

    var enabled: () -> Boolean = enabled
        set(value) {
            // Reset the pointer input whenever enabled changed.
@@ -185,12 +205,42 @@ internal class MultiPointerDraggableNode(
        bounds: IntSize
    ) = delegate.onPointerEvent(pointerEvent, pass, bounds)

    private var startedPosition: Offset? = null
    private var pointersDown: Int = 0

    override fun pointersInfo(): PointersInfo {
        return PointersInfo(
            startedPosition = startedPosition,
            // Note: We could have 0 pointers during fling or for other reasons.
            pointersDown = pointersDown.coerceAtLeast(1),
        )
    }

    private suspend fun PointerInputScope.pointerInput() {
        if (!enabled()) {
            return
        }

        coroutineScope {
            launch {
                // Intercepts pointer inputs and exposes [PointersInfo], via
                // [requireAncestorPointersInfoOwner], to our descendants.
                awaitPointerEventScope {
                    while (isActive) {
                        // During the Initial pass, we receive the event after our ancestors.
                        val pointers = awaitPointerEvent(PointerEventPass.Initial).changes

                        pointersDown = pointers.countDown()
                        if (pointersDown == 0) {
                            // There are no more pointers down
                            startedPosition = null
                        } else if (startedPosition == null) {
                            startedPosition = pointers.first().position
                        }
                    }
                }
            }

            awaitPointerEventScope {
                while (isActive) {
                    try {
@@ -314,15 +364,16 @@ internal class MultiPointerDraggableNode(
            }

        if (drag != null) {
            // Count the number of pressed pointers.
            val pressed = mutableSetOf<PointerId>()
            currentEvent.changes.fastForEach { change ->
                if (change.pressed) {
                    pressed.add(change.id)
                }
            }

            val controller = onDragStart(drag.position, overSlop, pressed.size)
            val controller =
                onDragStart(
                    // The startedPosition is the starting position when a gesture begins (when the
                    // first pointer touches the screen), not the point where we begin dragging.
                    // For example, this could be different if one of our children intercepts the
                    // gesture first and then we do.
                    requireNotNull(startedPosition),
                    overSlop,
                    pointersDown,
                )

            val successful: Boolean
            try {
@@ -461,4 +512,15 @@ internal class MultiPointerDraggableNode(
            }
        }
    }

    private fun List<PointerInputChange>.countDown() = fastSumBy { if (it.pressed) 1 else 0 }
}

internal fun interface PointersInfoOwner {
    fun pointersInfo(): PointersInfo
}

internal data class PointersInfo(
    val startedPosition: Offset?,
    val pointersDown: Int,
)
+6 −0
Original line number Diff line number Diff line
@@ -128,6 +128,7 @@ private class NestedScrollToSceneNode(
    bottomOrRightBehavior: NestedScrollBehavior,
    isExternalOverscrollGesture: () -> Boolean,
) : DelegatingNode() {
    lateinit var pointersInfoOwner: PointersInfoOwner
    private var priorityNestedScrollConnection: PriorityNestedScrollConnection =
        scenePriorityNestedScrollConnection(
            layoutImpl = layoutImpl,
@@ -135,6 +136,7 @@ private class NestedScrollToSceneNode(
            topOrLeftBehavior = topOrLeftBehavior,
            bottomOrRightBehavior = bottomOrRightBehavior,
            isExternalOverscrollGesture = isExternalOverscrollGesture,
            pointersInfoOwner = { pointersInfoOwner.pointersInfo() }
        )

    private var nestedScrollNode: DelegatableNode =
@@ -144,6 +146,7 @@ private class NestedScrollToSceneNode(
        )

    override fun onAttach() {
        pointersInfoOwner = requireAncestorPointersInfoOwner()
        delegate(nestedScrollNode)
    }

@@ -171,6 +174,7 @@ private class NestedScrollToSceneNode(
                topOrLeftBehavior = topOrLeftBehavior,
                bottomOrRightBehavior = bottomOrRightBehavior,
                isExternalOverscrollGesture = isExternalOverscrollGesture,
                pointersInfoOwner = pointersInfoOwner,
            )
        nestedScrollNode =
            nestedScrollModifierNode(
@@ -187,6 +191,7 @@ private fun scenePriorityNestedScrollConnection(
    topOrLeftBehavior: NestedScrollBehavior,
    bottomOrRightBehavior: NestedScrollBehavior,
    isExternalOverscrollGesture: () -> Boolean,
    pointersInfoOwner: PointersInfoOwner,
) =
    NestedScrollHandlerImpl(
            layoutImpl = layoutImpl,
@@ -194,5 +199,6 @@ private fun scenePriorityNestedScrollConnection(
            topOrLeftBehavior = topOrLeftBehavior,
            bottomOrRightBehavior = bottomOrRightBehavior,
            isExternalOverscrollGesture = isExternalOverscrollGesture,
            pointersInfoOwner = pointersInfoOwner,
        )
        .connection
+4 −1
Original line number Diff line number Diff line
@@ -113,7 +113,10 @@ class DraggableHandlerTest {
                    orientation = draggableHandler.orientation,
                    topOrLeftBehavior = nestedScrollBehavior,
                    bottomOrRightBehavior = nestedScrollBehavior,
                    isExternalOverscrollGesture = { isExternalOverscrollGesture }
                    isExternalOverscrollGesture = { isExternalOverscrollGesture },
                    pointersInfoOwner = {
                        PointersInfo(startedPosition = Offset.Zero, pointersDown = 1)
                    }
                )
                .connection

+74 −0
Original line number Diff line number Diff line
@@ -838,6 +838,80 @@ class ElementTest {
        fooElement.assertTopPositionInRootIsEqualTo(overscrollTranslateY * 1.5f)
    }

    @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
Loading