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

Commit 27f7b95d authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Add support for interruptions in shared values

This CL adds support for interruptions when animating shared
element/scene values, in a very similar way to what was already done for
the size, offset and alpha of elements in ag/26597678.

See b/290930950#comment29 for before/after videos.

Bug: 290930950
Test: atest ElementTest
Flag: com.android.systemui.scene_container
Change-Id: Ie2944544438aa75cc7d8bf4683ed4646d9103ef3
parent e6dbf7c1
Loading
Loading
Loading
Loading
+216 −55
Original line number Diff line number Diff line
@@ -27,12 +27,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.colorspace.ColorSpaces
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastLastOrNull
import androidx.compose.ui.util.lerp
import kotlin.math.roundToInt

/**
 * A [State] whose [value] is animated.
@@ -75,7 +75,7 @@ fun SceneScope.animateSceneIntAsState(
    key: ValueKey,
    canOverflow: Boolean = true,
): AnimatedState<Int> {
    return animateSceneValueAsState(value, key, ::lerp, canOverflow)
    return animateSceneValueAsState(value, key, SharedIntType, canOverflow)
}

/**
@@ -89,7 +89,19 @@ fun ElementScope<*>.animateElementIntAsState(
    key: ValueKey,
    canOverflow: Boolean = true,
): AnimatedState<Int> {
    return animateElementValueAsState(value, key, ::lerp, canOverflow)
    return animateElementValueAsState(value, key, SharedIntType, canOverflow)
}

private object SharedIntType : SharedValueType<Int, Int> {
    override val unspecifiedValue: Int = Int.MIN_VALUE
    override val zeroDeltaValue: Int = 0

    override fun lerp(a: Int, b: Int, progress: Float): Int =
        androidx.compose.ui.util.lerp(a, b, progress)

    override fun diff(a: Int, b: Int): Int = a - b

    override fun addWeighted(a: Int, b: Int, bWeight: Float): Int = (a + b * bWeight).roundToInt()
}

/**
@@ -103,7 +115,7 @@ fun SceneScope.animateSceneFloatAsState(
    key: ValueKey,
    canOverflow: Boolean = true,
): AnimatedState<Float> {
    return animateSceneValueAsState(value, key, ::lerp, canOverflow)
    return animateSceneValueAsState(value, key, SharedFloatType, canOverflow)
}

/**
@@ -117,7 +129,19 @@ fun ElementScope<*>.animateElementFloatAsState(
    key: ValueKey,
    canOverflow: Boolean = true,
): AnimatedState<Float> {
    return animateElementValueAsState(value, key, ::lerp, canOverflow)
    return animateElementValueAsState(value, key, SharedFloatType, canOverflow)
}

private object SharedFloatType : SharedValueType<Float, Float> {
    override val unspecifiedValue: Float = Float.MIN_VALUE
    override val zeroDeltaValue: Float = 0f

    override fun lerp(a: Float, b: Float, progress: Float): Float =
        androidx.compose.ui.util.lerp(a, b, progress)

    override fun diff(a: Float, b: Float): Float = a - b

    override fun addWeighted(a: Float, b: Float, bWeight: Float): Float = a + b * bWeight
}

/**
@@ -131,7 +155,7 @@ fun SceneScope.animateSceneDpAsState(
    key: ValueKey,
    canOverflow: Boolean = true,
): AnimatedState<Dp> {
    return animateSceneValueAsState(value, key, ::lerp, canOverflow)
    return animateSceneValueAsState(value, key, SharedDpType, canOverflow)
}

/**
@@ -145,7 +169,20 @@ fun ElementScope<*>.animateElementDpAsState(
    key: ValueKey,
    canOverflow: Boolean = true,
): AnimatedState<Dp> {
    return animateElementValueAsState(value, key, ::lerp, canOverflow)
    return animateElementValueAsState(value, key, SharedDpType, canOverflow)
}

private object SharedDpType : SharedValueType<Dp, Dp> {
    override val unspecifiedValue: Dp = Dp.Unspecified
    override val zeroDeltaValue: Dp = 0.dp

    override fun lerp(a: Dp, b: Dp, progress: Float): Dp {
        return androidx.compose.ui.unit.lerp(a, b, progress)
    }

    override fun diff(a: Dp, b: Dp): Dp = a - b

    override fun addWeighted(a: Dp, b: Dp, bWeight: Float): Dp = a + b * bWeight
}

/**
@@ -158,7 +195,7 @@ fun SceneScope.animateSceneColorAsState(
    value: Color,
    key: ValueKey,
): AnimatedState<Color> {
    return animateSceneValueAsState(value, key, ::lerp, canOverflow = false)
    return animateSceneValueAsState(value, key, SharedColorType, canOverflow = false)
}

/**
@@ -171,9 +208,56 @@ fun ElementScope<*>.animateElementColorAsState(
    value: Color,
    key: ValueKey,
): AnimatedState<Color> {
    return animateElementValueAsState(value, key, ::lerp, canOverflow = false)
    return animateElementValueAsState(value, key, SharedColorType, canOverflow = false)
}

private object SharedColorType : SharedValueType<Color, ColorDelta> {
    override val unspecifiedValue: Color = Color.Unspecified
    override val zeroDeltaValue: ColorDelta = ColorDelta(0f, 0f, 0f, 0f)

    override fun lerp(a: Color, b: Color, progress: Float): Color {
        return androidx.compose.ui.graphics.lerp(a, b, progress)
    }

    override fun diff(a: Color, b: Color): ColorDelta {
        // Similar to lerp, we convert colors to the Oklab color space to perform operations on
        // colors.
        val aOklab = a.convert(ColorSpaces.Oklab)
        val bOklab = b.convert(ColorSpaces.Oklab)
        return ColorDelta(
            red = aOklab.red - bOklab.red,
            green = aOklab.green - bOklab.green,
            blue = aOklab.blue - bOklab.blue,
            alpha = aOklab.alpha - bOklab.alpha,
        )
    }

    override fun addWeighted(a: Color, b: ColorDelta, bWeight: Float): Color {
        val aOklab = a.convert(ColorSpaces.Oklab)
        return Color(
                red = aOklab.red + b.red * bWeight,
                green = aOklab.green + b.green * bWeight,
                blue = aOklab.blue + b.blue * bWeight,
                alpha = aOklab.alpha + b.alpha * bWeight,
                colorSpace = ColorSpaces.Oklab,
            )
            .convert(aOklab.colorSpace)
    }
}

/**
 * Represents the diff between two colors in the same color space.
 *
 * Note: This class is necessary because Color() checks the bounds of its values and UncheckedColor
 * is internal.
 */
