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

Commit b7d5958b authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Propagate shared target values to ancestor STLs (1/2)

This CL improves support for shared values in nested STL by implementing
the same synchronization mechanism that is already done for elements:
nested STLs now propagates the target value of their shared values.

Similar to nested elements, this solution is not perfect and does not
nicely handle interruptions or multiple STLs transitioning at the same
time.

Bug: 438406908
Bug: 440007127
Fixes: 438406908
Test: AnimatedSharedAsStateTest
Flag: com.android.systemui.scene_container
Change-Id: I9087839df1f9808f3b2ecf1b764fdf2cf72ebbbf
parent 50f90eda
Loading
Loading
Loading
Loading
+84 −15
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.colorspace.ColorSpaces
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastLastOrNull
import com.android.compose.animation.scene.content.state.TransitionState
import kotlin.math.roundToInt
@@ -277,25 +278,19 @@ internal fun <T> animateSharedValueAsState(
    type: SharedValueType<T, *>,
    canOverflow: Boolean,
): AnimatedState<T> {
    val sharedTargetValue = SharedTargetValue(layoutImpl.state, value)
    DisposableEffect(layoutImpl, content, element, key) {
        // Create the associated maps that hold the current value for each (element, content) pair.
        val valueMap = layoutImpl.sharedValues.getOrPut(key) { mutableMapOf() }
        val sharedValue = valueMap.getOrPut(element) { SharedValue(type) } as SharedValue<T, *>
        val targetValues = sharedValue.targetValues
        targetValues[content] = value
        targetValues[content] = sharedTargetValue

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

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

                if (valueMap.isEmpty() && layoutImpl.sharedValues[key] === valueMap) {
                    layoutImpl.sharedValues.remove(key)
                }
            }
            maybePruneMaps(layoutImpl, element, key, valueMap, sharedValue, targetValues)
        }
    }

@@ -306,7 +301,39 @@ internal fun <T> animateSharedValueAsState(
            error("value is equal to $value, which is the undefined value for this type.")
        }

        sharedValue<T, Any>(layoutImpl, key, element).targetValues[content] = value
        sharedValue<T, Any>(layoutImpl, key, element).targetValues[content] = sharedTargetValue
    }

    // Propagate the value to ancestor STLs if this content is the current scene. Note that only
    // scenes can propagate their shared values to ancestor STLs, because multiple overlays with
    // different values could be shown and there is no way to decide why one should be propagated.
    if (
        layoutImpl.ancestors.isNotEmpty() &&
            content is SceneKey &&
            layoutImpl.state.currentScene == content
    ) {
        DisposableEffect(sharedTargetValue) {
            val valueMap = layoutImpl.sharedValues.getValue(key)
            val sharedValue = valueMap.getValue(element) as SharedValue<T, *>
            val targetValues = sharedValue.targetValues

            layoutImpl.ancestors.fastForEach { ancestor ->
                val previousTarget = targetValues[ancestor.inContent]
                if (previousTarget == null || previousTarget.sourceState == layoutImpl.state) {
                    targetValues[ancestor.inContent] = sharedTargetValue
                }
            }

            onDispose {
                layoutImpl.ancestors.fastForEach { ancestor ->
                    if (targetValues[ancestor.inContent] == sharedTargetValue) {
                        targetValues.remove(ancestor.inContent)
                    }
                }

                maybePruneMaps(layoutImpl, element, key, valueMap, sharedValue, targetValues)
            }
        }
    }

    return remember(layoutImpl, content, element, canOverflow) {
@@ -314,6 +341,23 @@ internal fun <T> animateSharedValueAsState(
    }
}

private fun maybePruneMaps(
    layoutImpl: SceneTransitionLayoutImpl,
    element: ElementKey?,
    key: ValueKey,
    valueMap: MutableMap<ElementKey?, SharedValue<*, *>>,
    sharedValue: SharedValue<*, *>,
    targetValues: Map<ContentKey, *>,
) {
    if (targetValues.isEmpty() && valueMap[element] === sharedValue) {
        valueMap.remove(element)

        if (valueMap.isEmpty() && layoutImpl.sharedValues[key] === valueMap) {
            layoutImpl.sharedValues.remove(key)
        }
    }
}

