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

Commit 3b018886 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Add MovableElementScope.animateXAsState

Bug: 291053742
Test: atest AnimatedSharedAsStateTest
Change-Id: I572a2b5cd2c7748f78a2e8dadf47e7f742ae3df8
parent d411cb16
Loading
Loading
Loading
Loading
+63 −21
Original line number Diff line number Diff line
@@ -17,12 +17,10 @@
package com.android.compose.animation.scene

import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.DisposableEffectResult
import androidx.compose.runtime.DisposableEffectScope
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.unit.Dp
@@ -44,6 +42,20 @@ fun SceneScope.animateSharedIntAsState(
    return animateSharedValueAsState(value, key, element, ::lerp, canOverflow)
}

/**
 * Animate a shared Int value.
 *
 * @see MovableElementScope.animateSharedValueAsState
 */
@Composable
fun MovableElementScope.animateSharedIntAsState(
    value: Int,
    debugName: String,
    canOverflow: Boolean = true,
): State<Int> {
    return animateSharedValueAsState(value, debugName, ::lerp, canOverflow)
}

/**
 * Animate a shared Float value.
 *
@@ -59,6 +71,20 @@ fun SceneScope.animateSharedFloatAsState(
    return animateSharedValueAsState(value, key, element, ::lerp, canOverflow)
}

/**
 * Animate a shared Float value.
 *
 * @see MovableElementScope.animateSharedValueAsState
 */
@Composable
fun MovableElementScope.animateSharedFloatAsState(
    value: Float,
    debugName: String,
    canOverflow: Boolean = true,
): State<Float> {
    return animateSharedValueAsState(value, debugName, ::lerp, canOverflow)
}

/**
 * Animate a shared Dp value.
 *
@@ -74,6 +100,20 @@ fun SceneScope.animateSharedDpAsState(
    return animateSharedValueAsState(value, key, element, ::lerp, canOverflow)
}

/**
 * Animate a shared Dp value.
 *
 * @see MovableElementScope.animateSharedValueAsState
 */
@Composable
fun MovableElementScope.animateSharedDpAsState(
    value: Dp,
    debugName: String,
    canOverflow: Boolean = true,
): State<Dp> {
    return animateSharedValueAsState(value, debugName, ::lerp, canOverflow)
}

/**
 * Animate a shared Color value.
 *
@@ -88,6 +128,19 @@ fun SceneScope.animateSharedColorAsState(
    return animateSharedValueAsState(value, key, element, ::lerp, canOverflow = false)
}

/**
 * Animate a shared Color value.
 *
 * @see MovableElementScope.animateSharedValueAsState
 */
@Composable
fun MovableElementScope.animateSharedColorAsState(
    value: Color,
    debugName: String,
): State<Color> {
    return animateSharedValueAsState(value, debugName, ::lerp, canOverflow = false)
}

