Move SceneTransitionLayout to a separate library (1/2)

This CL is a pure move that moves the code for SceneTransitionLayout
inside a separate library, so that it can be easily copied and reused as
part of the AndroidX benchmarks.

Bug: 300622679
Test: atest PlatformComposeSceneTransitionLayoutTests
Change-Id: I97cbe4515b1bc28db59c9f1234df823d727bc882
diff --git a/packages/SystemUI/compose/scene/Android.bp b/packages/SystemUI/compose/scene/Android.bp
new file mode 100644
index 0000000..050d1d5
--- /dev/null
+++ b/packages/SystemUI/compose/scene/Android.bp
@@ -0,0 +1,39 @@
+// 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 {
+    // See: http://go/android-license-faq
+    // A large-scale-change added 'default_applicable_licenses' to import
+    // all of the 'license_kinds' from "frameworks_base_packages_SystemUI_license"
+    // to get the below license kinds:
+    //   SPDX-license-identifier-Apache-2.0
+    default_applicable_licenses: ["frameworks_base_packages_SystemUI_license"],
+}
+
+android_library {
+    name: "PlatformComposeSceneTransitionLayout",
+    manifest: "AndroidManifest.xml",
+
+    srcs: [
+        "src/**/*.kt",
+    ],
+
+    static_libs: [
+        "androidx.compose.runtime_runtime",
+        "androidx.compose.material3_material3",
+    ],
+
+    kotlincflags: ["-Xjvm-default=all"],
+    use_resource_processor: true,
+}
diff --git a/packages/SystemUI/compose/scene/AndroidManifest.xml b/packages/SystemUI/compose/scene/AndroidManifest.xml
new file mode 100644
index 0000000..81131bb
--- /dev/null
+++ b/packages/SystemUI/compose/scene/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.compose.animation.scene">
+
+
+</manifest>
diff --git a/packages/SystemUI/compose/scene/OWNERS b/packages/SystemUI/compose/scene/OWNERS
new file mode 100644
index 0000000..33a59c2
--- /dev/null
+++ b/packages/SystemUI/compose/scene/OWNERS
@@ -0,0 +1,13 @@
+set noparent
+
+# Bug component: 1184816
+
+jdemeulenaere@google.com
+omarmt@google.com
+
+# SysUI Dr No's.
+# Don't send reviews here.
+dsandler@android.com
+cinek@google.com
+juliacr@google.com
+pixel@google.com
\ No newline at end of file
diff --git a/packages/SystemUI/compose/scene/TEST_MAPPING b/packages/SystemUI/compose/scene/TEST_MAPPING
new file mode 100644
index 0000000..f644a23
--- /dev/null
+++ b/packages/SystemUI/compose/scene/TEST_MAPPING
@@ -0,0 +1,48 @@
+{
+  "presubmit": [
+    {
+      "name": "PlatformComposeSceneTransitionLayoutTests",
+      "options": [
+        {
+          "exclude-annotation": "org.junit.Ignore"
+        },
+        {
+          "exclude-annotation": "androidx.test.filters.FlakyTest"
+        }
+      ]
+    },
+    {
+      "name": "PlatformComposeCoreTests",
+      "options": [
+        {
+          "exclude-annotation": "org.junit.Ignore"
+        },
+        {
+          "exclude-annotation": "androidx.test.filters.FlakyTest"
+        }
+      ]
+    },
+    {
+      "name": "SystemUIComposeFeaturesTests",
+      "options": [
+        {
+          "exclude-annotation": "org.junit.Ignore"
+        },
+        {
+          "exclude-annotation": "androidx.test.filters.FlakyTest"
+        }
+      ]
+    },
+    {
+      "name": "SystemUIComposeGalleryTests",
+      "options": [
+        {
+          "exclude-annotation": "org.junit.Ignore"
+        },
+        {
+          "exclude-annotation": "androidx.test.filters.FlakyTest"
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt
new file mode 100644
index 0000000..041fc48
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt
@@ -0,0 +1,204 @@
+/*
+ * Copyright 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.runtime.Composable
+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
+import androidx.compose.ui.unit.lerp
+import com.android.compose.ui.util.lerp
+
+/**
+ * Animate a shared Int value.
+ *
+ * @see SceneScope.animateSharedValueAsState
+ */
+@Composable
+fun SceneScope.animateSharedIntAsState(
+    value: Int,
+    key: ValueKey,
+    element: ElementKey,
+    canOverflow: Boolean = true,
+): State<Int> {
+    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.
+ *
+ * @see SceneScope.animateSharedValueAsState
+ */
+@Composable
+fun SceneScope.animateSharedFloatAsState(
+    value: Float,
+    key: ValueKey,
+    element: ElementKey,
+    canOverflow: Boolean = true,
+): State<Float> {
+    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.
+ *
+ * @see SceneScope.animateSharedValueAsState
+ */
+@Composable
+fun SceneScope.animateSharedDpAsState(
+    value: Dp,
+    key: ValueKey,
+    element: ElementKey,
+    canOverflow: Boolean = true,
+): State<Dp> {
+    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.
+ *
+ * @see SceneScope.animateSharedValueAsState
+ */
+@Composable
+fun SceneScope.animateSharedColorAsState(
+    value: Color,
+    key: ValueKey,
+    element: ElementKey,
+): State<Color> {
+    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,
+    scene: Scene,
+    element: Element,
+    key: ValueKey,
+    value: T,
+    lerp: (T, T, Float) -> T,
+    canOverflow: Boolean,
+): State<T> {
+    val sharedValue =
+        Snapshot.withoutReadObservation {
+            element.sceneValues.getValue(scene.key).sharedValues.getOrPut(key) {
+                Element.SharedValue(key, value)
+            } as Element.SharedValue<T>
+        }
+
+    if (value != sharedValue.value) {
+        sharedValue.value = value
+    }
+
+    return remember(layoutImpl, element, sharedValue, lerp, canOverflow) {
+        derivedStateOf { computeValue(layoutImpl, element, sharedValue, lerp, canOverflow) }
+    }
+}
+
+private fun <T> computeValue(
+    layoutImpl: SceneTransitionLayoutImpl,
+    element: Element,
+    sharedValue: Element.SharedValue<T>,
+    lerp: (T, T, Float) -> T,
+    canOverflow: Boolean,
+): T {
+    val state = layoutImpl.state.transitionState
+    if (
+        state !is TransitionState.Transition ||
+            state.fromScene == state.toScene ||
+            !layoutImpl.isTransitionReady(state)
+    ) {
+        return sharedValue.value
+    }
+
+    fun sceneValue(scene: SceneKey): Element.SharedValue<T>? {
+        val sceneValues = element.sceneValues[scene] ?: return null
+        val value = sceneValues.sharedValues[sharedValue.key] ?: return null
+        return value as Element.SharedValue<T>
+    }
+
+    val fromValue = sceneValue(state.fromScene)
+    val toValue = sceneValue(state.toScene)
+    return if (fromValue != null && toValue != null) {
+        val progress = if (canOverflow) state.progress else state.progress.coerceIn(0f, 1f)
+        lerp(fromValue.value, toValue.value, progress)
+    } else if (fromValue != null) {
+        fromValue.value
+    } else if (toValue != null) {
+        toValue.value
+    } else {
+        sharedValue.value
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt
new file mode 100644
index 0000000..88944f10
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright 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.Animatable
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.SpringSpec
+import kotlin.math.absoluteValue
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * Transition to [target] using a canned animation. This function will try to be smart and take over
+ * the currently running transition, if there is one.
+ */
+internal fun CoroutineScope.animateToScene(
+    layoutImpl: SceneTransitionLayoutImpl,
+    target: SceneKey,
+) {
+    val state = layoutImpl.state.transitionState
+    if (state.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
+        //     that the gesture was committed and the transition is animating to [scene] already.
+        //  3. The user is swiping from [target] to another scene and either:
+        //     a. didn't release their pointer yet.
+        //     b. released their pointer such that the swipe gesture was cancelled and the
+        //        transition is currently animating back to [target].
+        return
+    }
+
+    when (state) {
+        is TransitionState.Idle -> animate(layoutImpl, target)
+        is TransitionState.Transition -> {
+            if (state.toScene == state.fromScene) {
+                // Same as idle.
+                animate(layoutImpl, target)
+                return
+            }
+
+            // 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) {
+                // 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
+                if ((1f - progress).absoluteValue < ProgressVisibilityThreshold) {
+                    // The transition is already finished (progress ~= 1): no need to animate.
+                    layoutImpl.state.transitionState = TransitionState.Idle(state.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)
+                }
+
+                return
+            }
+
+            if (state.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
+                if (progress.absoluteValue < ProgressVisibilityThreshold) {
+                    // The transition is at progress ~= 0: no need to animate.
+                    layoutImpl.state.transitionState = TransitionState.Idle(state.currentScene)
+                } else {
+                    // TODO(b/290184746): Also take the current velocity into account.
+                    animate(layoutImpl, target, startProgress = progress, reversed = true)
+                }
+
+                return
+            }
+
+            // Generic interruption; the current transition is neither from or to [target].
+            // TODO(b/290930950): Better handle interruptions here.
+            animate(layoutImpl, target)
+        }
+    }
+}
+
+private fun CoroutineScope.animate(
+    layoutImpl: SceneTransitionLayoutImpl,
+    target: SceneKey,
+    startProgress: Float = 0f,
+    reversed: Boolean = false,
+) {
+    val fromScene = layoutImpl.state.transitionState.currentScene
+    val isUserInput =
+        (layoutImpl.state.transitionState as? TransitionState.Transition)?.isUserInputDriven
+            ?: 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) {
+            OneOffTransition(target, fromScene, currentScene = target, isUserInput, animatable)
+        } else {
+            OneOffTransition(fromScene, target, currentScene = target, isUserInput, animatable)
+        }
+
+    // Change the current layout state to use this new transition.
+    layoutImpl.state.transitionState = transition
+
+    // 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)
+        }
+    }
+}
+
+private class OneOffTransition(
+    override val fromScene: SceneKey,
+    override val toScene: SceneKey,
+    override val currentScene: SceneKey,
+    override val isUserInputDriven: Boolean,
+    private val animatable: Animatable<Float, AnimationVector1D>,
+) : TransitionState.Transition {
+    override val progress: Float
+        get() = animatable.value
+}
+
+// TODO(b/290184746): Compute a good default visibility threshold that depends on the layout size
+// and screen density.
+private const val ProgressVisibilityThreshold = 1e-3f
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt
new file mode 100644
index 0000000..ce96bbf
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt
@@ -0,0 +1,513 @@
+/*
+ * Copyright 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.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.movableContentOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.runtime.snapshots.SnapshotStateMap
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.isSpecified
+import androidx.compose.ui.geometry.lerp
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.IntermediateMeasureScope
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.layout.intermediateLayout
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.round
+import com.android.compose.animation.scene.transformation.PropertyTransformation
+import com.android.compose.modifiers.thenIf
+import com.android.compose.ui.util.lerp
+
+/** An element on screen, that can be composed in one or more scenes. */
+internal class Element(val key: ElementKey) {
+    /**
+     * The last values of this element, coming from any scene. Note that this value will be unstable
+     * if this element is present in multiple scenes but the shared element animation is disabled,
+     * given that multiple instances of the element with different states will write to these
+     * values. You should prefer using [TargetValues.lastValues] in the current scene if it is
+     * defined.
+     */
+    val lastSharedValues = Values()
+
+    /** The mapping between a scene and the values/state this element has in that scene, if any. */
+    val sceneValues = SnapshotStateMap<SceneKey, TargetValues>()
+
+    /**
+     * The movable content of this element, if this element is composed using
+     * [SceneScope.MovableElement].
+     */
+    val movableContent by
+        // This is only accessed from the composition (main) thread, so no need to use the default
+        // lock of lazy {} to synchronize.
+        lazy(mode = LazyThreadSafetyMode.NONE) {
+            movableContentOf { content: @Composable () -> Unit -> content() }
+        }
+
+    override fun toString(): String {
+        return "Element(key=$key)"
+    }
+
+    /** The current values of this element, either in a specific scene or in a shared context. */
+    class Values {
+        /** The offset of the element, relative to the SceneTransitionLayout containing it. */
+        var offset = Offset.Unspecified
+
+        /** The size of this element. */
+        var size = SizeUnspecified
+
+        /** The alpha of this element. */
+        var alpha = AlphaUnspecified
+    }
+
+    /** The target values of this element in a given scene. */
+    class TargetValues {
+        val lastValues = Values()
+
+        var targetSize by mutableStateOf(SizeUnspecified)
+        var targetOffset by mutableStateOf(Offset.Unspecified)
+
+        val sharedValues = SnapshotStateMap<ValueKey, SharedValue<*>>()
+    }
+
+    /** A shared value of this element. */
+    class SharedValue<T>(val key: ValueKey, initialValue: T) {
+        var value by mutableStateOf(initialValue)
+    }
+
+    companion object {
+        val SizeUnspecified = IntSize(Int.MAX_VALUE, Int.MAX_VALUE)
+        val AlphaUnspecified = Float.MIN_VALUE
+    }
+}
+
+/** The implementation of [SceneScope.element]. */
+@Composable
+@OptIn(ExperimentalComposeUiApi::class)
+internal fun Modifier.element(
+    layoutImpl: SceneTransitionLayoutImpl,
+    scene: Scene,
+    key: ElementKey,
+): Modifier {
+    val sceneValues = remember(scene, key) { Element.TargetValues() }
+    val element =
+        // Get the element associated to [key] if it was already composed in another scene,
+        // otherwise create it and add it to our Map<ElementKey, Element>. This is done inside a
+        // withoutReadObservation() because there is no need to recompose when that map is mutated.
+        Snapshot.withoutReadObservation {
+            val element =
+                layoutImpl.elements[key] ?: Element(key).also { layoutImpl.elements[key] = it }
+            val previousValues = element.sceneValues[scene.key]
+            if (previousValues == null) {
+                element.sceneValues[scene.key] = sceneValues
+            } else if (previousValues != sceneValues) {
+                error("$key was composed multiple times in $scene")
+            }
+
+            element
+        }
+    val lastSharedValues = element.lastSharedValues
+    val lastSceneValues = sceneValues.lastValues
+
+    DisposableEffect(scene, sceneValues, element) {
+        onDispose {
+            element.sceneValues.remove(scene.key)
+
+            // This was the last scene this element was in, so remove it from the map.
+            if (element.sceneValues.isEmpty()) {
+                layoutImpl.elements.remove(element.key)
+            }
+        }
+    }
+
+    val alpha =
+        remember(layoutImpl, element, scene, sceneValues) {
+            derivedStateOf { elementAlpha(layoutImpl, element, scene, sceneValues) }
+        }
+    val isOpaque by remember(alpha) { derivedStateOf { alpha.value == 1f } }
+    SideEffect {
+        if (isOpaque) {
+            lastSharedValues.alpha = 1f
+            lastSceneValues.alpha = 1f
+        }
+    }
+
+    return drawWithContent {
+            if (shouldDrawElement(layoutImpl, scene, element)) {
+                drawContent()
+            }
+        }
+        .modifierTransformations(layoutImpl, scene, element, sceneValues)
+        .intermediateLayout { measurable, constraints ->
+            val placeable =
+                measure(layoutImpl, scene, element, sceneValues, measurable, constraints)
+            layout(placeable.width, placeable.height) {
+                place(layoutImpl, scene, element, sceneValues, placeable, placementScope = this)
+            }
+        }
+        .thenIf(!isOpaque) {
+            Modifier.graphicsLayer {
+                val alpha = alpha.value
+                this.alpha = alpha
+                lastSharedValues.alpha = alpha
+                lastSceneValues.alpha = alpha
+            }
+        }
+        .testTag(key.testTag)
+}
+
+private fun shouldDrawElement(
+    layoutImpl: SceneTransitionLayoutImpl,
+    scene: Scene,
+    element: Element,
+): Boolean {
+    val state = layoutImpl.state.transitionState
+
+    // Always draw the element if there is no ongoing transition or if the element is not shared.
+    if (
+        state !is TransitionState.Transition ||
+            state.fromScene == state.toScene ||
+            !layoutImpl.isTransitionReady(state) ||
+            state.fromScene !in element.sceneValues ||
+            state.toScene !in element.sceneValues ||
+            !isSharedElementEnabled(layoutImpl, state, element.key)
+    ) {
+        return true
+    }
+
+    val otherScene =
+        layoutImpl.scenes.getValue(
+            if (scene.key == state.fromScene) {
+                state.toScene
+            } else {
+                state.fromScene
+            }
+        )
+
+    // When the element is shared, draw the one in the highest scene unless it is a background, i.e.
+    // it is usually drawn below everything else.
+    val isHighestScene = scene.zIndex > otherScene.zIndex
+    return if (element.key.isBackground) {
+        !isHighestScene
+    } else {
+        isHighestScene
+    }
+}
+
+private fun isSharedElementEnabled(
+    layoutImpl: SceneTransitionLayoutImpl,
+    transition: TransitionState.Transition,
+    element: ElementKey,
+): Boolean {
+    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
+
+    // The sharedElement() transformation must either be null or be the same in both scenes.
+    if (sharedInFromScene != sharedInToScene) {
+        error(
+            "Different sharedElement() transformations matched $element (from=$sharedInFromScene " +
+                "to=$sharedInToScene)"
+        )
+    }
+
+    return sharedInFromScene?.enabled ?: true
+}
+
+/**
+ * Chain the [com.android.compose.animation.scene.transformation.ModifierTransformation] applied
+ * throughout the current transition, if any.
+ */
+private fun Modifier.modifierTransformations(
+    layoutImpl: SceneTransitionLayoutImpl,
+    scene: Scene,
+    element: Element,
+    sceneValues: Element.TargetValues,
+): Modifier {
+    when (val state = layoutImpl.state.transitionState) {
+        is TransitionState.Idle -> return this
+        is TransitionState.Transition -> {
+            val fromScene = state.fromScene
+            val toScene = state.toScene
+            if (fromScene == toScene) {
+                // Same as idle.
+                return this
+            }
+
+            return layoutImpl.transitions
+                .transitionSpec(fromScene, state.toScene)
+                .transformations(element.key, scene.key)
+                .modifier
+                .fold(this) { modifier, transformation ->
+                    with(transformation) {
+                        modifier.transform(layoutImpl, scene, element, sceneValues)
+                    }
+                }
+        }
+    }
+}
+
+private fun elementAlpha(
+    layoutImpl: SceneTransitionLayoutImpl,
+    element: Element,
+    scene: Scene,
+    sceneValues: Element.TargetValues,
+): Float {
+    return computeValue(
+            layoutImpl,
+            scene,
+            element,
+            sceneValue = { 1f },
+            transformation = { it.alpha },
+            idleValue = 1f,
+            currentValue = { 1f },
+            lastValue = {
+                sceneValues.lastValues.alpha.takeIf { it != Element.AlphaUnspecified }
+                    ?: element.lastSharedValues.alpha.takeIf { it != Element.AlphaUnspecified }
+                        ?: 1f
+            },
+            ::lerp,
+        )
+        .coerceIn(0f, 1f)
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+private fun IntermediateMeasureScope.measure(
+    layoutImpl: SceneTransitionLayoutImpl,
+    scene: Scene,
+    element: Element,
+    sceneValues: Element.TargetValues,
+    measurable: Measurable,
+    constraints: Constraints,
+): Placeable {
+    // Update the size this element has in this scene when idle.
+    val targetSizeInScene = lookaheadSize
+    if (targetSizeInScene != sceneValues.targetSize) {
+        // TODO(b/290930950): Better handle when this changes to avoid instant size jumps.
+        sceneValues.targetSize = targetSizeInScene
+    }
+
+    // Some lambdas called (max once) by computeValue() will need to measure [measurable], in which
+    // case we store the resulting placeable here to make sure the element is not measured more than
+    // once.
+    var maybePlaceable: Placeable? = null
+
+    fun Placeable.size() = IntSize(width, height)
+
+    val targetSize =
+        computeValue(
+            layoutImpl,
+            scene,
+            element,
+            sceneValue = { it.targetSize },
+            transformation = { it.size },
+            idleValue = lookaheadSize,
+            currentValue = { measurable.measure(constraints).also { maybePlaceable = it }.size() },
+            lastValue = {
+                sceneValues.lastValues.size.takeIf { it != Element.SizeUnspecified }
+                    ?: element.lastSharedValues.size.takeIf { it != Element.SizeUnspecified }
+                        ?: measurable.measure(constraints).also { maybePlaceable = it }.size()
+            },
+            ::lerp,
+        )
+
+    val placeable =
+        maybePlaceable
+            ?: measurable.measure(
+                Constraints.fixed(
+                    targetSize.width.coerceAtLeast(0),
+                    targetSize.height.coerceAtLeast(0),
+                )
+            )
+
+    val size = placeable.size()
+    element.lastSharedValues.size = size
+    sceneValues.lastValues.size = size
+    return placeable
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+private fun IntermediateMeasureScope.place(
+    layoutImpl: SceneTransitionLayoutImpl,
+    scene: Scene,
+    element: Element,
+    sceneValues: Element.TargetValues,
+    placeable: Placeable,
+    placementScope: Placeable.PlacementScope,
+) {
+    with(placementScope) {
+        // Update the offset (relative to the SceneTransitionLayout) this element has in this scene
+        // when idle.
+        val coords = coordinates!!
+        val targetOffsetInScene = lookaheadScopeCoordinates.localLookaheadPositionOf(coords)
+        if (targetOffsetInScene != sceneValues.targetOffset) {
+            // TODO(b/290930950): Better handle when this changes to avoid instant offset jumps.
+            sceneValues.targetOffset = targetOffsetInScene
+        }
+
+        val currentOffset = lookaheadScopeCoordinates.localPositionOf(coords, Offset.Zero)
+        val targetOffset =
+            computeValue(
+                layoutImpl,
+                scene,
+                element,
+                sceneValue = { it.targetOffset },
+                transformation = { it.offset },
+                idleValue = targetOffsetInScene,
+                currentValue = { currentOffset },
+                lastValue = {
+                    sceneValues.lastValues.offset.takeIf { it.isSpecified }
+                        ?: element.lastSharedValues.offset.takeIf { it.isSpecified }
+                            ?: currentOffset
+                },
+                ::lerp,
+            )
+
+        element.lastSharedValues.offset = targetOffset
+        sceneValues.lastValues.offset = targetOffset
+        placeable.place((targetOffset - currentOffset).round())
+    }
+}
+
+/**
+ * Return the value that should be used depending on the current layout state and transition.
+ *
+ * Important: This function must remain inline because of all the lambda parameters. These lambdas
+ * are necessary because getting some of them might require some computation, like measuring a
+ * Measurable.
+ *
+ * @param layoutImpl the [SceneTransitionLayoutImpl] associated to [element].
+ * @param scene the scene containing [element].
+ * @param element the element being animated.
+ * @param sceneValue the value being animated.
+ * @param transformation the transformation associated to the value being animated.
+ * @param idleValue the value when idle, i.e. when there is no transition happening.
+ * @param currentValue the value that would be used if it is not transformed. Note that this is
+ *   different than [idleValue] even if the value is not transformed directly because it could be
+ *   impacted by the transformations on other elements, like a parent that is being translated or
+ *   resized.
+ * @param lastValue the last value that was used. This should be equal to [currentValue] if this is
+ *   the first time the value is set.
+ * @param lerp the linear interpolation function used to interpolate between two values of this
+ *   value type.
+ */
+private inline fun <T> computeValue(
+    layoutImpl: SceneTransitionLayoutImpl,
+    scene: Scene,
+    element: Element,
+    sceneValue: (Element.TargetValues) -> T,
+    transformation: (ElementTransformations) -> PropertyTransformation<T>?,
+    idleValue: T,
+    currentValue: () -> T,
+    lastValue: () -> T,
+    lerp: (T, T, Float) -> T,
+): T {
+    val state = layoutImpl.state.transitionState
+
+    // There is no ongoing transition.
+    if (state !is TransitionState.Transition || state.fromScene == state.toScene) {
+        return idleValue
+    }
+
+    // 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)) {
+        return lastValue()
+    }
+
+    val fromScene = state.fromScene
+    val toScene = state.toScene
+    val fromValues = element.sceneValues[fromScene]
+    val toValues = element.sceneValues[toScene]
+
+    if (fromValues == null && toValues == null) {
+        error("This should not happen, element $element is neither in $fromScene or $toScene")
+    }
+
+    // TODO(b/291053278): Handle overscroll correctly. We should probably coerce between [0f, 1f]
+    // here and consume overflows at drawing time, somehow reusing Compose OverflowEffect or some
+    // similar mechanism.
+    val transitionProgress = state.progress
+
+    // The element is shared: interpolate between the value in fromScene and the value in toScene.
+    // 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)) {
+        return lerp(
+            sceneValue(fromValues!!),
+            sceneValue(toValues!!),
+            transitionProgress,
+        )
+    }
+
+    val transformation =
+        transformation(
+            layoutImpl.transitions
+                .transitionSpec(fromScene, toScene)
+                .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).
+        ?: return currentValue()
+
+    // Get the transformed value, i.e. the target value at the beginning (for entering elements) or
+    // end (for leaving elements) of the transition.
+    val sceneValues =
+        checkNotNull(
+            when {
+                isSharedElement && scene.key == fromScene -> fromValues
+                isSharedElement -> toValues
+                else -> fromValues ?: toValues
+            }
+        )
+
+    val targetValue =
+        transformation.transform(
+            layoutImpl,
+            scene,
+            element,
+            sceneValues,
+            state,
+            idleValue,
+        )
+
+    // TODO(b/290184746): Make sure that we don't overflow transformations associated to a range.
+    val rangeProgress = transformation.range?.progress(transitionProgress) ?: transitionProgress
+
+    // Interpolate between the value at rest and the value before entering/after leaving.
+    val isEntering = scene.key == toScene
+    return if (isEntering) {
+        lerp(targetValue, idleValue, rangeProgress)
+    } else {
+        lerp(idleValue, targetValue, rangeProgress)
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/ElementMatcher.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/ElementMatcher.kt
new file mode 100644
index 0000000..98dbb67
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/ElementMatcher.kt
@@ -0,0 +1,37 @@
+/*
+ * 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
+
+/** An interface to match one or more elements. */
+interface ElementMatcher {
+    /** Whether the element with key [key] in scene [scene] matches this matcher. */
+    fun matches(key: ElementKey, scene: SceneKey): Boolean
+}
+
+/**
+ * Returns an [ElementMatcher] that matches elements in [scene] also matching [this]
+ * [ElementMatcher].
+ */
+fun ElementMatcher.inScene(scene: SceneKey): ElementMatcher {
+    val delegate = this
+    val matcherScene = scene
+    return object : ElementMatcher {
+        override fun matches(key: ElementKey, scene: SceneKey): Boolean {
+            return scene == matcherScene && delegate.matches(key, scene)
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt
new file mode 100644
index 0000000..bc015ee
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 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.annotation.VisibleForTesting
+
+/**
+ * A base class to create unique keys, associated to an [identity] that is used to check the
+ * equality of two key instances.
+ */
+sealed class Key(val debugName: String, val identity: Any) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (this.javaClass != other?.javaClass) return false
+        return identity == (other as? Key)?.identity
+    }
+
+    override fun hashCode(): Int {
+        return identity.hashCode()
+    }
+
+    override fun toString(): String {
+        return "Key(debugName=$debugName)"
+    }
+}
+
+/** Key for a scene. */
+class SceneKey(
+    name: String,
+    identity: Any = Object(),
+) : Key(name, identity) {
+    @VisibleForTesting val testTag: String = "scene:$name"
+
+    /** The unique [ElementKey] identifying this scene's root element. */
+    val rootElementKey = ElementKey(name, identity)
+
+    override fun toString(): String {
+        return "SceneKey(debugName=$debugName)"
+    }
+}
+
+/** Key for an element. */
+class ElementKey(
+    name: String,
+    identity: Any = Object(),
+
+    /**
+     * Whether this element is a background and usually drawn below other elements. This should be
+     * set to true to make sure that shared backgrounds are drawn below elements of other scenes.
+     */
+    val isBackground: Boolean = false,
+) : Key(name, identity), ElementMatcher {
+    @VisibleForTesting val testTag: String = "element:$name"
+
+    override fun matches(key: ElementKey, scene: SceneKey): Boolean {
+        return key == this
+    }
+
+    override fun toString(): String {
+        return "ElementKey(debugName=$debugName)"
+    }
+
+    companion object {
+        /** Matches any element whose [key identity][ElementKey.identity] matches [predicate]. */
+        fun withIdentity(predicate: (Any) -> Boolean): ElementMatcher {
+            return object : ElementMatcher {
+                override fun matches(key: ElementKey, scene: SceneKey): Boolean {
+                    return predicate(key.identity)
+                }
+            }
+        }
+    }
+}
+
+/** Key for a shared value of an element. */
+class ValueKey(name: String, identity: Any = Object()) : Key(name, identity) {
+    override fun toString(): String {
+        return "ValueKey(debugName=$debugName)"
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt
new file mode 100644
index 0000000..6dbeb69
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt
@@ -0,0 +1,200 @@
+/*
+ * 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 android.graphics.Picture
+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
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.graphics.Canvas
+import androidx.compose.ui.graphics.drawscope.draw
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.IntSize
+
+private const val TAG = "MovableElement"
+
+@Composable
+internal fun MovableElement(
+    layoutImpl: SceneTransitionLayoutImpl,
+    scene: Scene,
+    key: ElementKey,
+    modifier: Modifier,
+    content: @Composable MovableElementScope.() -> Unit,
+) {
+    Box(modifier.element(layoutImpl, scene, key)) {
+        // Get the Element from the map. It will always be the same and we don't want to recompose
+        // 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
+        // which case we still need to draw it.
+        val picture = remember { Picture() }
+
+        if (shouldComposeMovableElement(layoutImpl, scene.key, element)) {
+            Box(
+                Modifier.drawWithCache {
+                    val width = size.width.toInt()
+                    val height = size.height.toInt()
+
+                    onDrawWithContent {
+                        // Save the draw commands into [picture] for later to draw the last content
+                        // even when this movable content is not composed.
+                        val pictureCanvas = Canvas(picture.beginRecording(width, height))
+                        draw(this, this.layoutDirection, pictureCanvas, this.size) {
+                            this@onDrawWithContent.drawContent()
+                        }
+                        picture.endRecording()
+
+                        // Draw the content.
+                        drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) }
+                    }
+                }
+            ) {
+                element.movableContent { movableElementScope.content() }
+            }
+        } else {
+            // If we are not composed, we draw the previous drawing commands at the same size as the
+            // movable content when it was composed in this scene.
+            val sceneValues = element.sceneValues.getValue(scene.key)
+
+            Spacer(
+                Modifier.layout { measurable, _ ->
+                        val size =
+                            sceneValues.targetSize.takeIf { it != Element.SizeUnspecified }
+                                ?: IntSize.Zero
+                        val placeable =
+                            measurable.measure(Constraints.fixed(size.width, size.height))
+                        layout(size.width, size.height) { placeable.place(0, 0) }
+                    }
+                    .drawBehind {
+                        drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) }
+                    }
+            )
+        }
+    }
+}
+
+private fun shouldComposeMovableElement(
+    layoutImpl: SceneTransitionLayoutImpl,
+    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
+    if (fromScene == toScene) {
+        check(fromScene == scene)
+        return true
+    }
+
+    val fromReady = layoutImpl.isSceneReady(fromScene)
+    val toReady = layoutImpl.isSceneReady(toScene)
+
+    val otherScene =
+        when (scene) {
+            fromScene -> toScene
+            toScene -> fromScene
+            else ->
+                error(
+                    "shouldComposeMovableElement(scene=$scene) called with fromScene=$fromScene " +
+                        "and toScene=$toScene"
+                )
+        }
+
+    val isShared = otherScene in element.sceneValues
+
+    if (isShared && !toReady && !fromReady) {
+        // This should usually not happen given that fromScene should be ready, but let's log a
+        // warning here in case it does so it helps debugging flicker issues caused by this part of
+        // the code.
+        Log.w(
+            TAG,
+            "MovableElement $element might have to be composed for the first time in both " +
+                "fromScene=$fromScene and toScene=$toScene. This will probably lead to a flicker " +
+                "where the size of the element will jump from IntSize.Zero to its actual size " +
+                "during the transition."
+        )
+    }
+
+    // Element is not shared in this transition.
+    if (!isShared) {
+        return true
+    }
+
+    // toScene is not ready (because we are composing it for the first time), so we compose it there
+    // first. This is the most common scenario when starting a transition that has a shared movable
+    // element.
+    if (!toReady) {
+        return scene == toScene
+    }
+
+    // This should usually not happen, but if we are also composing for the first time in fromScene
+    // then we should compose it there only.
+    if (!fromReady) {
+        return scene == fromScene
+    }
+
+    // If we are ready in both scenes, then compose in the scene that has the highest zIndex (unless
+    // it is a background) given that this is the one that is going to be drawn.
+    val isHighestScene = layoutImpl.scene(scene).zIndex > layoutImpl.scene(otherScene).zIndex
+    return if (element.key.isBackground) {
+        !isHighestScene
+    } else {
+        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)
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/ObservableTransitionState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/ObservableTransitionState.kt
new file mode 100644
index 0000000..ccdec6e
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/ObservableTransitionState.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.runtime.snapshotFlow
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+
+/**
+ * A scene transition state.
+ *
+ * This models the same thing as [TransitionState], with the following distinctions:
+ * 1. [TransitionState] values are backed by the Snapshot system (Compose State objects) and can be
+ *    used by callers tracking State reads, for instance in Compose code during the composition,
+ *    layout or Compose drawing phases.
+ * 2. [ObservableTransitionState] values are backed by Kotlin [Flow]s and can be collected by
+ *    non-Compose code to observe value changes.
+ * 3. [ObservableTransitionState.Transition.fromScene] and
+ *    [ObservableTransitionState.Transition.toScene] will never be equal, while
+ *    [TransitionState.Transition.fromScene] and [TransitionState.Transition.toScene] can be equal.
+ */
+sealed class ObservableTransitionState {
+    /** No transition/animation is currently running. */
+    data class Idle(val scene: SceneKey) : ObservableTransitionState()
+
+    /** There is a transition animating between two scenes. */
+    data class Transition(
+        val fromScene: SceneKey,
+        val toScene: SceneKey,
+        val progress: Flow<Float>,
+
+        /**
+         * Whether the transition was originally triggered by user input rather than being
+         * programmatic. If this value is initially true, it will remain true until the transition
+         * fully completes, even if the user input that triggered the transition has ended. Any
+         * sub-transitions launched by this one will inherit this value. For example, if the user
+         * drags a pointer but does not exceed the threshold required to transition to another
+         * scene, this value will remain true after the pointer is no longer touching the screen and
+         * will be true in any transition created to animate back to the original position.
+         */
+        val isUserInputDriven: Boolean,
+    ) : ObservableTransitionState()
+}
+
+/**
+ * The current [ObservableTransitionState]. This models the same thing as
+ * [SceneTransitionLayoutState.transitionState], except that it is backed by Flows and can be used
+ * by non-Compose code to observe state changes.
+ */
+fun SceneTransitionLayoutState.observableTransitionState(): Flow<ObservableTransitionState> {
+    return snapshotFlow {
+            when (val state = transitionState) {
+                is TransitionState.Idle -> ObservableTransitionState.Idle(state.currentScene)
+                is TransitionState.Transition -> {
+                    if (state.fromScene == state.toScene) {
+                        ObservableTransitionState.Idle(state.currentScene)
+                    } else {
+                        ObservableTransitionState.Transition(
+                            fromScene = state.fromScene,
+                            toScene = state.toScene,
+                            progress = snapshotFlow { state.progress },
+                            isUserInputDriven = state.isUserInputDriven,
+                        )
+                    }
+                }
+            }
+        }
+        .distinctUntilChanged()
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt
new file mode 100644
index 0000000..3fd6828
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright 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.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onPlaced
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.zIndex
+
+/** A scene in a [SceneTransitionLayout]. */
+internal class Scene(
+    val key: SceneKey,
+    layoutImpl: SceneTransitionLayoutImpl,
+    content: @Composable SceneScope.() -> Unit,
+    actions: Map<UserAction, SceneKey>,
+    zIndex: Float,
+) {
+    private val scope = SceneScopeImpl(layoutImpl, this)
+
+    var content by mutableStateOf(content)
+    var userActions by mutableStateOf(actions)
+    var zIndex by mutableFloatStateOf(zIndex)
+    var size by mutableStateOf(IntSize.Zero)
+
+    @Composable
+    fun Content(modifier: Modifier = Modifier) {
+        Box(modifier.zIndex(zIndex).onPlaced { size = it.size }.testTag(key.testTag)) {
+            scope.content()
+        }
+    }
+
+    override fun toString(): String {
+        return "Scene(key=$key)"
+    }
+}
+
+private class SceneScopeImpl(
+    private val layoutImpl: SceneTransitionLayoutImpl,
+    private val scene: Scene,
+) : SceneScope {
+    @Composable
+    override fun Modifier.element(key: ElementKey): Modifier {
+        return element(layoutImpl, scene, key)
+    }
+
+    @Composable
+    override fun <T> animateSharedValueAsState(
+        value: T,
+        key: ValueKey,
+        element: ElementKey,
+        lerp: (T, T, Float) -> T,
+        canOverflow: Boolean
+    ): State<T> {
+        val element =
+            layoutImpl.elements[element]
+                ?: error(
+                    "Element $element is not composed. Make sure to call animateSharedXAsState " +
+                        "*after* Modifier.element(key)."
+                )
+
+        return animateSharedValueAsState(
+            layoutImpl,
+            scene,
+            element,
+            key,
+            value,
+            lerp,
+            canOverflow,
+        )
+    }
+
+    @Composable
+    override fun MovableElement(
+        key: ElementKey,
+        modifier: Modifier,
+        content: @Composable MovableElementScope.() -> Unit,
+    ) {
+        MovableElement(layoutImpl, scene, key, modifier, content)
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
new file mode 100644
index 0000000..74e66d2
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
@@ -0,0 +1,199 @@
+/*
+ * Copyright 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.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+
+/**
+ * [SceneTransitionLayout] is a container that automatically animates its content whenever
+ * [currentScene] changes, using the transitions defined in [transitions].
+ *
+ * Note: You should use [androidx.compose.animation.AnimatedContent] instead of
+ * [SceneTransitionLayout] if it fits your need. Use [SceneTransitionLayout] over AnimatedContent if
+ * you need support for swipe gestures, shared elements or transitions defined declaratively outside
+ * UI code.
+ *
+ * @param currentScene the current scene
+ * @param onChangeScene a mutator that should set [currentScene] to the given scene when called.
+ *   This is called when the user commits a transition to a new scene because of a [UserAction], for
+ *   instance by triggering back navigation or by swiping to a new scene.
+ * @param transitions the definition of the transitions used to animate a change of scene.
+ * @param state the observable state of this layout.
+ * @param scenes the configuration of the different scenes of this layout.
+ */
+@Composable
+fun SceneTransitionLayout(
+    currentScene: SceneKey,
+    onChangeScene: (SceneKey) -> Unit,
+    transitions: SceneTransitions,
+    modifier: Modifier = Modifier,
+    state: SceneTransitionLayoutState = remember { SceneTransitionLayoutState(currentScene) },
+    scenes: SceneTransitionLayoutScope.() -> Unit,
+) {
+    val density = LocalDensity.current
+    val layoutImpl = remember {
+        SceneTransitionLayoutImpl(
+            onChangeScene,
+            scenes,
+            transitions,
+            state,
+            density,
+        )
+    }
+
+    layoutImpl.onChangeScene = onChangeScene
+    layoutImpl.transitions = transitions
+    layoutImpl.density = density
+    layoutImpl.setScenes(scenes)
+    layoutImpl.setCurrentScene(currentScene)
+
+    layoutImpl.Content(modifier)
+}
+
+interface SceneTransitionLayoutScope {
+    /**
+     * Add a scene to this layout, identified by [key].
+     *
+     * You can configure [userActions] so that swiping on this layout or navigating back will
+     * transition to a different scene.
+     *
+     * Important: scene order along the z-axis follows call order. Calling scene(A) followed by
+     * scene(B) will mean that scene B renders after/above scene A.
+     */
+    fun scene(
+        key: SceneKey,
+        userActions: Map<UserAction, SceneKey> = emptyMap(),
+        content: @Composable SceneScope.() -> Unit,
+    )
+}
+
+/**
+ * A DSL marker to prevent people from nesting calls to Modifier.element() inside a MovableElement,
+ * which is not supported.
+ */
+@DslMarker annotation class ElementDsl
+
+@ElementDsl
+interface SceneScope {
+    /**
+     * Tag an element identified by [key].
+     *
+     * Tagging an element will allow you to reference that element when defining transitions, so
+     * that the element can be transformed and animated when the scene transitions in or out.
+     *
+     * Additionally, this [key] will be used to detect elements that are shared between scenes to
+     * automatically interpolate their size, offset and [shared values][animateSharedValueAsState].
+     *
+     * Note that shared elements tagged using this function will be duplicated in each scene they
+     * are part of, so any **internal** state (e.g. state created using `remember {
+     * mutableStateOf(...) }`) will be lost. If you need to preserve internal state, you should use
+     * [MovableElement] instead.
+     *
+     * @see MovableElement
+     *
+     * TODO(b/291566282): Migrate this to the new Modifier Node API and remove the @Composable
+     *   constraint.
+     */
+    @Composable fun Modifier.element(key: ElementKey): Modifier
+
+    /**
+     * Create a *movable* element identified by [key].
+     *
+     * This creates an element that will be automatically shared when present in multiple scenes and
+     * that can be transformed during transitions, the same way that [element] does. The major
+     * difference with [element] is that elements created with [MovableElement] will be "moved" and
+     * composed only once during transitions (as opposed to [element] that duplicates shared
+     * elements) so that any internal state is preserved during and after the transition.
+     *
+     * @see element
+     */
+    @Composable
+    fun MovableElement(
+        key: ElementKey,
+        modifier: Modifier,
+        content: @Composable MovableElementScope.() -> Unit,
+    )
+
+    /**
+     * Animate some value of a shared element.
+     *
+     * @param value the value of this shared value in the current scene.
+     * @param key the key of this shared value.
+     * @param element the element associated with this 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 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 animateSharedIntAsState
+     * @see animateSharedFloatAsState
+     * @see animateSharedDpAsState
+     * @see animateSharedColorAsState
+     */
+    @Composable
+    fun <T> animateSharedValueAsState(
+        value: T,
+        key: ValueKey,
+        element: ElementKey,
+        lerp: (start: T, stop: T, fraction: Float) -> T,
+        canOverflow: Boolean,
+    ): State<T>
+}
+
+// TODO(b/291053742): Add animateSharedValueAsState(targetValue) without any ValueKey and ElementKey
+// arguments to allow sharing values inside a movable element.
+@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
+
+/** The user navigated back, either using a gesture or by triggering a KEYCODE_BACK event. */
+data object Back : UserAction
+
+/** The user swiped on the container. */
+data class Swipe(
+    val direction: SwipeDirection,
+    val pointerCount: Int = 1,
+    val fromEdge: Edge? = null,
+) : UserAction {
+    companion object {
+        val Left = Swipe(SwipeDirection.Left)
+        val Up = Swipe(SwipeDirection.Up)
+        val Right = Swipe(SwipeDirection.Right)
+        val Down = Swipe(SwipeDirection.Down)
+    }
+}
+
+enum class SwipeDirection {
+    Up,
+    Down,
+    Left,
+    Right,
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
new file mode 100644
index 0000000..4952270
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
@@ -0,0 +1,204 @@
+/*
+ * Copyright 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.activity.compose.BackHandler
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateMap
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.layout.LookaheadScope
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
+import com.android.compose.ui.util.fastForEach
+import kotlinx.coroutines.channels.Channel
+
+internal class SceneTransitionLayoutImpl(
+    onChangeScene: (SceneKey) -> Unit,
+    builder: SceneTransitionLayoutScope.() -> Unit,
+    transitions: SceneTransitions,
+    internal val state: SceneTransitionLayoutState,
+    density: Density,
+) {
+    internal val scenes = SnapshotStateMap<SceneKey, Scene>()
+    internal val elements = SnapshotStateMap<ElementKey, Element>()
+
+    /** The scenes that are "ready", i.e. they were composed and fully laid-out at least once. */
+    private val readyScenes = SnapshotStateMap<SceneKey, Boolean>()
+
+    internal var onChangeScene by mutableStateOf(onChangeScene)
+    internal var transitions by mutableStateOf(transitions)
+    internal var density: Density by mutableStateOf(density)
+
+    /**
+     * The size of this layout. Note that this could be [IntSize.Zero] if this layour does not have
+     * any scene configured or right before the first measure pass of the layout.
+     */
+    internal var size by mutableStateOf(IntSize.Zero)
+
+    init {
+        setScenes(builder)
+    }
+
+    internal fun scene(key: SceneKey): Scene {
+        return scenes[key] ?: error("Scene $key is not configured")
+    }
+
+    internal fun setScenes(builder: SceneTransitionLayoutScope.() -> Unit) {
+        // Keep a reference of the current scenes. After processing [builder], the scenes that were
+        // not configured will be removed.
+        val scenesToRemove = scenes.keys.toMutableSet()
+
+        // The incrementing zIndex of each scene.
+        var zIndex = 0f
+
+        object : SceneTransitionLayoutScope {
+                override fun scene(
+                    key: SceneKey,
+                    userActions: Map<UserAction, SceneKey>,
+                    content: @Composable SceneScope.() -> Unit,
+                ) {
+                    scenesToRemove.remove(key)
+
+                    val scene = scenes[key]
+                    if (scene != null) {
+                        // Update an existing scene.
+                        scene.content = content
+                        scene.userActions = userActions
+                        scene.zIndex = zIndex
+                    } else {
+                        // New scene.
+                        scenes[key] =
+                            Scene(
+                                key,
+                                this@SceneTransitionLayoutImpl,
+                                content,
+                                userActions,
+                                zIndex,
+                            )
+                    }
+
+                    zIndex++
+                }
+            }
+            .builder()
+
+        scenesToRemove.forEach { scenes.remove(it) }
+    }
+
+    @Composable
+    internal fun setCurrentScene(key: SceneKey) {
+        val channel = remember { Channel<SceneKey>(Channel.CONFLATED) }
+        SideEffect { channel.trySend(key) }
+        LaunchedEffect(channel) {
+            for (newKey in channel) {
+                // Inspired by AnimateAsState.kt: let's poll the last value to avoid being one frame
+                // late.
+                val newKey = channel.tryReceive().getOrNull() ?: newKey
+                animateToScene(this@SceneTransitionLayoutImpl, newKey)
+            }
+        }
+    }
+
+    @Composable
+    @OptIn(ExperimentalComposeUiApi::class)
+    internal fun Content(modifier: Modifier) {
+        Box(
+            modifier
+                // Handle horizontal and vertical swipes on this layout.
+                // Note: order here is important and will give a slight priority to the vertical
+                // swipes.
+                .swipeToScene(layoutImpl = this, Orientation.Horizontal)
+                .swipeToScene(layoutImpl = this, Orientation.Vertical)
+                .onSizeChanged { size = it }
+        ) {
+            LookaheadScope {
+                val scenesToCompose =
+                    when (val state = state.transitionState) {
+                        is TransitionState.Idle -> listOf(scene(state.currentScene))
+                        is TransitionState.Transition -> {
+                            if (state.toScene != state.fromScene) {
+                                listOf(scene(state.toScene), scene(state.fromScene))
+                            } else {
+                                listOf(scene(state.fromScene))
+                            }
+                        }
+                    }
+
+                // Handle back events.
+                // TODO(b/290184746): Make sure that this works with SystemUI once we use
+                // SceneTransitionLayout in Flexiglass.
+                scene(state.transitionState.currentScene).userActions[Back]?.let { backScene ->
+                    BackHandler { onChangeScene(backScene) }
+                }
+
+                Box {
+                    scenesToCompose.fastForEach { scene ->
+                        val key = scene.key
+                        key(key) {
+                            // Mark this scene as ready once it has been composed, laid out and
+                            // drawn the first time. We have to do this in a LaunchedEffect here
+                            // because DisposableEffect runs between composition and layout.
+                            LaunchedEffect(key) { readyScenes[key] = true }
+                            DisposableEffect(key) { onDispose { readyScenes.remove(key) } }
+
+                            scene.Content(
+                                Modifier.drawWithContent {
+                                    when (val state = state.transitionState) {
+                                        is TransitionState.Idle -> drawContent()
+                                        is TransitionState.Transition -> {
+                                            // Don't draw scenes that are not ready yet.
+                                            if (
+                                                readyScenes.containsKey(key) ||
+                                                    state.fromScene == state.toScene
+                                            ) {
+                                                drawContent()
+                                            }
+                                        }
+                                    }
+                                }
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Return whether [transition] is ready, i.e. the elements of both scenes of the transition were
+     * laid out at least once.
+     */
+    internal fun isTransitionReady(transition: TransitionState.Transition): Boolean {
+        return readyScenes.containsKey(transition.fromScene) &&
+            readyScenes.containsKey(transition.toScene)
+    }
+
+    internal fun isSceneReady(scene: SceneKey): Boolean = readyScenes.containsKey(scene)
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt
new file mode 100644
index 0000000..7a21211
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright 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.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+
+/** The state of a [SceneTransitionLayout]. */
+class SceneTransitionLayoutState(initialScene: SceneKey) {
+    /**
+     * The current [TransitionState]. All values read here are backed by the Snapshot system.
+     *
+     * To observe those values outside of Compose/the Snapshot system, use
+     * [SceneTransitionLayoutState.observableTransitionState] instead.
+     */
+    var transitionState: TransitionState by mutableStateOf(TransitionState.Idle(initialScene))
+        internal set
+}
+
+sealed interface TransitionState {
+    /**
+     * The current effective scene. If a new transition was triggered, it would start from this
+     * scene.
+     *
+     * For instance, when swiping from scene A to scene B, the [currentScene] is A when the swipe
+     * gesture starts, but then if the user flings their finger and commits the transition to scene
+     * B, then [currentScene] becomes scene B even if the transition is not finished yet and is
+     * still animating to settle to scene B.
+     */
+    val currentScene: SceneKey
+
+    /** No transition/animation is currently running. */
+    data class Idle(override val currentScene: SceneKey) : TransitionState
+
+    /**
+     * There is a transition animating between two scenes.
+     *
+     * Important note: [fromScene] and [toScene] might be the same, in which case this [Transition]
+     * should be treated the same as [Idle]. This is designed on purpose so that a [Transition] can
+     * be started without knowing in advance where it is transitioning to, making the logic of
+     * [swipeToScene] easier to reason about.
+     */
+    interface Transition : TransitionState {
+        /** The scene this transition is starting from. */
+        val fromScene: SceneKey
+
+        /** The scene this transition is going to. */
+        val toScene: SceneKey
+
+        /**
+         * The progress of the transition. This is usually in the `[0; 1]` range, but it can also be
+         * less than `0` or greater than `1` when using transitions with a spring AnimationSpec or
+         * when flinging quickly during a swipe gesture.
+         */
+        val progress: Float
+
+        /** Whether the transition was triggered by user input rather than being programmatic. */
+        val isUserInputDriven: Boolean
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt
new file mode 100644
index 0000000..75dcb2e
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright 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.annotation.VisibleForTesting
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.snap
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.unit.IntSize
+import com.android.compose.animation.scene.transformation.AnchoredSize
+import com.android.compose.animation.scene.transformation.AnchoredTranslate
+import com.android.compose.animation.scene.transformation.EdgeTranslate
+import com.android.compose.animation.scene.transformation.Fade
+import com.android.compose.animation.scene.transformation.ModifierTransformation
+import com.android.compose.animation.scene.transformation.PropertyTransformation
+import com.android.compose.animation.scene.transformation.RangedPropertyTransformation
+import com.android.compose.animation.scene.transformation.ScaleSize
+import com.android.compose.animation.scene.transformation.SharedElementTransformation
+import com.android.compose.animation.scene.transformation.Transformation
+import com.android.compose.animation.scene.transformation.Translate
+import com.android.compose.ui.util.fastForEach
+import com.android.compose.ui.util.fastMap
+
+/** The transitions configuration of a [SceneTransitionLayout]. */
+class SceneTransitions(
+    @get:VisibleForTesting val transitionSpecs: List<TransitionSpec>,
+) {
+    private val cache = mutableMapOf<SceneKey, MutableMap<SceneKey, TransitionSpec>>()
+
+    @VisibleForTesting
+    fun transitionSpec(from: SceneKey, to: SceneKey): TransitionSpec {
+        return cache.getOrPut(from) { mutableMapOf() }.getOrPut(to) { findSpec(from, to) }
+    }
+
+    private fun findSpec(from: SceneKey, to: SceneKey): TransitionSpec {
+        val spec = transition(from, to) { it.from == from && it.to == to }
+        if (spec != null) {
+            return spec
+        }
+
+        val reversed = transition(from, to) { it.from == to && it.to == from }
+        if (reversed != null) {
+            return reversed.reverse()
+        }
+
+        val relaxedSpec =
+            transition(from, to) {
+                (it.from == from && it.to == null) || (it.to == to && it.from == null)
+            }
+        if (relaxedSpec != null) {
+            return relaxedSpec
+        }
+
+        return transition(from, to) {
+                (it.from == to && it.to == null) || (it.to == from && it.from == null)
+            }
+            ?.reverse()
+            ?: defaultTransition(from, to)
+    }
+
+    private fun transition(
+        from: SceneKey,
+        to: SceneKey,
+        filter: (TransitionSpec) -> Boolean,
+    ): TransitionSpec? {
+        var match: TransitionSpec? = null
+        transitionSpecs.fastForEach { spec ->
+            if (filter(spec)) {
+                if (match != null) {
+                    error("Found multiple transition specs for transition $from => $to")
+                }
+                match = spec
+            }
+        }
+        return match
+    }
+
+    private fun defaultTransition(from: SceneKey, to: SceneKey) =
+        TransitionSpec(from, to, emptyList(), snap())
+}
+
+/** The definition of a transition between [from] and [to]. */
+data class TransitionSpec(
+    val from: SceneKey?,
+    val to: SceneKey?,
+    val transformations: List<Transformation>,
+    val spec: AnimationSpec<Float>,
+) {
+    // TODO(b/302300957): Make sure this cache does not infinitely grow.
+    private val cache = mutableMapOf<ElementKey, MutableMap<SceneKey, ElementTransformations>>()
+
+    internal fun reverse(): TransitionSpec {
+        return copy(
+            from = to,
+            to = from,
+            transformations = transformations.fastMap { it.reverse() },
+        )
+    }
+
+    internal fun transformations(element: ElementKey, scene: SceneKey): ElementTransformations {
+        return cache
+            .getOrPut(element) { mutableMapOf() }
+            .getOrPut(scene) { computeTransformations(element, scene) }
+    }
+
+    /** Filter [transformations] to compute the [ElementTransformations] of [element]. */
+    private fun computeTransformations(
+        element: ElementKey,
+        scene: SceneKey,
+    ): ElementTransformations {
+        var shared: SharedElementTransformation? = null
+        val modifier = mutableListOf<ModifierTransformation>()
+        var offset: PropertyTransformation<Offset>? = null
+        var size: PropertyTransformation<IntSize>? = null
+        var alpha: PropertyTransformation<Float>? = null
+
+        fun <T> onPropertyTransformation(
+            root: PropertyTransformation<T>,
+            current: PropertyTransformation<T> = root,
+        ) {
+            when (current) {
+                is Translate,
+                is EdgeTranslate,
+                is AnchoredTranslate -> {
+                    throwIfNotNull(offset, element, name = "offset")
+                    offset = root as PropertyTransformation<Offset>
+                }
+                is ScaleSize,
+                is AnchoredSize -> {
+                    throwIfNotNull(size, element, name = "size")
+                    size = root as PropertyTransformation<IntSize>
+                }
+                is Fade -> {
+                    throwIfNotNull(alpha, element, name = "alpha")
+                    alpha = root as PropertyTransformation<Float>
+                }
+                is RangedPropertyTransformation -> onPropertyTransformation(root, current.delegate)
+            }
+        }
+
+        transformations.fastForEach { transformation ->
+            if (!transformation.matcher.matches(element, scene)) {
+                return@fastForEach
+            }
+
+            when (transformation) {
+                is SharedElementTransformation -> {
+                    throwIfNotNull(shared, element, name = "shared")
+                    shared = transformation
+                }
+                is ModifierTransformation -> modifier.add(transformation)
+                is PropertyTransformation<*> -> onPropertyTransformation(transformation)
+            }
+        }
+
+        return ElementTransformations(shared, modifier, offset, size, alpha)
+    }
+
+    private fun throwIfNotNull(
+        previous: Transformation?,
+        element: ElementKey,
+        name: String,
+    ) {
+        if (previous != null) {
+            error("$element has multiple $name transformations")
+        }
+    }
+}
+
+/** The transformations of an element during a transition. */
+internal class ElementTransformations(
+    val shared: SharedElementTransformation?,
+    val modifier: List<ModifierTransformation>,
+    val offset: PropertyTransformation<Offset>?,
+    val size: PropertyTransformation<IntSize>?,
+    val alpha: PropertyTransformation<Float>?,
+)
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
new file mode 100644
index 0000000..1cbfe30
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
@@ -0,0 +1,662 @@
+/*
+ * Copyright 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.Animatable
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.gestures.DraggableState
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.gestures.rememberDraggableState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Velocity
+import androidx.compose.ui.unit.dp
+import com.android.compose.nestedscroll.PriorityPostNestedScrollConnection
+import kotlin.math.absoluteValue
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+/**
+ * Configures the swipeable behavior of a [SceneTransitionLayout] depending on the current state.
+ */
+@Composable
+internal fun Modifier.swipeToScene(
+    layoutImpl: SceneTransitionLayoutImpl,
+    orientation: Orientation,
+): Modifier {
+    val state = layoutImpl.state.transitionState
+    val currentScene = layoutImpl.scene(state.currentScene)
+    val transition = remember {
+        // Note that the currentScene here does not matter, it's only used for initializing the
+        // transition and will be replaced when a drag event starts.
+        SwipeTransition(initialScene = currentScene)
+    }
+
+    val enabled = state == transition || currentScene.shouldEnableSwipes(orientation)
+
+    // Immediately start the drag if this our [transition] is currently animating to a scene (i.e.
+    // the user released their input pointer after swiping in this orientation) and the user can't
+    // swipe in the other direction.
+    val startDragImmediately =
+        state == transition &&
+            transition.isAnimatingOffset &&
+            !currentScene.shouldEnableSwipes(orientation.opposite())
+
+    // The velocity threshold at which the intent of the user is to swipe up or down. It is the same
+    // as SwipeableV2Defaults.VelocityThreshold.
+    val velocityThreshold = with(LocalDensity.current) { 125.dp.toPx() }
+
+    // The positional threshold at which the intent of the user is to swipe to the next scene. It is
+    // the same as SwipeableV2Defaults.PositionalThreshold.
+    val positionalThreshold = with(LocalDensity.current) { 56.dp.toPx() }
+
+    val draggableState = rememberDraggableState { delta ->
+        onDrag(layoutImpl, transition, orientation, delta)
+    }
+
+    return nestedScroll(
+            connection =
+                rememberSwipeToSceneNestedScrollConnection(
+                    orientation = orientation,
+                    coroutineScope = rememberCoroutineScope(),
+                    draggableState = draggableState,
+                    transition = transition,
+                    layoutImpl = layoutImpl,
+                    velocityThreshold = velocityThreshold,
+                    positionalThreshold = positionalThreshold
+                ),
+        )
+        .draggable(
+            state = draggableState,
+            orientation = orientation,
+            enabled = enabled,
+            startDragImmediately = startDragImmediately,
+            onDragStarted = { onDragStarted(layoutImpl, transition, orientation) },
+            onDragStopped = { velocity ->
+                onDragStopped(
+                    layoutImpl = layoutImpl,
+                    transition = transition,
+                    velocity = velocity,
+                    velocityThreshold = velocityThreshold,
+                    positionalThreshold = positionalThreshold,
+                )
+            },
+        )
+}
+
+private class SwipeTransition(initialScene: Scene) : TransitionState.Transition {
+    var _currentScene by mutableStateOf(initialScene)
+    override val currentScene: SceneKey
+        get() = _currentScene.key
+
+    var _fromScene by mutableStateOf(initialScene)
+    override val fromScene: SceneKey
+        get() = _fromScene.key
+
+    var _toScene by mutableStateOf(initialScene)
+    override val toScene: SceneKey
+        get() = _toScene.key
+
+    override val progress: Float
+        get() {
+            val offset = if (isAnimatingOffset) offsetAnimatable.value else dragOffset
+            if (distance == 0f) {
+                // This can happen only if fromScene == toScene.
+                error(
+                    "Transition.progress should be called only when Transition.fromScene != " +
+                        "Transition.toScene"
+                )
+            }
+            return offset / distance
+        }
+
+    override val isUserInputDriven = true
+
+    /** The current offset caused by the drag gesture. */
+    var dragOffset by mutableFloatStateOf(0f)
+
+    /**
+     * Whether the offset is animated (the user lifted their finger) or if it is driven by gesture.
+     */
+    var isAnimatingOffset by mutableStateOf(false)
+
+    /** The animatable used to animate the offset once the user lifted its finger. */
+    val offsetAnimatable = Animatable(0f, visibilityThreshold = OffsetVisibilityThreshold)
+
+    /** Job to check that there is at most one offset animation in progress. */
+    private var offsetAnimationJob: Job? = null
+
+    /** Ends any previous [offsetAnimationJob] and runs the new [job]. */
+    fun startOffsetAnimation(job: () -> Job) {
+        stopOffsetAnimation()
+        offsetAnimationJob = job()
+    }
+
+    /** Stops any ongoing offset animation. */
+    fun stopOffsetAnimation() {
+        offsetAnimationJob?.cancel()
+    }
+
+    /** The absolute distance between [fromScene] and [toScene]. */
+    var absoluteDistance = 0f
+
+    /**
+     * The signed distance between [fromScene] and [toScene]. It is negative if [fromScene] is above
+     * or to the left of [toScene].
+     */
+    var _distance by mutableFloatStateOf(0f)
+    val distance: Float
+        get() = _distance
+}
+
+/** The destination scene when swiping up or left from [this@upOrLeft]. */
+private fun Scene.upOrLeft(orientation: Orientation): SceneKey? {
+    return when (orientation) {
+        Orientation.Vertical -> userActions[Swipe.Up]
+        Orientation.Horizontal -> userActions[Swipe.Left]
+    }
+}
+
+/** The destination scene when swiping down or right from [this@downOrRight]. */
+private fun Scene.downOrRight(orientation: Orientation): SceneKey? {
+    return when (orientation) {
+        Orientation.Vertical -> userActions[Swipe.Down]
+        Orientation.Horizontal -> userActions[Swipe.Right]
+    }
+}
+
+/** Whether swipe should be enabled in the given [orientation]. */
+private fun Scene.shouldEnableSwipes(orientation: Orientation): Boolean {
+    return upOrLeft(orientation) != null || downOrRight(orientation) != null
+}
+
+private fun Orientation.opposite(): Orientation {
+    return when (this) {
+        Orientation.Vertical -> Orientation.Horizontal
+        Orientation.Horizontal -> Orientation.Vertical
+    }
+}
+
+private fun onDragStarted(
+    layoutImpl: SceneTransitionLayoutImpl,
+    transition: SwipeTransition,
+    orientation: Orientation,
+) {
+    if (layoutImpl.state.transitionState == transition) {
+        // This [transition] was already driving the animation: simply take over it.
+        if (transition.isAnimatingOffset) {
+            // Stop animating and start from where the current offset. Setting the animation job to
+            // `null` will effectively cancel the animation.
+            transition.stopOffsetAnimation()
+            transition.dragOffset = transition.offsetAnimatable.value
+        }
+
+        return
+    }
+
+    // TODO(b/290184746): Better handle interruptions here if state != idle.
+
+    val fromScene = layoutImpl.scene(layoutImpl.state.transitionState.currentScene)
+
+    transition._currentScene = fromScene
+    transition._fromScene = fromScene
+
+    // We don't know where we are transitioning to yet given that the drag just started, so set it
+    // to fromScene, which will effectively be treated the same as Idle(fromScene).
+    transition._toScene = fromScene
+
+    transition.stopOffsetAnimation()
+    transition.dragOffset = 0f
+
+    // Use the layout size in the swipe orientation for swipe distance.
+    // TODO(b/290184746): Also handle custom distances for transitions. With smaller distances, we
+    // will also have to make sure that we correctly handle overscroll.
+    transition.absoluteDistance =
+        when (orientation) {
+            Orientation.Horizontal -> layoutImpl.size.width
+            Orientation.Vertical -> layoutImpl.size.height
+        }.toFloat()
+
+    if (transition.absoluteDistance > 0f) {
+        layoutImpl.state.transitionState = transition
+    }
+}
+
+private fun onDrag(
+    layoutImpl: SceneTransitionLayoutImpl,
+    transition: SwipeTransition,
+    orientation: Orientation,
+    delta: Float,
+) {
+    transition.dragOffset += delta
+
+    // First check transition.fromScene should be changed for the case where the user quickly swiped
+    // twice in a row to accelerate the transition and go from A => B then B => C really fast.
+    maybeHandleAcceleratedSwipe(transition, orientation)
+
+    val offset = transition.dragOffset
+    val fromScene = transition._fromScene
+
+    // Compute the target scene depending on the current offset.
+    val target = fromScene.findTargetSceneAndDistance(orientation, offset, layoutImpl)
+
+    if (transition._toScene.key != target.sceneKey) {
+        transition._toScene = layoutImpl.scenes.getValue(target.sceneKey)
+    }
+
+    if (transition._distance != target.distance) {
+        transition._distance = target.distance
+    }
+}
+
+/**
+ * Change fromScene in the case where the user quickly swiped multiple times in the same direction
+ * to accelerate the transition from A => B then B => C.
+ */
+private fun maybeHandleAcceleratedSwipe(
+    transition: SwipeTransition,
+    orientation: Orientation,
+) {
+    val toScene = transition._toScene
+    val fromScene = transition._fromScene
+
+    // If the swipe was not committed, don't do anything.
+    if (fromScene == toScene || transition._currentScene != toScene) {
+        return
+    }
+
+    // If the offset is past the distance then let's change fromScene so that the user can swipe to
+    // the next screen or go back to the previous one.
+    val offset = transition.dragOffset
+    val absoluteDistance = transition.absoluteDistance
+    if (offset <= -absoluteDistance && fromScene.upOrLeft(orientation) == toScene.key) {
+        transition.dragOffset += absoluteDistance
+        transition._fromScene = toScene
+    } else if (offset >= absoluteDistance && fromScene.downOrRight(orientation) == toScene.key) {
+        transition.dragOffset -= absoluteDistance
+        transition._fromScene = toScene
+    }
+
+    // Important note: toScene and distance will be updated right after this function is called,
+    // using fromScene and dragOffset.
+}
+
+private data class TargetScene(
+    val sceneKey: SceneKey,
+    val distance: Float,
+)
+
+private fun Scene.findTargetSceneAndDistance(
+    orientation: Orientation,
+    directionOffset: Float,
+    layoutImpl: SceneTransitionLayoutImpl,
+): TargetScene {
+    val maxDistance =
+        when (orientation) {
+            Orientation.Horizontal -> layoutImpl.size.width
+            Orientation.Vertical -> layoutImpl.size.height
+        }.toFloat()
+
+    val upOrLeft = upOrLeft(orientation)
+    val downOrRight = downOrRight(orientation)
+
+    // Compute the target scene depending on the current offset.
+    return when {
+        directionOffset < 0f && upOrLeft != null -> {
+            TargetScene(
+                sceneKey = upOrLeft,
+                distance = -maxDistance,
+            )
+        }
+        directionOffset > 0f && downOrRight != null -> {
+            TargetScene(
+                sceneKey = downOrRight,
+                distance = maxDistance,
+            )
+        }
+        else -> {
+            TargetScene(
+                sceneKey = key,
+                distance = 0f,
+            )
+        }
+    }
+}
+
+private fun CoroutineScope.onDragStopped(
+    layoutImpl: SceneTransitionLayoutImpl,
+    transition: SwipeTransition,
+    velocity: Float,
+    velocityThreshold: Float,
+    positionalThreshold: Float,
+    canChangeScene: Boolean = true,
+) {
+    // The state was changed since the drag started; don't do anything.
+    if (layoutImpl.state.transitionState != transition) {
+        return
+    }
+
+    // We were not animating.
+    if (transition._fromScene == transition._toScene) {
+        layoutImpl.state.transitionState = TransitionState.Idle(transition._fromScene.key)
+        return
+    }
+
+    // Compute the destination scene (and therefore offset) to settle in.
+    val targetScene: Scene
+    val targetOffset: Float
+    val offset = transition.dragOffset
+    val distance = transition.distance
+    if (
+        canChangeScene &&
+            shouldCommitSwipe(
+                offset,
+                distance,
+                velocity,
+                velocityThreshold,
+                positionalThreshold,
+                wasCommitted = transition._currentScene == transition._toScene,
+            )
+    ) {
+        targetOffset = distance
+        targetScene = transition._toScene
+    } else {
+        targetOffset = 0f
+        targetScene = transition._fromScene
+    }
+
+    // If the effective current scene changed, it should be reflected right now in the current scene
+    // state, even before the settle animation is ongoing. That way all the swipeables and back
+    // handlers will be refreshed and the user can for instance quickly swipe vertically from A => B
+    // then horizontally from B => C, or swipe from A => B then immediately go back B => A.
+    if (targetScene != transition._currentScene) {
+        transition._currentScene = targetScene
+        layoutImpl.onChangeScene(targetScene.key)
+    }
+
+    animateOffset(
+        transition = transition,
+        layoutImpl = layoutImpl,
+        initialVelocity = velocity,
+        targetOffset = targetOffset,
+        targetScene = targetScene.key
+    )
+}
+
+/**
+ * Whether the swipe to the target scene should be committed or not. This is inspired by
+ * SwipeableV2.computeTarget().
+ */
+private fun shouldCommitSwipe(
+    offset: Float,
+    distance: Float,
+    velocity: Float,
+    velocityThreshold: Float,
+    positionalThreshold: Float,
+    wasCommitted: Boolean,
+): Boolean {
+    fun isCloserToTarget(): Boolean {
+        return (offset - distance).absoluteValue < offset.absoluteValue
+    }
+
+    // Swiping up or left.
+    if (distance < 0f) {
+        return if (offset > 0f || velocity >= velocityThreshold) {
+            false
+        } else {
+            velocity <= -velocityThreshold ||
+                (offset <= -positionalThreshold && !wasCommitted) ||
+                isCloserToTarget()
+        }
+    }
+
+    // Swiping down or right.
+    return if (offset < 0f || velocity <= -velocityThreshold) {
+        false
+    } else {
+        velocity >= velocityThreshold ||
+            (offset >= positionalThreshold && !wasCommitted) ||
+            isCloserToTarget()
+    }
+}
+
+private fun CoroutineScope.animateOffset(
+    transition: SwipeTransition,
+    layoutImpl: SceneTransitionLayoutImpl,
+    initialVelocity: Float,
+    targetOffset: Float,
+    targetScene: SceneKey,
+) {
+    transition.startOffsetAnimation {
+        launch {
+                if (!transition.isAnimatingOffset) {
+                    transition.offsetAnimatable.snapTo(transition.dragOffset)
+                }
+                transition.isAnimatingOffset = true
+
+                transition.offsetAnimatable.animateTo(
+                    targetOffset,
+                    // TODO(b/290184746): Make this spring spec configurable.
+                    spring(
+                        stiffness = Spring.StiffnessMediumLow,
+                        visibilityThreshold = OffsetVisibilityThreshold
+                    ),
+                    initialVelocity = initialVelocity,
+                )
+
+                // Now that the animation is done, the state should be idle. Note that if the state
+                // was changed since this animation started, some external code changed it and we
+                // shouldn't do anything here. Note also that this job will be cancelled in the case
+                // where the user intercepts this swipe.
+                if (layoutImpl.state.transitionState == transition) {
+                    layoutImpl.state.transitionState = TransitionState.Idle(targetScene)
+                }
+            }
+            .also { it.invokeOnCompletion { transition.isAnimatingOffset = false } }
+    }
+}
+
+private fun CoroutineScope.animateOverscroll(
+    layoutImpl: SceneTransitionLayoutImpl,
+    transition: SwipeTransition,
+    velocity: Velocity,
+    orientation: Orientation,
+): Velocity {
+    val velocityAmount =
+        when (orientation) {
+            Orientation.Vertical -> velocity.y
+            Orientation.Horizontal -> velocity.x
+        }
+
+    if (velocityAmount == 0f) {
+        // There is no remaining velocity
+        return Velocity.Zero
+    }
+
+    val fromScene = layoutImpl.scene(layoutImpl.state.transitionState.currentScene)
+    val target = fromScene.findTargetSceneAndDistance(orientation, velocityAmount, layoutImpl)
+    val isValidTarget = target.distance != 0f && target.sceneKey != fromScene.key
+
+    if (!isValidTarget || layoutImpl.state.transitionState == transition) {
+        // We have not found a valid target or we are already in a transition
+        return Velocity.Zero
+    }
+
+    transition._currentScene = fromScene
+    transition._fromScene = fromScene
+    transition._toScene = layoutImpl.scene(target.sceneKey)
+    transition._distance = target.distance
+    transition.absoluteDistance = target.distance.absoluteValue
+    transition.stopOffsetAnimation()
+    transition.dragOffset = 0f
+
+    layoutImpl.state.transitionState = transition
+
+    animateOffset(
+        transition = transition,
+        layoutImpl = layoutImpl,
+        initialVelocity = velocityAmount,
+        targetOffset = 0f,
+        targetScene = fromScene.key
+    )
+
+    // The animateOffset animation consumes any remaining velocity.
+    return velocity
+}
+
+/**
+ * The number of pixels below which there won't be a visible difference in the transition and from
+ * which the animation can stop.
+ */
+private const val OffsetVisibilityThreshold = 0.5f
+
+@Composable
+private fun rememberSwipeToSceneNestedScrollConnection(
+    orientation: Orientation,
+    coroutineScope: CoroutineScope,
+    draggableState: DraggableState,
+    transition: SwipeTransition,
+    layoutImpl: SceneTransitionLayoutImpl,
+    velocityThreshold: Float,
+    positionalThreshold: Float,
+): PriorityPostNestedScrollConnection {
+    val density = LocalDensity.current
+    val scrollConnection =
+        remember(
+            orientation,
+            coroutineScope,
+            draggableState,
+            transition,
+            layoutImpl,
+            velocityThreshold,
+            positionalThreshold,
+            density,
+        ) {
+            fun Offset.toAmount() =
+                when (orientation) {
+                    Orientation.Horizontal -> x
+                    Orientation.Vertical -> y
+                }
+
+            fun Velocity.toAmount() =
+                when (orientation) {
+                    Orientation.Horizontal -> x
+                    Orientation.Vertical -> y
+                }
+
+            fun Float.toOffset() =
+                when (orientation) {
+                    Orientation.Horizontal -> Offset(x = this, y = 0f)
+                    Orientation.Vertical -> Offset(x = 0f, y = this)
+                }
+
+            // The next potential scene is calculated during the canStart
+            var nextScene: SceneKey? = null
+
+            // This is the scene on which we will have priority during the scroll gesture.
+            var priorityScene: SceneKey? = null
+
+            // If we performed a long gesture before entering priority mode, we would have to avoid
+            // moving on to the next scene.
+            var gestureStartedOnNestedChild = false
+
+            PriorityPostNestedScrollConnection(
+                canStart = { offsetAvailable, offsetBeforeStart ->
+                    val amount = offsetAvailable.toAmount()
+                    if (amount == 0f) return@PriorityPostNestedScrollConnection false
+
+                    gestureStartedOnNestedChild = offsetBeforeStart != Offset.Zero
+
+                    val fromScene = layoutImpl.scene(layoutImpl.state.transitionState.currentScene)
+                    nextScene =
+                        when {
+                            amount < 0f -> fromScene.upOrLeft(orientation)
+                            amount > 0f -> fromScene.downOrRight(orientation)
+                            else -> null
+                        }
+
+                    nextScene != null
+                },
+                canContinueScroll = { priorityScene == transition._toScene.key },
+                onStart = {
+                    priorityScene = nextScene
+                    onDragStarted(layoutImpl, transition, orientation)
+                },
+                onScroll = { offsetAvailable ->
+                    val amount = offsetAvailable.toAmount()
+
+                    // TODO(b/297842071) We should handle the overscroll or slow drag if the gesture
+                    // is initiated in a nested child.
+
+                    // Appends a new coroutine to attempt to drag by [amount] px. In this case we
+                    // are assuming that the [coroutineScope] is tied to the main thread and that
+                    // calls to [launch] are therefore queued.
+                    coroutineScope.launch { draggableState.drag { dragBy(amount) } }
+
+                    amount.toOffset()
+                },
+                onStop = { velocityAvailable ->
+                    priorityScene = null
+
+                    coroutineScope.onDragStopped(
+                        layoutImpl = layoutImpl,
+                        transition = transition,
+                        velocity = velocityAvailable.toAmount(),
+                        velocityThreshold = velocityThreshold,
+                        positionalThreshold = positionalThreshold,
+                        canChangeScene = !gestureStartedOnNestedChild
+                    )
+
+                    // The onDragStopped animation consumes any remaining velocity.
+                    velocityAvailable
+                },
+                onPostFling = { velocityAvailable ->
+                    // If there is any velocity left, we can try running an overscroll animation
+                    // between scenes.
+                    coroutineScope.animateOverscroll(
+                        layoutImpl = layoutImpl,
+                        transition = transition,
+                        velocity = velocityAvailable,
+                        orientation = orientation
+                    )
+                },
+            )
+        }
+    DisposableEffect(scrollConnection) {
+        onDispose {
+            coroutineScope.launch {
+                // This should ensure that the draggableState is in a consistent state and that it
+                // does not cause any unexpected behavior.
+                scrollConnection.reset()
+            }
+        }
+    }
+    return scrollConnection
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt
new file mode 100644
index 0000000..4966977
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt
@@ -0,0 +1,203 @@
+/*
+ * Copyright 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.AnimationSpec
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/** Define the [transitions][SceneTransitions] to be used with a [SceneTransitionLayout]. */
+fun transitions(builder: SceneTransitionsBuilder.() -> Unit): SceneTransitions {
+    return transitionsImpl(builder)
+}
+
+@DslMarker annotation class TransitionDsl
+
+@TransitionDsl
+interface SceneTransitionsBuilder {
+    /**
+     * Define the default animation to be played when transitioning [to] the specified scene, from
+     * any scene. For the animation specification to apply only when transitioning between two
+     * specific scenes, use [from] instead.
+     *
+     * @see from
+     */
+    fun to(
+        to: SceneKey,
+        builder: TransitionBuilder.() -> Unit = {},
+    ): TransitionSpec
+
+    /**
+     * Define the animation to be played when transitioning [from] the specified scene. For the
+     * animation specification to apply only when transitioning between two specific scenes, pass
+     * the destination scene via the [to] argument.
+     *
+     * When looking up which transition should be used when animating from scene A to scene B, we
+     * pick the single transition matching one of these predicates (in order of importance):
+     * 1. from == A && to == B
+     * 2. to == A && from == B, which is then treated in reverse.
+     * 3. (from == A && to == null) || (from == null && to == B)
+     * 4. (from == B && to == null) || (from == null && to == A), which is then treated in reverse.
+     */
+    fun from(
+        from: SceneKey,
+        to: SceneKey? = null,
+        builder: TransitionBuilder.() -> Unit = {},
+    ): TransitionSpec
+}
+
+@TransitionDsl
+interface TransitionBuilder : PropertyTransformationBuilder {
+    /**
+     * The [AnimationSpec] used to animate the progress of this transition from `0` to `1` when
+     * performing programmatic (not input pointer tracking) animations.
+     */
+    var spec: AnimationSpec<Float>
+
+    /**
+     * Define a progress-based range for the transformations inside [builder].
+     *
+     * For instance, the following will fade `Foo` during the first half of the transition then it
+     * will translate it by 100.dp during the second half.
+     *
+     * ```
+     * fractionRange(end = 0.5f) { fade(Foo) }
+     * fractionRange(start = 0.5f) { translate(Foo, x = 100.dp) }
+     * ```
+     *
+     * @param start the start of the range, in the [0; 1] range.
+     * @param end the end of the range, in the [0; 1] range.
+     */
+    fun fractionRange(
+        start: Float? = null,
+        end: Float? = null,
+        builder: PropertyTransformationBuilder.() -> Unit,
+    )
+
+    /**
+     * Define a timestamp-based range for the transformations inside [builder].
+     *
+     * For instance, the following will fade `Foo` during the first half of the transition then it
+     * will translate it by 100.dp during the second half.
+     *
+     * ```
+     * spec = tween(500)
+     * timestampRange(end = 250) { fade(Foo) }
+     * timestampRange(start = 250) { translate(Foo, x = 100.dp) }
+     * ```
+     *
+     * Important: [spec] must be a [androidx.compose.animation.core.DurationBasedAnimationSpec] if
+     * you call [timestampRange], otherwise this will throw. The spec duration will be used to
+     * transform this range into a [fractionRange].
+     *
+     * @param startMillis the start of the range, in the [0; spec.duration] range.
+     * @param endMillis the end of the range, in the [0; spec.duration] range.
+     */
+    fun timestampRange(
+        startMillis: Int? = null,
+        endMillis: Int? = null,
+        builder: PropertyTransformationBuilder.() -> Unit,
+    )
+
+    /**
+     * Configure the shared transition when [matcher] is shared between two scenes.
+     *
+     * @param enabled whether the matched element(s) should actually be shared in this transition.
+     *   Defaults to true.
+     */
+    fun sharedElement(matcher: ElementMatcher, enabled: Boolean = true)
+
+    /**
+     * Punch a hole in the element(s) matching [matcher] that has the same bounds as [bounds] and
+     * using the given [shape].
+     *
+     * Punching a hole in an element will "remove" any pixel drawn by that element in the hole area.
+     * This can be used to make content drawn below an opaque element visible. For example, if we
+     * have [this lockscreen scene](http://shortn/_VYySFnJDhN) drawn below
+     * [this shade scene](http://shortn/_fpxGUk0Rg7) and punch a hole in the latter using the big
+     * clock time bounds and a RoundedCornerShape(10dp), [this](http://shortn/_qt80IvORFj) would be
+     * the result.
+     */
+    fun punchHole(matcher: ElementMatcher, bounds: ElementKey, shape: Shape = RectangleShape)
+
+    /**
+     * Adds the transformations in [builder] but in reversed order. This allows you to partially
+     * reuse the definition of the transition from scene `Foo` to scene `Bar` inside the definition
+     * of the transition from scene `Bar` to scene `Foo`.
+     */
+    fun reversed(builder: TransitionBuilder.() -> Unit)
+}
+
+@TransitionDsl
+interface PropertyTransformationBuilder {
+    /**
+     * Fade the element(s) matching [matcher]. This will automatically fade in or fade out if the
+     * element is entering or leaving the scene, respectively.
+     */
+    fun fade(matcher: ElementMatcher)
+
+    /** Translate the element(s) matching [matcher] by ([x], [y]) dp. */
+    fun translate(matcher: ElementMatcher, x: Dp = 0.dp, y: Dp = 0.dp)
+
+    /**
+     * Translate the element(s) matching [matcher] from/to the [edge] of the [SceneTransitionLayout]
+     * animating it.
+     *
+     * If [startsOutsideLayoutBounds] is `true`, then the element will start completely outside of
+     * the layout bounds (i.e. none of it will be visible at progress = 0f if the layout clips its
+     * content). If it is `false`, then the element will start aligned with the edge of the layout
+     * (i.e. it will be completely visible at progress = 0f).
+     */
+    fun translate(matcher: ElementMatcher, edge: Edge, startsOutsideLayoutBounds: Boolean = true)
+
+    /**
+     * Translate the element(s) matching [matcher] by the same amount that [anchor] is translated
+     * during this transition.
+     *
+     * Note: This currently only works if [anchor] is a shared element of this transition.
+     *
+     * TODO(b/290184746): Also support anchors that are not shared but translated because of other
+     *   transformations, like an edge translation.
+     */
+    fun anchoredTranslate(matcher: ElementMatcher, anchor: ElementKey)
+
+    /**
+     * Scale the [width] and [height] of the element(s) matching [matcher]. Note that this scaling
+     * is done during layout, so it will potentially impact the size and position of other elements.
+     *
+     * TODO(b/290184746): Also provide a scaleDrawing() to scale an element at drawing time.
+     */
+    fun scaleSize(matcher: ElementMatcher, width: Float = 1f, height: Float = 1f)
+
+    /**
+     * Scale the element(s) matching [matcher] so that it grows/shrinks to the same size as [anchor]
+     * .
+     *
+     * Note: This currently only works if [anchor] is a shared element of this transition.
+     */
+    fun anchoredSize(matcher: ElementMatcher, anchor: ElementKey)
+}
+
+/** The edge of a [SceneTransitionLayout]. */
+enum class Edge {
+    Left,
+    Right,
+    Top,
+    Bottom,
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt
new file mode 100644
index 0000000..f1c2717
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt
@@ -0,0 +1,180 @@
+/*
+ * Copyright 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.AnimationSpec
+import androidx.compose.animation.core.DurationBasedAnimationSpec
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.spring
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import com.android.compose.animation.scene.transformation.AnchoredSize
+import com.android.compose.animation.scene.transformation.AnchoredTranslate
+import com.android.compose.animation.scene.transformation.EdgeTranslate
+import com.android.compose.animation.scene.transformation.Fade
+import com.android.compose.animation.scene.transformation.PropertyTransformation
+import com.android.compose.animation.scene.transformation.PunchHole
+import com.android.compose.animation.scene.transformation.RangedPropertyTransformation
+import com.android.compose.animation.scene.transformation.ScaleSize
+import com.android.compose.animation.scene.transformation.SharedElementTransformation
+import com.android.compose.animation.scene.transformation.Transformation
+import com.android.compose.animation.scene.transformation.TransformationRange
+import com.android.compose.animation.scene.transformation.Translate
+
+internal fun transitionsImpl(
+    builder: SceneTransitionsBuilder.() -> Unit,
+): SceneTransitions {
+    val impl = SceneTransitionsBuilderImpl().apply(builder)
+    return SceneTransitions(impl.transitionSpecs)
+}
+
+private class SceneTransitionsBuilderImpl : SceneTransitionsBuilder {
+    val transitionSpecs = mutableListOf<TransitionSpec>()
+
+    override fun to(to: SceneKey, builder: TransitionBuilder.() -> Unit): TransitionSpec {
+        return transition(from = null, to = to, builder)
+    }
+
+    override fun from(
+        from: SceneKey,
+        to: SceneKey?,
+        builder: TransitionBuilder.() -> Unit
+    ): TransitionSpec {
+        return transition(from = from, to = to, builder)
+    }
+
+    private fun transition(
+        from: SceneKey?,
+        to: SceneKey?,
+        builder: TransitionBuilder.() -> Unit,
+    ): TransitionSpec {
+        val impl = TransitionBuilderImpl().apply(builder)
+        val spec =
+            TransitionSpec(
+                from,
+                to,
+                impl.transformations,
+                impl.spec,
+            )
+        transitionSpecs.add(spec)
+        return spec
+    }
+}
+
+internal class TransitionBuilderImpl : TransitionBuilder {
+    val transformations = mutableListOf<Transformation>()
+    override var spec: AnimationSpec<Float> = spring(stiffness = Spring.StiffnessLow)
+
+    private var range: TransformationRange? = null
+    private var reversed = false
+    private val durationMillis: Int by lazy {
+        val spec = spec
+        if (spec !is DurationBasedAnimationSpec) {
+            error("timestampRange {} can only be used with a DurationBasedAnimationSpec")
+        }
+
+        spec.vectorize(Float.VectorConverter).durationMillis
+    }
+
+    override fun punchHole(matcher: ElementMatcher, bounds: ElementKey, shape: Shape) {
+        transformations.add(PunchHole(matcher, bounds, shape))
+    }
+
+    override fun reversed(builder: TransitionBuilder.() -> Unit) {
+        reversed = true
+        builder()
+        reversed = false
+    }
+
+    override fun fractionRange(
+        start: Float?,
+        end: Float?,
+        builder: PropertyTransformationBuilder.() -> Unit
+    ) {
+        range = TransformationRange(start, end)
+        builder()
+        range = null
+    }
+
+    override fun sharedElement(matcher: ElementMatcher, enabled: Boolean) {
+        transformations.add(SharedElementTransformation(matcher, enabled))
+    }
+
+    override fun timestampRange(
+        startMillis: Int?,
+        endMillis: Int?,
+        builder: PropertyTransformationBuilder.() -> Unit
+    ) {
+        if (startMillis != null && (startMillis < 0 || startMillis > durationMillis)) {
+            error("invalid start value: startMillis=$startMillis durationMillis=$durationMillis")
+        }
+
+        if (endMillis != null && (endMillis < 0 || endMillis > durationMillis)) {
+            error("invalid end value: endMillis=$startMillis durationMillis=$durationMillis")
+        }
+
+        val start = startMillis?.let { it.toFloat() / durationMillis }
+        val end = endMillis?.let { it.toFloat() / durationMillis }
+        fractionRange(start, end, builder)
+    }
+
+    private fun transformation(transformation: PropertyTransformation<*>) {
+        val transformation =
+            if (range != null) {
+                RangedPropertyTransformation(transformation, range!!)
+            } else {
+                transformation
+            }
+
+        transformations.add(
+            if (reversed) {
+                transformation.reverse()
+            } else {
+                transformation
+            }
+        )
+    }
+
+    override fun fade(matcher: ElementMatcher) {
+        transformation(Fade(matcher))
+    }
+
+    override fun translate(matcher: ElementMatcher, x: Dp, y: Dp) {
+        transformation(Translate(matcher, x, y))
+    }
+
+    override fun translate(
+        matcher: ElementMatcher,
+        edge: Edge,
+        startsOutsideLayoutBounds: Boolean
+    ) {
+        transformation(EdgeTranslate(matcher, edge, startsOutsideLayoutBounds))
+    }
+
+    override fun anchoredTranslate(matcher: ElementMatcher, anchor: ElementKey) {
+        transformation(AnchoredTranslate(matcher, anchor))
+    }
+
+    override fun scaleSize(matcher: ElementMatcher, width: Float, height: Float) {
+        transformation(ScaleSize(matcher, width, height))
+    }
+
+    override fun anchoredSize(matcher: ElementMatcher, anchor: ElementKey) {
+        transformation(AnchoredSize(matcher, anchor))
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt
new file mode 100644
index 0000000..95385d5
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright 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.transformation
+
+import androidx.compose.ui.unit.IntSize
+import com.android.compose.animation.scene.Element
+import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.ElementMatcher
+import com.android.compose.animation.scene.Scene
+import com.android.compose.animation.scene.SceneKey
+import com.android.compose.animation.scene.SceneTransitionLayoutImpl
+import com.android.compose.animation.scene.TransitionState
+
+/** Anchor the size of an element to the size of another element. */
+internal class AnchoredSize(
+    override val matcher: ElementMatcher,
+    private val anchor: ElementKey,
+) : PropertyTransformation<IntSize> {
+    override fun transform(
+        layoutImpl: SceneTransitionLayoutImpl,
+        scene: Scene,
+        element: Element,
+        sceneValues: Element.TargetValues,
+        transition: TransitionState.Transition,
+        value: IntSize,
+    ): IntSize {
+        fun anchorSizeIn(scene: SceneKey): IntSize {
+            val size = layoutImpl.elements[anchor]?.sceneValues?.get(scene)?.targetSize
+            return if (size != null && size != Element.SizeUnspecified) {
+                size
+            } else {
+                value
+            }
+        }
+
+        // This simple implementation assumes that the size of [element] is the same as the size of
+        // the [anchor] in [scene], so simply transform to the size of the anchor in the other
+        // scene.
+        return if (scene.key == transition.fromScene) {
+            anchorSizeIn(transition.toScene)
+        } else {
+            anchorSizeIn(transition.fromScene)
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt
new file mode 100644
index 0000000..a1d6319
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 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.transformation
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.isSpecified
+import com.android.compose.animation.scene.Element
+import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.ElementMatcher
+import com.android.compose.animation.scene.Scene
+import com.android.compose.animation.scene.SceneKey
+import com.android.compose.animation.scene.SceneTransitionLayoutImpl
+import com.android.compose.animation.scene.TransitionState
+
+/** Anchor the translation of an element to another element. */
+internal class AnchoredTranslate(
+    override val matcher: ElementMatcher,
+    private val anchor: ElementKey,
+) : PropertyTransformation<Offset> {
+    override fun transform(
+        layoutImpl: SceneTransitionLayoutImpl,
+        scene: Scene,
+        element: Element,
+        sceneValues: Element.TargetValues,
+        transition: TransitionState.Transition,
+        value: Offset,
+    ): Offset {
+        val anchor = layoutImpl.elements[anchor] ?: return value
+        fun anchorOffsetIn(scene: SceneKey): Offset? {
+            return anchor.sceneValues[scene]?.targetOffset?.takeIf { it.isSpecified }
+        }
+
+        // [element] will move the same amount as [anchor] does.
+        // TODO(b/290184746): Also support anchors that are not shared but translated because of
+        // other transformations, like an edge translation.
+        val anchorFromOffset = anchorOffsetIn(transition.fromScene) ?: return value
+        val anchorToOffset = anchorOffsetIn(transition.toScene) ?: return value
+        val offset = anchorToOffset - anchorFromOffset
+
+        return if (scene.key == transition.toScene) {
+            Offset(
+                value.x - offset.x,
+                value.y - offset.y,
+            )
+        } else {
+            Offset(
+                value.x + offset.x,
+                value.y + offset.y,
+            )
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt
new file mode 100644
index 0000000..840800d
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright 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.transformation
+
+import androidx.compose.ui.geometry.Offset
+import com.android.compose.animation.scene.Edge
+import com.android.compose.animation.scene.Element
+import com.android.compose.animation.scene.ElementMatcher
+import com.android.compose.animation.scene.Scene
+import com.android.compose.animation.scene.SceneTransitionLayoutImpl
+import com.android.compose.animation.scene.TransitionState
+
+/** Translate an element from an edge of the layout. */
+internal class EdgeTranslate(
+    override val matcher: ElementMatcher,
+    private val edge: Edge,
+    private val startsOutsideLayoutBounds: Boolean = true,
+) : PropertyTransformation<Offset> {
+    override fun transform(
+        layoutImpl: SceneTransitionLayoutImpl,
+        scene: Scene,
+        element: Element,
+        sceneValues: Element.TargetValues,
+        transition: TransitionState.Transition,
+        value: Offset
+    ): Offset {
+        val sceneSize = scene.size
+        val elementSize = sceneValues.targetSize
+        if (elementSize == Element.SizeUnspecified) {
+            return value
+        }
+
+        return when (edge) {
+            Edge.Top ->
+                if (startsOutsideLayoutBounds) {
+                    Offset(value.x, -elementSize.height.toFloat())
+                } else {
+                    Offset(value.x, 0f)
+                }
+            Edge.Left ->
+                if (startsOutsideLayoutBounds) {
+                    Offset(-elementSize.width.toFloat(), value.y)
+                } else {
+                    Offset(0f, value.y)
+                }
+            Edge.Bottom ->
+                if (startsOutsideLayoutBounds) {
+                    Offset(value.x, sceneSize.height.toFloat())
+                } else {
+                    Offset(value.x, (sceneSize.height - elementSize.height).toFloat())
+                }
+            Edge.Right ->
+                if (startsOutsideLayoutBounds) {
+                    Offset(sceneSize.width.toFloat(), value.y)
+                } else {
+                    Offset((sceneSize.width - elementSize.width).toFloat(), value.y)
+                }
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Fade.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Fade.kt
new file mode 100644
index 0000000..17032dc
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Fade.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 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.transformation
+
+import com.android.compose.animation.scene.Element
+import com.android.compose.animation.scene.ElementMatcher
+import com.android.compose.animation.scene.Scene
+import com.android.compose.animation.scene.SceneTransitionLayoutImpl
+import com.android.compose.animation.scene.TransitionState
+
+/** Fade an element in or out. */
+internal class Fade(
+    override val matcher: ElementMatcher,
+) : PropertyTransformation<Float> {
+    override fun transform(
+        layoutImpl: SceneTransitionLayoutImpl,
+        scene: Scene,
+        element: Element,
+        sceneValues: Element.TargetValues,
+        transition: TransitionState.Transition,
+        value: Float
+    ): Float {
+        // Return the alpha value of [element] either when it starts fading in or when it finished
+        // fading out, which is `0` in both cases.
+        return 0f
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/PunchHole.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/PunchHole.kt
new file mode 100644
index 0000000..62d67f0
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/PunchHole.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright 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.transformation
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.toRect
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.drawOutline
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.graphics.withSaveLayer
+import androidx.compose.ui.unit.toSize
+import com.android.compose.animation.scene.Element
+import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.ElementMatcher
+import com.android.compose.animation.scene.Scene
+import com.android.compose.animation.scene.SceneTransitionLayoutImpl
+
+/** Punch a hole in an element using the bounds of another element and a given [shape]. */
+internal class PunchHole(
+    override val matcher: ElementMatcher,
+    private val bounds: ElementKey,
+    private val shape: Shape,
+) : ModifierTransformation {
+    override fun Modifier.transform(
+        layoutImpl: SceneTransitionLayoutImpl,
+        scene: Scene,
+        element: Element,
+        sceneValues: Element.TargetValues,
+    ): Modifier {
+        return drawWithContent {
+            val bounds = layoutImpl.elements[bounds]
+            if (
+                bounds == null ||
+                    bounds.lastSharedValues.size == Element.SizeUnspecified ||
+                    bounds.lastSharedValues.offset == Offset.Unspecified
+            ) {
+                drawContent()
+                return@drawWithContent
+            }
+
+            drawIntoCanvas { canvas ->
+                canvas.withSaveLayer(size.toRect(), Paint()) {
+                    drawContent()
+
+                    val offset = bounds.lastSharedValues.offset - element.lastSharedValues.offset
+                    translate(offset.x, offset.y) { drawHole(bounds) }
+                }
+            }
+        }
+    }
+
+    private fun DrawScope.drawHole(bounds: Element) {
+        val boundsSize = bounds.lastSharedValues.size.toSize()
+        if (shape == RectangleShape) {
+            drawRect(Color.Black, size = boundsSize, blendMode = BlendMode.DstOut)
+            return
+        }
+
+        // TODO(b/290184746): Cache outline if the size of bounds does not change.
+        drawOutline(
+            shape.createOutline(
+                boundsSize,
+                layoutDirection,
+                this,
+            ),
+            Color.Black,
+            blendMode = BlendMode.DstOut,
+        )
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/ScaleSize.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/ScaleSize.kt
new file mode 100644
index 0000000..233ae59
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/ScaleSize.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 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.transformation
+
+import androidx.compose.ui.unit.IntSize
+import com.android.compose.animation.scene.Element
+import com.android.compose.animation.scene.ElementMatcher
+import com.android.compose.animation.scene.Scene
+import com.android.compose.animation.scene.SceneTransitionLayoutImpl
+import com.android.compose.animation.scene.TransitionState
+import kotlin.math.roundToInt
+
+/**
+ * Scales the size of an element. Note that this makes the element resize every frame and will
+ * therefore impact the layout of other elements.
+ */
+internal class ScaleSize(
+    override val matcher: ElementMatcher,
+    private val width: Float = 1f,
+    private val height: Float = 1f,
+) : PropertyTransformation<IntSize> {
+    override fun transform(
+        layoutImpl: SceneTransitionLayoutImpl,
+        scene: Scene,
+        element: Element,
+        sceneValues: Element.TargetValues,
+        transition: TransitionState.Transition,
+        value: IntSize,
+    ): IntSize {
+        return IntSize(
+            width = (value.width * width).roundToInt(),
+            height = (value.height * height).roundToInt(),
+        )
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Transformation.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Transformation.kt
new file mode 100644
index 0000000..2ef8d56
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Transformation.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright 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.transformation
+
+import androidx.compose.ui.Modifier
+import com.android.compose.animation.scene.Element
+import com.android.compose.animation.scene.ElementMatcher
+import com.android.compose.animation.scene.Scene
+import com.android.compose.animation.scene.SceneTransitionLayoutImpl
+import com.android.compose.animation.scene.TransitionState
+
+/** A transformation applied to one or more elements during a transition. */
+sealed interface Transformation {
+    /**
+     * The matcher that should match the element(s) to which this transformation should be applied.
+     */
+    val matcher: ElementMatcher
+
+    /**
+     * The range during which the transformation is applied. If it is `null`, then the
+     * transformation will be applied throughout the whole scene transition.
+     */
+    // TODO(b/240432457): Move this back to PropertyTransformation.
+    val range: TransformationRange?
+        get() = null
+
+    /*
+     * Reverse this transformation. This is called when we use Transition(from = A, to = B) when
+     * animating from B to A and there is no Transition(from = B, to = A) defined.
+     */
+    fun reverse(): Transformation = this
+}
+
+internal class SharedElementTransformation(
+    override val matcher: ElementMatcher,
+    internal val enabled: Boolean,
+) : Transformation
+
+/** A transformation that is applied on the element during the whole transition. */
+internal interface ModifierTransformation : Transformation {
+    /** Apply the transformation to [element]. */
+    // TODO(b/290184746): Figure out a public API for custom transformations that don't have access
+    // to these internal classes.
+    fun Modifier.transform(
+        layoutImpl: SceneTransitionLayoutImpl,
+        scene: Scene,
+        element: Element,
+        sceneValues: Element.TargetValues,
+    ): Modifier
+}
+
+/** A transformation that changes the value of an element property, like its size or offset. */
+internal sealed interface PropertyTransformation<T> : Transformation {
+    /**
+     * Transform [value], i.e. the value of the transformed property without this transformation.
+     */
+    // TODO(b/290184746): Figure out a public API for custom transformations that don't have access
+    // to these internal classes.
+    fun transform(
+        layoutImpl: SceneTransitionLayoutImpl,
+        scene: Scene,
+        element: Element,
+        sceneValues: Element.TargetValues,
+        transition: TransitionState.Transition,
+        value: T,
+    ): T
+}
+
+/**
+ * A [PropertyTransformation] associated to a range. This is a helper class so that normal
+ * implementations of [PropertyTransformation] don't have to take care of reversing their range when
+ * they are reversed.
+ */
+internal class RangedPropertyTransformation<T>(
+    val delegate: PropertyTransformation<T>,
+    override val range: TransformationRange,
+) : PropertyTransformation<T> by delegate {
+    override fun reverse(): Transformation {
+        return RangedPropertyTransformation(
+            delegate.reverse() as PropertyTransformation<T>,
+            range.reverse()
+        )
+    }
+}
+
+/** The progress-based range of a [PropertyTransformation]. */
+data class TransformationRange(
+    val start: Float,
+    val end: Float,
+) {
+    constructor(
+        start: Float? = null,
+        end: Float? = null
+    ) : this(start ?: BoundUnspecified, end ?: BoundUnspecified)
+
+    init {
+        require(!start.isSpecified() || (start in 0f..1f))
+        require(!end.isSpecified() || (end in 0f..1f))
+        require(!start.isSpecified() || !end.isSpecified() || start <= end)
+    }
+
+    /** Reverse this range. */
+    fun reverse() = TransformationRange(start = reverseBound(end), end = reverseBound(start))
+
+    /** Get the progress of this range given the global [transitionProgress]. */
+    fun progress(transitionProgress: Float): Float {
+        return when {
+            start.isSpecified() && end.isSpecified() ->
+                ((transitionProgress - start) / (end - start)).coerceIn(0f, 1f)
+            !start.isSpecified() && !end.isSpecified() -> transitionProgress
+            end.isSpecified() -> (transitionProgress / end).coerceAtMost(1f)
+            else -> ((transitionProgress - start) / (1f - start)).coerceAtLeast(0f)
+        }
+    }
+
+    private fun Float.isSpecified() = this != BoundUnspecified
+
+    private fun reverseBound(bound: Float): Float {
+        return if (bound.isSpecified()) {
+            1f - bound
+        } else {
+            BoundUnspecified
+        }
+    }
+
+    companion object {
+        const val BoundUnspecified = Float.MIN_VALUE
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Translate.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Translate.kt
new file mode 100644
index 0000000..864b937
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Translate.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 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.transformation
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.android.compose.animation.scene.Element
+import com.android.compose.animation.scene.ElementMatcher
+import com.android.compose.animation.scene.Scene
+import com.android.compose.animation.scene.SceneTransitionLayoutImpl
+import com.android.compose.animation.scene.TransitionState
+
+/** Translate an element by a fixed amount of density-independent pixels. */
+internal class Translate(
+    override val matcher: ElementMatcher,
+    private val x: Dp = 0.dp,
+    private val y: Dp = 0.dp,
+) : PropertyTransformation<Offset> {
+    override fun transform(
+        layoutImpl: SceneTransitionLayoutImpl,
+        scene: Scene,
+        element: Element,
+        sceneValues: Element.TargetValues,
+        transition: TransitionState.Transition,
+        value: Offset,
+    ): Offset {
+        return with(layoutImpl.density) {
+            Offset(
+                value.x + x.toPx(),
+                value.y + y.toPx(),
+            )
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/grid/Grids.kt b/packages/SystemUI/compose/scene/src/com/android/compose/grid/Grids.kt
new file mode 100644
index 0000000..27f0948
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/grid/Grids.kt
@@ -0,0 +1,191 @@
+/*
+ * 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.grid
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kotlin.math.ceil
+import kotlin.math.max
+import kotlin.math.roundToInt
+
+/**
+ * Renders a grid with [columns] columns.
+ *
+ * Child composables will be arranged row by row.
+ *
+ * Each column is spaced from the columns to its left and right by [horizontalSpacing]. Each cell
+ * inside a column is spaced from the cells above and below it with [verticalSpacing].
+ */
+@Composable
+fun VerticalGrid(
+    columns: Int,
+    modifier: Modifier = Modifier,
+    verticalSpacing: Dp = 0.dp,
+    horizontalSpacing: Dp = 0.dp,
+    content: @Composable () -> Unit,
+) {
+    Grid(
+        primarySpaces = columns,
+        isVertical = true,
+        modifier = modifier,
+        verticalSpacing = verticalSpacing,
+        horizontalSpacing = horizontalSpacing,
+        content = content,
+    )
+}
+
+/**
+ * Renders a grid with [rows] rows.
+ *
+ * Child composables will be arranged column by column.
+ *
+ * Each column is spaced from the columns to its left and right by [horizontalSpacing]. Each cell
+ * inside a column is spaced from the cells above and below it with [verticalSpacing].
+ */
+@Composable
+fun HorizontalGrid(
+    rows: Int,
+    modifier: Modifier = Modifier,
+    verticalSpacing: Dp = 0.dp,
+    horizontalSpacing: Dp = 0.dp,
+    content: @Composable () -> Unit,
+) {
+    Grid(
+        primarySpaces = rows,
+        isVertical = false,
+        modifier = modifier,
+        verticalSpacing = verticalSpacing,
+        horizontalSpacing = horizontalSpacing,
+        content = content,
+    )
+}
+
+@Composable
+private fun Grid(
+    primarySpaces: Int,
+    isVertical: Boolean,
+    modifier: Modifier = Modifier,
+    verticalSpacing: Dp,
+    horizontalSpacing: Dp,
+    content: @Composable () -> Unit,
+) {
+    check(primarySpaces > 0) {
+        "Must provide a positive number of ${if (isVertical) "columns" else "rows"}"
+    }
+
+    val sizeCache = remember {
+        object {
+            var rowHeights = intArrayOf()
+            var columnWidths = intArrayOf()
+        }
+    }
+
+    Layout(
+        modifier = modifier,
+        content = content,
+    ) { measurables, constraints ->
+        val cells = measurables.size
+        val columns: Int
+        val rows: Int
+        if (isVertical) {
+            columns = primarySpaces
+            rows = ceil(cells.toFloat() / primarySpaces).toInt()
+        } else {
+            columns = ceil(cells.toFloat() / primarySpaces).toInt()
+            rows = primarySpaces
+        }
+
+        if (sizeCache.rowHeights.size != rows) {
+            sizeCache.rowHeights = IntArray(rows) { 0 }
+        }
+        if (sizeCache.columnWidths.size != columns) {
+            sizeCache.columnWidths = IntArray(columns) { 0 }
+        }
+
+        val totalHorizontalSpacingBetweenChildren =
+            ((columns - 1) * horizontalSpacing.toPx()).roundToInt()
+        val totalVerticalSpacingBetweenChildren = ((rows - 1) * verticalSpacing.toPx()).roundToInt()
+        val childConstraints =
+            Constraints(
+                maxWidth =
+                    if (constraints.maxWidth != Constraints.Infinity) {
+                        (constraints.maxWidth - totalHorizontalSpacingBetweenChildren) / columns
+                    } else {
+                        Constraints.Infinity
+                    },
+                maxHeight =
+                    if (constraints.maxHeight != Constraints.Infinity) {
+                        (constraints.maxHeight - totalVerticalSpacingBetweenChildren) / rows
+                    } else {
+                        Constraints.Infinity
+                    }
+            )
+
+        val placeables = buildList {
+            for (cellIndex in measurables.indices) {
+                val column: Int
+                val row: Int
+                if (isVertical) {
+                    column = cellIndex % columns
+                    row = cellIndex / columns
+                } else {
+                    column = cellIndex / rows
+                    row = cellIndex % rows
+                }
+
+                val placeable = measurables[cellIndex].measure(childConstraints)
+                sizeCache.rowHeights[row] = max(sizeCache.rowHeights[row], placeable.height)
+                sizeCache.columnWidths[column] =
+                    max(sizeCache.columnWidths[column], placeable.width)
+                add(placeable)
+            }
+        }
+
+        var totalWidth = totalHorizontalSpacingBetweenChildren
+        for (column in sizeCache.columnWidths.indices) {
+            totalWidth += sizeCache.columnWidths[column]
+        }
+
+        var totalHeight = totalVerticalSpacingBetweenChildren
+        for (row in sizeCache.rowHeights.indices) {
+            totalHeight += sizeCache.rowHeights[row]
+        }
+
+        layout(totalWidth, totalHeight) {
+            var y = 0
+            repeat(rows) { row ->
+                var x = 0
+                var maxChildHeight = 0
+                repeat(columns) { column ->
+                    val cellIndex = row * columns + column
+                    if (cellIndex < cells) {
+                        val placeable = placeables[cellIndex]
+                        placeable.placeRelative(x, y)
+                        x += placeable.width + horizontalSpacing.roundToPx()
+                        maxChildHeight = max(maxChildHeight, placeable.height)
+                    }
+                }
+                y += maxChildHeight + verticalSpacing.roundToPx()
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/modifiers/ConditionalModifiers.kt b/packages/SystemUI/compose/scene/src/com/android/compose/modifiers/ConditionalModifiers.kt
new file mode 100644
index 0000000..135a6e4
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/modifiers/ConditionalModifiers.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.modifiers
+
+import androidx.compose.ui.Modifier
+
+/**
+ * Concatenates this modifier with another if `condition` is true.
+ *
+ * @param condition Whether or not to apply the modifiers.
+ * @param factory Creates the modifier to concatenate with the current one.
+ * @return a Modifier representing this modifier followed by other in sequence.
+ * @see Modifier.then
+ *
+ * This method allows inline conditional addition of modifiers to a modifier chain. Instead of
+ * writing
+ *
+ * ```
+ * val aModifier = Modifier.a()
+ * val bModifier = if(condition) aModifier.b() else aModifier
+ * Composable(modifier = bModifier)
+ * ```
+ *
+ * You can instead write
+ *
+ * ```
+ * Composable(modifier = Modifier.a().thenIf(condition){
+ *  Modifier.b()
+ * }
+ * ```
+ *
+ * This makes the modifier chain easier to read.
+ *
+ * Note that unlike the non-factory version, the conditional modifier is recreated each time, and
+ * may never be created at all.
+ */
+inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier =
+    if (condition) this.then(factory()) else this
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/LargeTopAppBarNestedScrollConnection.kt b/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/LargeTopAppBarNestedScrollConnection.kt
new file mode 100644
index 0000000..cea8d9a
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/LargeTopAppBarNestedScrollConnection.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.nestedscroll
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+
+/**
+ * A [NestedScrollConnection] that listens for all vertical scroll events and responds in the
+ * following way:
+ * - If you **scroll up**, it **first brings the [height]** back to the [minHeight] and then allows
+ *   scrolling of the children (usually the content).
+ * - If you **scroll down**, it **first allows scrolling of the children** (usually the content) and
+ *   then resets the [height] to [maxHeight].
+ *
+ * This behavior is useful for implementing a
+ * [Large top app bar](https://m3.material.io/components/top-app-bar/specs) effect or something
+ * similar.
+ *
+ * @sample com.android.compose.animation.scene.demo.Shade
+ */
+class LargeTopAppBarNestedScrollConnection(
+    private val height: () -> Float,
+    private val onChangeHeight: (Float) -> Unit,
+    private val minHeight: Float,
+    private val maxHeight: Float,
+) : NestedScrollConnection {
+
+    constructor(
+        height: () -> Float,
+        onHeightChanged: (Float) -> Unit,
+        heightRange: ClosedFloatingPointRange<Float>,
+    ) : this(
+        height = height,
+        onChangeHeight = onHeightChanged,
+        minHeight = heightRange.start,
+        maxHeight = heightRange.endInclusive,
+    )
+
+    /**
+     * When swiping up, the LargeTopAppBar will shrink (to [minHeight]) and the content will expand.
+     * Then, you can then scroll down the content.
+     */
+    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+        val y = available.y
+        val currentHeight = height()
+        if (y >= 0 || currentHeight <= minHeight) {
+            return Offset.Zero
+        }
+
+        val amountLeft = minHeight - currentHeight
+        val amountConsumed = y.coerceAtLeast(amountLeft)
+        onChangeHeight(currentHeight + amountConsumed)
+        return Offset(0f, amountConsumed)
+    }
+
+    /**
+     * When swiping down, the content will scroll up until it reaches the top. Then, the
+     * LargeTopAppBar will expand until it reaches its [maxHeight].
+     */
+    override fun onPostScroll(
+        consumed: Offset,
+        available: Offset,
+        source: NestedScrollSource
+    ): Offset {
+        val y = available.y
+        val currentHeight = height()
+        if (y <= 0 || currentHeight >= maxHeight) {
+            return Offset.Zero
+        }
+
+        val amountLeft = maxHeight - currentHeight
+        val amountConsumed = y.coerceAtMost(amountLeft)
+        onChangeHeight(currentHeight + amountConsumed)
+        return Offset(0f, amountConsumed)
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/PriorityPostNestedScrollConnection.kt b/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/PriorityPostNestedScrollConnection.kt
new file mode 100644
index 0000000..793a9a5
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/PriorityPostNestedScrollConnection.kt
@@ -0,0 +1,126 @@
+/*
+ * 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.nestedscroll
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.unit.Velocity
+
+/**
+ * This [NestedScrollConnection] waits for a child to scroll ([onPostScroll]), and then decides (via
+ * [canStart]) if it should take over scrolling. If it does, it will scroll before its children,
+ * until [canContinueScroll] allows it.
+ *
+ * Note: Call [reset] before destroying this object to make sure you always get a call to [onStop]
+ * after [onStart].
+ *
+ * @sample com.android.compose.animation.scene.rememberSwipeToSceneNestedScrollConnection
+ */
+class PriorityPostNestedScrollConnection(
+    private val canStart: (offsetAvailable: Offset, offsetBeforeStart: Offset) -> Boolean,
+    private val canContinueScroll: () -> Boolean,
+    private val onStart: () -> Unit,
+    private val onScroll: (offsetAvailable: Offset) -> Offset,
+    private val onStop: (velocityAvailable: Velocity) -> Velocity,
+    private val onPostFling: suspend (velocityAvailable: Velocity) -> Velocity,
+) : NestedScrollConnection {
+
+    /** In priority mode [onPreScroll] events are first consumed by the parent, via [onScroll]. */
+    private var isPriorityMode = false
+
+    private var offsetScrolledBeforePriorityMode = Offset.Zero
+
+    override fun onPostScroll(
+        consumed: Offset,
+        available: Offset,
+        source: NestedScrollSource,
+    ): Offset {
+        // The offset before the start takes into account the up and down movements, starting from
+        // the beginning or from the last fling gesture.
+        val offsetBeforeStart = offsetScrolledBeforePriorityMode - available
+
+        if (
+            isPriorityMode ||
+                source == NestedScrollSource.Fling ||
+                !canStart(available, offsetBeforeStart)
+        ) {
+            // The priority mode cannot start so we won't consume the available offset.
+            return Offset.Zero
+        }
+
+        // Step 1: It's our turn! We start capturing scroll events when one of our children has an
+        // available offset following a scroll event.
+        isPriorityMode = true
+
+        // Note: onStop will be called if we cannot continue to scroll (step 3a), or the finger is
+        // lifted (step 3b), or this object has been destroyed (step 3c).
+        onStart()
+
+        return onScroll(available)
+    }
+
+    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+        if (!isPriorityMode) {
+            if (source != NestedScrollSource.Fling) {
+                // We want to track the amount of offset consumed before entering priority mode
+                offsetScrolledBeforePriorityMode += available
+            }
+
+            return Offset.Zero
+        }
+
+        if (!canContinueScroll()) {
+            // Step 3a: We have lost priority and we no longer need to intercept scroll events.
+            onPriorityStop(velocity = Velocity.Zero)
+            return Offset.Zero
+        }
+
+        // Step 2: We have the priority and can consume the scroll events.
+        return onScroll(available)
+    }
+
+    override suspend fun onPreFling(available: Velocity): Velocity {
+        // Step 3b: The finger is lifted, we can stop intercepting scroll events and use the speed
+        // of the fling gesture.
+        return onPriorityStop(velocity = available)
+    }
+
+    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+        return onPostFling(available)
+    }
+
+    /** Method to call before destroying the object or to reset the initial state. */
+    fun reset() {
+        // Step 3c: To ensure that an onStop is always called for every onStart.
+        onPriorityStop(velocity = Velocity.Zero)
+    }
+
+    private fun onPriorityStop(velocity: Velocity): Velocity {
+
+        // We can restart tracking the consumed offsets from scratch.
+        offsetScrolledBeforePriorityMode = Offset.Zero
+
+        if (!isPriorityMode) {
+            return Velocity.Zero
+        }
+
+        isPriorityMode = false
+
+        return onStop(velocity)
+    }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/ui/util/ListUtils.kt b/packages/SystemUI/compose/scene/src/com/android/compose/ui/util/ListUtils.kt
new file mode 100644
index 0000000..741f00d
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/ui/util/ListUtils.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 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.ui.util
+
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.contract
+
+/**
+ * Iterates through a [List] using the index and calls [action] for each item. This does not
+ * allocate an iterator like [Iterable.forEach].
+ *
+ * **Do not use for collections that come from public APIs**, since they may not support random
+ * access in an efficient way, and this method may actually be a lot slower. Only use for
+ * collections that are created by code we control and are known to support random access.
+ */
+@Suppress("BanInlineOptIn")
+@OptIn(ExperimentalContracts::class)
+internal inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
+    contract { callsInPlace(action) }
+    for (index in indices) {
+        val item = get(index)
+        action(item)
+    }
+}
+
+/**
+ * Returns a list containing the results of applying the given [transform] function to each element
+ * in the original collection.
+ *
+ * **Do not use for collections that come from public APIs**, since they may not support random
+ * access in an efficient way, and this method may actually be a lot slower. Only use for
+ * collections that are created by code we control and are known to support random access.
+ */
+@Suppress("BanInlineOptIn")
+@OptIn(ExperimentalContracts::class)
+internal inline fun <T, R> List<T>.fastMap(transform: (T) -> R): List<R> {
+    contract { callsInPlace(transform) }
+    val target = ArrayList<R>(size)
+    fastForEach { target += transform(it) }
+    return target
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/ui/util/MathHelpers.kt b/packages/SystemUI/compose/scene/src/com/android/compose/ui/util/MathHelpers.kt
new file mode 100644
index 0000000..eb1a634
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/ui/util/MathHelpers.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.ui.util
+
+import androidx.compose.ui.unit.IntSize
+import kotlin.math.roundToInt
+import kotlin.math.roundToLong
+
+/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */
+fun lerp(start: Float, stop: Float, fraction: Float): Float {
+    return (1 - fraction) * start + fraction * stop
+}
+
+/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */
+fun lerp(start: Int, stop: Int, fraction: Float): Int {
+    return start + ((stop - start) * fraction.toDouble()).roundToInt()
+}
+
+/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */
+fun lerp(start: Long, stop: Long, fraction: Float): Long {
+    return start + ((stop - start) * fraction.toDouble()).roundToLong()
+}
+
+/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */
+fun lerp(start: IntSize, stop: IntSize, fraction: Float): IntSize {
+    return IntSize(
+        lerp(start.width, stop.width, fraction),
+        lerp(start.height, stop.height, fraction)
+    )
+}
diff --git a/packages/SystemUI/compose/scene/tests/Android.bp b/packages/SystemUI/compose/scene/tests/Android.bp
new file mode 100644
index 0000000..b53fae2
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/Android.bp
@@ -0,0 +1,50 @@
+// 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 {
+    // See: http://go/android-license-faq
+    // A large-scale-change added 'default_applicable_licenses' to import
+    // all of the 'license_kinds' from "frameworks_base_packages_SystemUI_license"
+    // to get the below license kinds:
+    //   SPDX-license-identifier-Apache-2.0
+    default_applicable_licenses: ["frameworks_base_packages_SystemUI_license"],
+}
+
+android_test {
+    name: "PlatformComposeSceneTransitionLayoutTests",
+    manifest: "AndroidManifest.xml",
+    test_suites: ["device-tests"],
+    sdk_version: "current",
+    certificate: "platform",
+
+    srcs: [
+        "src/**/*.kt",
+    ],
+
+    static_libs: [
+        "PlatformComposeSceneTransitionLayout",
+
+        "androidx.test.runner",
+        "androidx.test.ext.junit",
+
+        "androidx.compose.runtime_runtime",
+        "androidx.compose.ui_ui-test-junit4",
+        "androidx.compose.ui_ui-test-manifest",
+
+        "truth",
+    ],
+
+    kotlincflags: ["-Xjvm-default=all"],
+    use_resource_processor: true,
+}
diff --git a/packages/SystemUI/compose/scene/tests/AndroidManifest.xml b/packages/SystemUI/compose/scene/tests/AndroidManifest.xml
new file mode 100644
index 0000000..1a9172e
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/AndroidManifest.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.compose.animation.scene.tests" >
+
+    <application>
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+                     android:targetPackage="com.android.compose.animation.scene.tests"
+                     android:label="Tests for SceneTransitionLayout"/>
+
+</manifest>
\ No newline at end of file
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/AnimatedSharedAsStateTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/AnimatedSharedAsStateTest.kt
new file mode 100644
index 0000000..7b7695e
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/AnimatedSharedAsStateTest.kt
@@ -0,0 +1,218 @@
+/*
+ * 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)
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementTest.kt
new file mode 100644
index 0000000..4204cd5
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementTest.kt
@@ -0,0 +1,207 @@
+/*
+ * 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.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasParent
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.test.assertSizeIsEqualTo
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class MovableElementTest {
+    @get:Rule val rule = createComposeRule()
+
+    /** An element that displays a counter that is incremented whenever this element is clicked. */
+    @Composable
+    private fun Counter(modifier: Modifier = Modifier) {
+        var count by remember { mutableIntStateOf(0) }
+        Box(modifier.fillMaxSize().clickable { count++ }) { Text("count: $count") }
+    }
+
+    @Composable
+    private fun SceneScope.MovableCounter(key: ElementKey, modifier: Modifier) {
+        MovableElement(key, modifier) { Counter() }
+    }
+
+    @Test
+    fun modifierElementIsDuplicatedDuringTransitions() {
+        rule.testTransition(
+            fromSceneContent = {
+                Box(Modifier.element(TestElements.Foo).size(50.dp)) { Counter() }
+            },
+            toSceneContent = { Box(Modifier.element(TestElements.Foo).size(100.dp)) { Counter() } },
+            transition = { spec = tween(durationMillis = 16 * 4, easing = LinearEasing) },
+            fromScene = TestScenes.SceneA,
+            toScene = TestScenes.SceneB,
+        ) {
+            before {
+                // Click 3 times on the counter.
+                rule.onNodeWithText("count: 0").assertIsDisplayed().performClick()
+                rule.onNodeWithText("count: 1").assertIsDisplayed().performClick()
+                rule.onNodeWithText("count: 2").assertIsDisplayed().performClick()
+                rule
+                    .onNodeWithText("count: 3")
+                    .assertIsDisplayed()
+                    .assertSizeIsEqualTo(50.dp, 50.dp)
+
+                // There are no other counters.
+                assertThat(
+                        rule
+                            .onAllNodesWithText("count: ", substring = true)
+                            .fetchSemanticsNodes()
+                            .size
+                    )
+                    .isEqualTo(1)
+            }
+
+            at(32) {
+                // In the middle of the transition, there are 2 copies of the counter: the previous
+                // one from scene A (equal to 3) and the new one from scene B (equal to 0).
+                rule
+                    .onNode(
+                        hasText("count: 3") and
+                            hasParent(isElement(TestElements.Foo, scene = TestScenes.SceneA))
+                    )
+                    .assertIsDisplayed()
+                    .assertSizeIsEqualTo(75.dp, 75.dp)
+
+                rule
+                    .onNode(
+                        hasText("count: 0") and
+                            hasParent(isElement(TestElements.Foo, scene = TestScenes.SceneB))
+                    )
+                    .assertIsDisplayed()
+                    .assertSizeIsEqualTo(75.dp, 75.dp)
+
+                // There are exactly 2 counters.
+                assertThat(
+                        rule
+                            .onAllNodesWithText("count: ", substring = true)
+                            .fetchSemanticsNodes()
+                            .size
+                    )
+                    .isEqualTo(2)
+            }
+
+            after {
+                // At the end of the transition, only the counter from scene B is composed.
+                rule
+                    .onNodeWithText("count: 0")
+                    .assertIsDisplayed()
+                    .assertSizeIsEqualTo(100.dp, 100.dp)
+
+                // There are no other counters.
+                assertThat(
+                        rule
+                            .onAllNodesWithText("count: ", substring = true)
+                            .fetchSemanticsNodes()
+                            .size
+                    )
+                    .isEqualTo(1)
+            }
+        }
+    }
+
+    @Test
+    fun movableElementIsMovedAndComposedOnlyOnce() {
+        rule.testTransition(
+            fromSceneContent = { MovableCounter(TestElements.Foo, Modifier.size(50.dp)) },
+            toSceneContent = { MovableCounter(TestElements.Foo, Modifier.size(100.dp)) },
+            transition = { spec = tween(durationMillis = 16 * 4, easing = LinearEasing) },
+            fromScene = TestScenes.SceneA,
+            toScene = TestScenes.SceneB,
+        ) {
+            before {
+                // Click 3 times on the counter.
+                rule.onNodeWithText("count: 0").assertIsDisplayed().performClick()
+                rule.onNodeWithText("count: 1").assertIsDisplayed().performClick()
+                rule.onNodeWithText("count: 2").assertIsDisplayed().performClick()
+                rule
+                    .onNodeWithText("count: 3")
+                    .assertIsDisplayed()
+                    .assertSizeIsEqualTo(50.dp, 50.dp)
+
+                // There are no other counters.
+                assertThat(
+                        rule
+                            .onAllNodesWithText("count: ", substring = true)
+                            .fetchSemanticsNodes()
+                            .size
+                    )
+                    .isEqualTo(1)
+            }
+
+            at(32) {
+                // During the transition, there is a single counter that is moved, with the current
+                // value.
+                rule
+                    .onNode(hasText("count: 3"))
+                    .assertIsDisplayed()
+                    .assertSizeIsEqualTo(75.dp, 75.dp)
+
+                // There are no other counters.
+                assertThat(
+                        rule
+                            .onAllNodesWithText("count: ", substring = true)
+                            .fetchSemanticsNodes()
+                            .size
+                    )
+                    .isEqualTo(1)
+            }
+
+            after {
+                // At the end of the transition, the counter still has the current value.
+                rule
+                    .onNodeWithText("count: 3")
+                    .assertIsDisplayed()
+                    .assertSizeIsEqualTo(100.dp, 100.dp)
+
+                // There are no other counters.
+                assertThat(
+                        rule
+                            .onAllNodesWithText("count: ", substring = true)
+                            .fetchSemanticsNodes()
+                            .size
+                    )
+                    .isEqualTo(1)
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ObservableTransitionStateTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ObservableTransitionStateTest.kt
new file mode 100644
index 0000000..04b3f8a
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ObservableTransitionStateTest.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.ui.test.junit4.createComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ObservableTransitionStateTest {
+    @get:Rule val rule = createComposeRule()
+
+    @Test
+    fun testObservableTransitionState() = runTest {
+        val state = SceneTransitionLayoutState(TestScenes.SceneA)
+
+        // Collect the current observable state into [observableState].
+        // TODO(b/290184746): Use collectValues {} once it is extracted into a library that can be
+        // reused by non-SystemUI testing code.
+        var observableState: ObservableTransitionState? = null
+        backgroundScope.launch {
+            state.observableTransitionState().collect { observableState = it }
+        }
+
+        fun observableState(): ObservableTransitionState {
+            runCurrent()
+            return observableState!!
+        }
+
+        fun ObservableTransitionState.Transition.progress(): Float {
+            var lastProgress = -1f
+            backgroundScope.launch { progress.collect { lastProgress = it } }
+            runCurrent()
+            return lastProgress
+        }
+
+        rule.testTransition(
+            from = TestScenes.SceneA,
+            to = TestScenes.SceneB,
+            transitionLayout = { currentScene, onChangeScene ->
+                SceneTransitionLayout(
+                    currentScene,
+                    onChangeScene,
+                    EmptyTestTransitions,
+                    state = state,
+                ) {
+                    scene(TestScenes.SceneA) {}
+                    scene(TestScenes.SceneB) {}
+                }
+            }
+        ) {
+            before {
+                assertThat(observableState())
+                    .isEqualTo(ObservableTransitionState.Idle(TestScenes.SceneA))
+            }
+            at(0) {
+                val state = observableState()
+                assertThat(state).isInstanceOf(ObservableTransitionState.Transition::class.java)
+                assertThat((state as ObservableTransitionState.Transition).fromScene)
+                    .isEqualTo(TestScenes.SceneA)
+                assertThat(state.toScene).isEqualTo(TestScenes.SceneB)
+                assertThat(state.progress()).isEqualTo(0f)
+            }
+            at(TestTransitionDuration / 2) {
+                val state = observableState()
+                assertThat((state as ObservableTransitionState.Transition).fromScene)
+                    .isEqualTo(TestScenes.SceneA)
+                assertThat(state.toScene).isEqualTo(TestScenes.SceneB)
+                assertThat(state.progress()).isEqualTo(0.5f)
+            }
+            after {
+                assertThat(observableState())
+                    .isEqualTo(ObservableTransitionState.Idle(TestScenes.SceneB))
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt
new file mode 100644
index 0000000..5afd420
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt
@@ -0,0 +1,323 @@
+/*
+ * 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.activity.ComponentActivity
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onChild
+import androidx.compose.ui.test.onFirst
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.test.subjects.DpOffsetSubject
+import com.android.compose.test.subjects.assertThat
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SceneTransitionLayoutTest {
+    companion object {
+        private val LayoutSize = 300.dp
+    }
+
+    private var currentScene by mutableStateOf(TestScenes.SceneA)
+    private val layoutState = SceneTransitionLayoutState(currentScene)
+
+    // We use createAndroidComposeRule() here and not createComposeRule() because we need an
+    // activity for testBack().
+    @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
+
+    /** The content under test. */
+    @Composable
+    private fun TestContent() {
+        SceneTransitionLayout(
+            currentScene,
+            { currentScene = it },
+            EmptyTestTransitions,
+            state = layoutState,
+            modifier = Modifier.size(LayoutSize),
+        ) {
+            scene(
+                TestScenes.SceneA,
+                userActions = mapOf(Back to TestScenes.SceneB),
+            ) {
+                Box(Modifier.fillMaxSize()) {
+                    SharedFoo(size = 50.dp, childOffset = 0.dp, Modifier.align(Alignment.TopEnd))
+                    Text("SceneA")
+                }
+            }
+            scene(TestScenes.SceneB) {
+                Box(Modifier.fillMaxSize()) {
+                    SharedFoo(
+                        size = 100.dp,
+                        childOffset = 50.dp,
+                        Modifier.align(Alignment.TopStart),
+                    )
+                    Text("SceneB")
+                }
+            }
+            scene(TestScenes.SceneC) {
+                Box(Modifier.fillMaxSize()) {
+                    SharedFoo(
+                        size = 150.dp,
+                        childOffset = 100.dp,
+                        Modifier.align(Alignment.BottomStart),
+                    )
+                    Text("SceneC")
+                }
+            }
+        }
+    }
+
+    @Composable
+    private fun SceneScope.SharedFoo(size: Dp, childOffset: Dp, modifier: Modifier = Modifier) {
+        Box(
+            modifier
+                .size(size)
+                .background(Color.Red)
+                .element(TestElements.Foo)
+                .testTag(TestElements.Foo.debugName)
+        ) {
+            // Offset the single child of Foo by some animated shared offset.
+            val offset by animateSharedDpAsState(childOffset, TestValues.Value1, TestElements.Foo)
+
+            Box(
+                Modifier.offset {
+                        val pxOffset = offset.roundToPx()
+                        IntOffset(pxOffset, pxOffset)
+                    }
+                    .size(30.dp)
+                    .background(Color.Blue)
+                    .testTag(TestElements.Bar.debugName)
+            )
+        }
+    }
+
+    @Test
+    fun testOnlyCurrentSceneIsDisplayed() {
+        rule.setContent { TestContent() }
+
+        // Only scene A is displayed.
+        rule.onNodeWithText("SceneA").assertIsDisplayed()
+        rule.onNodeWithText("SceneB").assertDoesNotExist()
+        rule.onNodeWithText("SceneC").assertDoesNotExist()
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
+
+        // Change to scene B. Only that scene is displayed.
+        currentScene = TestScenes.SceneB
+        rule.onNodeWithText("SceneA").assertDoesNotExist()
+        rule.onNodeWithText("SceneB").assertIsDisplayed()
+        rule.onNodeWithText("SceneC").assertDoesNotExist()
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneB)
+    }
+
+    @Test
+    fun testBack() {
+        rule.setContent { TestContent() }
+
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
+
+        rule.activity.onBackPressed()
+        rule.waitForIdle()
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneB)
+    }
+
+    @Test
+    fun testTransitionState() {
+        rule.setContent { TestContent() }
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
+
+        // We will advance the clock manually.
+        rule.mainClock.autoAdvance = false
+
+        // Change the current scene. Until composition is triggered, this won't change the layout
+        // state.
+        currentScene = TestScenes.SceneB
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
+
+        // On the next frame, we will recompose because currentScene changed, which will start the
+        // transition (i.e. it will change the transitionState to be a Transition) in a
+        // LaunchedEffect.
+        rule.mainClock.advanceTimeByFrame()
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Transition::class.java)
+        val transition = layoutState.transitionState as TransitionState.Transition
+        assertThat(transition.fromScene).isEqualTo(TestScenes.SceneA)
+        assertThat(transition.toScene).isEqualTo(TestScenes.SceneB)
+        assertThat(transition.progress).isEqualTo(0f)
+
+        // Then, on the next frame, the animator we started gets its initial value and clock
+        // starting time. We are now at progress = 0f.
+        rule.mainClock.advanceTimeByFrame()
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Transition::class.java)
+        assertThat((layoutState.transitionState as TransitionState.Transition).progress)
+            .isEqualTo(0f)
+
+        // The test transition lasts 480ms. 240ms after the start of the transition, we are at
+        // progress = 0.5f.
+        rule.mainClock.advanceTimeBy(TestTransitionDuration / 2)
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Transition::class.java)
+        assertThat((layoutState.transitionState as TransitionState.Transition).progress)
+            .isEqualTo(0.5f)
+
+        // (240-16) ms later, i.e. one frame before the transition is finished, we are at
+        // progress=(480-16)/480.
+        rule.mainClock.advanceTimeBy(TestTransitionDuration / 2 - 16)
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Transition::class.java)
+        assertThat((layoutState.transitionState as TransitionState.Transition).progress)
+            .isEqualTo((TestTransitionDuration - 16) / 480f)
+
+        // one frame (16ms) later, the transition is finished and we are in the idle state in scene
+        // B.
+        rule.mainClock.advanceTimeByFrame()
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneB)
+    }
+
+    @Test
+    fun testSharedElement() {
+        rule.setContent { TestContent() }
+
+        // In scene A, the shared element SharedFoo() is at the top end of the layout and has a size
+        // of 50.dp.
+        var sharedFoo = rule.onNodeWithTag(TestElements.Foo.testTag, useUnmergedTree = true)
+        sharedFoo.assertWidthIsEqualTo(50.dp)
+        sharedFoo.assertHeightIsEqualTo(50.dp)
+        sharedFoo.assertPositionInRootIsEqualTo(
+            expectedTop = 0.dp,
+            expectedLeft = LayoutSize - 50.dp,
+        )
+
+        // The shared offset of the single child of SharedFoo() is 0dp in scene A.
+        assertThat(sharedFoo.onChild().offsetRelativeTo(sharedFoo)).isEqualTo(DpOffset(0.dp, 0.dp))
+
+        // Pause animations to test the state mid-transition.
+        rule.mainClock.autoAdvance = false
+
+        // Go to scene B and let the animation start. See [testLayoutState()] and
+        // [androidx.compose.ui.test.MainTestClock] to understand why we need to advance the clock
+        // by 2 frames to be at the start of the animation.
+        currentScene = TestScenes.SceneB
+        rule.mainClock.advanceTimeByFrame()
+        rule.mainClock.advanceTimeByFrame()
+
+        // Advance to the middle of the animation.
+        rule.mainClock.advanceTimeBy(TestTransitionDuration / 2)
+
+        // We need to use onAllNodesWithTag().onFirst() here given that shared elements are
+        // composed and laid out in both scenes (but drawn only in one).
+        sharedFoo = rule.onAllNodesWithTag(TestElements.Foo.testTag).onFirst()
+
+        // In scene B, foo is at the top start (x = 0, y = 0) of the layout and has a size of
+        // 100.dp. We pause at the middle of the transition, so it should now be 75.dp given that we
+        // use a linear interpolator. Foo was at (x = layoutSize - 50dp, y = 0) in SceneA and is
+        // going to (x = 0, y = 0), so the offset should now be half what it was.
+        assertThat((layoutState.transitionState as TransitionState.Transition).progress)
+            .isEqualTo(0.5f)
+        sharedFoo.assertWidthIsEqualTo(75.dp)
+        sharedFoo.assertHeightIsEqualTo(75.dp)
+        sharedFoo.assertPositionInRootIsEqualTo(
+            expectedTop = 0.dp,
+            expectedLeft = (LayoutSize - 50.dp) / 2
+        )
+
+        // The shared offset of the single child of SharedFoo() is 50dp in scene B and 0dp in Scene
+        // A, so it should be 25dp now.
+        assertThat(sharedFoo.onChild().offsetRelativeTo(sharedFoo))
+            .isWithin(DpOffsetSubject.DefaultTolerance)
+            .of(DpOffset(25.dp, 25.dp))
+
+        // Animate to scene C, let the animation start then go to the middle of the transition.
+        currentScene = TestScenes.SceneC
+        rule.mainClock.advanceTimeByFrame()
+        rule.mainClock.advanceTimeByFrame()
+        rule.mainClock.advanceTimeBy(TestTransitionDuration / 2)
+
+        // In Scene C, foo is at the bottom start of the layout and has a size of 150.dp. The
+        // transition scene B => scene C is using a FastOutSlowIn interpolator.
+        val interpolatedProgress = FastOutSlowInEasing.transform(0.5f)
+        val expectedTop = (LayoutSize - 150.dp) * interpolatedProgress
+        val expectedLeft = 0.dp
+        val expectedSize = 100.dp + (150.dp - 100.dp) * interpolatedProgress
+
+        sharedFoo = rule.onAllNodesWithTag(TestElements.Foo.testTag).onFirst()
+        assertThat((layoutState.transitionState as TransitionState.Transition).progress)
+            .isEqualTo(interpolatedProgress)
+        sharedFoo.assertWidthIsEqualTo(expectedSize)
+        sharedFoo.assertHeightIsEqualTo(expectedSize)
+        sharedFoo.assertPositionInRootIsEqualTo(expectedLeft, expectedTop)
+
+        // The shared offset of the single child of SharedFoo() is 50dp in scene B and 100dp in
+        // Scene C.
+        val expectedOffset = 50.dp + (100.dp - 50.dp) * interpolatedProgress
+        assertThat(sharedFoo.onChild().offsetRelativeTo(sharedFoo))
+            .isWithin(DpOffsetSubject.DefaultTolerance)
+            .of(DpOffset(expectedOffset, expectedOffset))
+
+        // Go back to scene A. This should happen instantly (once the animation started, i.e. after
+        // 2 frames) given that we use a snap() animation spec.
+        currentScene = TestScenes.SceneA
+        rule.mainClock.advanceTimeByFrame()
+        rule.mainClock.advanceTimeByFrame()
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
+    }
+
+    private fun SemanticsNodeInteraction.offsetRelativeTo(
+        other: SemanticsNodeInteraction,
+    ): DpOffset {
+        val node = fetchSemanticsNode()
+        val bounds = node.boundsInRoot
+        val otherBounds = other.fetchSemanticsNode().boundsInRoot
+        return with(node.layoutInfo.density) {
+            DpOffset(
+                x = (bounds.left - otherBounds.left).toDp(),
+                y = (bounds.top - otherBounds.top).toDp(),
+            )
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt
new file mode 100644
index 0000000..df3b72a
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt
@@ -0,0 +1,245 @@
+/*
+ * 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.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeWithVelocity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SwipeToSceneTest {
+    companion object {
+        private val LayoutWidth = 200.dp
+        private val LayoutHeight = 400.dp
+
+        /** The middle of the layout, in pixels. */
+        private val Density.middle: Offset
+            get() = Offset((LayoutWidth / 2).toPx(), (LayoutHeight / 2).toPx())
+    }
+
+    private var currentScene by mutableStateOf(TestScenes.SceneA)
+    private val layoutState = SceneTransitionLayoutState(currentScene)
+
+    @get:Rule val rule = createComposeRule()
+
+    /** The content under test. */
+    @Composable
+    private fun TestContent() {
+        SceneTransitionLayout(
+            currentScene,
+            { currentScene = it },
+            EmptyTestTransitions,
+            state = layoutState,
+            modifier = Modifier.size(LayoutWidth, LayoutHeight).testTag(TestElements.Foo.debugName),
+        ) {
+            scene(
+                TestScenes.SceneA,
+                userActions =
+                    mapOf(
+                        Swipe.Left to TestScenes.SceneB,
+                        Swipe.Down to TestScenes.SceneC,
+                    ),
+            ) {
+                Box(Modifier.fillMaxSize())
+            }
+            scene(
+                TestScenes.SceneB,
+                userActions = mapOf(Swipe.Right to TestScenes.SceneA),
+            ) {
+                Box(Modifier.fillMaxSize())
+            }
+            scene(
+                TestScenes.SceneC,
+                userActions = mapOf(Swipe.Down to TestScenes.SceneA),
+            ) {
+                Box(Modifier.fillMaxSize())
+            }
+        }
+    }
+
+    @Test
+    fun testDragWithPositionalThreshold() {
+        // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
+        // detected as a drag event.
+        var touchSlop = 0f
+        rule.setContent {
+            touchSlop = LocalViewConfiguration.current.touchSlop
+            TestContent()
+        }
+
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
+
+        // Drag left (i.e. from right to left) by 55dp. We pick 55dp here because 56dp is the
+        // positional threshold from which we commit the gesture.
+        rule.onRoot().performTouchInput {
+            down(middle)
+
+            // We use a high delay so that the velocity of the gesture is slow (otherwise it would
+            // commit the gesture, even if we are below the positional threshold).
+            moveBy(Offset(-55.dp.toPx() - touchSlop, 0f), delayMillis = 1_000)
+        }
+
+        // We should be at a progress = 55dp / LayoutWidth given that we use the layout size in
+        // the gesture axis as swipe distance.
+        var transition = layoutState.transitionState
+        assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
+        assertThat((transition as TransitionState.Transition).fromScene)
+            .isEqualTo(TestScenes.SceneA)
+        assertThat(transition.toScene).isEqualTo(TestScenes.SceneB)
+        assertThat(transition.currentScene).isEqualTo(TestScenes.SceneA)
+        assertThat(transition.progress).isEqualTo(55.dp / LayoutWidth)
+        assertThat(transition.isUserInputDriven).isTrue()
+
+        // Release the finger. We should now be animating back to A (currentScene = SceneA) given
+        // that 55dp < positional threshold.
+        rule.onRoot().performTouchInput { up() }
+        transition = layoutState.transitionState
+        assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
+        assertThat((transition as TransitionState.Transition).fromScene)
+            .isEqualTo(TestScenes.SceneA)
+        assertThat(transition.toScene).isEqualTo(TestScenes.SceneB)
+        assertThat(transition.currentScene).isEqualTo(TestScenes.SceneA)
+        assertThat(transition.progress).isEqualTo(55.dp / LayoutWidth)
+        assertThat(transition.isUserInputDriven).isTrue()
+
+        // Wait for the animation to finish. We should now be in scene A.
+        rule.waitForIdle()
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
+
+        // Now we do the same but vertically and with a drag distance of 56dp, which is >=
+        // positional threshold.
+        rule.onRoot().performTouchInput {
+            down(middle)
+            moveBy(Offset(0f, 56.dp.toPx() + touchSlop), delayMillis = 1_000)
+        }
+
+        // Drag is in progress, so currentScene = SceneA and progress = 56dp / LayoutHeight
+        transition = layoutState.transitionState
+        assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
+        assertThat((transition as TransitionState.Transition).fromScene)
+            .isEqualTo(TestScenes.SceneA)
+        assertThat(transition.toScene).isEqualTo(TestScenes.SceneC)
+        assertThat(transition.currentScene).isEqualTo(TestScenes.SceneA)
+        assertThat(transition.progress).isEqualTo(56.dp / LayoutHeight)
+        assertThat(transition.isUserInputDriven).isTrue()
+
+        // Release the finger. We should now be animating to C (currentScene = SceneC) given
+        // that 56dp >= positional threshold.
+        rule.onRoot().performTouchInput { up() }
+        transition = layoutState.transitionState
+        assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
+        assertThat((transition as TransitionState.Transition).fromScene)
+            .isEqualTo(TestScenes.SceneA)
+        assertThat(transition.toScene).isEqualTo(TestScenes.SceneC)
+        assertThat(transition.currentScene).isEqualTo(TestScenes.SceneC)
+        assertThat(transition.progress).isEqualTo(56.dp / LayoutHeight)
+        assertThat(transition.isUserInputDriven).isTrue()
+
+        // Wait for the animation to finish. We should now be in scene C.
+        rule.waitForIdle()
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneC)
+    }
+
+    @Test
+    fun testSwipeWithVelocityThreshold() {
+        // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
+        // detected as a drag event.
+        var touchSlop = 0f
+        rule.setContent {
+            touchSlop = LocalViewConfiguration.current.touchSlop
+            TestContent()
+        }
+
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
+
+        // Swipe left (i.e. from right to left) using a velocity of 124 dp/s. We pick 124 dp/s here
+        // because 125 dp/s is the velocity threshold from which we commit the gesture. We also use
+        // a swipe distance < 56dp, the positional threshold, to make sure that we don't commit
+        // the gesture because of a large enough swipe distance.
+        rule.onRoot().performTouchInput {
+            swipeWithVelocity(
+                start = middle,
+                end = middle - Offset(55.dp.toPx() + touchSlop, 0f),
+                endVelocity = 124.dp.toPx(),
+            )
+        }
+
+        // We should be animating back to A (currentScene = SceneA) given that 124 dp/s < velocity
+        // threshold.
+        var transition = layoutState.transitionState
+        assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
+        assertThat((transition as TransitionState.Transition).fromScene)
+            .isEqualTo(TestScenes.SceneA)
+        assertThat(transition.toScene).isEqualTo(TestScenes.SceneB)
+        assertThat(transition.currentScene).isEqualTo(TestScenes.SceneA)
+        assertThat(transition.progress).isEqualTo(55.dp / LayoutWidth)
+
+        // Wait for the animation to finish. We should now be in scene A.
+        rule.waitForIdle()
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
+
+        // Now we do the same but vertically and with a swipe velocity of 126dp, which is >
+        // velocity threshold. Note that in theory we could have used 125 dp (= velocity threshold)
+        // but it doesn't work reliably with how swipeWithVelocity() computes move events to get to
+        // the target velocity, probably because of float rounding errors.
+        rule.onRoot().performTouchInput {
+            swipeWithVelocity(
+                start = middle,
+                end = middle + Offset(0f, 55.dp.toPx() + touchSlop),
+                endVelocity = 126.dp.toPx(),
+            )
+        }
+
+        // We should be animating to C (currentScene = SceneC).
+        transition = layoutState.transitionState
+        assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
+        assertThat((transition as TransitionState.Transition).fromScene)
+            .isEqualTo(TestScenes.SceneA)
+        assertThat(transition.toScene).isEqualTo(TestScenes.SceneC)
+        assertThat(transition.currentScene).isEqualTo(TestScenes.SceneC)
+        assertThat(transition.progress).isEqualTo(55.dp / LayoutHeight)
+
+        // Wait for the animation to finish. We should now be in scene C.
+        rule.waitForIdle()
+        assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+        assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneC)
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TestTransition.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TestTransition.kt
new file mode 100644
index 0000000..e0ae1be
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TestTransition.kt
@@ -0,0 +1,258 @@
+/*
+ * 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.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.SemanticsNodeInteractionCollection
+import androidx.compose.ui.test.hasParent
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.onAllNodesWithTag
+
+@DslMarker annotation class TransitionTestDsl
+
+@TransitionTestDsl
+interface TransitionTestBuilder {
+    /**
+     * Assert on the state of the layout before the transition starts.
+     *
+     * This should be called maximum once, before [at] or [after] is called.
+     */
+    fun before(builder: TransitionTestAssertionScope.() -> Unit)
+
+    /**
+     * Assert on the state of the layout during the transition at [timestamp].
+     *
+     * This should be called after [before] is called and before [after] is called. Successive calls
+     * to [at] must be called with increasing [timestamp].
+     *
+     * Important: [timestamp] must be a multiple of 16 (the duration of a frame on the JVM/Android).
+     * There is no intermediary state between `t` and `t + 16` , so testing transitions outside of
+     * `t = 0`, `t = 16`, `t = 32`, etc does not make sense.
+     */
+    fun at(timestamp: Long, builder: TransitionTestAssertionScope.() -> Unit)
+
+    /**
+     * Assert on the state of the layout after the transition finished.
+     *
+     * This should be called maximum once, after [before] or [at] is called.
+     */
+    fun after(builder: TransitionTestAssertionScope.() -> Unit)
+}
+
+@TransitionTestDsl
+interface TransitionTestAssertionScope {
+    fun isElement(element: ElementKey, scene: SceneKey? = null): SemanticsMatcher
+
+    /**
+     * Assert on [element].
+     *
+     * Note that presence/value assertions on the returned [SemanticsNodeInteraction] will fail if 0
+     * or more than 1 elements matched [element]. If you need to assert on a shared element that
+     * will be present multiple times in the layout during transitions, either specify the [scene]
+     * in which you are matching or use [onSharedElement] instead.
+     */
+    fun onElement(element: ElementKey, scene: SceneKey? = null): SemanticsNodeInteraction
+
+    /**
+     * Assert on a shared [element]. This will throw if [element] is not shared and present only in
+     * one scene during a transition.
+     */
+    fun onSharedElement(element: ElementKey): SemanticsNodeInteractionCollection
+}
+
+/**
+ * Test the transition between [fromSceneContent] and [toSceneContent] at different points in time.
+ *
+ * @sample com.android.compose.animation.scene.transformation.TranslateTest
+ */
+fun ComposeContentTestRule.testTransition(
+    fromSceneContent: @Composable SceneScope.() -> Unit,
+    toSceneContent: @Composable SceneScope.() -> Unit,
+    transition: TransitionBuilder.() -> Unit,
+    layoutModifier: Modifier = Modifier,
+    fromScene: SceneKey = TestScenes.SceneA,
+    toScene: SceneKey = TestScenes.SceneB,
+    builder: TransitionTestBuilder.() -> Unit,
+) {
+    testTransition(
+        from = fromScene,
+        to = toScene,
+        transitionLayout = { currentScene, onChangeScene ->
+            SceneTransitionLayout(
+                currentScene,
+                onChangeScene,
+                transitions { from(fromScene, to = toScene, transition) },
+                layoutModifier.fillMaxSize(),
+            ) {
+                scene(fromScene, content = fromSceneContent)
+                scene(toScene, content = toSceneContent)
+            }
+        },
+        builder,
+    )
+}
+
+/**
+ * Test the transition between two scenes of [transitionLayout][SceneTransitionLayout] at different
+ * points in time.
+ */
+fun ComposeContentTestRule.testTransition(
+    from: SceneKey,
+    to: SceneKey,
+    transitionLayout:
+        @Composable
+        (
+            currentScene: SceneKey,
+            onChangeScene: (SceneKey) -> Unit,
+        ) -> Unit,
+    builder: TransitionTestBuilder.() -> Unit,
+) {
+    val test = transitionTest(builder)
+    val assertionScope =
+        object : TransitionTestAssertionScope {
+            override fun isElement(element: ElementKey, scene: SceneKey?): SemanticsMatcher {
+                return if (scene == null) {
+                    hasTestTag(element.testTag)
+                } else {
+                    hasTestTag(element.testTag) and hasParent(hasTestTag(scene.testTag))
+                }
+            }
+
+            override fun onElement(
+                element: ElementKey,
+                scene: SceneKey?
+            ): SemanticsNodeInteraction {
+                return onNode(isElement(element, scene))
+            }
+
+            override fun onSharedElement(element: ElementKey): SemanticsNodeInteractionCollection {
+                val interaction = onAllNodesWithTag(element.testTag)
+                val matches = interaction.fetchSemanticsNodes(atLeastOneRootRequired = false).size
+                if (matches < 2) {
+                    error("Element $element is not shared ($matches matches)")
+                }
+                return interaction
+            }
+        }
+
+    var currentScene by mutableStateOf(from)
+    setContent { transitionLayout(currentScene, { currentScene = it }) }
+
+    // Wait for the UI to be idle then test the before state.
+    waitForIdle()
+    test.before(assertionScope)
+
+    // Manually advance the clock to the start of the animation.
+    mainClock.autoAdvance = false
+
+    // Change the current scene.
+    currentScene = to
+
+    // Advance by a frame to trigger recomposition, which will start the transition (i.e. it will
+    // change the transitionState to be a Transition) in a LaunchedEffect.
+    mainClock.advanceTimeByFrame()
+
+    // Advance by another frame so that the animator we started gets its initial value and clock
+    // starting time. We are now at progress = 0f.
+    mainClock.advanceTimeByFrame()
+    waitForIdle()
+
+    // Test the assertions at specific points in time.
+    test.timestamps.forEach { tsAssertion ->
+        if (tsAssertion.timestampDelta > 0L) {
+            mainClock.advanceTimeBy(tsAssertion.timestampDelta)
+            waitForIdle()
+        }
+
+        tsAssertion.assertion(assertionScope)
+    }
+
+    // Go to the end state and test it.
+    mainClock.autoAdvance = true
+    waitForIdle()
+    test.after(assertionScope)
+}
+
+private fun transitionTest(builder: TransitionTestBuilder.() -> Unit): TransitionTest {
+    // Collect the assertion lambdas in [TransitionTest]. Note that the ordering is forced by the
+    // builder, e.g. `before {}` must be called before everything else, then `at {}` (in increasing
+    // order of timestamp), then `after {}`. That way the test code is run with the same order as it
+    // is written, to avoid confusion.
+
+    val impl =
+        object : TransitionTestBuilder {
+                var before: (TransitionTestAssertionScope.() -> Unit)? = null
+                var after: (TransitionTestAssertionScope.() -> Unit)? = null
+                val timestamps = mutableListOf<TimestampAssertion>()
+
+                private var currentTimestamp = 0L
+
+                override fun before(builder: TransitionTestAssertionScope.() -> Unit) {
+                    check(before == null) { "before {} must be called maximum once" }
+                    check(after == null) { "before {} must be called before after {}" }
+                    check(timestamps.isEmpty()) { "before {} must be called before at(...) {}" }
+
+                    before = builder
+                }
+
+                override fun at(timestamp: Long, builder: TransitionTestAssertionScope.() -> Unit) {
+                    check(after == null) { "at(...) {} must be called before after {}" }
+                    check(timestamp >= currentTimestamp) {
+                        "at(...) must be called with timestamps in increasing order"
+                    }
+                    check(timestamp % 16 == 0L) {
+                        "timestamp must be a multiple of the frame time (16ms)"
+                    }
+
+                    val delta = timestamp - currentTimestamp
+                    currentTimestamp = timestamp
+
+                    timestamps.add(TimestampAssertion(delta, builder))
+                }
+
+                override fun after(builder: TransitionTestAssertionScope.() -> Unit) {
+                    check(after == null) { "after {} must be called maximum once" }
+                    after = builder
+                }
+            }
+            .apply(builder)
+
+    return TransitionTest(
+        before = impl.before ?: {},
+        timestamps = impl.timestamps,
+        after = impl.after ?: {},
+    )
+}
+
+private class TransitionTest(
+    val before: TransitionTestAssertionScope.() -> Unit,
+    val after: TransitionTestAssertionScope.() -> Unit,
+    val timestamps: List<TimestampAssertion>,
+)
+
+private class TimestampAssertion(
+    val timestampDelta: Long,
+    val assertion: TransitionTestAssertionScope.() -> Unit,
+)
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TestValues.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TestValues.kt
new file mode 100644
index 0000000..b4c393e
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TestValues.kt
@@ -0,0 +1,61 @@
+/*
+ * 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.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.core.tween
+
+/** Scenes keys that can be reused by tests. */
+object TestScenes {
+    val SceneA = SceneKey("SceneA")
+    val SceneB = SceneKey("SceneB")
+    val SceneC = SceneKey("SceneC")
+}
+
+/** Element keys that can be reused by tests. */
+object TestElements {
+    val Foo = ElementKey("Foo")
+    val Bar = ElementKey("Bar")
+}
+
+/** 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
+// C JVM/Android. Doing so allows us for instance to test the state at progress = 0.5f given that t
+// = 240ms is also a multiple of 16.
+val TestTransitionDuration = 480L
+
+/** A definition of empty transitions between [TestScenes], using different animation specs. */
+val EmptyTestTransitions = transitions {
+    from(TestScenes.SceneA, to = TestScenes.SceneB) {
+        spec = tween(durationMillis = TestTransitionDuration.toInt(), easing = LinearEasing)
+    }
+
+    from(TestScenes.SceneB, to = TestScenes.SceneC) {
+        spec = tween(durationMillis = TestTransitionDuration.toInt(), easing = FastOutSlowInEasing)
+    }
+
+    from(TestScenes.SceneC, to = TestScenes.SceneA) { spec = snap() }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TransitionDslTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TransitionDslTest.kt
new file mode 100644
index 0000000..fa94b250
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TransitionDslTest.kt
@@ -0,0 +1,190 @@
+/*
+ * 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.SpringSpec
+import androidx.compose.animation.core.TweenSpec
+import androidx.compose.animation.core.tween
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.transformation.Transformation
+import com.android.compose.animation.scene.transformation.TransformationRange
+import com.google.common.truth.Correspondence
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class TransitionDslTest {
+    @Test
+    fun emptyTransitions() {
+        val transitions = transitions {}
+        assertThat(transitions.transitionSpecs).isEmpty()
+    }
+
+    @Test
+    fun manyTransitions() {
+        val transitions = transitions {
+            from(TestScenes.SceneA, to = TestScenes.SceneB)
+            from(TestScenes.SceneB, to = TestScenes.SceneC)
+            from(TestScenes.SceneC, to = TestScenes.SceneA)
+        }
+        assertThat(transitions.transitionSpecs).hasSize(3)
+    }
+
+    @Test
+    fun toFromBuilders() {
+        val transitions = transitions {
+            from(TestScenes.SceneA, to = TestScenes.SceneB)
+            from(TestScenes.SceneB)
+            to(TestScenes.SceneC)
+        }
+
+        assertThat(transitions.transitionSpecs)
+            .comparingElementsUsing(
+                Correspondence.transforming<TransitionSpec, Pair<SceneKey?, SceneKey?>>(
+                    { it?.from to it?.to },
+                    "has (from, to) equal to"
+                )
+            )
+            .containsExactly(
+                TestScenes.SceneA to TestScenes.SceneB,
+                TestScenes.SceneB to null,
+                null to TestScenes.SceneC,
+            )
+    }
+
+    @Test
+    fun defaultTransitionSpec() {
+        val transitions = transitions { from(TestScenes.SceneA, to = TestScenes.SceneB) }
+        val transition = transitions.transitionSpecs.single()
+        assertThat(transition.spec).isInstanceOf(SpringSpec::class.java)
+    }
+
+    @Test
+    fun customTransitionSpec() {
+        val transitions = transitions {
+            from(TestScenes.SceneA, to = TestScenes.SceneB) { spec = tween(durationMillis = 42) }
+        }
+        val transition = transitions.transitionSpecs.single()
+        assertThat(transition.spec).isInstanceOf(TweenSpec::class.java)
+        assertThat((transition.spec as TweenSpec).durationMillis).isEqualTo(42)
+    }
+
+    @Test
+    fun defaultRange() {
+        val transitions = transitions {
+            from(TestScenes.SceneA, to = TestScenes.SceneB) { fade(TestElements.Foo) }
+        }
+
+        val transition = transitions.transitionSpecs.single()
+        assertThat(transition.transformations.size).isEqualTo(1)
+        assertThat(transition.transformations.single().range).isEqualTo(null)
+    }
+
+    @Test
+    fun fractionRange() {
+        val transitions = transitions {
+            from(TestScenes.SceneA, to = TestScenes.SceneB) {
+                fractionRange(start = 0.1f, end = 0.8f) { fade(TestElements.Foo) }
+                fractionRange(start = 0.2f) { fade(TestElements.Foo) }
+                fractionRange(end = 0.9f) { fade(TestElements.Foo) }
+            }
+        }
+
+        val transition = transitions.transitionSpecs.single()
+        assertThat(transition.transformations)
+            .comparingElementsUsing(TRANSFORMATION_RANGE)
+            .containsExactly(
+                TransformationRange(start = 0.1f, end = 0.8f),
+                TransformationRange(start = 0.2f, end = TransformationRange.BoundUnspecified),
+                TransformationRange(start = TransformationRange.BoundUnspecified, end = 0.9f),
+            )
+    }
+
+    @Test
+    fun timestampRange() {
+        val transitions = transitions {
+            from(TestScenes.SceneA, to = TestScenes.SceneB) {
+                spec = tween(500)
+
+                timestampRange(startMillis = 100, endMillis = 300) { fade(TestElements.Foo) }
+                timestampRange(startMillis = 200) { fade(TestElements.Foo) }
+                timestampRange(endMillis = 400) { fade(TestElements.Foo) }
+            }
+        }
+
+        val transition = transitions.transitionSpecs.single()
+        assertThat(transition.transformations)
+            .comparingElementsUsing(TRANSFORMATION_RANGE)
+            .containsExactly(
+                TransformationRange(start = 100 / 500f, end = 300 / 500f),
+                TransformationRange(start = 200 / 500f, end = TransformationRange.BoundUnspecified),
+                TransformationRange(start = TransformationRange.BoundUnspecified, end = 400 / 500f),
+            )
+    }
+
+    @Test
+    fun reversed() {
+        val transitions = transitions {
+            from(TestScenes.SceneA, to = TestScenes.SceneB) {
+                spec = tween(500)
+                reversed {
+                    fractionRange(start = 0.1f, end = 0.8f) { fade(TestElements.Foo) }
+                    timestampRange(startMillis = 100, endMillis = 300) { fade(TestElements.Foo) }
+                }
+            }
+        }
+
+        val transition = transitions.transitionSpecs.single()
+        assertThat(transition.transformations)
+            .comparingElementsUsing(TRANSFORMATION_RANGE)
+            .containsExactly(
+                TransformationRange(start = 1f - 0.8f, end = 1f - 0.1f),
+                TransformationRange(start = 1f - 300 / 500f, end = 1f - 100 / 500f),
+            )
+    }
+
+    @Test
+    fun defaultReversed() {
+        val transitions = transitions {
+            from(TestScenes.SceneA, to = TestScenes.SceneB) {
+                spec = tween(500)
+                fractionRange(start = 0.1f, end = 0.8f) { fade(TestElements.Foo) }
+                timestampRange(startMillis = 100, endMillis = 300) { fade(TestElements.Foo) }
+            }
+        }
+
+        // Fetch the transition from B to A, which will automatically reverse the transition from A
+        // to B we defined.
+        val transition =
+            transitions.transitionSpec(from = TestScenes.SceneB, to = TestScenes.SceneA)
+        assertThat(transition.transformations)
+            .comparingElementsUsing(TRANSFORMATION_RANGE)
+            .containsExactly(
+                TransformationRange(start = 1f - 0.8f, end = 1f - 0.1f),
+                TransformationRange(start = 1f - 300 / 500f, end = 1f - 100 / 500f),
+            )
+    }
+
+    companion object {
+        private val TRANSFORMATION_RANGE =
+            Correspondence.transforming<Transformation, TransformationRange?>(
+                { it?.range },
+                "has range equal to"
+            )
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/AnchoredSizeTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/AnchoredSizeTest.kt
new file mode 100644
index 0000000..8ef6757
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/AnchoredSizeTest.kt
@@ -0,0 +1,88 @@
+/*
+ * 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.transformation
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.TestElements
+import com.android.compose.animation.scene.testTransition
+import com.android.compose.test.assertSizeIsEqualTo
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AnchoredSizeTest {
+    @get:Rule val rule = createComposeRule()
+
+    @Test
+    fun testAnchoredSizeEnter() {
+        rule.testTransition(
+            fromSceneContent = { Box(Modifier.size(100.dp, 100.dp).element(TestElements.Foo)) },
+            toSceneContent = {
+                Box(Modifier.size(50.dp, 50.dp).element(TestElements.Foo))
+                Box(Modifier.size(200.dp, 60.dp).element(TestElements.Bar))
+            },
+            transition = {
+                // Scale during 4 frames.
+                spec = tween(16 * 4, easing = LinearEasing)
+                anchoredSize(TestElements.Bar, TestElements.Foo)
+            },
+        ) {
+            // Bar is entering. It starts at the same size as Foo in scene A in and scales to its
+            // final size in scene B.
+            before { onElement(TestElements.Bar).assertDoesNotExist() }
+            at(0) { onElement(TestElements.Bar).assertSizeIsEqualTo(100.dp, 100.dp) }
+            at(16) { onElement(TestElements.Bar).assertSizeIsEqualTo(125.dp, 90.dp) }
+            at(32) { onElement(TestElements.Bar).assertSizeIsEqualTo(150.dp, 80.dp) }
+            at(48) { onElement(TestElements.Bar).assertSizeIsEqualTo(175.dp, 70.dp) }
+            at(64) { onElement(TestElements.Bar).assertSizeIsEqualTo(200.dp, 60.dp) }
+            after { onElement(TestElements.Bar).assertSizeIsEqualTo(200.dp, 60.dp) }
+        }
+    }
+
+    @Test
+    fun testAnchoredSizeExit() {
+        rule.testTransition(
+            fromSceneContent = {
+                Box(Modifier.size(100.dp, 100.dp).element(TestElements.Foo))
+                Box(Modifier.size(100.dp, 100.dp).element(TestElements.Bar))
+            },
+            toSceneContent = { Box(Modifier.size(200.dp, 60.dp).element(TestElements.Foo)) },
+            transition = {
+                // Scale during 4 frames.
+                spec = tween(16 * 4, easing = LinearEasing)
+                anchoredSize(TestElements.Bar, TestElements.Foo)
+            },
+        ) {
+            // Bar is leaving. It starts at 100dp x 100dp in scene A and is scaled to 200dp x 60dp,
+            // the size of Foo in scene B.
+            before { onElement(TestElements.Bar).assertSizeIsEqualTo(100.dp, 100.dp) }
+            at(0) { onElement(TestElements.Bar).assertSizeIsEqualTo(100.dp, 100.dp) }
+            at(16) { onElement(TestElements.Bar).assertSizeIsEqualTo(125.dp, 90.dp) }
+            at(32) { onElement(TestElements.Bar).assertSizeIsEqualTo(150.dp, 80.dp) }
+            at(48) { onElement(TestElements.Bar).assertSizeIsEqualTo(175.dp, 70.dp) }
+            after { onElement(TestElements.Bar).assertDoesNotExist() }
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/AnchoredTranslateTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/AnchoredTranslateTest.kt
new file mode 100644
index 0000000..d1205e7
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/AnchoredTranslateTest.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.transformation
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.offset
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertPositionInRootIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.TestElements
+import com.android.compose.animation.scene.testTransition
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AnchoredTranslateTest {
+    @get:Rule val rule = createComposeRule()
+
+    @Test
+    fun testAnchoredTranslateExit() {
+        rule.testTransition(
+            fromSceneContent = {
+                Box(Modifier.offset(10.dp, 50.dp).element(TestElements.Foo))
+                Box(Modifier.offset(20.dp, 40.dp).element(TestElements.Bar))
+            },
+            toSceneContent = { Box(Modifier.offset(30.dp, 10.dp).element(TestElements.Foo)) },
+            transition = {
+                // Anchor Bar to Foo, which is moving from (10dp, 50dp) to (30dp, 10dp).
+                spec = tween(16 * 4, easing = LinearEasing)
+                anchoredTranslate(TestElements.Bar, TestElements.Foo)
+            },
+        ) {
+            // Bar moves by (20dp, -40dp), like Foo.
+            before { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(20.dp, 40.dp) }
+            at(0) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(20.dp, 40.dp) }
+            at(16) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(25.dp, 30.dp) }
+            at(32) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(30.dp, 20.dp) }
+            at(48) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(35.dp, 10.dp) }
+            after { onElement(TestElements.Bar).assertDoesNotExist() }
+        }
+    }
+
+    @Test
+    fun testAnchoredTranslateEnter() {
+        rule.testTransition(
+            fromSceneContent = { Box(Modifier.offset(10.dp, 50.dp).element(TestElements.Foo)) },
+            toSceneContent = {
+                Box(Modifier.offset(30.dp, 10.dp).element(TestElements.Foo))
+                Box(Modifier.offset(20.dp, 40.dp).element(TestElements.Bar))
+            },
+            transition = {
+                // Anchor Bar to Foo, which is moving from (10dp, 50dp) to (30dp, 10dp).
+                spec = tween(16 * 4, easing = LinearEasing)
+                anchoredTranslate(TestElements.Bar, TestElements.Foo)
+            },
+        ) {
+            // Bar moves by (20dp, -40dp), like Foo.
+            before { onElement(TestElements.Bar).assertDoesNotExist() }
+            at(0) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(0.dp, 80.dp) }
+            at(16) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(5.dp, 70.dp) }
+            at(32) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(10.dp, 60.dp) }
+            at(48) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(15.dp, 50.dp) }
+            at(64) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(20.dp, 40.dp) }
+            after { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(20.dp, 40.dp) }
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/EdgeTranslateTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/EdgeTranslateTest.kt
new file mode 100644
index 0000000..2a27763
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/EdgeTranslateTest.kt
@@ -0,0 +1,153 @@
+/*
+ * 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.transformation
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertPositionInRootIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.Edge
+import com.android.compose.animation.scene.TestElements
+import com.android.compose.animation.scene.TransitionTestBuilder
+import com.android.compose.animation.scene.testTransition
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class EdgeTranslateTest {
+
+    @get:Rule val rule = createComposeRule()
+
+    private fun testEdgeTranslate(
+        edge: Edge,
+        startsOutsideLayoutBounds: Boolean,
+        builder: TransitionTestBuilder.() -> Unit,
+    ) {
+        rule.testTransition(
+            // The layout under test is 300dp x 300dp.
+            layoutModifier = Modifier.size(300.dp),
+            fromSceneContent = {},
+            toSceneContent = {
+                // Foo is 100dp x 100dp in the center of the layout, so at offset = (100dp, 100dp)
+                Box(Modifier.fillMaxSize()) {
+                    Box(Modifier.size(100.dp).element(TestElements.Foo).align(Alignment.Center))
+                }
+            },
+            transition = {
+                spec = tween(16 * 4, easing = LinearEasing)
+                translate(TestElements.Foo, edge, startsOutsideLayoutBounds)
+            },
+            builder = builder,
+        )
+    }
+
+    @Test
+    fun testEntersFromTop_startsOutsideLayoutBounds() {
+        testEdgeTranslate(Edge.Top, startsOutsideLayoutBounds = true) {
+            before { onElement(TestElements.Foo).assertDoesNotExist() }
+            at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, (-100).dp) }
+            at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 0.dp) }
+            at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+            after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+        }
+    }
+
+    @Test
+    fun testEntersFromTop_startsInsideLayoutBounds() {
+        testEdgeTranslate(Edge.Top, startsOutsideLayoutBounds = false) {
+            before { onElement(TestElements.Foo).assertDoesNotExist() }
+            at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 0.dp) }
+            at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 50.dp) }
+            at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+            after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+        }
+    }
+
+    @Test
+    fun testEntersFromBottom_startsOutsideLayoutBounds() {
+        testEdgeTranslate(Edge.Bottom, startsOutsideLayoutBounds = true) {
+            before { onElement(TestElements.Foo).assertDoesNotExist() }
+            at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 300.dp) }
+            at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 200.dp) }
+            at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+            after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+        }
+    }
+
+    @Test
+    fun testEntersFromBottom_startsInsideLayoutBounds() {
+        testEdgeTranslate(Edge.Bottom, startsOutsideLayoutBounds = false) {
+            before { onElement(TestElements.Foo).assertDoesNotExist() }
+            at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 200.dp) }
+            at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 150.dp) }
+            at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+            after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+        }
+    }
+
+    @Test
+    fun testEntersFromLeft_startsOutsideLayoutBounds() {
+        testEdgeTranslate(Edge.Left, startsOutsideLayoutBounds = true) {
+            before { onElement(TestElements.Foo).assertDoesNotExist() }
+            at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo((-100).dp, 100.dp) }
+            at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(0.dp, 100.dp) }
+            at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+            after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+        }
+    }
+
+    @Test
+    fun testEntersFromLeft_startsInsideLayoutBounds() {
+        testEdgeTranslate(Edge.Left, startsOutsideLayoutBounds = false) {
+            before { onElement(TestElements.Foo).assertDoesNotExist() }
+            at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(0.dp, 100.dp) }
+            at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(50.dp, 100.dp) }
+            at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+            after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+        }
+    }
+
+    @Test
+    fun testEntersFromRight_startsOutsideLayoutBounds() {
+        testEdgeTranslate(Edge.Right, startsOutsideLayoutBounds = true) {
+            before { onElement(TestElements.Foo).assertDoesNotExist() }
+            at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(300.dp, 100.dp) }
+            at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(200.dp, 100.dp) }
+            at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+            after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+        }
+    }
+
+    @Test
+    fun testEntersFromRight_startsInsideLayoutBounds() {
+        testEdgeTranslate(Edge.Right, startsOutsideLayoutBounds = false) {
+            before { onElement(TestElements.Foo).assertDoesNotExist() }
+            at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(200.dp, 100.dp) }
+            at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(150.dp, 100.dp) }
+            at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+            after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/ScaleSizeTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/ScaleSizeTest.kt
new file mode 100644
index 0000000..384355c
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/ScaleSizeTest.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.transformation
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.TestElements
+import com.android.compose.animation.scene.testTransition
+import com.android.compose.test.assertSizeIsEqualTo
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ScaleSizeTest {
+    @get:Rule val rule = createComposeRule()
+
+    @Test
+    fun testScaleSize() {
+        rule.testTransition(
+            fromSceneContent = {},
+            toSceneContent = { Box(Modifier.size(100.dp).element(TestElements.Foo)) },
+            transition = {
+                // Scale during 4 frames.
+                spec = tween(16 * 4, easing = LinearEasing)
+                scaleSize(TestElements.Foo, width = 2f, height = 0.5f)
+            },
+        ) {
+            // Foo is entering, is 100dp x 100dp at rest and is scaled by (2, 0.5) during the
+            // transition so it starts at 200dp x 50dp.
+            before { onElement(TestElements.Foo).assertDoesNotExist() }
+            at(0) { onElement(TestElements.Foo).assertSizeIsEqualTo(200.dp, 50.dp) }
+            at(16) { onElement(TestElements.Foo).assertSizeIsEqualTo(175.dp, 62.5.dp) }
+            at(32) { onElement(TestElements.Foo).assertSizeIsEqualTo(150.dp, 75.dp) }
+            at(48) { onElement(TestElements.Foo).assertSizeIsEqualTo(125.dp, 87.5.dp) }
+            at(64) { onElement(TestElements.Foo).assertSizeIsEqualTo(100.dp, 100.dp) }
+            after { onElement(TestElements.Foo).assertSizeIsEqualTo(100.dp, 100.dp) }
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/SharedElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/SharedElementTest.kt
new file mode 100644
index 0000000..e94eff3
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/SharedElementTest.kt
@@ -0,0 +1,156 @@
+/*
+ * 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.transformation
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertPositionInRootIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.Edge
+import com.android.compose.animation.scene.TestElements
+import com.android.compose.animation.scene.TestScenes
+import com.android.compose.animation.scene.inScene
+import com.android.compose.animation.scene.testTransition
+import com.android.compose.test.assertSizeIsEqualTo
+import com.android.compose.test.onEach
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SharedElementTest {
+    @get:Rule val rule = createComposeRule()
+
+    @Test
+    fun testSharedElement() {
+        rule.testTransition(
+            fromSceneContent = {
+                // Foo is at (10, 50) with a size of (20, 80).
+                Box(Modifier.offset(10.dp, 50.dp).element(TestElements.Foo).size(20.dp, 80.dp))
+            },
+            toSceneContent = {
+                // Foo is at (50, 70) with a size of (10, 40).
+                Box(Modifier.offset(50.dp, 70.dp).element(TestElements.Foo).size(10.dp, 40.dp))
+            },
+            transition = {
+                spec = tween(16 * 4, easing = LinearEasing)
+                // Elements should be shared by default.
+            }
+        ) {
+            before {
+                onElement(TestElements.Foo).assertPositionInRootIsEqualTo(10.dp, 50.dp)
+                onElement(TestElements.Foo).assertSizeIsEqualTo(20.dp, 80.dp)
+            }
+            at(0) {
+                onSharedElement(TestElements.Foo).onEach {
+                    assertPositionInRootIsEqualTo(10.dp, 50.dp)
+                    assertSizeIsEqualTo(20.dp, 80.dp)
+                }
+            }
+            at(16) {
+                onSharedElement(TestElements.Foo).onEach {
+                    assertPositionInRootIsEqualTo(20.dp, 55.dp)
+                    assertSizeIsEqualTo(17.5.dp, 70.dp)
+                }
+            }
+            at(32) {
+                onSharedElement(TestElements.Foo).onEach {
+                    assertPositionInRootIsEqualTo(30.dp, 60.dp)
+                    assertSizeIsEqualTo(15.dp, 60.dp)
+                }
+            }
+            at(48) {
+                onSharedElement(TestElements.Foo).onEach {
+                    assertPositionInRootIsEqualTo(40.dp, 65.dp)
+                    assertSizeIsEqualTo(12.5.dp, 50.dp)
+                }
+            }
+            after {
+                onElement(TestElements.Foo).assertPositionInRootIsEqualTo(50.dp, 70.dp)
+                onElement(TestElements.Foo).assertSizeIsEqualTo(10.dp, 40.dp)
+            }
+        }
+    }
+
+    @Test
+    fun testSharedElementDisabled() {
+        rule.testTransition(
+            fromScene = TestScenes.SceneA,
+            toScene = TestScenes.SceneB,
+            // The full layout is 100x100.
+            layoutModifier = Modifier.size(100.dp),
+            fromSceneContent = {
+                Box(Modifier.fillMaxSize()) {
+                    // Foo is at (10, 50).
+                    Box(Modifier.offset(10.dp, 50.dp).element(TestElements.Foo))
+                }
+            },
+            toSceneContent = {
+                Box(Modifier.fillMaxSize()) {
+                    // Foo is at (50, 60).
+                    Box(Modifier.offset(50.dp, 60.dp).element(TestElements.Foo))
+                }
+            },
+            transition = {
+                spec = tween(16 * 4, easing = LinearEasing)
+
+                // Disable the shared element animation.
+                sharedElement(TestElements.Foo, enabled = false)
+
+                // In SceneA, Foo leaves to the left edge.
+                translate(TestElements.Foo.inScene(TestScenes.SceneA), Edge.Left)
+
+                // In SceneB, Foo comes from the bottom edge.
+                translate(TestElements.Foo.inScene(TestScenes.SceneB), Edge.Bottom)
+            },
+        ) {
+            before { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(10.dp, 50.dp) }
+            at(0) {
+                onElement(TestElements.Foo, scene = TestScenes.SceneA)
+                    .assertPositionInRootIsEqualTo(10.dp, 50.dp)
+                onElement(TestElements.Foo, scene = TestScenes.SceneB)
+                    .assertPositionInRootIsEqualTo(50.dp, 100.dp)
+            }
+            at(16) {
+                onElement(TestElements.Foo, scene = TestScenes.SceneA)
+                    .assertPositionInRootIsEqualTo(7.5.dp, 50.dp)
+                onElement(TestElements.Foo, scene = TestScenes.SceneB)
+                    .assertPositionInRootIsEqualTo(50.dp, 90.dp)
+            }
+            at(32) {
+                onElement(TestElements.Foo, scene = TestScenes.SceneA)
+                    .assertPositionInRootIsEqualTo(5.dp, 50.dp)
+                onElement(TestElements.Foo, scene = TestScenes.SceneB)
+                    .assertPositionInRootIsEqualTo(50.dp, 80.dp)
+            }
+            at(48) {
+                onElement(TestElements.Foo, scene = TestScenes.SceneA)
+                    .assertPositionInRootIsEqualTo(2.5.dp, 50.dp)
+                onElement(TestElements.Foo, scene = TestScenes.SceneB)
+                    .assertPositionInRootIsEqualTo(50.dp, 70.dp)
+            }
+            after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(50.dp, 60.dp) }
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/TranslateTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/TranslateTest.kt
new file mode 100644
index 0000000..1d559fd
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/TranslateTest.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.transformation
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.offset
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertPositionInRootIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.TestElements
+import com.android.compose.animation.scene.testTransition
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class TranslateTest {
+    @get:Rule val rule = createComposeRule()
+
+    @Test
+    fun testTranslateExit() {
+        rule.testTransition(
+            fromSceneContent = {
+                // Foo is at (10dp, 50dp) and is exiting.
+                Box(Modifier.offset(10.dp, 50.dp).element(TestElements.Foo))
+            },
+            toSceneContent = {},
+            transition = {
+                // Foo is translated by (20dp, -40dp) during 4 frames.
+                spec = tween(16 * 4, easing = LinearEasing)
+                translate(TestElements.Foo, x = 20.dp, y = (-40).dp)
+            },
+        ) {
+            before { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(10.dp, 50.dp) }
+            at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(10.dp, 50.dp) }
+            at(16) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(15.dp, 40.dp) }
+            at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(20.dp, 30.dp) }
+            at(48) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(25.dp, 20.dp) }
+            after { onElement(TestElements.Foo).assertDoesNotExist() }
+        }
+    }
+
+    @Test
+    fun testTranslateEnter() {
+        rule.testTransition(
+            fromSceneContent = {},
+            toSceneContent = {
+                // Foo is entering to (10dp, 50dp)
+                Box(Modifier.offset(10.dp, 50.dp).element(TestElements.Foo))
+            },
+            transition = {
+                // Foo is translated from (10dp, 50) + (20dp, -40dp) during 4 frames.
+                spec = tween(16 * 4, easing = LinearEasing)
+                translate(TestElements.Foo, x = 20.dp, y = (-40).dp)
+            },
+        ) {
+            before { onElement(TestElements.Foo).assertDoesNotExist() }
+            at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(30.dp, 10.dp) }
+            at(16) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(25.dp, 20.dp) }
+            at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(20.dp, 30.dp) }
+            at(48) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(15.dp, 40.dp) }
+            at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(10.dp, 50.dp) }
+            after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(10.dp, 50.dp) }
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/LargeTopAppBarNestedScrollConnectionTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/LargeTopAppBarNestedScrollConnectionTest.kt
new file mode 100644
index 0000000..03d231a
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/LargeTopAppBarNestedScrollConnectionTest.kt
@@ -0,0 +1,145 @@
+/*
+ * 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.nestedscroll
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@RunWith(Parameterized::class)
+class LargeTopAppBarNestedScrollConnectionTest(testCase: TestCase) {
+    val scrollSource = testCase.scrollSource
+
+    private var height = 0f
+
+    private fun buildScrollConnection(heightRange: ClosedFloatingPointRange<Float>) =
+        LargeTopAppBarNestedScrollConnection(
+            height = { height },
+            onHeightChanged = { height = it },
+            heightRange = heightRange,
+        )
+
+    @Test
+    fun onScrollUp_consumeHeightFirst() {
+        val scrollConnection = buildScrollConnection(heightRange = 0f..2f)
+        height = 1f
+
+        val offsetConsumed =
+            scrollConnection.onPreScroll(available = Offset(x = 0f, y = -1f), source = scrollSource)
+
+        // It can decrease by 1 the height
+        assertThat(offsetConsumed).isEqualTo(Offset(0f, -1f))
+        assertThat(height).isEqualTo(0f)
+    }
+
+    @Test
+    fun onScrollUp_consumeDownToMin() {
+        val scrollConnection = buildScrollConnection(heightRange = 0f..2f)
+        height = 0f
+
+        val offsetConsumed =
+            scrollConnection.onPreScroll(available = Offset(x = 0f, y = -1f), source = scrollSource)
+
+        // It should not change the height (already at min)
+        assertThat(offsetConsumed).isEqualTo(Offset.Zero)
+        assertThat(height).isEqualTo(0f)
+    }
+
+    @Test
+    fun onScrollUp_ignorePostScroll() {
+        val scrollConnection = buildScrollConnection(heightRange = 0f..2f)
+        height = 1f
+
+        val offsetConsumed =
+            scrollConnection.onPostScroll(
+                consumed = Offset.Zero,
+                available = Offset(x = 0f, y = -1f),
+                source = scrollSource
+            )
+
+        // It should ignore all onPostScroll events
+        assertThat(offsetConsumed).isEqualTo(Offset.Zero)
+        assertThat(height).isEqualTo(1f)
+    }
+
+    @Test
+    fun onScrollDown_allowConsumeContentFirst() {
+        val scrollConnection = buildScrollConnection(heightRange = 0f..2f)
+        height = 1f
+
+        val offsetConsumed =
+            scrollConnection.onPreScroll(available = Offset(x = 0f, y = 1f), source = scrollSource)
+
+        // It should ignore all onPreScroll events
+        assertThat(offsetConsumed).isEqualTo(Offset.Zero)
+        assertThat(height).isEqualTo(1f)
+    }
+
+    @Test
+    fun onScrollDown_consumeHeightPostScroll() {
+        val scrollConnection = buildScrollConnection(heightRange = 0f..2f)
+        height = 1f
+
+        val offsetConsumed =
+            scrollConnection.onPostScroll(
+                consumed = Offset.Zero,
+                available = Offset(x = 0f, y = 1f),
+                source = scrollSource
+            )
+
+        // It can increase by 1 the height
+        assertThat(offsetConsumed).isEqualTo(Offset(0f, 1f))
+        assertThat(height).isEqualTo(2f)
+    }
+
+    @Test
+    fun onScrollDown_consumeUpToMax() {
+        val scrollConnection = buildScrollConnection(heightRange = 0f..2f)
+        height = 2f
+
+        val offsetConsumed =
+            scrollConnection.onPostScroll(
+                consumed = Offset.Zero,
+                available = Offset(x = 0f, y = 1f),
+                source = scrollSource
+            )
+
+        // It should not change the height (already at max)
+        assertThat(offsetConsumed).isEqualTo(Offset.Zero)
+        assertThat(height).isEqualTo(2f)
+    }
+
+    // NestedScroll Source is a value/inline class and must be wrapped in a parameterized test
+    // https://youtrack.jetbrains.com/issue/KT-35523/Parameterized-JUnit-tests-with-inline-classes-throw-IllegalArgumentException
+    data class TestCase(val scrollSource: NestedScrollSource) {
+        override fun toString() = scrollSource.toString()
+    }
+
+    companion object {
+        @Parameterized.Parameters(name = "{0}")
+        @JvmStatic
+        fun data(): List<TestCase> =
+            listOf(
+                TestCase(NestedScrollSource.Drag),
+                TestCase(NestedScrollSource.Fling),
+                TestCase(NestedScrollSource.Wheel),
+            )
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/PriorityPostNestedScrollConnectionTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/PriorityPostNestedScrollConnectionTest.kt
new file mode 100644
index 0000000..8e2b77a
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/PriorityPostNestedScrollConnectionTest.kt
@@ -0,0 +1,172 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.compose.nestedscroll
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.unit.Velocity
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PriorityPostNestedScrollConnectionTest {
+    private var canStart = false
+    private var canContinueScroll = false
+    private var isStarted = false
+    private var lastScroll: Offset? = null
+    private var returnOnScroll = Offset.Zero
+    private var lastStop: Velocity? = null
+    private var returnOnStop = Velocity.Zero
+    private var lastOnPostFling: Velocity? = null
+    private var returnOnPostFling = Velocity.Zero
+
+    private val scrollConnection =
+        PriorityPostNestedScrollConnection(
+            canStart = { _, _ -> canStart },
+            canContinueScroll = { canContinueScroll },
+            onStart = { isStarted = true },
+            onScroll = {
+                lastScroll = it
+                returnOnScroll
+            },
+            onStop = {
+                lastStop = it
+                returnOnStop
+            },
+            onPostFling = {
+                lastOnPostFling = it
+                returnOnPostFling
+            },
+        )
+
+    private val offset1 = Offset(1f, 1f)
+    private val offset2 = Offset(2f, 2f)
+    private val velocity1 = Velocity(1f, 1f)
+    private val velocity2 = Velocity(2f, 2f)
+
+    private fun startPriorityMode() {
+        canStart = true
+        scrollConnection.onPostScroll(
+            consumed = Offset.Zero,
+            available = Offset.Zero,
+            source = NestedScrollSource.Drag
+        )
+    }
+
+    @Test
+    fun step1_priorityModeShouldStartOnlyOnPostScroll() = runTest {
+        canStart = true
+
+        scrollConnection.onPreScroll(available = Offset.Zero, source = NestedScrollSource.Drag)
+        assertThat(isStarted).isEqualTo(false)
+
+        scrollConnection.onPreFling(available = Velocity.Zero)
+        assertThat(isStarted).isEqualTo(false)
+
+        scrollConnection.onPostFling(consumed = Velocity.Zero, available = Velocity.Zero)
+        assertThat(isStarted).isEqualTo(false)
+
+        startPriorityMode()
+        assertThat(isStarted).isEqualTo(true)
+    }
+
+    @Test
+    fun step1_priorityModeShouldStartOnlyIfAllowed() {
+        scrollConnection.onPostScroll(
+            consumed = Offset.Zero,
+            available = Offset.Zero,
+            source = NestedScrollSource.Drag
+        )
+        assertThat(isStarted).isEqualTo(false)
+
+        startPriorityMode()
+        assertThat(isStarted).isEqualTo(true)
+    }
+
+    @Test
+    fun step1_onPriorityModeStarted_receiveAvailableOffset() {
+        canStart = true
+
+        scrollConnection.onPostScroll(
+            consumed = offset1,
+            available = offset2,
+            source = NestedScrollSource.Drag
+        )
+
+        assertThat(lastScroll).isEqualTo(offset2)
+    }
+
+    @Test
+    fun step2_onPriorityMode_shouldContinueIfAllowed() {
+        startPriorityMode()
+        canContinueScroll = true
+
+        scrollConnection.onPreScroll(available = offset1, source = NestedScrollSource.Drag)
+        assertThat(lastScroll).isEqualTo(offset1)
+
+        canContinueScroll = false
+        scrollConnection.onPreScroll(available = offset2, source = NestedScrollSource.Drag)
+        assertThat(lastScroll).isNotEqualTo(offset2)
+        assertThat(lastScroll).isEqualTo(offset1)
+    }
+
+    @Test
+    fun step3a_onPriorityMode_shouldStopIfCannotContinue() {
+        startPriorityMode()
+        canContinueScroll = false
+
+        scrollConnection.onPreScroll(available = Offset.Zero, source = NestedScrollSource.Drag)
+
+        assertThat(lastStop).isNotNull()
+    }
+
+    @Test
+    fun step3b_onPriorityMode_shouldStopOnFling() = runTest {
+        startPriorityMode()
+        canContinueScroll = true
+
+        scrollConnection.onPreFling(available = Velocity.Zero)
+
+        assertThat(lastStop).isNotNull()
+    }
+
+    @Test
+    fun step3c_onPriorityMode_shouldStopOnReset() {
+        startPriorityMode()
+        canContinueScroll = true
+
+        scrollConnection.reset()
+
+        assertThat(lastStop).isNotNull()
+    }
+
+    @Test
+    fun receive_onPostFling() = runTest {
+        scrollConnection.onPostFling(
+            consumed = velocity1,
+            available = velocity2,
+        )
+
+        assertThat(lastOnPostFling).isEqualTo(velocity2)
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/test/Selectors.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/test/Selectors.kt
new file mode 100644
index 0000000..d6f64bf
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/test/Selectors.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.test
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.SemanticsNodeInteractionCollection
+
+/** Assert [assert] on each element of [this] [SemanticsNodeInteractionCollection]. */
+fun SemanticsNodeInteractionCollection.onEach(assert: SemanticsNodeInteraction.() -> Unit) {
+    for (i in 0 until this.fetchSemanticsNodes().size) {
+        get(i).assert()
+    }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/test/SizeAssertions.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/test/SizeAssertions.kt
new file mode 100644
index 0000000..fbd1b51
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/test/SizeAssertions.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2022 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.test
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.unit.Dp
+
+fun SemanticsNodeInteraction.assertSizeIsEqualTo(expectedWidth: Dp, expectedHeight: Dp) {
+    assertWidthIsEqualTo(expectedWidth)
+    assertHeightIsEqualTo(expectedHeight)
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/test/subjects/DpOffsetSubject.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/test/subjects/DpOffsetSubject.kt
new file mode 100644
index 0000000..bf7bf98
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/test/subjects/DpOffsetSubject.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2022 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.test.subjects
+
+import androidx.compose.ui.test.assertIsEqualTo
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpOffset
+import com.google.common.truth.FailureMetadata
+import com.google.common.truth.Subject
+import com.google.common.truth.Subject.Factory
+import com.google.common.truth.Truth.assertAbout
+
+/** Assert on a [DpOffset]. */
+fun assertThat(dpOffset: DpOffset): DpOffsetSubject {
+    return assertAbout(DpOffsetSubject.dpOffsets()).that(dpOffset)
+}
+
+/** A Truth subject to assert on [DpOffset] with some tolerance. Inspired by FloatSubject. */
+class DpOffsetSubject(
+    metadata: FailureMetadata,
+    private val actual: DpOffset,
+) : Subject(metadata, actual) {
+    fun isWithin(tolerance: Dp): TolerantDpOffsetComparison {
+        return object : TolerantDpOffsetComparison {
+            override fun of(expected: DpOffset) {
+                actual.x.assertIsEqualTo(expected.x, "offset.x", tolerance)
+                actual.y.assertIsEqualTo(expected.y, "offset.y", tolerance)
+            }
+        }
+    }
+
+    interface TolerantDpOffsetComparison {
+        fun of(expected: DpOffset)
+    }
+
+    companion object {
+        val DefaultTolerance = Dp(.5f)
+
+        fun dpOffsets() =
+            Factory<DpOffsetSubject, DpOffset> { metadata, actual ->
+                DpOffsetSubject(metadata, actual)
+            }
+    }
+}