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

Commit 93920ee4 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Use NestedDraggable to handle STL gestures

This CL makes SceneTransitionLayout use Modifier.nestedDraggable(). This
is a pure refactoring that should be invisible to consumers, and should
make it easier to reason about nested scrolling and gesture handling in
STL.

Bug: 378470603
Test: atest PlatformComposeSceneTransitionLayoutTests
Flag: com.android.systemui.scene_container
Change-Id: Ib0af0b57a00401170596c1b4d140068cd6a8dadf
parent 4a5b3c4d
Loading
Loading
Loading
Loading
+150 −229
Original line number Diff line number Diff line
@@ -16,62 +16,29 @@

package com.android.compose.animation.scene

import androidx.compose.foundation.OverscrollEffect
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.util.fastCoerceIn
import com.android.compose.animation.scene.content.Content
import com.android.compose.animation.scene.content.state.TransitionState.Companion.DistanceUnspecified
import com.android.compose.nestedscroll.OnStopScope
import com.android.compose.nestedscroll.PriorityNestedScrollConnection
import com.android.compose.nestedscroll.ScrollController
import com.android.compose.animation.scene.effect.GestureEffect
import com.android.compose.gesture.NestedDraggable
import com.android.compose.ui.util.SpaceVectorConverter
import kotlin.math.absoluteValue
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

internal interface DraggableHandler {
    /**
     * Start a drag with the given [pointersDown] and [overSlop].
     *
     * The returned [DragController] should be used to continue or stop the drag.
     */
    fun onDragStarted(pointersDown: PointersInfo.PointersDown?, overSlop: Float): DragController
}

/**
 * The [DragController] provides control over the transition between two scenes through the [onDrag]
 * and [onStop] methods.
 */
internal interface DragController {
    /**
     * Drag the current scene by [delta] pixels.
     *
     * @param delta The distance to drag the scene in pixels.
     * @return the consumed [delta]
     */
    fun onDrag(delta: Float): Float

    /**
     * Stop the current drag with the given [velocity].
     *
     * @param velocity The velocity of the drag when it stopped.
     * @return the consumed [velocity] when the animation complete
     */
    suspend fun onStop(velocity: Float): Float

    /** Cancels the current drag. */
    fun onCancel()
}