private class ColorDelta(
    val red: Float,
    val green: Float,
    val blue: Float,
    val alpha: Float,
)

@Composable
internal fun <T> animateSharedValueAsState(
    layoutImpl: SceneTransitionLayoutImpl,
@@ -181,23 +265,22 @@ internal fun <T> animateSharedValueAsState(
    element: ElementKey?,
    key: ValueKey,
    value: T,
    lerp: (T, T, Float) -> T,
    type: SharedValueType<T, *>,
    canOverflow: Boolean,
): AnimatedState<T> {
    DisposableEffect(layoutImpl, scene, element, key) {
        // Create the associated maps that hold the current value for each (element, scene) pair.
        val valueMap = layoutImpl.sharedValues.getOrPut(key) { mutableMapOf() }
        val sceneToValueMap =
            valueMap.getOrPut(element) { SnapshotStateMap<SceneKey, Any>() }
                as SnapshotStateMap<SceneKey, T>
        sceneToValueMap[scene] = value
        val sharedValue = valueMap.getOrPut(element) { SharedValue(type) } as SharedValue<T, *>
        val targetValues = sharedValue.targetValues
        targetValues[scene] = value

        onDispose {
            // Remove the value associated to the current scene, and eventually remove the maps if
            // they are empty.
            sceneToValueMap.remove(scene)
            targetValues.remove(scene)

            if (sceneToValueMap.isEmpty() && valueMap[element] === sceneToValueMap) {
            if (targetValues.isEmpty() && valueMap[element] === sharedValue) {
                valueMap.remove(element)

                if (valueMap.isEmpty() && layoutImpl.sharedValues[key] === valueMap) {
@@ -209,19 +292,25 @@ internal fun <T> animateSharedValueAsState(

    // Update the current value. Note that side effects run after disposable effects, so we know
    // that the associated maps were created at this point.
    SideEffect { sceneToValueMap<T>(layoutImpl, key, element)[scene] = value }
    SideEffect {
        if (value == type.unspecifiedValue) {
            error("value is equal to $value, which is the undefined value for this type.")
        }

    return remember(layoutImpl, scene, element, lerp, canOverflow) {
        AnimatedStateImpl(layoutImpl, scene, element, key, lerp, canOverflow)
        sharedValue<T, Any>(layoutImpl, key, element).targetValues[scene] = value
    }

    return remember(layoutImpl, scene, element, canOverflow) {
        AnimatedStateImpl<T, Any>(layoutImpl, scene, element, key, canOverflow)
    }
}

private fun <T> sceneToValueMap(
private fun <T, Delta> sharedValue(
    layoutImpl: SceneTransitionLayoutImpl,
    key: ValueKey,
    element: ElementKey?
): MutableMap<SceneKey, T> {
    return layoutImpl.sharedValues[key]?.get(element)?.let { it as SnapshotStateMap<SceneKey, T> }
): SharedValue<T, Delta> {
    return layoutImpl.sharedValues[key]?.get(element)?.let { it as SharedValue<T, Delta> }
        ?: error(valueReadTooEarlyMessage(key))
}

@@ -230,31 +319,62 @@ private fun valueReadTooEarlyMessage(key: ValueKey) =
        "means that you are reading it during composition, which you should not do. See the " +
        "documentation of AnimatedState for more information."

private class AnimatedStateImpl<T>(
internal class SharedValue<T, Delta>(
    val type: SharedValueType<T, Delta>,
) {
    /** The target value of this shared value for each scene. */
    val targetValues = SnapshotStateMap<SceneKey, T>()

    /** The last value of this shared value. */
    var lastValue: T = type.unspecifiedValue

    /** The value of this shared value before the last interruption (if any). */
    var valueBeforeInterruption: T = type.unspecifiedValue

    /** The delta value to add to this shared value to have smoother interruptions. */
    var valueInterruptionDelta = type.zeroDeltaValue

    /** The last transition that was used when the value of this shared state. */
    var lastTransition: TransitionState.Transition? = null
}

private class AnimatedStateImpl<T, Delta>(
    private val layoutImpl: SceneTransitionLayoutImpl,
    private val scene: SceneKey,
    private val element: ElementKey?,
    private val key: ValueKey,
    private val lerp: (T, T, Float) -> T,
    private val canOverflow: Boolean,
) : AnimatedState<T> {
    override val value: T
        get() = valueOrNull() ?: error(valueReadTooEarlyMessage(key))

    private fun valueOrNull(): T? {
        val sceneToValueMap = sceneToValueMap<T>(layoutImpl, key, element)
        fun sceneValue(scene: SceneKey): T? = sceneToValueMap[scene]
        get() = value()

        val transition =
            transition(sceneToValueMap)
                ?: return sceneValue(layoutImpl.state.transitionState.currentScene)
    private fun value(): T {
        val sharedValue = sharedValue<T, Delta>(layoutImpl, key, element)
        val transition = transition(sharedValue)
        val value: T =
            valueOrNull(sharedValue, transition)
                // TODO(b/311600838): Remove this. We should not have to fallback to the current
                    // scene value, but we have to because code of removed nodes can still run if
                    // they are placed with a graphics layer.
                    ?: sceneValue(scene)
                // scene value, but we have to because code of removed nodes can still run if they
                // are placed with a graphics layer.
                ?: sharedValue[scene]
                ?: error(valueReadTooEarlyMessage(key))
        val interruptedValue = computeInterruptedValue(sharedValue, transition, value)
        sharedValue.lastValue = interruptedValue
        return interruptedValue
    }

    private operator fun SharedValue<T, *>.get(scene: SceneKey): T? = targetValues[scene]

    private fun valueOrNull(
        sharedValue: SharedValue<T, *>,
        transition: TransitionState.Transition?,
    ): T? {
        if (transition == null) {
            return sharedValue[layoutImpl.state.transitionState.currentScene]
        }

        val fromValue = sceneValue(transition.fromScene)
        val toValue = sceneValue(transition.toScene)
        val fromValue = sharedValue[transition.fromScene]
        val toValue = sharedValue[transition.toScene]
        return if (fromValue != null && toValue != null) {
            if (fromValue == toValue) {
                // Optimization: avoid reading progress if the values are the same, so we don't
@@ -266,26 +386,22 @@ private class AnimatedStateImpl<T>(
                if (!canOverflow && transition is TransitionState.HasOverscrollProperties) {
                    val bouncingScene = transition.bouncingScene
                    if (bouncingScene != null) {
                        return sceneValue(bouncingScene)
                        return sharedValue[bouncingScene]
                    }
                }

                val progress =
                    if (canOverflow) transition.progress
                    else transition.progress.fastCoerceIn(0f, 1f)
                lerp(fromValue, toValue, progress)
                sharedValue.type.lerp(fromValue, toValue, progress)
            }
        } else
            fromValue
                ?: toValue
                // TODO(b/311600838): Remove this. We should not have to fallback to the current
                // scene value, but we have to because code of removed nodes can still run if they
                // are placed with a graphics layer.
                ?: sceneValue(scene)
        } else fromValue ?: toValue
    }

    private fun transition(sceneToValueMap: Map<SceneKey, *>): TransitionState.Transition? {
        return if (element != null) {
    private fun transition(sharedValue: SharedValue<T, Delta>): TransitionState.Transition? {
        val targetValues = sharedValue.targetValues
        val transition =
            if (element != null) {
                layoutImpl.elements[element]?.sceneStates?.let { sceneStates ->
                    layoutImpl.state.currentTransitions.fastLastOrNull { transition ->
                        transition.fromScene in sceneStates || transition.toScene in sceneStates
@@ -293,7 +409,52 @@ private class AnimatedStateImpl<T>(
                }
            } else {
                layoutImpl.state.currentTransitions.fastLastOrNull { transition ->
                transition.fromScene in sceneToValueMap || transition.toScene in sceneToValueMap
                    transition.fromScene in targetValues || transition.toScene in targetValues
                }
            }

        val previousTransition = sharedValue.lastTransition
        sharedValue.lastTransition = transition

        if (transition != previousTransition && transition != null && previousTransition != null) {
            // The previous transition was interrupted by another transition.
            sharedValue.valueBeforeInterruption = sharedValue.lastValue
            sharedValue.valueInterruptionDelta = sharedValue.type.zeroDeltaValue
        } else if (transition == null && previousTransition != null) {
            // The transition was just finished.
            sharedValue.valueBeforeInterruption = sharedValue.type.unspecifiedValue
            sharedValue.valueInterruptionDelta = sharedValue.type.zeroDeltaValue
        }

        return transition
    }

    /**
     * Compute what [value] should be if we take the
     * [interruption progress][TransitionState.Transition.interruptionProgress] of [transition] into
     * account.
     */
    private fun computeInterruptedValue(
        sharedValue: SharedValue<T, Delta>,
        transition: TransitionState.Transition?,
        value: T,
    ): T {
        val type = sharedValue.type
        if (sharedValue.valueBeforeInterruption != type.unspecifiedValue) {
            sharedValue.valueInterruptionDelta =
                type.diff(sharedValue.valueBeforeInterruption, value)
            sharedValue.valueBeforeInterruption = type.unspecifiedValue
        }

        val delta = sharedValue.valueInterruptionDelta
        return if (delta == type.zeroDeltaValue || transition == null) {
            value
        } else {
            val interruptionProgress = transition.interruptionProgress(layoutImpl)
            if (interruptionProgress == 0f) {
                value
            } else {
                type.addWeighted(value, delta, interruptionProgress)
            }
        }
    }
+3 −4
Original line number Diff line number Diff line
@@ -77,7 +77,7 @@ private abstract class BaseElementScope<ContentScope>(
    override fun <T> animateElementValueAsState(
        value: T,
        key: ValueKey,
        lerp: (start: T, stop: T, fraction: Float) -> T,
        type: SharedValueType<T, *>,
        canOverflow: Boolean
    ): AnimatedState<T> {
        return animateSharedValueAsState(
@@ -86,7 +86,7 @@ private abstract class BaseElementScope<ContentScope>(
            element,
            key,
            value,
            lerp,
            type,
            canOverflow,
        )
    }
@@ -184,8 +184,7 @@ private fun shouldComposeMovableElement(
                fromSceneZIndex = layoutImpl.scenes.getValue(transition.fromScene).zIndex,
                toSceneZIndex = layoutImpl.scenes.getValue(transition.toScene).zIndex,
            ) != null
        }
            ?: return false
        } ?: return false

    // Always compose movable elements in the scene picked by their scene picker.
    return shouldDrawOrComposeSharedElement(
+2 −4
Original line number Diff line number Diff line
@@ -24,7 +24,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.approachLayout
import androidx.compose.ui.platform.testTag
@@ -69,7 +68,6 @@ internal class Scene(
    }

    @Composable
    @OptIn(ExperimentalComposeUiApi::class)
    fun Content(modifier: Modifier = Modifier) {
        Box(
            modifier
@@ -125,7 +123,7 @@ internal class SceneScopeImpl(
    override fun <T> animateSceneValueAsState(
        value: T,
        key: ValueKey,
        lerp: (T, T, Float) -> T,
        type: SharedValueType<T, *>,
        canOverflow: Boolean
    ): AnimatedState<T> {
        return animateSharedValueAsState(
@@ -134,7 +132,7 @@ internal class SceneScopeImpl(
            element = null,
            key = key,
            value = value,
            lerp = lerp,
            type = type,
            canOverflow = canOverflow,
        )
    }
+32 −8
Original line number Diff line number Diff line
@@ -284,9 +284,7 @@ interface SceneScope : BaseSceneScope {
     *
     * @param value the value of this shared value in the current scene.
     * @param key the key of this shared value.
     * @param lerp the *linear* interpolation function that should be used to interpolate between
     *   two different values. Note that it has to be linear because the [fraction] passed to this
     *   interpolator is already interpolated.
     * @param type the [SharedValueType] of this animated value.
     * @param canOverflow whether this value can overflow past the values it is interpolated
     *   between, for instance because the transition is animated using a bouncy spring.
     * @see animateSceneIntAsState
@@ -298,11 +296,39 @@ interface SceneScope : BaseSceneScope {
    fun <T> animateSceneValueAsState(
        value: T,
        key: ValueKey,
        lerp: (start: T, stop: T, fraction: Float) -> T,
        type: SharedValueType<T, *>,
        canOverflow: Boolean,
    ): AnimatedState<T>
}

/**
 * The type of a shared value animated using [ElementScope.animateElementValueAsState] or
 * [SceneScope.animateSceneValueAsState].
 */
@Stable
interface SharedValueType<T, Delta> {
    /** The unspecified value for this type. */
    val unspecifiedValue: T

    /**
     * The zero value of this type. It should be equal to what [diff(x, x)] returns for any value of
     * x.
     */
    val zeroDeltaValue: Delta

    /**
     * Return the linear interpolation of [a] and [b] at the given [progress], i.e. `a + (b - a) *
     * progress`.
     */
    fun lerp(a: T, b: T, progress: Float): T

    /** Return `a - b`. */
    fun diff(a: T, b: T): Delta

    /** Return `a + b * bWeight`. */
    fun addWeighted(a: T, b: Delta, bWeight: Float): T
}

@Stable
@ElementDsl
interface ElementScope<ContentScope> {
@@ -311,9 +337,7 @@ interface ElementScope<ContentScope> {
     *
     * @param value the value of this shared value in the current scene.
     * @param key the key of this shared value.
     * @param lerp the *linear* interpolation function that should be used to interpolate between
     *   two different values. Note that it has to be linear because the [fraction] passed to this
     *   interpolator is already interpolated.
     * @param type the [SharedValueType] of this animated value.
     * @param canOverflow whether this value can overflow past the values it is interpolated
     *   between, for instance because the transition is animated using a bouncy spring.
     * @see animateElementIntAsState
@@ -325,7 +349,7 @@ interface ElementScope<ContentScope> {
    fun <T> animateElementValueAsState(
        value: T,
        key: ValueKey,
        lerp: (start: T, stop: T, fraction: Float) -> T,
        type: SharedValueType<T, *>,
        canOverflow: Boolean,
    ): AnimatedState<T>

+5 −6
Original line number Diff line number Diff line
@@ -85,15 +85,14 @@ internal class SceneTransitionLayoutImpl(
     * The different values of a shared value keyed by a a [ValueKey] and the different elements and
     * scenes it is associated to.
     */
    private var _sharedValues:
        MutableMap<ValueKey, MutableMap<ElementKey?, SnapshotStateMap<SceneKey, *>>>? =
    private var _sharedValues: MutableMap<ValueKey, MutableMap<ElementKey?, SharedValue<*, *>>>? =
        null
    internal val sharedValues:
        MutableMap<ValueKey, MutableMap<ElementKey?, SnapshotStateMap<SceneKey, *>>>
    internal val sharedValues: MutableMap<ValueKey, MutableMap<ElementKey?, SharedValue<*, *>>>
        get() =
            _sharedValues
                ?: mutableMapOf<ValueKey, MutableMap<ElementKey?, SnapshotStateMap<SceneKey, *>>>()
                    .also { _sharedValues = it }
                ?: mutableMapOf<ValueKey, MutableMap<ElementKey?, SharedValue<*, *>>>().also {
                    _sharedValues = it
                }

    // TODO(b/317958526): Lazily allocate scene gesture handlers the first time they are needed.
    private val horizontalDraggableHandler: DraggableHandlerImpl
Loading