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

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

Merge changes from topic "stl-interrupt" into main

* changes:
  Take the progress velocity into account during triggered transitions
  Introduce InterruptionHandler to STL
  Clear interruption values when necessary
parents 2975c017 2975d22e
Loading
Loading
Loading
Loading
+68 −24
Original line number Original line Diff line number Diff line
@@ -47,8 +47,11 @@ internal fun CoroutineScope.animateToScene(
    }
    }


    return when (transitionState) {
    return when (transitionState) {
        is TransitionState.Idle -> animate(layoutState, target, transitionKey)
        is TransitionState.Idle ->
            animate(layoutState, target, transitionKey, isInitiatedByUserInput = false)
        is TransitionState.Transition -> {
        is TransitionState.Transition -> {
            val isInitiatedByUserInput = transitionState.isInitiatedByUserInput

            // A transition is currently running: first check whether `transition.toScene` or
            // 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
            // `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.
            // can be accelerated or reversed to end up in the target state.
@@ -68,8 +71,14 @@ internal fun CoroutineScope.animateToScene(
                } else {
                } else {
                    // The transition is in progress: start the canned animation at the same
                    // The transition is in progress: start the canned animation at the same
                    // progress as it was in.
                    // progress as it was in.
                    // TODO(b/290184746): Also take the current velocity into account.
                    animate(
                    animate(layoutState, target, transitionKey, startProgress = progress)
                        layoutState,
                        target,
                        transitionKey,
                        isInitiatedByUserInput,
                        initialProgress = progress,
                        initialVelocity = transitionState.progressVelocity,
                    )
                }
                }
            } else if (transitionState.fromScene == target) {
            } else if (transitionState.fromScene == target) {
                // There is a transition from [target] to another scene: simply animate the same
                // There is a transition from [target] to another scene: simply animate the same
@@ -83,19 +92,52 @@ internal fun CoroutineScope.animateToScene(
                    layoutState.finishTransition(transitionState, target)
                    layoutState.finishTransition(transitionState, target)
                    null
                    null
                } else {
                } else {
                    // TODO(b/290184746): Also take the current velocity into account.
                    animate(
                    animate(
                        layoutState,
                        layoutState,
                        target,
                        target,
                        transitionKey,
                        transitionKey,
                        startProgress = progress,
                        isInitiatedByUserInput,
                        initialProgress = progress,
                        initialVelocity = transitionState.progressVelocity,
                        reversed = true,
                        reversed = true,
                    )
                    )
                }
                }
            } else {
            } else {
                // Generic interruption; the current transition is neither from or to [target].
                // Generic interruption; the current transition is neither from or to [target].
                // TODO(b/290930950): Better handle interruptions here.
                val interruptionResult =
                animate(layoutState, target, transitionKey)
                    layoutState.transitions.interruptionHandler.onInterruption(
                        transitionState,
                        target,
                    )
                        ?: DefaultInterruptionHandler.onInterruption(transitionState, target)

                val animateFrom = interruptionResult.animateFrom
                if (
                    animateFrom != transitionState.toScene &&
                        animateFrom != transitionState.fromScene
                ) {
                    error(
                        "InterruptionResult.animateFrom must be either the fromScene " +
                            "(${transitionState.fromScene.debugName}) or the toScene " +
                            "(${transitionState.toScene.debugName}) of the interrupted transition."
                    )
                }

                // If we were A => B and that we are now animating A => C, add a transition B => A
                // to the list of transitions so that B "disappears back to A".
                val chain = interruptionResult.chain
                if (chain && animateFrom != transitionState.currentScene) {
                    animateToScene(layoutState, animateFrom, transitionKey = null)
                }

                animate(
                    layoutState,
                    target,
                    transitionKey,
                    isInitiatedByUserInput,
                    fromScene = animateFrom,
                    chain = chain,
                )
            }
            }
        }
        }
    }
    }
