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

Commit 2153fff8 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere Committed by Android (Google) Code Review
Browse files

Merge "Introduce TransitionState.Transition.finish() (1/2)" into main

parents e25a6774 0924833e
Loading
Loading
Loading
Loading
+16 −7
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.SpringSpec
import kotlin.math.absoluteValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch

/**
@@ -147,13 +148,16 @@ private fun CoroutineScope.animate(
        }

    // Animate the progress to its target value.
    transition.job =
        launch { animatable.animateTo(targetProgress, animationSpec) }
        .invokeOnCompletion {
            // Settle the state to Idle(target). Note that this will do nothing if this transition
            // was replaced/interrupted by another one, and this also runs if this coroutine is
            // cancelled, i.e. if [this] coroutine scope is cancelled.
            .apply {
                invokeOnCompletion {
                    // Settle the state to Idle(target). Note that this will do nothing if this
                    // transition was replaced/interrupted by another one, and this also runs if
                    // this coroutine is cancelled, i.e. if [this] coroutine scope is cancelled.
                    layoutState.finishTransition(transition, target)
                }
            }

    return transition
}
@@ -174,8 +178,13 @@ private class OneOffTransition(
     */
    lateinit var animatable: Animatable<Float, AnimationVector1D>

    /** The job that is animating [animatable]. */
    lateinit var job: Job

    override val progress: Float
        get() = animatable.value

    override fun finish(): Job = job
}

// TODO(b/290184746): Compute a good default visibility threshold that depends on the layout size
+98 −32
Original line number Diff line number Diff line
@@ -97,9 +97,15 @@ internal class DraggableHandlerImpl(
            return false
        }

        val swipeTransition = dragController.swipeTransition

        // Don't intercept a transition that is finishing.
        if (swipeTransition.isFinishing) {
            return false
        }

        // Only intercept the current transition if one of the 2 swipes results is also a transition
        // between the same pair of scenes.
        val swipeTransition = dragController.swipeTransition
        val fromScene = swipeTransition._currentScene
        val swipes = computeSwipes(fromScene, startedPosition, pointersDown = 1)
        val (upOrLeft, downOrRight) = swipes.computeSwipesResults(fromScene)
@@ -150,15 +156,24 @@ internal class DraggableHandlerImpl(

        val fromScene = layoutImpl.scene(transitionState.currentScene)
        val swipes = computeSwipes(fromScene, startedPosition, pointersDown)
        val result = swipes.findUserActionResult(fromScene, overSlop, true)

        // As we were unable to locate a valid target scene, the initial SwipeTransition cannot be
        // defined. Consequently, a simple NoOp Controller will be returned.
        if (result == null) return NoOpDragController
        val result =
            swipes.findUserActionResult(fromScene, overSlop, true)
            // As we were unable to locate a valid target scene, the initial SwipeTransition
            // cannot be defined. Consequently, a simple NoOp Controller will be returned.
            ?: return NoOpDragController

        return updateDragController(
            swipes = swipes,
            swipeTransition = SwipeTransition(fromScene, result, swipes, layoutImpl, orientation)
            swipeTransition =
                SwipeTransition(
                    layoutImpl.state,
                    coroutineScope,
                    fromScene,
                    result,
                    swipes,
                    layoutImpl,
                    orientation,
                )
        )
    }