private fun <T, Delta> sharedValue(
    layoutImpl: SceneTransitionLayoutImpl,
    key: ValueKey,
@@ -330,7 +374,7 @@ private fun valueReadTooEarlyMessage(key: ValueKey) =

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

    /** The last value of this shared value. */
    var lastValue: T = type.unspecifiedValue
@@ -345,6 +389,12 @@ internal class SharedValue<T, Delta>(val type: SharedValueType<T, Delta>) {
    var lastTransition: TransitionState.Transition? = null
}

/**
 * A holder for the target value of a [SharedValue] that keeps a reference of the [sourceState]
 * owning that value, which is used to propagate shared value to ancestors from nested STLs.
 */
internal data class SharedTargetValue<T>(val sourceState: SceneTransitionLayoutState, val value: T)

private class AnimatedStateImpl<T, Delta>(
    private val layoutImpl: SceneTransitionLayoutImpl,
    private val content: ContentKey,
@@ -370,7 +420,9 @@ private class AnimatedStateImpl<T, Delta>(
        return interruptedValue
    }

    private operator fun SharedValue<T, *>.get(content: ContentKey): T? = targetValues[content]
    private operator fun SharedValue<T, *>.get(content: ContentKey): T? {
        return targetValues[content]?.value
    }

    private fun valueOrNull(
        sharedValue: SharedValue<T, *>,
@@ -430,19 +482,26 @@ private class AnimatedStateImpl<T, Delta>(

    private fun transition(sharedValue: SharedValue<T, Delta>): TransitionState.Transition? {
        val targetValues = sharedValue.targetValues
        val nestedTransitionStates = getAllNestedTransitionStates(layoutImpl)
        val transition =
            if (element != null) {
                layoutImpl.elements[element]?.let { element ->
                    elementState(
                        listOf(layoutImpl.state.transitionStates),
                        nestedTransitionStates,
                        elementKey = element.key,
                        isInContent = { it in element.stateByContent },
                    )
                        as? TransitionState.Transition
                }
            } else {
                layoutImpl.state.currentTransitions.fastLastOrNull { transition ->
                    transition.fromContent in targetValues || transition.toContent in targetValues
                nestedTransitionStates.fastFirstNotNullOfOrNull { transitionStates ->
                    transitionStates
                        .fastLastOrNull { transitionState ->
                            transitionState is TransitionState.Transition &&
                                (transitionState.fromContent in targetValues ||
                                    transitionState.toContent in targetValues)
                        }
                        ?.let { it as TransitionState.Transition }
                }
            }

@@ -504,3 +563,13 @@ private class AnimatedStateImpl<T, Delta>(
        return state
    }
}

private inline fun <T, R : Any> List<T>.fastFirstNotNullOfOrNull(transform: (T) -> R?): R? {
    fastForEach { element ->
        val result = transform(element)
        if (result != null) {
            return result
        }
    }
    return null
}
+8 −4
Original line number Diff line number Diff line
@@ -181,10 +181,14 @@ internal class SceneTransitionLayoutImpl(
        null
    internal val sharedValues: MutableMap<ValueKey, MutableMap<ElementKey?, SharedValue<*, *>>>
        get() =
            if (ancestors.isNotEmpty()) {
                ancestors[0].layoutImpl.sharedValues
            } else {
                _sharedValues
                    ?: mutableMapOf<ValueKey, MutableMap<ElementKey?, SharedValue<*, *>>>().also {
                        _sharedValues = it
                    }
            }

    // TODO(b/317958526): Lazily allocate scene gesture handlers the first time they are needed.
    internal val horizontalDraggableHandler: DraggableHandler
+180 −0
Original line number Diff line number Diff line
@@ -25,6 +25,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -39,6 +41,9 @@ import com.android.compose.animation.scene.TestScenes.SceneA
import com.android.compose.animation.scene.TestScenes.SceneB
import com.android.compose.animation.scene.TestScenes.SceneC
import com.android.compose.animation.scene.TestScenes.SceneD
import com.android.compose.animation.scene.TestScenes.SceneE
import com.android.compose.animation.scene.TestScenes.SceneF
import com.android.compose.animation.scene.TestScenes.SceneG
import com.android.compose.test.setContentAndCreateMainScope
import com.android.compose.test.transition
import com.google.common.truth.Truth.assertThat
@@ -451,4 +456,179 @@ class AnimatedSharedAsStateTest {
        val interpolated = SharedColorType.addWeighted(a, delta, 0.5f) // a + (b - a) * 0.5f
        rule.setContent { Box(Modifier.fillMaxSize().background(interpolated)) }
    }

    @Test
    fun nestedValue() {
        val valueKey = ValueKey("value")

        @Composable
        fun ContentScope.AnimatedFloat(targetValue: Float, value: (Float?) -> Unit) {
            val state = animateContentFloatAsState(targetValue, valueKey)
            LaunchedEffect(state) {
                try {
                    snapshotFlow<Float?> { state.value }.collect(value)
                } finally {
                    value(null)
                }
            }
        }

        val outerState = rule.runOnUiThread { MutableSceneTransitionLayoutStateForTests(SceneB) }
        val nestedState = rule.runOnUiThread { MutableSceneTransitionLayoutStateForTests(SceneC) }

        var sceneAValue: Float? = null
        var sceneCValue: Float? = null
        var sceneDValue: Float? = null
        val scope =
            rule.setContentAndCreateMainScope {
                SceneTransitionLayout(outerState) {
                    scene(SceneA) { AnimatedFloat(targetValue = 10f) { sceneAValue = it } }
                    scene(SceneB) {
                        NestedSceneTransitionLayout(nestedState, Modifier) {
                            scene(SceneC) { AnimatedFloat(targetValue = 20f) { sceneCValue = it } }
                            scene(SceneD) { AnimatedFloat(targetValue = 30f) { sceneDValue = it } }
                        }
                    }
                }
            }

        assertThat(sceneAValue).isEqualTo(null) // not composed
        assertThat(sceneCValue).isEqualTo(20f)
        assertThat(sceneDValue).isEqualTo(null) // not composed

        // Start C => D. We interpolate between SceneC (20f) and SceneD (30f).
        var currentScene by mutableStateOf(SceneC)
        scope.launch {
            nestedState.startTransition(
                transition(
                    from = SceneC,
                    to = SceneD,
                    current = { currentScene },
                    progress = { 0.5f },
                )
            )
        }
        rule.waitForIdle()
        assertThat(sceneAValue).isEqualTo(null) // not composed
        assertThat(sceneCValue).isEqualTo(25f)
        assertThat(sceneDValue).isEqualTo(25f)

        // Start B => A. Outer transitions have priority, so we now interpolate between SceneA (10f)
        // and SceneB = SceneC (20f) given that nestedState.currentScene == SceneC.
        scope.launch {
            outerState.startTransition(transition(from = SceneB, to = SceneA, progress = { 0.5f }))
        }
        rule.waitForIdle()
        assertThat(sceneAValue).isEqualTo(15f)
        assertThat(sceneCValue).isEqualTo(15f)
        assertThat(sceneDValue).isEqualTo(15f)

        // Interpolate between SceneA (10f) and SceneD (30f).
        currentScene = SceneD
        rule.waitForIdle()
        assertThat(sceneAValue).isEqualTo(20f)
        assertThat(sceneCValue).isEqualTo(20f)
        assertThat(sceneDValue).isEqualTo(20f)
    }

    @Test
    fun nestedValuePropagation() {
        val valueKey = ValueKey("value")
        val state1 = rule.runOnUiThread { MutableSceneTransitionLayoutStateForTests(SceneA) }
        val state2 = rule.runOnUiThread { MutableSceneTransitionLayoutStateForTests(SceneB) }
        val state3 = rule.runOnUiThread { MutableSceneTransitionLayoutStateForTests(SceneC) }
        val state4 = rule.runOnUiThread { MutableSceneTransitionLayoutStateForTests(SceneD) }

        lateinit var layoutImpl: SceneTransitionLayoutImpl
        val scope =
            rule.setContentAndCreateMainScope {
                SceneTransitionLayoutForTesting(state1, onLayoutImpl = { layoutImpl = it }) {
                    scene(SceneA) {
                        // no animateContentFloatAsState here, should inherit from
                        // nested STL.
                        NestedSceneTransitionLayout(state2, Modifier) {
                            scene(SceneB) {
                                animateContentFloatAsState(0f, valueKey)

                                NestedSceneTransitionLayout(state3, Modifier) {
                                    scene(SceneC) {
                                        // no animateContentFloatAsState here, should inherit from
                                        // nested STL.
                                        NestedSceneTransitionLayout(state4, Modifier) {
                                            scene(SceneD) {
                                                animateContentFloatAsState(10f, valueKey)
                                            }
                                            scene(SceneE) {
                                                animateContentFloatAsState(20f, valueKey)
                                            }
                                            scene(SceneF) {}
                                        }
                                    }
                                }
                            }
                            scene(SceneG) {}
                        }
                    }
                }
            }

        val targetValues =
            layoutImpl.sharedValues.getValue(valueKey).getValue(/* elementKey= */ null).targetValues
        assertThat(targetValues[SceneA]?.value).isEqualTo(0f) // inherited from SceneB
        assertThat(targetValues[SceneB]?.value).isEqualTo(0f) // inherited from SceneB
        assertThat(targetValues[SceneC]?.value).isEqualTo(10f) // inherited from SceneD
        assertThat(targetValues[SceneD]?.value).isEqualTo(10f) // inherited from SceneD
        assertThat(targetValues[SceneE]?.value).isEqualTo(null) // not composed
        assertThat(targetValues[SceneF]?.value).isEqualTo(null) // not composed

        // state4: D => E.
        var currentScene by mutableStateOf(SceneD)
        val dToE = transition(from = SceneD, to = SceneE, current = { currentScene })
        scope.launch { state4.startTransition(dToE) }
        rule.waitForIdle()
        assertThat(targetValues[SceneA]?.value).isEqualTo(0f) // inherited from SceneB
        assertThat(targetValues[SceneB]?.value).isEqualTo(0f) // inherited from SceneB
        assertThat(targetValues[SceneC]?.value).isEqualTo(10f) // inherited from SceneD
        assertThat(targetValues[SceneD]?.value).isEqualTo(10f) // inherited from SceneD
        assertThat(targetValues[SceneE]?.value).isEqualTo(20f) // inherited from SceneE
        assertThat(targetValues[SceneF]?.value).isEqualTo(null) // not composed

        currentScene = SceneE
        rule.waitForIdle()
        assertThat(targetValues[SceneA]?.value).isEqualTo(0f) // inherited from SceneB
        assertThat(targetValues[SceneB]?.value).isEqualTo(0f) // inherited from SceneB
        assertThat(targetValues[SceneC]?.value).isEqualTo(20f) // inherited from SceneE
        assertThat(targetValues[SceneD]?.value).isEqualTo(10f) // inherited from SceneD
        assertThat(targetValues[SceneE]?.value).isEqualTo(20f) // inherited from SceneE
        assertThat(targetValues[SceneF]?.value).isEqualTo(null) // not composed

        dToE.finish()
        rule.waitForIdle()
        assertThat(targetValues[SceneA]?.value).isEqualTo(0f) // inherited from SceneB
        assertThat(targetValues[SceneB]?.value).isEqualTo(0f) // inherited from SceneB
        assertThat(targetValues[SceneC]?.value).isEqualTo(20f) // inherited from SceneE
        assertThat(targetValues[SceneD]?.value).isEqualTo(null) // not composed
        assertThat(targetValues[SceneE]?.value).isEqualTo(20f) // inherited from SceneE
        assertThat(targetValues[SceneF]?.value).isEqualTo(null) // not composed

        // state4: snap to F.
        scope.launch { state4.snapTo(SceneF) }
        rule.waitForIdle()
        assertThat(targetValues[SceneA]?.value).isEqualTo(0f) // inherited from SceneB
        assertThat(targetValues[SceneB]?.value).isEqualTo(0f) // inherited from SceneB
        assertThat(targetValues[SceneC]?.value).isEqualTo(null) // inherited from SceneE
        assertThat(targetValues[SceneD]?.value).isEqualTo(null) // not composed
        assertThat(targetValues[SceneE]?.value).isEqualTo(null) // not composed
        assertThat(targetValues[SceneF]?.value).isEqualTo(null) // no animateContentFloatAsState()

        // state2: snap to G.
        scope.launch { state2.snapTo(SceneG) }
        rule.waitForIdle()
        assertThat(targetValues[SceneA]?.value).isEqualTo(null) // no animateContentFloatAsState()
        assertThat(targetValues[SceneB]?.value).isEqualTo(null) // not composed
        assertThat(targetValues[SceneC]?.value).isEqualTo(null) // not composed
        assertThat(targetValues[SceneD]?.value).isEqualTo(null) // not composed
        assertThat(targetValues[SceneE]?.value).isEqualTo(null) // not composed
        assertThat(targetValues[SceneF]?.value).isEqualTo(null) // not composed
    }
}
+2 −0
Original line number Diff line number Diff line
@@ -28,6 +28,8 @@ object TestScenes {
    val SceneC = SceneKey("SceneC")
    val SceneD = SceneKey("SceneD")
    val SceneE = SceneKey("SceneE")
    val SceneF = SceneKey("SceneF")
    val SceneG = SceneKey("SceneG")
}

/** Overlay keys that can be reused by tests. */