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)
+ }
+ }
+}