@@ -278,7 +293,7 @@ private class DragControllerImpl(
     * @return the consumed delta
     */
    override fun onDrag(delta: Float) {
        if (delta == 0f || !isDrivingTransition) return
        if (delta == 0f || !isDrivingTransition || swipeTransition.isFinishing) return
        swipeTransition.dragOffset += delta

        val (fromScene, acceleratedOffset) =
@@ -306,6 +321,8 @@ private class DragControllerImpl(
        ) {
            val swipeTransition =
                SwipeTransition(
                        layoutState = layoutState,
                        coroutineScope = draggableHandler.coroutineScope,
                        fromScene = fromScene,
                        result = result,
                        swipes = swipes,
@@ -356,15 +373,9 @@ private class DragControllerImpl(
        }
    }

    private fun snapToScene(scene: SceneKey) {
        if (!isDrivingTransition) return
        swipeTransition.cancelOffsetAnimation()
        layoutState.finishTransition(swipeTransition, idleScene = scene)
    }

    override fun onStop(velocity: Float, canChangeScene: Boolean) {
        // The state was changed since the drag started; don't do anything.
        if (!isDrivingTransition) {
        if (!isDrivingTransition || swipeTransition.isFinishing) {
            return
        }

@@ -390,7 +401,7 @@ private class DragControllerImpl(
                coroutineScope = draggableHandler.coroutineScope,
                initialVelocity = velocity,
                targetOffset = targetOffset,
                onAnimationCompleted = { snapToScene(targetScene.key) }
                targetScene = targetScene.key,
            )
        }

@@ -452,12 +463,14 @@ private class DragControllerImpl(
                val result = swipes.findUserActionResultStrict(velocity)
                if (result == null) {
                    // We will not animate
                    snapToScene(fromScene.key)
                    swipeTransition.snapToScene(fromScene.key)
                    return
                }

                val newSwipeTransition =
                    SwipeTransition(
                            layoutState = layoutState,
                            coroutineScope = draggableHandler.coroutineScope,
                            fromScene = fromScene,
                            result = result,
                            swipes = swipes,
@@ -515,6 +528,8 @@ private class DragControllerImpl(
}

private fun SwipeTransition(
    layoutState: BaseSceneTransitionLayoutState,
    coroutineScope: CoroutineScope,
    fromScene: Scene,
    result: UserActionResult,
    swipes: Swipes,
@@ -531,6 +546,8 @@ private fun SwipeTransition(
        }

    return SwipeTransition(
        layoutState = layoutState,
        coroutineScope = coroutineScope,
        key = result.transitionKey,
        _fromScene = fromScene,
        _toScene = layoutImpl.scene(result.toScene),
@@ -542,6 +559,8 @@ private fun SwipeTransition(

private fun SwipeTransition(old: SwipeTransition): SwipeTransition {
    return SwipeTransition(
            layoutState = old.layoutState,
            coroutineScope = old.coroutineScope,
            key = old.key,
            _fromScene = old._fromScene,
            _toScene = old._toScene,
@@ -556,6 +575,8 @@ private fun SwipeTransition(old: SwipeTransition): SwipeTransition {
}

private class SwipeTransition(
    val layoutState: BaseSceneTransitionLayoutState,
    val coroutineScope: CoroutineScope,
    val key: TransitionKey?,
    val _fromScene: Scene,
    val _toScene: Scene,
@@ -609,6 +630,10 @@ private class SwipeTransition(

    private var lastDistance = DistanceUnspecified

    /** Whether [TransitionState.Transition.finish] was called on this transition. */
    var isFinishing = false
        private set

    /**
     * The signed distance between [fromScene] and [toScene]. It is negative if [fromScene] is above
     * or to the left of [toScene].
@@ -640,9 +665,9 @@ private class SwipeTransition(
    }

    /** Ends any previous [offsetAnimation] and runs the new [animation]. */
    private fun startOffsetAnimation(animation: () -> OffsetAnimation) {
    private fun startOffsetAnimation(animation: () -> OffsetAnimation): OffsetAnimation {
        cancelOffsetAnimation()
        offsetAnimation = animation()
        return animation().also { offsetAnimation = it }
    }

    /** Cancel any ongoing offset animation. */
@@ -661,26 +686,67 @@ private class SwipeTransition(
        coroutineScope: CoroutineScope,
        initialVelocity: Float,
        targetOffset: Float,
        onAnimationCompleted: () -> Unit,
    ) {
        startOffsetAnimation {
        targetScene: SceneKey,
    ): OffsetAnimation {
        return startOffsetAnimation {
            val animatable = Animatable(dragOffset, OffsetVisibilityThreshold)
            val job =
                coroutineScope.launch {
                coroutineScope
                    .launch {
                        animatable.animateTo(
                            targetValue = targetOffset,
                            animationSpec = swipeSpec,
                            initialVelocity = initialVelocity,
                        )

                    onAnimationCompleted()
                    }
                    // Make sure that we settle to target scene at the end of the animation or if
                    // the animation is cancelled.
                    .apply { invokeOnCompletion { snapToScene(targetScene) } }

            OffsetAnimation(animatable, job)
        }
    }

    private class OffsetAnimation(
    fun snapToScene(scene: SceneKey) {
        if (layoutState.transitionState != this) return
        cancelOffsetAnimation()
        layoutState.finishTransition(this, idleScene = scene)
    }

    override fun finish(): Job {
        if (isFinishing) return requireNotNull(offsetAnimation).job
        isFinishing = true

        // If we were already animating the offset, simply return the job.
        offsetAnimation?.let {
            return it.job
        }

        // Animate to the current scene.
        val targetScene = currentScene
        val targetOffset =
            if (targetScene == fromScene) {
                0f
            } else {
                val distance = distance()
                check(distance != DistanceUnspecified) {
                    "targetScene != fromScene but distance is unspecified"
                }
                distance
            }

        val animation =
            animateOffset(
                coroutineScope = coroutineScope,
                initialVelocity = 0f,
                targetOffset = targetOffset,
                targetScene = currentScene,
            )
        check(offsetAnimation == animation)
        return animation.job
    }

    internal class OffsetAnimation(
        /** The animatable used to animate the offset. */
        val animatable: Animatable<Float, AnimationVector1D>,

+17 −6
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@ import com.android.compose.animation.scene.transition.link.LinkedTransition
import com.android.compose.animation.scene.transition.link.StateLink
import kotlin.math.absoluteValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel

/**
@@ -188,13 +189,8 @@ sealed interface TransitionState {
        val fromScene: SceneKey,

        /** The scene this transition is going to. Can't be the same as fromScene */
        val toScene: SceneKey
        val toScene: SceneKey,
    ) : TransitionState {

        init {
            check(fromScene != toScene)
        }

        /**
         * The progress of the transition. This is usually in the `[0; 1]` range, but it can also be
         * less than `0` or greater than `1` when using transitions with a spring AnimationSpec or
@@ -208,6 +204,21 @@ sealed interface TransitionState {
        /** Whether user input is currently driving the transition. */
        abstract val isUserInputOngoing: Boolean

        init {
            check(fromScene != toScene)
        }

        /**
         * Force this transition to finish and animate to [currentScene], so that this transition
         * progress will settle to either 0% (if [currentScene] == [fromScene]) or 100% (if
         * [currentScene] == [toScene]) in a finite amount of time.
         *
         * @return the [Job] that animates the progress to [currentScene]. It can be used to wait
         *   until the animation is complete or cancel it to snap to [currentScene]. Calling
         *   [finish] multiple times will return the same [Job].
         */
        abstract fun finish(): Job

        /**
         * Whether we are transitioning. If [from] or [to] is empty, we will also check that they
         * match the scenes we are animating from and/or to.
+3 −0
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ package com.android.compose.animation.scene.transition.link

import com.android.compose.animation.scene.SceneKey
import com.android.compose.animation.scene.TransitionState
import kotlinx.coroutines.Job

/** A linked transition which is driven by a [originalTransition]. */
internal class LinkedTransition(
@@ -43,4 +44,6 @@ internal class LinkedTransition(

    override val progress: Float
        get() = originalTransition.progress

    override fun finish(): Job = originalTransition.finish()
}
+45 −0
Original line number Diff line number Diff line
@@ -39,6 +39,7 @@ import com.android.compose.test.runMonotonicClockTest
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import org.junit.Test
import org.junit.runner.RunWith
@@ -891,6 +892,50 @@ class DraggableHandlerTest {
        assertThat(transitionState).isNotSameInstanceAs(firstTransition)
    }

    @Test
    fun finish() = runGestureTest {
        // Start at scene C.
        navigateToSceneC()

        // Swipe up from the middle to transition to scene B.
        val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
        onDragStarted(startedPosition = middle, overSlop = up(0.1f))
        assertTransition(fromScene = SceneC, toScene = SceneB, isUserInputOngoing = true)

        // The current transition can be intercepted.
        assertThat(draggableHandler.shouldImmediatelyIntercept(middle)).isTrue()

        // Finish the transition.
        val transition = transitionState as Transition
        val job = transition.finish()
        assertTransition(isUserInputOngoing = false)

        // The current transition can not be intercepted anymore.
        assertThat(draggableHandler.shouldImmediatelyIntercept(middle)).isFalse()

        // Calling finish() multiple times returns the same Job.
        assertThat(transition.finish()).isSameInstanceAs(job)
        assertThat(transition.finish()).isSameInstanceAs(job)
        assertThat(transition.finish()).isSameInstanceAs(job)

        // We can join the job to wait for the animation to end.
        assertTransition()
        job.join()
        assertIdle(SceneC)
    }

    @Test
    fun finish_cancelled() = runGestureTest {
        // Swipe up from the middle to transition to scene B.
        val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
        onDragStarted(startedPosition = middle, overSlop = up(0.1f))
        assertTransition(fromScene = SceneA, toScene = SceneB)

        // Finish the transition and cancel the returned job.
        (transitionState as Transition).finish().cancelAndJoin()
        assertIdle(SceneA)
    }

    @Test
    fun blockTransition() = runGestureTest {
        assertIdle(SceneA)
Loading