@Composable
internal fun <T> animateSharedValueAsState(
    layoutImpl: SceneTransitionLayoutImpl,
@@ -98,13 +151,15 @@ internal fun <T> animateSharedValueAsState(
    lerp: (T, T, Float) -> T,
    canOverflow: Boolean,
): State<T> {
    val sharedValue = remember(key) { Element.SharedValue(key, value) }
    if (value != sharedValue.value) {
        sharedValue.value = value
    val sharedValue =
        Snapshot.withoutReadObservation {
            element.sceneValues.getValue(scene.key).sharedValues.getOrPut(key) {
                Element.SharedValue(key, value)
            } as Element.SharedValue<T>
        }

    DisposableEffect(element, scene, sharedValue) {
        addSharedValueToElement(element, scene, sharedValue)
    if (value != sharedValue.value) {
        sharedValue.value = value
    }

    return remember(layoutImpl, element, sharedValue, lerp, canOverflow) {
@@ -112,19 +167,6 @@ internal fun <T> animateSharedValueAsState(
    }
}

private fun <T> DisposableEffectScope.addSharedValueToElement(
    element: Element,
    scene: Scene,
    sharedValue: Element.SharedValue<T>,
): DisposableEffectResult {
    val sceneValues =
        element.sceneValues[scene.key] ?: error("Element $element is not present in $scene")
    val sharedValues = sceneValues.sharedValues

    sharedValues[sharedValue.key] = sharedValue
    return onDispose { sharedValues.remove(sharedValue.key) }
}

private fun <T> computeValue(
    layoutImpl: SceneTransitionLayoutImpl,
    element: Element,
+23 −3
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ import android.util.Log
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
@@ -36,8 +37,6 @@ import androidx.compose.ui.unit.IntSize

private const val TAG = "MovableElement"

private object MovableElementScopeImpl : MovableElementScope

@Composable
internal fun MovableElement(
    layoutImpl: SceneTransitionLayoutImpl,
@@ -51,6 +50,10 @@ internal fun MovableElement(
        // every time an element is added/removed from SceneTransitionLayoutImpl.elements, so we
        // disable read observation during the look-up in that map.
        val element = Snapshot.withoutReadObservation { layoutImpl.elements.getValue(key) }
        val movableElementScope =
            remember(layoutImpl, element, scene) {
                MovableElementScopeImpl(layoutImpl, element, scene)
            }

        // The [Picture] to which we save the last drawing commands of this element. This is
        // necessary because the content of this element might not be composed in this scene, in
@@ -77,7 +80,7 @@ internal fun MovableElement(
                    }
                }
            ) {
                element.movableContent { MovableElementScopeImpl.content() }
                element.movableContent { movableElementScope.content() }
            }
        } else {
            // If we are not composed, we draw the previous drawing commands at the same size as the
@@ -178,3 +181,20 @@ private fun shouldComposeMovableElement(
        isHighestScene
    }
}

private class MovableElementScopeImpl(
    private val layoutImpl: SceneTransitionLayoutImpl,
    private val element: Element,
    private val scene: Scene,
) : MovableElementScope {
    @Composable
    override fun <T> animateSharedValueAsState(
        value: T,
        debugName: String,
        lerp: (start: T, stop: T, fraction: Float) -> T,
        canOverflow: Boolean,
    ): State<T> {
        val key = remember { ValueKey(debugName) }
        return animateSharedValueAsState(layoutImpl, scene, element, key, value, lerp, canOverflow)
    }
}
+10 −1
Original line number Diff line number Diff line
@@ -160,7 +160,16 @@ interface SceneScope {

// TODO(b/291053742): Add animateSharedValueAsState(targetValue) without any ValueKey and ElementKey
// arguments to allow sharing values inside a movable element.
@ElementDsl interface MovableElementScope
@ElementDsl
interface MovableElementScope {
    @Composable
    fun <T> animateSharedValueAsState(
        value: T,
        debugName: String,
        lerp: (start: T, stop: T, fraction: Float) -> T,
        canOverflow: Boolean,
    ): State<T>
}

/** An action performed by the user. */
sealed interface UserAction
+218 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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

import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.compose.ui.util.lerp
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class AnimatedSharedAsStateTest {
    @get:Rule val rule = createComposeRule()

    private data class Values(
        val int: Int,
        val float: Float,
        val dp: Dp,
        val color: Color,
    )

    private fun lerp(start: Values, stop: Values, fraction: Float): Values {
        return Values(
            int = lerp(start.int, stop.int, fraction),
            float = lerp(start.float, stop.float, fraction),
            dp = lerp(start.dp, stop.dp, fraction),
            color = lerp(start.color, stop.color, fraction),
        )
    }

    @Composable
    private fun SceneScope.Foo(
        targetValues: Values,
        onCurrentValueChanged: (Values) -> Unit,
    ) {
        val key = TestElements.Foo
        Box(Modifier.element(key)) {
            val int by animateSharedIntAsState(targetValues.int, TestValues.Value1, key)
            val float by animateSharedFloatAsState(targetValues.float, TestValues.Value2, key)
            val dp by animateSharedDpAsState(targetValues.dp, TestValues.Value3, key)
            val color by animateSharedColorAsState(targetValues.color, TestValues.Value4, key)

            // Make sure we read the values during composition, so that we recompose and call
            // onCurrentValueChanged() with the latest values.
            val currentValues = Values(int, float, dp, color)
            SideEffect { onCurrentValueChanged(currentValues) }
        }
    }

    @Composable
    private fun SceneScope.MovableFoo(
        targetValues: Values,
        onCurrentValueChanged: (Values) -> Unit,
    ) {
        val key = TestElements.Foo
        MovableElement(key = key, Modifier) {
            val int by
                animateSharedIntAsState(targetValues.int, debugName = TestValues.Value1.debugName)
            val float by
                animateSharedFloatAsState(
                    targetValues.float,
                    debugName = TestValues.Value2.debugName
                )
            val dp by
                animateSharedDpAsState(targetValues.dp, debugName = TestValues.Value3.debugName)
            val color by
                animateSharedColorAsState(
                    targetValues.color,
                    debugName = TestValues.Value4.debugName
                )

            // Make sure we read the values during composition, so that we recompose and call
            // onCurrentValueChanged() with the latest values.
            val currentValues = Values(int, float, dp, color)
            SideEffect { onCurrentValueChanged(currentValues) }
        }
    }

    @Test
    fun animateSharedValues() {
        val fromValues = Values(int = 0, float = 0f, dp = 0.dp, color = Color.Red)
        val toValues = Values(int = 100, float = 100f, dp = 100.dp, color = Color.Blue)

        var lastValueInFrom = fromValues
        var lastValueInTo = toValues

        rule.testTransition(
            fromSceneContent = {
                Foo(targetValues = fromValues, onCurrentValueChanged = { lastValueInFrom = it })
            },
            toSceneContent = {
                Foo(targetValues = toValues, onCurrentValueChanged = { lastValueInTo = it })
            },
            transition = {
                // The transition lasts 64ms = 4 frames.
                spec = tween(durationMillis = 16 * 4, easing = LinearEasing)
            },
            fromScene = TestScenes.SceneA,
            toScene = TestScenes.SceneB,
        ) {
            before {
                assertThat(lastValueInFrom).isEqualTo(fromValues)

                // to was not composed yet, so lastValueInTo was not set yet.
                assertThat(lastValueInTo).isEqualTo(toValues)
            }

            at(16) {
                // Given that we use Modifier.element() here, animateSharedXAsState is composed in
                // both scenes and values should be interpolated with the transition fraction.
                val expectedValues = lerp(fromValues, toValues, fraction = 0.25f)
                assertThat(lastValueInFrom).isEqualTo(expectedValues)
                assertThat(lastValueInTo).isEqualTo(expectedValues)
            }

            at(32) {
                val expectedValues = lerp(fromValues, toValues, fraction = 0.5f)
                assertThat(lastValueInFrom).isEqualTo(expectedValues)
                assertThat(lastValueInTo).isEqualTo(expectedValues)
            }

            at(48) {
                val expectedValues = lerp(fromValues, toValues, fraction = 0.75f)
                assertThat(lastValueInFrom).isEqualTo(expectedValues)
                assertThat(lastValueInTo).isEqualTo(expectedValues)
            }

            after {
                assertThat(lastValueInFrom).isEqualTo(toValues)
                assertThat(lastValueInTo).isEqualTo(toValues)
            }
        }
    }

    @Test
    fun movableAnimateSharedValues() {
        val fromValues = Values(int = 0, float = 0f, dp = 0.dp, color = Color.Red)
        val toValues = Values(int = 100, float = 100f, dp = 100.dp, color = Color.Blue)

        var lastValueInFrom = fromValues
        var lastValueInTo = toValues

        rule.testTransition(
            fromSceneContent = {
                MovableFoo(
                    targetValues = fromValues,
                    onCurrentValueChanged = { lastValueInFrom = it }
                )
            },
            toSceneContent = {
                MovableFoo(targetValues = toValues, onCurrentValueChanged = { lastValueInTo = it })
            },
            transition = {
                // The transition lasts 64ms = 4 frames.
                spec = tween(durationMillis = 16 * 4, easing = LinearEasing)
            },
            fromScene = TestScenes.SceneA,
            toScene = TestScenes.SceneB,
        ) {
            before {
                assertThat(lastValueInFrom).isEqualTo(fromValues)

                // to was not composed yet, so lastValueInTo was not set yet.
                assertThat(lastValueInTo).isEqualTo(toValues)
            }

            at(16) {
                // Given that we use MovableElement here, animateSharedXAsState is composed only
                // once, in the highest scene (in this case, in toScene).
                assertThat(lastValueInFrom).isEqualTo(fromValues)
                assertThat(lastValueInTo).isEqualTo(lerp(fromValues, toValues, fraction = 0.25f))
            }

            at(32) {
                assertThat(lastValueInFrom).isEqualTo(fromValues)
                assertThat(lastValueInTo).isEqualTo(lerp(fromValues, toValues, fraction = 0.5f))
            }

            at(48) {
                assertThat(lastValueInFrom).isEqualTo(fromValues)
                assertThat(lastValueInTo).isEqualTo(lerp(fromValues, toValues, fraction = 0.75f))
            }

            after {
                assertThat(lastValueInFrom).isEqualTo(fromValues)
                assertThat(lastValueInTo).isEqualTo(toValues)
            }
        }
    }
}
+3 −0
Original line number Diff line number Diff line
@@ -37,6 +37,9 @@ object TestElements {
/** Value keys that can be reused by tests. */
object TestValues {
    val Value1 = ValueKey("Value1")
    val Value2 = ValueKey("Value2")
    val Value3 = ValueKey("Value3")
    val Value4 = ValueKey("Value4")
}

// We use a transition duration of 480ms here because it is a multiple of 16, the time of a frame in