internal class DraggableHandlerImpl(
internal class DraggableHandler(
    internal val layoutImpl: SceneTransitionLayoutImpl,
    internal val orientation: Orientation,
) : DraggableHandler {
    private val gestureEffectProvider: (ContentKey) -> GestureEffect,
) : NestedDraggable {
    /** The [DraggableHandler] can only have one active [DragController] at a time. */
    private var dragController: DragControllerImpl? = null

@@ -92,20 +59,36 @@ internal class DraggableHandlerImpl(
    internal val positionalThreshold
        get() = with(layoutImpl.density) { 56.dp.toPx() }

    /** The [OverscrollEffect] that should consume any overscroll on this draggable. */
    internal val overscrollEffect: OverscrollEffect = DelegatingOverscrollEffect()

    override fun shouldStartDrag(change: PointerInputChange): Boolean {
        return layoutImpl.swipeDetector.detectSwipe(change)
    }

    override fun shouldConsumeNestedScroll(sign: Float): Boolean {
        return this.enabled()
    }

    override fun onDragStarted(
        pointersDown: PointersInfo.PointersDown?,
        overSlop: Float,
    ): DragController {
        check(overSlop != 0f)
        val swipes = computeSwipes(pointersDown)
        position: Offset,
        sign: Float,
        pointersDown: Int,
        pointerType: PointerType?,
    ): NestedDraggable.Controller {
        check(sign != 0f)
        val swipes = computeSwipes(position, pointersDown, pointerType)
        val fromContent = layoutImpl.contentForUserActions()

        swipes.updateSwipesResults(fromContent)
        val upOrLeft = swipes.upOrLeftResult
        val downOrRight = swipes.downOrRightResult
        val result =
            (if (overSlop < 0f) swipes.upOrLeftResult else swipes.downOrRightResult)
                // As we were unable to locate a valid target scene, the initial SwipeAnimation
                // cannot be defined. Consequently, a simple NoOp Controller will be returned.
                ?: return NoOpDragController
            when {
                sign < 0 -> upOrLeft ?: downOrRight
                sign >= 0f -> downOrRight ?: upOrLeft
                else -> null
            } ?: return NoOpDragController

        val swipeAnimation = createSwipeAnimation(swipes, result)
        return updateDragController(swipes, swipeAnimation)
@@ -143,20 +126,109 @@ internal class DraggableHandlerImpl(
        )
    }

    private fun computeSwipes(pointersDown: PointersInfo.PointersDown?): Swipes {
        val fromSource = pointersDown?.let { resolveSwipeSource(it.startedPosition) }
    private fun computeSwipes(
        position: Offset,
        pointersDown: Int,
        pointerType: PointerType?,
    ): Swipes {
        val fromSource = resolveSwipeSource(position)
        return Swipes(
            upOrLeft = resolveSwipe(orientation, isUpOrLeft = true, pointersDown, fromSource),
            downOrRight = resolveSwipe(orientation, isUpOrLeft = false, pointersDown, fromSource),
            upOrLeft =
                resolveSwipe(orientation, isUpOrLeft = true, fromSource, pointersDown, pointerType),
            downOrRight =
                resolveSwipe(orientation, isUpOrLeft = false, fromSource, pointersDown, pointerType),
        )
    }

    /**
     * An implementation of [OverscrollEffect] that delegates to the correct content effect
     * depending on the current scene/overlays and transition.
     */
    private inner class DelegatingOverscrollEffect :
        OverscrollEffect, SpaceVectorConverter by SpaceVectorConverter(orientation) {
        private var currentContent: ContentKey? = null
        private var currentDelegate: GestureEffect? = null
            set(value) {
                field?.let { delegate ->
                    if (delegate.isInProgress) {
                        layoutImpl.animationScope.launch { delegate.ensureApplyToFlingIsCalled() }
                    }
                }

                field = value
            }

        override val isInProgress: Boolean
            get() = currentDelegate?.isInProgress ?: false

        override fun applyToScroll(
            delta: Offset,
            source: NestedScrollSource,
            performScroll: (Offset) -> Offset,
        ): Offset {
            val available = delta.toFloat()
            if (available == 0f) {
                return performScroll(delta)
            }

            ensureDelegateIsNotNull(available)
            val delegate = checkNotNull(currentDelegate)
            return if (delegate.node.node.isAttached) {
                delegate.applyToScroll(delta, source, performScroll)
            } else {
                performScroll(delta)
            }
        }

        override suspend fun applyToFling(
            velocity: Velocity,
            performFling: suspend (Velocity) -> Velocity,
        ) {
            val available = velocity.toFloat()
            if (available != 0f && isDrivingTransition) {
                ensureDelegateIsNotNull(available)
            }

            // Note: we set currentDelegate and currentContent to null before calling performFling,
            // which can suspend and take a lot of time.
            val delegate = currentDelegate
            currentDelegate = null
            currentContent = null

            if (delegate != null && delegate.node.node.isAttached) {
                delegate.applyToFling(velocity, performFling)
            } else {
                performFling(velocity)
            }
        }

        private fun ensureDelegateIsNotNull(direction: Float) {
            require(direction != 0f)
            if (isInProgress) {
                return
            }

            val content =
                if (isDrivingTransition) {
                    checkNotNull(dragController).swipeAnimation.contentByDirection(direction)
                } else {
                    layoutImpl.contentForUserActions().key
                }

            if (content != currentContent) {
                currentContent = content
                currentDelegate = gestureEffectProvider(content)
            }
        }
    }
}

private fun resolveSwipe(
    orientation: Orientation,
    isUpOrLeft: Boolean,
    pointersDown: PointersInfo.PointersDown?,
    fromSource: SwipeSource.Resolved?,
    pointersDown: Int,
    pointerType: PointerType?,
): Swipe.Resolved {
    return Swipe.Resolved(
        direction =
@@ -175,28 +247,22 @@ private fun resolveSwipe(
                        SwipeDirection.Resolved.Down
                    }
            },
        // If the number of pointers is not specified, 1 is assumed.
        pointerCount = pointersDown?.count ?: 1,
        // Resolves the pointer type only if all pointers are of the same type.
        pointersType = pointersDown?.countByType?.keys?.singleOrNull(),
        pointerCount = pointersDown,
        pointerType = pointerType,
        fromSource = fromSource,
    )
}

/** @param swipes The [Swipes] associated to the current gesture. */
private class DragControllerImpl(
    private val draggableHandler: DraggableHandlerImpl,
    private val draggableHandler: DraggableHandler,
    val swipes: Swipes,
    var swipeAnimation: SwipeAnimation<*>,
) : DragController, SpaceVectorConverter by SpaceVectorConverter(draggableHandler.orientation) {
) :
    NestedDraggable.Controller,
    SpaceVectorConverter by SpaceVectorConverter(draggableHandler.orientation) {
    val layoutState = draggableHandler.layoutImpl.state

    val overscrollableContent: OverscrollableContent =
        when (draggableHandler.orientation) {
            Orientation.Vertical -> draggableHandler.layoutImpl.verticalOverscrollableContent
            Orientation.Horizontal -> draggableHandler.layoutImpl.horizontalOverscrollableContent
        }

    /**
     * Whether this handle is active. If this returns false, calling [onDrag] and [onStop] will do
     * nothing.
@@ -231,57 +297,25 @@ private class DragControllerImpl(
        if (delta == 0f || !isDrivingTransition || initialAnimation.isAnimatingOffset()) {
            return 0f
        }

        // swipeAnimation can change during the gesture, we want to always use the initial reference
        // during the whole drag gesture.
        return dragWithOverscroll(delta, animation = initialAnimation)
    }

    private fun <T : ContentKey> dragWithOverscroll(
        delta: Float,
        animation: SwipeAnimation<T>,
    ): Float {
        require(delta != 0f) { "delta should not be 0" }
        var overscrollEffect = overscrollableContent.currentOverscrollEffect

        // If we're already overscrolling, continue with the current effect for a smooth finish.
        if (overscrollEffect == null || !overscrollEffect.isInProgress) {
            // Otherwise, determine the target content (toContent or fromContent) for the new
            // overscroll effect based on the gesture's direction.
            val content = animation.contentByDirection(delta)
            overscrollEffect = overscrollableContent.applyOverscrollEffectOn(content)
        }

        // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
        if (!overscrollEffect.node.node.isAttached) {
            return drag(delta, animation)
        }

        return overscrollEffect
            .applyToScroll(
                delta = delta.toOffset(),
                source = NestedScrollSource.UserInput,
                performScroll = {
                    val preScrollAvailable = it.toFloat()
                    drag(preScrollAvailable, animation).toOffset()
                },
            )
            .toFloat()
        return drag(delta, animation = initialAnimation)
    }

    private fun <T : ContentKey> drag(delta: Float, animation: SwipeAnimation<T>): Float {
        if (delta == 0f) return 0f

        val distance = animation.distance()
        val previousOffset = animation.dragOffset
        val desiredOffset = previousOffset + delta
        val desiredProgress = animation.computeProgress(desiredOffset)

        // Note: the distance could be negative if fromContent is above or to the left of toContent.
        val newOffset =
            when {
                distance == DistanceUnspecified ||
                    animation.contentTransition.isWithinProgressRange(desiredProgress) ->
                    desiredOffset
                distance == DistanceUnspecified -> {
                    // Consume everything so that we don't overscroll, this will be coerced later
                    // when the distance is defined.
                    delta
                }
                distance > 0f -> desiredOffset.fastCoerceIn(0f, distance)
                else -> desiredOffset.fastCoerceIn(distance, 0f)
            }
@@ -290,12 +324,8 @@ private class DragControllerImpl(
        return newOffset - previousOffset
    }

    override suspend fun onStop(velocity: Float): Float {
        // To ensure that any ongoing animation completes gracefully and avoids an undefined state,
        // we execute the actual `onStop` logic in a non-cancellable context. This prevents the
        // coroutine from being cancelled prematurely, which could interrupt the animation.
        // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
        return withContext(NonCancellable) { onStop(velocity, swipeAnimation) }
    override suspend fun onDragStopped(velocity: Float, awaitFling: suspend () -> Unit): Float {
        return onStop(velocity, swipeAnimation, awaitFling)
    }

    private suspend fun <T : ContentKey> onStop(
@@ -306,6 +336,7 @@ private class DragControllerImpl(
        // callbacks (like onAnimationCompleted()) might incorrectly finish a new transition that
        // replaced this one.
        swipeAnimation: SwipeAnimation<T>,
        awaitFling: suspend () -> Unit,
    ): Float {
        // The state was changed since the drag started; don't do anything.
        if (!isDrivingTransition || swipeAnimation.isAnimatingOffset()) {
@@ -337,33 +368,7 @@ private class DragControllerImpl(
                fromContent
            }

        val overscrollEffect = overscrollableContent.applyOverscrollEffectOn(targetContent)

        // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
        if (!overscrollEffect.node.node.isAttached) {
            return swipeAnimation.animateOffset(velocity, targetContent)
        }

        val overscrollCompletable = CompletableDeferred<Unit>()
        try {
            overscrollEffect.applyToFling(
                velocity = velocity.toVelocity(),
                performFling = {
                    val velocityLeft = it.toFloat()
                    swipeAnimation
                        .animateOffset(
                            velocityLeft,
                            targetContent,
                            overscrollCompletable = overscrollCompletable,
                        )
                        .toVelocity()
                },
            )
        } finally {
            overscrollCompletable.complete(Unit)
        }

        return velocity
        return swipeAnimation.animateOffset(velocity, targetContent, awaitFling = awaitFling)
    }

    /**
@@ -408,10 +413,6 @@ private class DragControllerImpl(
                isCloserToTarget()
        }
    }

    override fun onCancel() {
        swipeAnimation.contentTransition.coroutineScope.launch { onStop(velocity = 0f) }
    }
}

/** The [Swipe] associated to a given fromScene, startedPosition and pointersDown. */
@@ -453,15 +454,15 @@ internal class Swipes(val upOrLeft: Swipe.Resolved, val downOrRight: Swipe.Resol
                    (actionSwipe.fromSource != null &&
                        actionSwipe.fromSource != swipe.fromSource) ||
                    // The action requires a specific pointerType.
                    (actionSwipe.pointersType != null &&
                        actionSwipe.pointersType != swipe.pointersType)
                    (actionSwipe.pointerType != null &&
                        actionSwipe.pointerType != swipe.pointerType)
            ) {
                // This action is not eligible.
                return@forEach
            }

            val sameFromSource = actionSwipe.fromSource == swipe.fromSource
            val samePointerType = actionSwipe.pointersType == swipe.pointersType
            val samePointerType = actionSwipe.pointerType == swipe.pointerType
            // Prioritize actions with a perfect match.
            if (sameFromSource && samePointerType) {
                return actionResult
@@ -496,82 +497,6 @@ internal class Swipes(val upOrLeft: Swipe.Resolved, val downOrRight: Swipe.Resol
    }
}

internal class NestedScrollHandlerImpl(
    private val draggableHandler: DraggableHandlerImpl,
    private val pointersInfoOwner: PointersInfoOwner,
) {
    val connection: PriorityNestedScrollConnection = nestedScrollConnection()

    private fun nestedScrollConnection(): PriorityNestedScrollConnection {
        var lastPointersDown: PointersInfo.PointersDown? = null

        return PriorityNestedScrollConnection(
            orientation = draggableHandler.orientation,
            canStartPreScroll = { _, _, _ -> false },
            canStartPostScroll = { offsetAvailable, _, _ ->
                if (offsetAvailable == 0f) return@PriorityNestedScrollConnection false

                lastPointersDown =
                    when (val info = pointersInfoOwner.pointersInfo()) {
                        PointersInfo.MouseWheel -> {
                            // Do not support mouse wheel interactions
                            return@PriorityNestedScrollConnection false
                        }

                        is PointersInfo.PointersDown -> info
                        null -> null
                    }

                draggableHandler.layoutImpl
                    .contentForUserActions()
                    .shouldEnableSwipes(draggableHandler.orientation)
            },
            onStart = { firstScroll ->
                scrollController(
                    dragController =
                        draggableHandler.onDragStarted(
                            pointersDown = lastPointersDown,
                            overSlop = firstScroll,
                        ),
                    pointersInfoOwner = pointersInfoOwner,
                )
            },
        )
    }
}

private fun scrollController(
    dragController: DragController,
    pointersInfoOwner: PointersInfoOwner,
): ScrollController {
    return object : ScrollController {
        override fun onScroll(deltaScroll: Float, source: NestedScrollSource): Float {
            if (pointersInfoOwner.pointersInfo() == PointersInfo.MouseWheel) {
                // Do not support mouse wheel interactions
                return 0f
            }

            return dragController.onDrag(delta = deltaScroll)
        }

        override suspend fun OnStopScope.onStop(initialVelocity: Float): Float {
            return dragController.onStop(velocity = initialVelocity)
        }

        override fun onCancel() {
            dragController.onCancel()
        }

        /**
         * We need to maintain scroll priority even if the scene transition can no longer consume
         * the scroll gesture to allow us to return to the previous scene.
         */
        override fun canCancelScroll(available: Float, consumed: Float) = false

        override fun canStopOnPreFling() = true
    }
}

/**
 * The number of pixels below which there won't be a visible difference in the transition and from
 * which the animation can stop.
@@ -580,12 +505,8 @@ private fun scrollController(
// account instead.
internal const val OffsetVisibilityThreshold = 0.5f

private object NoOpDragController : DragController {
private object NoOpDragController : NestedDraggable.Controller {
    override fun onDrag(delta: Float) = 0f

    override suspend fun onStop(velocity: Float) = 0f

    override fun onCancel() {
        /* do nothing */
    }
    override suspend fun onDragStopped(velocity: Float, awaitFling: suspend () -> Unit): Float = 0f
}
+0 −678

File deleted.

Preview size limit exceeded, changes collapsed.

+18 −16
Original line number Diff line number Diff line
@@ -457,7 +457,7 @@ data class Swipe
private constructor(
    val direction: SwipeDirection,
    val pointerCount: Int = 1,
    val pointersType: PointerType? = null,
    val pointerType: PointerType? = null,
    val fromSource: SwipeSource? = null,
) : UserAction() {
    companion object {
@@ -470,46 +470,46 @@ private constructor(

        fun Left(
            pointerCount: Int = 1,
            pointersType: PointerType? = null,
            pointerType: PointerType? = null,
            fromSource: SwipeSource? = null,
        ) = Swipe(SwipeDirection.Left, pointerCount, pointersType, fromSource)
        ) = Swipe(SwipeDirection.Left, pointerCount, pointerType, fromSource)

        fun Up(
            pointerCount: Int = 1,
            pointersType: PointerType? = null,
            pointerType: PointerType? = null,
            fromSource: SwipeSource? = null,
        ) = Swipe(SwipeDirection.Up, pointerCount, pointersType, fromSource)
        ) = Swipe(SwipeDirection.Up, pointerCount, pointerType, fromSource)

        fun Right(
            pointerCount: Int = 1,
            pointersType: PointerType? = null,
            pointerType: PointerType? = null,
            fromSource: SwipeSource? = null,
        ) = Swipe(SwipeDirection.Right, pointerCount, pointersType, fromSource)
        ) = Swipe(SwipeDirection.Right, pointerCount, pointerType, fromSource)

        fun Down(
            pointerCount: Int = 1,
            pointersType: PointerType? = null,
            pointerType: PointerType? = null,
            fromSource: SwipeSource? = null,
        ) = Swipe(SwipeDirection.Down, pointerCount, pointersType, fromSource)
        ) = Swipe(SwipeDirection.Down, pointerCount, pointerType, fromSource)

        fun Start(
            pointerCount: Int = 1,
            pointersType: PointerType? = null,
            pointerType: PointerType? = null,
            fromSource: SwipeSource? = null,
        ) = Swipe(SwipeDirection.Start, pointerCount, pointersType, fromSource)
        ) = Swipe(SwipeDirection.Start, pointerCount, pointerType, fromSource)

        fun End(
            pointerCount: Int = 1,
            pointersType: PointerType? = null,
            pointerType: PointerType? = null,
            fromSource: SwipeSource? = null,
        ) = Swipe(SwipeDirection.End, pointerCount, pointersType, fromSource)
        ) = Swipe(SwipeDirection.End, pointerCount, pointerType, fromSource)
    }

    override fun resolve(layoutDirection: LayoutDirection): UserAction.Resolved {
        return Resolved(
            direction = direction.resolve(layoutDirection),
            pointerCount = pointerCount,
            pointersType = pointersType,
            pointerType = pointerType,
            fromSource = fromSource?.resolve(layoutDirection),
        )
    }
