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

Commit ad920d71 authored by Omar Miatello's avatar Omar Miatello Committed by Android (Google) Code Review
Browse files

Merge changes I3d04ea2a,I7ade358f,Id0ef8ab9 into main

* changes:
  Improvements for stopping animations in SceneGestureHandler
  PriorityNestedScrollConnection simplification: remove onPostFling method
  SceneGestureHandler simplification: remove overscroll method
parents 9397c86d 20f85cba
Loading
Loading
Loading
Loading
+131 −120
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@

package com.android.compose.animation.scene

import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
@@ -130,10 +131,12 @@ class SceneGestureHandler(
    internal val currentScene: Scene
        get() = layoutImpl.scene(transitionState.currentScene)

    internal val isDrivingTransition
    @VisibleForTesting
    val isDrivingTransition
        get() = transitionState == swipeTransition

    internal var isAnimatingOffset
    @VisibleForTesting
    var isAnimatingOffset
        get() = swipeTransition.isAnimatingOffset
        private set(value) {
            swipeTransition.isAnimatingOffset = value
@@ -157,17 +160,21 @@ class SceneGestureHandler(
    internal fun onDragStarted() {
        if (isDrivingTransition) {
            // This [transition] was already driving the animation: simply take over it.
            if (isAnimatingOffset) {
                // Stop animating and start from where the current offset. Setting the animation job
                // to `null` will effectively cancel the animation.
            // Stop animating and start from where the current offset.
            swipeTransition.stopOffsetAnimation()
                swipeTransition.dragOffset = swipeTransition.offsetAnimatable.value
            }

            return
        }

        val transition = transitionState
        if (transition is TransitionState.Transition) {
            // TODO(b/290184746): Better handle interruptions here if state != idle.
            Log.w(
                TAG,
                "start from TransitionState.Transition is not fully supported: from" +
                    " ${transition.fromScene} to ${transition.toScene} " +
                    "(progress ${transition.progress})"
            )
        }

        val fromScene = currentScene

@@ -196,6 +203,8 @@ class SceneGestureHandler(
    }

    internal fun onDrag(delta: Float) {
        if (delta == 0f) return

        swipeTransition.dragOffset += delta

        // First check transition.fromScene should be changed for the case where the user quickly
@@ -293,48 +302,77 @@ class SceneGestureHandler(
            return
        }

        fun animateTo(targetScene: Scene, targetOffset: Float) {
            // If the effective current scene changed, it should be reflected right now in the
            // current scene state, even before the settle animation is ongoing. That way all the
            // swipeables and back handlers will be refreshed and the user can for instance quickly
            // swipe vertically from A => B then horizontally from B => C, or swipe from A => B then
            // immediately go back B => A.
            if (targetScene != swipeTransition._currentScene) {
                swipeTransition._currentScene = targetScene
                layoutImpl.onChangeScene(targetScene.key)
            }

            animateOffset(
                initialVelocity = velocity,
                targetOffset = targetOffset,
                targetScene = targetScene.key
            )
        }

        val fromScene = swipeTransition._fromScene
        if (canChangeScene) {
            // If we are halfway between two scenes, we check what the target will be based on the
            // velocity and offset of the transition, then we launch the animation.

            val toScene = swipeTransition._toScene
            if (fromScene == toScene) {
                // We were not animating.
        if (swipeTransition._fromScene == swipeTransition._toScene) {
            transitionState = TransitionState.Idle(swipeTransition._fromScene.key)
                transitionState = TransitionState.Idle(fromScene.key)
                return
            }

            // Compute the destination scene (and therefore offset) to settle in.
        val targetOffset: Float
        val targetScene: Scene
            val offset = swipeTransition.dragOffset
            val distance = swipeTransition.distance
            if (
            canChangeScene &&
                shouldCommitSwipe(
                    offset,
                    distance,
                    velocity,
                    wasCommitted = swipeTransition._currentScene == swipeTransition._toScene,
                    wasCommitted = swipeTransition._currentScene == toScene,
                )
            ) {
            targetOffset = distance
            targetScene = swipeTransition._toScene
                // Animate to the next scene
                animateTo(targetScene = toScene, targetOffset = distance)
            } else {
            targetOffset = 0f
            targetScene = swipeTransition._fromScene
                // Animate to the initial scene
                animateTo(targetScene = fromScene, targetOffset = 0f)
            }
        } else {
            // We are doing an overscroll animation between scenes. In this case, we can also start
            // from the idle position.

        // If the effective current scene changed, it should be reflected right now in the current
        // scene state, even before the settle animation is ongoing. That way all the swipeables and
        // back handlers will be refreshed and the user can for instance quickly swipe vertically
        // from A => B then horizontally from B => C, or swipe from A => B then immediately go back
        // B => A.
        if (targetScene != swipeTransition._currentScene) {
            swipeTransition._currentScene = targetScene
            layoutImpl.onChangeScene(targetScene.key)
        }
            val startFromIdlePosition = swipeTransition.dragOffset == 0f

        animateOffset(
            initialVelocity = velocity,
            targetOffset = targetOffset,
            targetScene = targetScene.key
        )
            if (startFromIdlePosition) {
                // If there is a next scene, we start the overscroll animation.
                val target = fromScene.findTargetSceneAndDistance(velocity)
                val isValidTarget = target.distance != 0f && target.sceneKey != fromScene.key
                if (isValidTarget) {
                    swipeTransition._toScene = layoutImpl.scene(target.sceneKey)
                    swipeTransition._distance = target.distance

                    animateTo(targetScene = fromScene, targetOffset = 0f)
                } else {
                    // We will not animate
                    transitionState = TransitionState.Idle(fromScene.key)
                }
            } else {
                // We were between two scenes: animate to the initial scene.
                animateTo(targetScene = fromScene, targetOffset = 0f)
            }
        }
    }

    /**
@@ -378,8 +416,7 @@ class SceneGestureHandler(
        targetScene: SceneKey,
    ) {
        swipeTransition.startOffsetAnimation {
            coroutineScope
                .launch {
            coroutineScope.launch {
                if (!isAnimatingOffset) {
                    swipeTransition.offsetAnimatable.snapTo(swipeTransition.dragOffset)
                }
@@ -395,57 +432,17 @@ class SceneGestureHandler(
                    initialVelocity = initialVelocity,
                )

                    // Now that the animation is done, the state should be idle. Note that if the
                    // state was changed since this animation started, some external code changed it
                    // and we shouldn't do anything here. Note also that this job will be cancelled
                    // in the case where the user intercepts this swipe.
                isAnimatingOffset = false

                // Now that the animation is done, the state should be idle. Note that if the state
                // was changed since this animation started, some external code changed it and we
                // shouldn't do anything here. Note also that this job will be cancelled in the case
                // where the user intercepts this swipe.
                if (isDrivingTransition) {
                    transitionState = TransitionState.Idle(targetScene)
                }
            }
                .also { it.invokeOnCompletion { isAnimatingOffset = false } }
        }
    }

    internal fun animateOverscroll(velocity: Velocity): Velocity {
        val velocityAmount =
            when (orientation) {
                Orientation.Vertical -> velocity.y
                Orientation.Horizontal -> velocity.x
            }

        if (velocityAmount == 0f) {
            // There is no remaining velocity
            return Velocity.Zero
        }

        val fromScene = currentScene
        val target = fromScene.findTargetSceneAndDistance(velocityAmount)
        val isValidTarget = target.distance != 0f && target.sceneKey != fromScene.key

        if (!isValidTarget || isDrivingTransition) {
            // We have not found a valid target or we are already in a transition
            return Velocity.Zero
        }

        swipeTransition._currentScene = fromScene
        swipeTransition._fromScene = fromScene
        swipeTransition._toScene = layoutImpl.scene(target.sceneKey)
        swipeTransition._distance = target.distance
        swipeTransition.absoluteDistance = target.distance.absoluteValue
        swipeTransition.stopOffsetAnimation()
        swipeTransition.dragOffset = 0f

        transitionState = swipeTransition

        animateOffset(
            initialVelocity = velocityAmount,
            targetOffset = 0f,
            targetScene = fromScene.key
        )

        // The animateOffset animation consumes any remaining velocity.
        return velocity
    }

    private class SwipeTransition(initialScene: Scene) : TransitionState.Transition {
@@ -500,6 +497,11 @@ class SceneGestureHandler(
        /** Stops any ongoing offset animation. */
        fun stopOffsetAnimation() {
            offsetAnimationJob?.cancel()

            if (isAnimatingOffset) {
                isAnimatingOffset = false
                dragOffset = offsetAnimatable.value
            }
        }

        /** The absolute distance between [fromScene] and [toScene]. */
@@ -513,6 +515,10 @@ class SceneGestureHandler(
        val distance: Float
            get() = _distance
    }

    companion object {
        private const val TAG = "SceneGestureHandler"
    }
}

private class SceneDraggableHandler(
@@ -566,6 +572,15 @@ class SceneNestedScrollHandler(
        // moving on to the next scene.
        var gestureStartedOnNestedChild = false

        fun findNextScene(amount: Float): SceneKey? {
            val fromScene = gestureHandler.currentScene
            return when {
                amount < 0f -> fromScene.upOrLeft(gestureHandler.orientation)
                amount > 0f -> fromScene.downOrRight(gestureHandler.orientation)
                else -> null
            }
        }

        return PriorityNestedScrollConnection(
            canStartPreScroll = { offsetAvailable, offsetBeforeStart ->
                gestureStartedOnNestedChild = offsetBeforeStart != Offset.Zero
@@ -586,15 +601,16 @@ class SceneNestedScrollHandler(
                if (amount == 0f) return@PriorityNestedScrollConnection false

                gestureStartedOnNestedChild = offsetBeforeStart != Offset.Zero
                nextScene = findNextScene(amount)
                nextScene != null
            },
            canStartPostFling = { velocityAvailable ->
                val amount = velocityAvailable.toAmount()
                if (amount == 0f) return@PriorityNestedScrollConnection false

                val fromScene = gestureHandler.currentScene
                nextScene =
                    when {
                        amount < 0f -> fromScene.upOrLeft(gestureHandler.orientation)
                        amount > 0f -> fromScene.downOrRight(gestureHandler.orientation)
                        else -> null
                    }

                // We could start an overscroll animation
                gestureStartedOnNestedChild = true
                nextScene = findNextScene(amount)
                nextScene != null
            },
            canContinueScroll = { priorityScene == gestureHandler.swipeTransitionToScene.key },
@@ -622,11 +638,6 @@ class SceneNestedScrollHandler(
                // The onDragStopped animation consumes any remaining velocity.
                velocityAvailable
            },
            onPostFling = { velocityAvailable ->
                // If there is any velocity left, we can try running an overscroll animation between
                // scenes.
                gestureHandler.animateOverscroll(velocity = velocityAvailable)
            },
        )
    }
}
+9 −2
Original line number Diff line number Diff line
@@ -34,11 +34,11 @@ import androidx.compose.ui.unit.Velocity
class PriorityNestedScrollConnection(
    private val canStartPreScroll: (offsetAvailable: Offset, offsetBeforeStart: Offset) -> Boolean,
    private val canStartPostScroll: (offsetAvailable: Offset, offsetBeforeStart: Offset) -> Boolean,
    private val canStartPostFling: (velocityAvailable: Velocity) -> Boolean,
    private val canContinueScroll: () -> Boolean,
    private val onStart: () -> Unit,
    private val onScroll: (offsetAvailable: Offset) -> Offset,
    private val onStop: (velocityAvailable: Velocity) -> Velocity,
    private val onPostFling: suspend (velocityAvailable: Velocity) -> Velocity,
) : NestedScrollConnection {

    /** In priority mode [onPreScroll] events are first consumed by the parent, via [onScroll]. */
@@ -102,7 +102,14 @@ class PriorityNestedScrollConnection(
    }

    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return onPostFling(available)
        if (!canStartPostFling(available)) {
            return Velocity.Zero
        }

        onPriorityStart(available = Offset.Zero)

        // This is the last event of a scroll gesture.
        return onPriorityStop(available)
    }

    /** Method to call before destroying the object or to reset the initial state. */
+32 −1
Original line number Diff line number Diff line
@@ -47,7 +47,7 @@ class SceneGestureHandlerTest {
            scene(SceneC) { Text("SceneC") }
        }

        private val sceneGestureHandler =
        val sceneGestureHandler =
            SceneGestureHandler(
                layoutImpl =
                    SceneTransitionLayoutImpl(
@@ -81,6 +81,10 @@ class SceneGestureHandlerTest {
            coroutineScope.testScheduler.advanceUntilIdle()
        }

        fun runCurrent() {
            coroutineScope.testScheduler.runCurrent()
        }

        fun assertScene(currentScene: SceneKey, isIdle: Boolean) {
            val idleMsg = if (isIdle) "MUST" else "MUST NOT"
            assertWithMessage("transitionState $idleMsg be Idle")
@@ -164,6 +168,33 @@ class SceneGestureHandlerTest {
        assertScene(currentScene = SceneA, isIdle = true)
    }

    @Test
    fun startGestureDuringAnimatingOffset_shouldImmediatelyStopTheAnimation() = runGestureTest {
        draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero)
        assertScene(currentScene = SceneA, isIdle = false)

        draggable.onDelta(pixels = deltaInPixels10)
        assertScene(currentScene = SceneA, isIdle = false)

        draggable.onDragStopped(
            coroutineScope = coroutineScope,
            velocity = velocityThreshold,
        )

        // The stop animation is not started yet
        assertThat(sceneGestureHandler.isAnimatingOffset).isFalse()

        runCurrent()

        assertThat(sceneGestureHandler.isAnimatingOffset).isTrue()
        assertThat(sceneGestureHandler.isDrivingTransition).isTrue()
        assertScene(currentScene = SceneC, isIdle = false)

        // Start a new gesture while the offset is animating
        draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero)
        assertThat(sceneGestureHandler.isAnimatingOffset).isFalse()
    }

    @Test
    fun onInitialPreScroll_doNotChangeState() = runGestureTest {
        nestedScroll.onPreScroll(available = offsetY10, source = NestedScrollSource.Drag)
+26 −7
Original line number Diff line number Diff line
@@ -32,19 +32,19 @@ import org.junit.runner.RunWith
class PriorityNestedScrollConnectionTest {
    private var canStartPreScroll = false
    private var canStartPostScroll = false
    private var canStartPostFling = false
    private var canContinueScroll = false
    private var isStarted = false
    private var lastScroll: Offset? = null
    private var returnOnScroll = Offset.Zero
    private var lastStop: Velocity? = null
    private var returnOnStop = Velocity.Zero
    private var lastOnPostFling: Velocity? = null
    private var returnOnPostFling = Velocity.Zero

    private val scrollConnection =
        PriorityNestedScrollConnection(
            canStartPreScroll = { _, _ -> canStartPreScroll },
            canStartPostScroll = { _, _ -> canStartPostScroll },
            canStartPostFling = { canStartPostFling },
            canContinueScroll = { canContinueScroll },
            onStart = { isStarted = true },
            onScroll = {
@@ -55,10 +55,6 @@ class PriorityNestedScrollConnectionTest {
                lastStop = it
                returnOnStop
            },
            onPostFling = {
                lastOnPostFling = it
                returnOnPostFling
            },
        )

    private val offset1 = Offset(1f, 1f)
@@ -185,11 +181,34 @@ class PriorityNestedScrollConnectionTest {

    @Test
    fun receive_onPostFling() = runTest {
        canStartPostFling = true

        scrollConnection.onPostFling(
            consumed = velocity1,
            available = velocity2,
        )

        assertThat(lastOnPostFling).isEqualTo(velocity2)
        assertThat(lastStop).isEqualTo(velocity2)
    }

    @Test
    fun step1_priorityModeShouldStartOnlyOnPostFling() = runTest {
        canStartPostFling = true

        scrollConnection.onPreScroll(available = Offset.Zero, source = NestedScrollSource.Drag)
        assertThat(isStarted).isEqualTo(false)

        scrollConnection.onPostScroll(
            consumed = Offset.Zero,
            available = Offset.Zero,
            source = NestedScrollSource.Drag
        )
        assertThat(isStarted).isEqualTo(false)

        scrollConnection.onPreFling(available = Velocity.Zero)
        assertThat(isStarted).isEqualTo(false)

        scrollConnection.onPostFling(consumed = Velocity.Zero, available = Velocity.Zero)
        assertThat(isStarted).isEqualTo(true)
    }
}