@@ -103,32 +145,31 @@ internal fun CoroutineScope.animateToScene(


private fun CoroutineScope.animate(
private fun CoroutineScope.animate(
    layoutState: BaseSceneTransitionLayoutState,
    layoutState: BaseSceneTransitionLayoutState,
    target: SceneKey,
    targetScene: SceneKey,
    transitionKey: TransitionKey?,
    transitionKey: TransitionKey?,
    startProgress: Float = 0f,
    isInitiatedByUserInput: Boolean,
    initialProgress: Float = 0f,
    initialVelocity: Float = 0f,
    reversed: Boolean = false,
    reversed: Boolean = false,
    fromScene: SceneKey = layoutState.transitionState.currentScene,
    chain: Boolean = true,
): TransitionState.Transition {
): TransitionState.Transition {
    val fromScene = layoutState.transitionState.currentScene
    val isUserInput =
        (layoutState.transitionState as? TransitionState.Transition)?.isInitiatedByUserInput
            ?: false

    val targetProgress = if (reversed) 0f else 1f
    val targetProgress = if (reversed) 0f else 1f
    val transition =
    val transition =
        if (reversed) {
        if (reversed) {
            OneOffTransition(
            OneOffTransition(
                fromScene = target,
                fromScene = targetScene,
                toScene = fromScene,
                toScene = fromScene,
                currentScene = target,
                currentScene = targetScene,
                isInitiatedByUserInput = isUserInput,
                isInitiatedByUserInput = isInitiatedByUserInput,
                isUserInputOngoing = false,
                isUserInputOngoing = false,
            )
            )
        } else {
        } else {
            OneOffTransition(
            OneOffTransition(
                fromScene = fromScene,
                fromScene = fromScene,
                toScene = target,
                toScene = targetScene,
                currentScene = target,
                currentScene = targetScene,
                isInitiatedByUserInput = isUserInput,
                isInitiatedByUserInput = isInitiatedByUserInput,
                isUserInputOngoing = false,
                isUserInputOngoing = false,
            )
            )
        }
        }
@@ -136,7 +177,7 @@ private fun CoroutineScope.animate(
    // Change the current layout state to start this new transition. This will compute the
    // 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
    // TransformationSpec associated to this transition, which we need to initialize the Animatable
    // that will actually animate it.
    // that will actually animate it.
    layoutState.startTransition(transition, transitionKey)
    layoutState.startTransition(transition, transitionKey, chain)


    // The transition now contains the transformation spec that we should use to instantiate the
    // The transition now contains the transformation spec that we should use to instantiate the
    // Animatable.
    // Animatable.
@@ -144,19 +185,19 @@ private fun CoroutineScope.animate(
    val visibilityThreshold =
    val visibilityThreshold =
        (animationSpec as? SpringSpec)?.visibilityThreshold ?: ProgressVisibilityThreshold
        (animationSpec as? SpringSpec)?.visibilityThreshold ?: ProgressVisibilityThreshold
    val animatable =
    val animatable =
        Animatable(startProgress, visibilityThreshold = visibilityThreshold).also {
        Animatable(initialProgress, visibilityThreshold = visibilityThreshold).also {
            transition.animatable = it
            transition.animatable = it
        }
        }


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


@@ -185,6 +226,9 @@ private class OneOffTransition(
    override val progress: Float
    override val progress: Float
        get() = animatable.value
        get() = animatable.value


    override val progressVelocity: Float
        get() = animatable.velocity

    override fun finish(): Job = job
    override fun finish(): Job = job
}
}


+12 −0
Original line number Original line Diff line number Diff line
@@ -579,6 +579,18 @@ private class SwipeTransition(
            return offset / distance
            return offset / distance
        }
        }


    override val progressVelocity: Float
        get() {
            val animatable = offsetAnimation?.animatable ?: return 0f
            val distance = distance()
            if (distance == DistanceUnspecified) {
                return 0f
            }

            val velocityInDistanceUnit = animatable.velocity
            return velocityInDistanceUnit / distance.absoluteValue
        }

    override val isInitiatedByUserInput = true
    override val isInitiatedByUserInput = true


    override var bouncingScene: SceneKey? = null
    override var bouncingScene: SceneKey? = null
+21 −8
Original line number Original line Diff line number Diff line
@@ -329,10 +329,9 @@ private fun elementTransition(


    if (transition == null && previousTransition != null) {
    if (transition == null && previousTransition != null) {
        // The transition was just finished.
        // The transition was just finished.
        element.sceneStates.values.forEach { sceneState ->
        element.sceneStates.values.forEach {
            sceneState.offsetInterruptionDelta = Offset.Zero
            it.clearValuesBeforeInterruption()
            sceneState.scaleInterruptionDelta = Scale.Zero
            it.clearInterruptionDeltas()
            sceneState.alphaInterruptionDelta = 0f
        }
        }
    }
    }


@@ -375,12 +374,22 @@ private fun prepareInterruption(element: Element) {
        sceneState.scaleBeforeInterruption = lastScale
        sceneState.scaleBeforeInterruption = lastScale
        sceneState.alphaBeforeInterruption = lastAlpha
        sceneState.alphaBeforeInterruption = lastAlpha


        sceneState.offsetInterruptionDelta = Offset.Zero
        sceneState.clearInterruptionDeltas()
        sceneState.scaleInterruptionDelta = Scale.Zero
        sceneState.alphaInterruptionDelta = 0f
    }
    }
}
}


private fun Element.SceneState.clearInterruptionDeltas() {
    offsetInterruptionDelta = Offset.Zero
    scaleInterruptionDelta = Scale.Zero
    alphaInterruptionDelta = 0f
}

private fun Element.SceneState.clearValuesBeforeInterruption() {
    offsetBeforeInterruption = Offset.Unspecified
    scaleBeforeInterruption = Scale.Unspecified
    alphaBeforeInterruption = Element.AlphaUnspecified
}

/**
/**
 * Compute what [value] should be if we take the
 * Compute what [value] should be if we take the
 * [interruption progress][TransitionState.Transition.interruptionProgress] of [transition] into
 * [interruption progress][TransitionState.Transition.interruptionProgress] of [transition] into
@@ -744,7 +753,11 @@ private fun ApproachMeasureScope.place(
        // No need to place the element in this scene if we don't want to draw it anyways.
        // No need to place the element in this scene if we don't want to draw it anyways.
        if (!shouldPlaceElement(layoutImpl, scene, element, transition)) {
        if (!shouldPlaceElement(layoutImpl, scene, element, transition)) {
            sceneState.lastOffset = Offset.Unspecified
            sceneState.lastOffset = Offset.Unspecified
            sceneState.offsetBeforeInterruption = Offset.Unspecified
            sceneState.lastScale = Scale.Unspecified
            sceneState.lastAlpha = Element.AlphaUnspecified

            sceneState.clearValuesBeforeInterruption()
            sceneState.clearInterruptionDeltas()
            return
            return
        }
        }


+85 −0
Original line number Original line Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.compose.animation.scene

/**
 * A handler to specify how a transition should be interrupted.
 *
 * @see DefaultInterruptionHandler
 * @see SceneTransitionsBuilder.interruptionHandler
 */
interface InterruptionHandler {
    /**
     * This function is called when [interrupted] is interrupted: it is currently animating between
     * [interrupted.fromScene] and [interrupted.toScene], and we will now animate to
     * [newTargetScene].
     *
     * If this returns `null`, then the [default behavior][DefaultInterruptionHandler] will be used:
     * we will animate from [interrupted.currentScene] and chaining will be enabled (see
     * [InterruptionResult] for more information about chaining).
     *
     * @see InterruptionResult
     */
    fun onInterruption(
        interrupted: TransitionState.Transition,
        newTargetScene: SceneKey,
    ): InterruptionResult?
}

/**
 * The result of an interruption that specifies how we should handle a transition A => B now that we
 * have to animate to C.
 *
 * For instance, if the interrupted transition was A => B and currentScene = B:
 * - animateFrom = B && chain = true => there will be 2 transitions running in parallel, A => B and
 *   B => C.
 * - animateFrom = A && chain = true => there will be 2 transitions running in parallel, B => A and
 *   A => C.
 * - animateFrom = B && chain = false => there will be 1 transition running, B => C.
 * - animateFrom = A && chain = false => there will be 1 transition running, A => C.
 */
class InterruptionResult(
    /**
     * The scene we should animate from when transitioning to C.
     *
     * Important: This **must** be either [TransitionState.Transition.fromScene] or
     * [TransitionState.Transition.toScene] of the transition that was interrupted.
     */
    val animateFrom: SceneKey,

    /**
     * Whether chaining is enabled, i.e. if the new transition to C should run in parallel with the
     * previous one(s) or if it should be the only remaining transition that is running.
     */
    val chain: Boolean = true,
)

/**
 * The default interruption handler: we animate from [TransitionState.Transition.currentScene] and
 * chaining is enabled.
 */
object DefaultInterruptionHandler : InterruptionHandler {
    override fun onInterruption(
        interrupted: TransitionState.Transition,
        newTargetScene: SceneKey,
    ): InterruptionResult {
        return InterruptionResult(
            animateFrom = interrupted.currentScene,
            chain = true,
        )
    }
}
+31 −21
Original line number Original line Diff line number Diff line
@@ -227,6 +227,9 @@ sealed interface TransitionState {
         */
         */
        abstract val progress: Float
        abstract val progress: Float


        /** The current velocity of [progress], in progress units. */
        abstract val progressVelocity: Float

        /** Whether the transition was triggered by user input rather than being programmatic. */
        /** Whether the transition was triggered by user input rather than being programmatic. */
        abstract val isInitiatedByUserInput: Boolean
        abstract val isInitiatedByUserInput: Boolean


@@ -422,13 +425,18 @@ internal abstract class BaseSceneTransitionLayoutState(
    }
    }


    /**
    /**
     * Start a new [transition], instantly interrupting any ongoing transition if there was one.
     * Start a new [transition].
     *
     * If [chain] is `true`, then the transitions will simply be added to [currentTransitions] and
     * will run in parallel to the current transitions. If [chain] is `false`, then the list of
     * [currentTransitions] will be cleared and [transition] will be the only running transition.
     *
     *
     * Important: you *must* call [finishTransition] once the transition is finished.
     * Important: you *must* call [finishTransition] once the transition is finished.
     */
     */
    internal fun startTransition(
    internal fun startTransition(
        transition: TransitionState.Transition,
        transition: TransitionState.Transition,
        transitionKey: TransitionKey?,
        transitionKey: TransitionKey?,
        chain: Boolean = true,
    ) {
    ) {
        // Compute the [TransformationSpec] when the transition starts.
        // Compute the [TransformationSpec] when the transition starts.
        val fromScene = transition.fromScene
        val fromScene = transition.fromScene
@@ -471,26 +479,10 @@ internal abstract class BaseSceneTransitionLayoutState(
                    finishTransition(currentState, currentState.currentScene)
                    finishTransition(currentState, currentState.currentScene)
                }
                }


                // Check that we don't have too many concurrent transitions.
                val tooManyTransitions = transitionStates.size >= MAX_CONCURRENT_TRANSITIONS
                if (transitionStates.size >= MAX_CONCURRENT_TRANSITIONS) {
                val clearCurrentTransitions = !chain || tooManyTransitions
                    Log.wtf(
                if (clearCurrentTransitions) {
                        TAG,
                    if (tooManyTransitions) logTooManyTransitions()
                        buildString {
                            appendLine("Potential leak detected in SceneTransitionLayoutState!")
                            appendLine(
                                "  Some transition(s) never called STLState.finishTransition()."
                            )
                            appendLine("  Transitions (size=${transitionStates.size}):")
                            transitionStates.fastForEach { state ->
                                val transition = state as TransitionState.Transition
                                val from = transition.fromScene
                                val to = transition.toScene
                                val indicator =
                                    if (finishedTransitions.contains(transition)) "x" else " "
                                appendLine("  [$indicator] $from => $to ($transition)")
                            }
                        }
                    )


                    // Force finish all transitions.
                    // Force finish all transitions.
                    while (currentTransitions.isNotEmpty()) {
                    while (currentTransitions.isNotEmpty()) {
@@ -511,6 +503,24 @@ internal abstract class BaseSceneTransitionLayoutState(
        }
        }
    }
    }


    private fun logTooManyTransitions() {
        Log.wtf(
            TAG,
            buildString {
                appendLine("Potential leak detected in SceneTransitionLayoutState!")
                appendLine("  Some transition(s) never called STLState.finishTransition().")
                appendLine("  Transitions (size=${transitionStates.size}):")
                transitionStates.fastForEach { state ->
                    val transition = state as TransitionState.Transition
                    val from = transition.fromScene
                    val to = transition.toScene
                    val indicator = if (finishedTransitions.contains(transition)) "x" else " "
                    appendLine("  [$indicator] $from => $to ($transition)")
                }
            }
        )
    }

    private fun cancelActiveTransitionLinks() {
    private fun cancelActiveTransitionLinks() {
        for ((link, linkedTransition) in activeTransitionLinks) {
        for ((link, linkedTransition) in activeTransitionLinks) {
            link.target.finishTransition(linkedTransition, linkedTransition.currentScene)
            link.target.finishTransition(linkedTransition, linkedTransition.currentScene)
Loading