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

Commit 1dab822b authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Compute transformations when transitions start

This CL changes when we compute the transformations associated to a
transition.

Before this CL, we assumed that transition specs would never change and
their associated transformations were cached. Because we want user code
to provide different transformations depending on app state (see
b/308961608#comment13 for more context), this CL changes that and
computes the transformations of a transition right when the transition
is started.

This CL also makes SceneTransitionLayoutImpl more leightweight by
removing the need for Snapshot-backed data structure, making sure that
we rely on those only for data that is read during composition or
written during composition and outside effects.

Test: PlatformComposeSceneTransitionLayoutTests
Bug: 315763713
Flag: N/A
Change-Id: Id432d5febdc2d84a9bdaab1a9704957d377756ad
parent 9d708f7e
Loading
Loading
Loading
Loading
+6 −5
Original line number Diff line number Diff line
@@ -174,8 +174,8 @@ private fun <T> computeValue(
    lerp: (T, T, Float) -> T,
    canOverflow: Boolean,
): T {
    val state = layoutImpl.state.transitionState
    if (state !is TransitionState.Transition || !layoutImpl.isTransitionReady(state)) {
    val transition = layoutImpl.state.currentTransition
    if (transition == null || !layoutImpl.isTransitionReady(transition)) {
        return sharedValue.value
    }

@@ -191,10 +191,11 @@ private fun <T> computeValue(
        return value as Element.SharedValue<T>
    }

    val fromValue = sceneValue(state.fromScene)
    val toValue = sceneValue(state.toScene)
    val fromValue = sceneValue(transition.fromScene)
    val toValue = sceneValue(transition.toScene)
    return if (fromValue != null && toValue != null) {
        val progress = if (canOverflow) state.progress else state.progress.coerceIn(0f, 1f)
        val progress =
            if (canOverflow) transition.progress else transition.progress.coerceIn(0f, 1f)
        lerp(fromValue.value, toValue.value, progress)
    } else if (fromValue != null) {
        fromValue.value
+47 −36
Original line number Diff line number Diff line
@@ -28,11 +28,11 @@ import kotlinx.coroutines.launch
 * the currently running transition, if there is one.
 */
internal fun CoroutineScope.animateToScene(
    layoutImpl: SceneTransitionLayoutImpl,
    layoutState: SceneTransitionLayoutStateImpl,
    target: SceneKey,
) {
    val state = layoutImpl.state.transitionState
    if (state.currentScene == target) {
    val transitionState = layoutState.transitionState
    if (transitionState.currentScene == target) {
        // This can happen in 3 different situations, for which there isn't anything else to do:
        //  1. There is no ongoing transition and [target] is already the current scene.
        //  2. The user is swiping to [target] from another scene and released their pointer such
@@ -44,44 +44,47 @@ internal fun CoroutineScope.animateToScene(
        return
    }

    when (state) {
        is TransitionState.Idle -> animate(layoutImpl, target)
    when (transitionState) {
        is TransitionState.Idle -> animate(layoutState, target)
        is TransitionState.Transition -> {
            // A transition is currently running: first check whether `transition.toScene` or
            // `transition.fromScene` is the same as our target scene, in which case the transition
            // can be accelerated or reversed to end up in the target state.

            if (state.toScene == target) {
            if (transitionState.toScene == target) {
                // The user is currently swiping to [target] but didn't release their pointer yet:
                // animate the progress to `1`.

                check(state.fromScene == state.currentScene)
                val progress = state.progress
                check(transitionState.fromScene == transitionState.currentScene)
                val progress = transitionState.progress
                if ((1f - progress).absoluteValue < ProgressVisibilityThreshold) {
                    // The transition is already finished (progress ~= 1): no need to animate.
                    layoutImpl.state.transitionState = TransitionState.Idle(state.currentScene)
                    // The transition is already finished (progress ~= 1): no need to animate. We
                    // finish the current transition early to make sure that the current state
                    // change is committed.
                    layoutState.finishTransition(transitionState, transitionState.currentScene)
                } else {
                    // The transition is in progress: start the canned animation at the same
                    // progress as it was in.
                    // TODO(b/290184746): Also take the current velocity into account.
                    animate(layoutImpl, target, startProgress = progress)
                    animate(layoutState, target, startProgress = progress)
                }

                return
            }

            if (state.fromScene == target) {
            if (transitionState.fromScene == target) {
                // There is a transition from [target] to another scene: simply animate the same
                // transition progress to `0`.

                check(state.toScene == state.currentScene)
                val progress = state.progress
                check(transitionState.toScene == transitionState.currentScene)
                val progress = transitionState.progress
                if (progress.absoluteValue < ProgressVisibilityThreshold) {
                    // The transition is at progress ~= 0: no need to animate.
                    layoutImpl.state.transitionState = TransitionState.Idle(state.currentScene)
                    // The transition is at progress ~= 0: no need to animate.We finish the current
                    // transition early to make sure that the current state change is committed.
                    layoutState.finishTransition(transitionState, transitionState.currentScene)
                } else {
                    // TODO(b/290184746): Also take the current velocity into account.
                    animate(layoutImpl, target, startProgress = progress, reversed = true)
                    animate(layoutState, target, startProgress = progress, reversed = true)
                }

                return
@@ -89,27 +92,22 @@ internal fun CoroutineScope.animateToScene(

            // Generic interruption; the current transition is neither from or to [target].
            // TODO(b/290930950): Better handle interruptions here.
            animate(layoutImpl, target)
            animate(layoutState, target)
        }
    }
}

private fun CoroutineScope.animate(
    layoutImpl: SceneTransitionLayoutImpl,
    layoutState: SceneTransitionLayoutStateImpl,
    target: SceneKey,
    startProgress: Float = 0f,
    reversed: Boolean = false,
) {
    val fromScene = layoutImpl.state.transitionState.currentScene
    val fromScene = layoutState.transitionState.currentScene
    val isUserInput =
        (layoutImpl.state.transitionState as? TransitionState.Transition)?.isInitiatedByUserInput
        (layoutState.transitionState as? TransitionState.Transition)?.isInitiatedByUserInput
            ?: false

    val animationSpec = layoutImpl.transitions.transitionSpec(fromScene, target).spec
    val visibilityThreshold =
        (animationSpec as? SpringSpec)?.visibilityThreshold ?: ProgressVisibilityThreshold
    val animatable = Animatable(startProgress, visibilityThreshold = visibilityThreshold)

    val targetProgress = if (reversed) 0f else 1f
    val transition =
        if (reversed) {
@@ -119,7 +117,6 @@ private fun CoroutineScope.animate(
                currentScene = target,
                isInitiatedByUserInput = isUserInput,
                isUserInputOngoing = false,
                animatable = animatable,
            )
        } else {
            OneOffTransition(
@@ -128,21 +125,27 @@ private fun CoroutineScope.animate(
                currentScene = target,
                isInitiatedByUserInput = isUserInput,
                isUserInputOngoing = false,
                animatable = animatable,
            )
        }

    // Change the current layout state to use this new transition.
    layoutImpl.state.transitionState = transition
    // Change the current layout state to start this new transition. This will compute the
    // TransformationSpec associated to this transition, which we need to initialize the Animatable
    // that will actually animate it.
    layoutState.startTransition(transition)

    // The transformation now contains the spec that we should use to instantiate the Animatable.
    val animationSpec = layoutState.transformationSpec.progressSpec
    val visibilityThreshold =
        (animationSpec as? SpringSpec)?.visibilityThreshold ?: ProgressVisibilityThreshold
    val animatable =
        Animatable(startProgress, visibilityThreshold = visibilityThreshold).also {
            transition.animatable = it
        }

    // Animate the progress to its target value.
    launch {
        animatable.animateTo(targetProgress, animationSpec)

        // Unless some other external state change happened, the state should now be idle.
        if (layoutImpl.state.transitionState == transition) {
            layoutImpl.state.transitionState = TransitionState.Idle(target)
        }
        layoutState.finishTransition(transition, target)
    }
}

@@ -152,8 +155,16 @@ private class OneOffTransition(
    override val currentScene: SceneKey,
    override val isInitiatedByUserInput: Boolean,
    override val isUserInputOngoing: Boolean,
    private val animatable: Animatable<Float, AnimationVector1D>,
) : TransitionState.Transition(fromScene, toScene) {
    /**
     * The animatable used to animate this transition.
     *
     * Note: This is lateinit because we need to first create this Transition object so that
     * [SceneTransitionLayoutState] can compute the transformations and animation spec associated to
     * it, which is need to initialize this Animatable.
     */
    lateinit var animatable: Animatable<Float, AnimationVector1D>

    override val progress: Float
        get() = animatable.value
}
+39 −55
Original line number Diff line number Diff line
@@ -181,15 +181,11 @@ private data class ElementModifier(
}

internal class ElementNode(
    layoutImpl: SceneTransitionLayoutImpl,
    scene: Scene,
    element: Element,
    sceneValues: Element.TargetValues,
    private var layoutImpl: SceneTransitionLayoutImpl,
    private var scene: Scene,
    private var element: Element,
    private var sceneValues: Element.TargetValues,
) : Modifier.Node(), DrawModifierNode {
    private var layoutImpl: SceneTransitionLayoutImpl = layoutImpl
    private var scene: Scene = scene
    private var element: Element = element
    private var sceneValues: Element.TargetValues = sceneValues

    override fun onAttach() {
        super.onAttach()
@@ -283,26 +279,27 @@ private fun shouldDrawElement(
    scene: Scene,
    element: Element,
): Boolean {
    val state = layoutImpl.state.transitionState
    val transition = layoutImpl.state.currentTransition

    // Always draw the element if there is no ongoing transition or if the element is not shared.
    if (
        state !is TransitionState.Transition ||
            !layoutImpl.isTransitionReady(state) ||
            state.fromScene !in element.sceneValues ||
            state.toScene !in element.sceneValues
        transition == null ||
            !layoutImpl.isTransitionReady(transition) ||
            transition.fromScene !in element.sceneValues ||
            transition.toScene !in element.sceneValues
    ) {
        return true
    }

    val sharedTransformation = sharedElementTransformation(layoutImpl, state, element.key)
    val sharedTransformation =
        sharedElementTransformation(layoutImpl.state, transition, element.key)
    if (sharedTransformation?.enabled == false) {
        return true
    }

    return shouldDrawOrComposeSharedElement(
        layoutImpl,
        state,
        transition,
        scene.key,
        element.key,
        sharedTransformation,
@@ -331,21 +328,21 @@ internal fun shouldDrawOrComposeSharedElement(
}

private fun isSharedElementEnabled(
    layoutImpl: SceneTransitionLayoutImpl,
    layoutState: SceneTransitionLayoutStateImpl,
    transition: TransitionState.Transition,
    element: ElementKey,
): Boolean {
    return sharedElementTransformation(layoutImpl, transition, element)?.enabled ?: true
    return sharedElementTransformation(layoutState, transition, element)?.enabled ?: true
}

internal fun sharedElementTransformation(
    layoutImpl: SceneTransitionLayoutImpl,
    layoutState: SceneTransitionLayoutStateImpl,
    transition: TransitionState.Transition,
    element: ElementKey,
): SharedElementTransformation? {
    val spec = layoutImpl.transitions.transitionSpec(transition.fromScene, transition.toScene)
    val sharedInFromScene = spec.transformations(element, transition.fromScene).shared
    val sharedInToScene = spec.transformations(element, transition.toScene).shared
    val transformationSpec = layoutState.transformationSpec
    val sharedInFromScene = transformationSpec.transformations(element, transition.fromScene).shared
    val sharedInToScene = transformationSpec.transformations(element, transition.toScene).shared

    // The sharedElement() transformation must either be null or be the same in both scenes.
    if (sharedInFromScene != sharedInToScene) {
@@ -371,13 +368,9 @@ private fun isElementOpaque(
    scene: Scene,
    sceneValues: Element.TargetValues,
): Boolean {
    val state = layoutImpl.state.transitionState

    if (state !is TransitionState.Transition) {
        return true
    }
    val transition = layoutImpl.state.currentTransition ?: return true

    if (!layoutImpl.isTransitionReady(state)) {
    if (!layoutImpl.isTransitionReady(transition)) {
        val lastValue =
            sceneValues.lastValues.alpha.takeIf { it != Element.AlphaUnspecified }
                ?: element.lastSharedValues.alpha.takeIf { it != Element.AlphaUnspecified } ?: 1f
@@ -385,8 +378,8 @@ private fun isElementOpaque(
        return lastValue == 1f
    }

    val fromScene = state.fromScene
    val toScene = state.toScene
    val fromScene = transition.fromScene
    val toScene = transition.toScene
    val fromValues = element.sceneValues[fromScene]
    val toValues = element.sceneValues[toScene]

@@ -395,14 +388,11 @@ private fun isElementOpaque(
    }

    val isSharedElement = fromValues != null && toValues != null
    if (isSharedElement && isSharedElementEnabled(layoutImpl, state, element.key)) {
    if (isSharedElement && isSharedElementEnabled(layoutImpl.state, transition, element.key)) {
        return true
    }

    return layoutImpl.transitions
        .transitionSpec(fromScene, toScene)
        .transformations(element.key, scene.key)
        .alpha == null
    return layoutImpl.state.transformationSpec.transformations(element.key, scene.key).alpha == null
}

/**
@@ -607,24 +597,22 @@ private inline fun <T> computeValue(
    lastValue: () -> T,
    lerp: (T, T, Float) -> T,
): T {
    val state = layoutImpl.state.transitionState

    // There is no ongoing transition.
    if (state !is TransitionState.Transition) {
        // Even if this element SceneTransitionLayout is not animated, the layout itself might be
        // animated (e.g. by another parent SceneTransitionLayout), in which case this element still
        // need to participate in the layout phase.
        return currentValue()
    }
    val transition =
        layoutImpl.state.currentTransition
        // There is no ongoing transition. Even if this element SceneTransitionLayout is not
        // animated, the layout itself might be animated (e.g. by another parent
        // SceneTransitionLayout), in which case this element still need to participate in the
        // layout phase.
        ?: return currentValue()

    // A transition was started but it's not ready yet (not all elements have been composed/laid
    // out yet). Use the last value that was set, to make sure elements don't unexpectedly jump.
    if (!layoutImpl.isTransitionReady(state)) {
    if (!layoutImpl.isTransitionReady(transition)) {
        return lastValue()
    }

    val fromScene = state.fromScene
    val toScene = state.toScene
    val fromScene = transition.fromScene
    val toScene = transition.toScene
    val fromValues = element.sceneValues[fromScene]
    val toValues = element.sceneValues[toScene]

@@ -638,21 +626,17 @@ private inline fun <T> computeValue(
    // TODO(b/290184746): Support non linear shared paths as well as a way to make sure that shared
    // elements follow the finger direction.
    val isSharedElement = fromValues != null && toValues != null
    if (isSharedElement && isSharedElementEnabled(layoutImpl, state, element.key)) {
    if (isSharedElement && isSharedElementEnabled(layoutImpl.state, transition, element.key)) {
        val start = sceneValue(fromValues!!)
        val end = sceneValue(toValues!!)

        // Make sure we don't read progress if values are the same and we don't need to interpolate,
        // so we don't invalidate the phase where this is read.
        return if (start == end) start else lerp(start, end, state.progress)
        return if (start == end) start else lerp(start, end, transition.progress)
    }

    val transformation =
        transformation(
            layoutImpl.transitions
                .transitionSpec(fromScene, toScene)
                .transformations(element.key, scene.key)
        )
        transformation(layoutImpl.state.transformationSpec.transformations(element.key, scene.key))
        // If there is no transformation explicitly associated to this element value, let's use
        // the value given by the system (like the current position and size given by the layout
        // pass).
@@ -675,7 +659,7 @@ private inline fun <T> computeValue(
            scene,
            element,
            sceneValues,
            state,
            transition,
            idleValue,
        )

@@ -685,7 +669,7 @@ private inline fun <T> computeValue(
        return targetValue
    }

    val progress = state.progress
    val progress = transition.progress
    // TODO(b/290184746): Make sure that we don't overflow transformations associated to a range.
    val rangeProgress = transformation.range?.progress(progress) ?: progress

+9 −13
Original line number Diff line number Diff line
@@ -120,17 +120,13 @@ private fun shouldComposeMovableElement(
    scene: SceneKey,
    element: Element,
): Boolean {
    val transitionState = layoutImpl.state.transitionState

    // If we are idle, there is only one [scene] that is composed so we can compose our movable
    // content here.
    if (transitionState is TransitionState.Idle) {
        check(transitionState.currentScene == scene)
        return true
    }

    val fromScene = (transitionState as TransitionState.Transition).fromScene
    val toScene = transitionState.toScene
    val transition =
        layoutImpl.state.currentTransition
        // If we are idle, there is only one [scene] that is composed so we can compose our
        // movable content here.
        ?: return true
    val fromScene = transition.fromScene
    val toScene = transition.toScene

    val fromReady = layoutImpl.isSceneReady(fromScene)
    val toReady = layoutImpl.isSceneReady(toScene)
@@ -181,10 +177,10 @@ private fun shouldComposeMovableElement(

    return shouldDrawOrComposeSharedElement(
        layoutImpl,
        transitionState,
        transition,
        scene,
        element.key,
        sharedElementTransformation(layoutImpl, transitionState, element.key),
        sharedElementTransformation(layoutImpl.state, transition, element.key),
    )
}

+2 −1
Original line number Diff line number Diff line
@@ -179,7 +179,8 @@ private fun scenePriorityNestedScrollConnection(
    bottomOrRightBehavior: NestedScrollBehavior,
) =
    SceneNestedScrollHandler(
            gestureHandler = layoutImpl.gestureHandler(orientation = orientation),
            layoutImpl = layoutImpl,
            orientation = orientation,
            topOrLeftBehavior = topOrLeftBehavior,
            bottomOrRightBehavior = bottomOrRightBehavior,
        )
Loading