@@ -519,7 +519,7 @@ private constructor(
        val direction: SwipeDirection.Resolved,
        val pointerCount: Int,
        val fromSource: SwipeSource.Resolved?,
        val pointersType: PointerType?,
        val pointerType: PointerType?,
    ) : UserAction.Resolved()
}

@@ -724,6 +724,7 @@ internal fun SceneTransitionLayoutForTesting(
                density = density,
                layoutDirection = layoutDirection,
                swipeSourceDetector = swipeSourceDetector,
                swipeDetector = swipeDetector,
                transitionInterceptionThreshold = transitionInterceptionThreshold,
                builder = builder,
                animationScope = animationScope,
@@ -767,8 +768,9 @@ internal fun SceneTransitionLayoutForTesting(
        layoutImpl.density = density
        layoutImpl.layoutDirection = layoutDirection
        layoutImpl.swipeSourceDetector = swipeSourceDetector
        layoutImpl.swipeDetector = swipeDetector
        layoutImpl.transitionInterceptionThreshold = transitionInterceptionThreshold
    }

    layoutImpl.Content(modifier, swipeDetector)
    layoutImpl.Content(modifier)
}
+29 −41

File changed.

Preview size limit exceeded, changes collapsed.

+24 −34

File changed.

Preview size limit exceeded, changes collapsed.

Loading