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

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

STL dispatches events using the Nested Scroll system

Now, the MultiPointerDraggable in SceneTransitionLayout is part of the
Nested Scroll system.

Drag events are exposed as scrolls, and ancestors can intercept them
during the preScroll and postScroll phases.
Stop and cancel events are exposed as fling events, and they can be
intercepted in the onPreFling and onPostFling phases.

Test: atest MultiPointerDraggableTest
Bug: 336710600
Flag: com.android.systemui.scene_container
Change-Id: I5df016c3e9249112f7586c94857874dcac693029
parent 44488a44
Loading
Loading
Loading
Loading
+136 −31
Original line number Diff line number Diff line
@@ -16,12 +16,16 @@

package com.android.compose.animation.scene

import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
@@ -36,13 +40,11 @@ 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.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
@@ -51,6 +53,7 @@ import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastFirstOrNull
import androidx.compose.ui.util.fastSumBy
import com.android.compose.ui.util.SpaceVectorConverter
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.sign
import kotlinx.coroutines.coroutineScope
@@ -71,6 +74,7 @@ import kotlinx.coroutines.launch
 * dragged) and a second pointer is down and dragged. This is an implementation detail that might
 * change in the future.
 */
@VisibleForTesting
@Stable
internal fun Modifier.multiPointerDraggable(
    orientation: Orientation,
@@ -78,6 +82,7 @@ internal fun Modifier.multiPointerDraggable(
    startDragImmediately: (startedPosition: Offset) -> Boolean,
    onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController,
    swipeDetector: SwipeDetector = DefaultSwipeDetector,
    dispatcher: NestedScrollDispatcher,
): Modifier =
    this.then(
        MultiPointerDraggableElement(
@@ -86,6 +91,7 @@ internal fun Modifier.multiPointerDraggable(
            startDragImmediately,
            onDragStarted,
            swipeDetector,
            dispatcher,
        )
    )

@@ -96,6 +102,7 @@ private data class MultiPointerDraggableElement(
    private val onDragStarted:
        (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController,
    private val swipeDetector: SwipeDetector,
    private val dispatcher: NestedScrollDispatcher,
) : ModifierNodeElement<MultiPointerDraggableNode>() {
    override fun create(): MultiPointerDraggableNode =
        MultiPointerDraggableNode(
@@ -104,6 +111,7 @@ private data class MultiPointerDraggableElement(
            startDragImmediately = startDragImmediately,
            onDragStarted = onDragStarted,
            swipeDetector = swipeDetector,
            dispatcher = dispatcher,
        )

    override fun update(node: MultiPointerDraggableNode) {
@@ -122,11 +130,13 @@ internal class MultiPointerDraggableNode(
    var onDragStarted:
        (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController,
    var swipeDetector: SwipeDetector = DefaultSwipeDetector,
    private val dispatcher: NestedScrollDispatcher,
) :
    DelegatingNode(),
    PointerInputModifierNode,
    CompositionLocalConsumerModifierNode,
    ObserverModifierNode {
    ObserverModifierNode,
    SpaceVectorConverter {
    private val pointerInputHandler: suspend PointerInputScope.() -> Unit = { pointerInput() }
    private val delegate = delegate(SuspendingPointerInputModifierNode(pointerInputHandler))
    private val velocityTracker = VelocityTracker()
@@ -141,26 +151,22 @@ internal class MultiPointerDraggableNode(
            }
        }

    private var _toFloat = orientation.toFunctionOffsetToFloat()
    private var converter = SpaceVectorConverter(orientation)

    private fun Offset.toFloat(): Float = _toFloat(this)
    override fun Offset.toFloat(): Float = with(converter) { this@toFloat.toFloat() }

    private fun Orientation.toFunctionOffsetToFloat(): (Offset) -> Float =
        when (this) {
            Orientation.Vertical -> {
                { it.y }
            }
            Orientation.Horizontal -> {
                { it.x }
            }
        }
    override fun Velocity.toFloat(): Float = with(converter) { this@toFloat.toFloat() }

    override fun Float.toOffset(): Offset = with(converter) { this@toOffset.toOffset() }

    override fun Float.toVelocity(): Velocity = with(converter) { this@toVelocity.toVelocity() }

    var orientation: Orientation = orientation
        set(value) {
            // Reset the pointer input whenever orientation changed.
            if (value != field) {
                field = value
                _toFloat = field.toFunctionOffsetToFloat()
                converter = SpaceVectorConverter(value)
                delegate.resetPointerInputHandler()
            }
        }
@@ -240,28 +246,32 @@ internal class MultiPointerDraggableNode(
                                },
                                onDrag = { controller, change, amount ->
                                    velocityTracker.addPointerInputChange(change)
                                    controller.onDrag(amount)
                                    dispatchScrollEvents(
                                        availableOnPreScroll = amount,
                                        onScroll = { controller.onDrag(it) },
                                        source = NestedScrollSource.UserInput,
                                    )
                                },
                                onDragEnd = { controller ->
                                    val viewConfiguration = currentValueOf(LocalViewConfiguration)
                                    val maxVelocity =
                                        viewConfiguration.maximumFlingVelocity.let {
                                            Velocity(it, it)
                                        }
                                    val velocity = velocityTracker.calculateVelocity(maxVelocity)
                                    controller.onStop(
                                        velocity =
                                            when (orientation) {
                                                Orientation.Horizontal -> velocity.x
                                                Orientation.Vertical -> velocity.y
                                            },
                                        canChangeScene = true,
                                    startFlingGesture(
                                        initialVelocity =
                                            currentValueOf(LocalViewConfiguration)
                                                .maximumFlingVelocity
                                                .let {
                                                    val maxVelocity = Velocity(it, it)
                                                    velocityTracker.calculateVelocity(maxVelocity)
                                                }
                                                .toFloat(),
                                        onFling = { controller.onStop(it, canChangeScene = true) }
                                    )
                                },
                                onDragCancel = { controller ->
                                    controller.onStop(velocity = 0f, canChangeScene = true)
                                    startFlingGesture(
                                        initialVelocity = 0f,
                                        onFling = { controller.onStop(it, canChangeScene = true) }
                                    )
                                },
                                swipeDetector = swipeDetector
                                swipeDetector = swipeDetector,
                            )
                        } catch (exception: CancellationException) {
                            // If the coroutine scope is active, we can just restart the drag cycle.
@@ -275,6 +285,101 @@ internal class MultiPointerDraggableNode(
        }
    }

    /**
     * Start a fling gesture in another CoroutineScope, this is to ensure that even when the pointer
     * input scope is reset we will continue any coroutine scope that we started from these methods
     * while the pointer input scope was active.
     *
     * Note: Inspired by [androidx.compose.foundation.gestures.ScrollableNode.onDragStopped]
     */
    private fun startFlingGesture(initialVelocity: Float, onFling: (velocity: Float) -> Float) {
        // Note: [AwaitPointerEventScope] is annotated as @RestrictsSuspension, we need another
        // CoroutineScope to run the fling gestures.
        // We do not need to cancel this [Job], the source will take care of emitting an
        // [onPostFling] before starting a new gesture.
        dispatcher.coroutineScope.launch {
            dispatchFlingEvents(availableOnPreFling = initialVelocity, onFling = onFling)
        }
    }

    /**
     * Use the nested scroll system to fire scroll events. This allows us to consume events from our
     * ancestors during the pre-scroll and post-scroll phases.
     *
     * @param availableOnPreScroll amount available before the scroll, this can be partially
     *   consumed by our ancestors.
     * @param onScroll function that returns the amount consumed during a scroll given the amount
     *   available after the [NestedScrollConnection.onPreScroll].
     * @param source the source of the scroll event
     * @return Total offset consumed.
     */
    private inline fun dispatchScrollEvents(
        availableOnPreScroll: Float,
        onScroll: (delta: Float) -> Float,
        source: NestedScrollSource,
    ): Float {
        // PreScroll phase
        val consumedByPreScroll =
            dispatcher
                .dispatchPreScroll(
                    available = availableOnPreScroll.toOffset(),
                    source = source,
                )
                .toFloat()

        // Scroll phase
        val availableOnScroll = availableOnPreScroll - consumedByPreScroll
        val consumedBySelfScroll = onScroll(availableOnScroll)

        // PostScroll phase
        val availableOnPostScroll = availableOnScroll - consumedBySelfScroll
        val consumedByPostScroll =
            dispatcher
                .dispatchPostScroll(
                    consumed = consumedBySelfScroll.toOffset(),
                    available = availableOnPostScroll.toOffset(),
                    source = source,
                )
                .toFloat()

        return consumedByPreScroll + consumedBySelfScroll + consumedByPostScroll
    }

    /**
     * Use the nested scroll system to fire fling events. This allows us to consume events from our
     * ancestors during the pre-fling and post-fling phases.
     *
     * @param availableOnPreFling velocity available before the fling, this can be partially
     *   consumed by our ancestors.
     * @param onFling function that returns the velocity consumed during the fling given the
     *   velocity available after the [NestedScrollConnection.onPreFling].
     * @return Total velocity consumed.
     */
    private suspend inline fun dispatchFlingEvents(
        availableOnPreFling: Float,
        onFling: (velocity: Float) -> Float,
    ): Float {
        // PreFling phase
        val consumedByPreFling =
            dispatcher.dispatchPreFling(available = availableOnPreFling.toVelocity()).toFloat()

        // Fling phase
        val availableOnFling = availableOnPreFling - consumedByPreFling
        val consumedBySelfFling = onFling(availableOnFling)

        // PostFling phase
        val availableOnPostFling = availableOnFling - consumedBySelfFling
        val consumedByPostFling =
            dispatcher
                .dispatchPostFling(
                    consumed = consumedBySelfFling.toVelocity(),
                    available = availableOnPostFling.toVelocity(),
                )
                .toFloat()

        return consumedByPreFling + consumedBySelfFling + consumedByPostFling
    }

    /**
     * Detect drag gestures in the given [orientation].
     *
+4 −1
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ import androidx.compose.foundation.gestures.Orientation
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
@@ -57,6 +58,7 @@ private class SwipeToSceneNode(
    draggableHandler: DraggableHandlerImpl,
    swipeDetector: SwipeDetector,
) : DelegatingNode(), PointerInputModifierNode {
    private val dispatcher = NestedScrollDispatcher()
    private val multiPointerDraggableNode =
        delegate(
            MultiPointerDraggableNode(
@@ -65,6 +67,7 @@ private class SwipeToSceneNode(
                startDragImmediately = ::startDragImmediately,
                onDragStarted = draggableHandler::onDragStarted,
                swipeDetector = swipeDetector,
                dispatcher = dispatcher,
            )
        )

@@ -93,7 +96,7 @@ private class SwipeToSceneNode(
        )

    init {
        delegate(nestedScrollModifierNode(nestedScrollHandlerImpl.connection, dispatcher = null))
        delegate(nestedScrollModifierNode(nestedScrollHandlerImpl.connection, dispatcher))
        delegate(ScrollBehaviorOwnerNode(draggableHandler.nestedScrollKey, nestedScrollHandlerImpl))
    }

+136 −4
Original line number Diff line number Diff line
@@ -28,6 +28,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
@@ -37,6 +41,7 @@ import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.Velocity
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.coroutineScope
@@ -49,17 +54,22 @@ import org.junit.runner.RunWith
class MultiPointerDraggableTest {
    @get:Rule val rule = createComposeRule()

    private val emptyConnection = object : NestedScrollConnection {}
    private val defaultDispatcher = NestedScrollDispatcher()

    private fun Modifier.nestedScrollDispatcher() = nestedScroll(emptyConnection, defaultDispatcher)

    private class SimpleDragController(
        val onDrag: () -> Unit,
        val onStop: () -> Unit,
        val onDrag: (delta: Float) -> Unit,
        val onStop: (velocity: Float) -> Unit,
    ) : DragController {
        override fun onDrag(delta: Float): Float {
            onDrag()
            onDrag.invoke(delta)
            return delta
        }

        override fun onStop(velocity: Float, canChangeScene: Boolean): Float {
            onStop()
            onStop.invoke(velocity)
            return velocity
        }
    }
@@ -79,6 +89,7 @@ class MultiPointerDraggableTest {
            touchSlop = LocalViewConfiguration.current.touchSlop
            Box(
                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
                    .nestedScrollDispatcher()
                    .multiPointerDraggable(
                        orientation = Orientation.Vertical,
                        enabled = { enabled },
@@ -90,6 +101,7 @@ class MultiPointerDraggableTest {
                                onStop = { stopped = true },
                            )
                        },
                        dispatcher = defaultDispatcher,
                    )
            )
        }
@@ -145,6 +157,7 @@ class MultiPointerDraggableTest {
            touchSlop = LocalViewConfiguration.current.touchSlop
            Box(
                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
                    .nestedScrollDispatcher()
                    .multiPointerDraggable(
                        orientation = Orientation.Vertical,
                        enabled = { true },
@@ -157,6 +170,7 @@ class MultiPointerDraggableTest {
                                onStop = { stopped = true },
                            )
                        },
                        dispatcher = defaultDispatcher,
                    )
                    .pointerInput(Unit) {
                        coroutineScope {
@@ -217,6 +231,7 @@ class MultiPointerDraggableTest {
            touchSlop = LocalViewConfiguration.current.touchSlop
            Box(
                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
                    .nestedScrollDispatcher()
                    .multiPointerDraggable(
                        orientation = Orientation.Vertical,
                        enabled = { true },
@@ -228,6 +243,7 @@ class MultiPointerDraggableTest {
                                onStop = { stopped = true },
                            )
                        },
                        dispatcher = defaultDispatcher,
                    )
            ) {
                if (hasScrollable) {
@@ -335,6 +351,7 @@ class MultiPointerDraggableTest {
            touchSlop = LocalViewConfiguration.current.touchSlop
            Box(
                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
                    .nestedScrollDispatcher()
                    .multiPointerDraggable(
                        orientation = Orientation.Vertical,
                        enabled = { true },
@@ -346,6 +363,7 @@ class MultiPointerDraggableTest {
                                onStop = { stopped = true },
                            )
                        },
                        dispatcher = defaultDispatcher,
                    )
            ) {
                Box(
@@ -436,6 +454,7 @@ class MultiPointerDraggableTest {
            touchSlop = LocalViewConfiguration.current.touchSlop
            Box(
                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
                    .nestedScrollDispatcher()
                    .multiPointerDraggable(
                        orientation = Orientation.Vertical,
                        enabled = { true },
@@ -447,6 +466,7 @@ class MultiPointerDraggableTest {
                                onStop = { verticalStopped = true },
                            )
                        },
                        dispatcher = defaultDispatcher,
                    )
                    .multiPointerDraggable(
                        orientation = Orientation.Horizontal,
@@ -459,6 +479,7 @@ class MultiPointerDraggableTest {
                                onStop = { horizontalStopped = true },
                            )
                        },
                        dispatcher = defaultDispatcher,
                    )
            )
        }
@@ -539,6 +560,7 @@ class MultiPointerDraggableTest {
            touchSlop = LocalViewConfiguration.current.touchSlop
            Box(
                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
                    .nestedScrollDispatcher()
                    .multiPointerDraggable(
                        orientation = Orientation.Vertical,
                        enabled = { true },
@@ -557,6 +579,7 @@ class MultiPointerDraggableTest {
                                onStop = { /* do nothing */ },
                            )
                        },
                        dispatcher = defaultDispatcher,
                    )
            ) {}
        }
@@ -587,4 +610,113 @@ class MultiPointerDraggableTest {

        assertThat(started).isTrue()
    }

    @Test
    fun multiPointerNestedScrollDispatcher() {
        val size = 200f
        val middle = Offset(size / 2f, size / 2f)
        var touchSlop = 0f

        var consumedOnPreScroll = 0f

        var availableOnPreScroll = Float.MIN_VALUE
        var availableOnPostScroll = Float.MIN_VALUE
        var availableOnPreFling = Float.MIN_VALUE
        var availableOnPostFling = Float.MIN_VALUE

        var consumedOnDrag = 0f
        var consumedOnDragStop = 0f

        val connection =
            object : NestedScrollConnection {
                override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                    availableOnPreScroll = available.y
                    return Offset(0f, consumedOnPreScroll)
                }

                override fun onPostScroll(
                    consumed: Offset,
                    available: Offset,
                    source: NestedScrollSource
                ): Offset {
                    availableOnPostScroll = available.y
                    return Offset.Zero
                }

                override suspend fun onPreFling(available: Velocity): Velocity {
                    availableOnPreFling = available.y
                    return Velocity.Zero
                }

                override suspend fun onPostFling(
                    consumed: Velocity,
                    available: Velocity
                ): Velocity {
                    availableOnPostFling = available.y
                    return Velocity.Zero
                }
            }

        rule.setContent {
            touchSlop = LocalViewConfiguration.current.touchSlop
            Box(
                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
                    .nestedScroll(connection)
                    .nestedScrollDispatcher()
                    .multiPointerDraggable(
                        orientation = Orientation.Vertical,
                        enabled = { true },
                        startDragImmediately = { false },
                        onDragStarted = { _, _, _ ->
                            SimpleDragController(
                                onDrag = { consumedOnDrag = it },
                                onStop = { consumedOnDragStop = it },
                            )
                        },
                        dispatcher = defaultDispatcher,
                    )
            )
        }

        fun startDrag() {
            rule.onRoot().performTouchInput {
                down(middle)
                moveBy(Offset(0f, touchSlop))
            }
        }

        fun continueDrag() {
            rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) }
        }

        fun stopDrag() {
            rule.onRoot().performTouchInput { up() }
        }

        startDrag()

        continueDrag()
        assertThat(availableOnPreScroll).isEqualTo(touchSlop)
        assertThat(consumedOnDrag).isEqualTo(touchSlop)
        assertThat(availableOnPostScroll).isEqualTo(0f)

        // Parent node consumes half of the gesture
        consumedOnPreScroll = touchSlop / 2f
        continueDrag()
        assertThat(availableOnPreScroll).isEqualTo(touchSlop)
        assertThat(consumedOnDrag).isEqualTo(touchSlop / 2f)
        assertThat(availableOnPostScroll).isEqualTo(0f)

        // Parent node consumes the gesture
        consumedOnPreScroll = touchSlop
        continueDrag()
        assertThat(availableOnPreScroll).isEqualTo(touchSlop)
        assertThat(consumedOnDrag).isEqualTo(0f)
        assertThat(availableOnPostScroll).isEqualTo(0f)

        // Parent node can intercept the velocity on stop
        stopDrag()
        assertThat(availableOnPreFling).isEqualTo(consumedOnDragStop)
        assertThat(availableOnPostFling).isEqualTo(0f)
    }
}