Introduce CustomPropertyTransformation
This CL introduces a new CustomPropertyTransformation to the DSL. This
allows users of STL to create custom transformations that are more
complex than the default transformations. The goal is to create custom
transformations using our Motion Mechanics to implement better Dual
Shade transitions. See go/stl-dual-shade for details.
At first these custom transformations will be limited to normal
transition specs, and we throw an exception when they are defined in a
preview spec or overscroll spec. We plan to rework overscroll and
previews in the coming weeks, at which point we will reconcile them with
custom transformations.
Bug: 376438969
Test: atest CustomTransformationTest
Test: atest SceneTransitionLayoutTest
Flag: com.android.systemui.scene_container
Change-Id: I91a27f82d458b2acaffb8c9cbd096dde9c2c04a2
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
index d976e8e..44f60cb 100644
--- 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
@@ -50,6 +50,8 @@
import androidx.compose.ui.util.lerp
import com.android.compose.animation.scene.content.Content
import com.android.compose.animation.scene.content.state.TransitionState
+import com.android.compose.animation.scene.transformation.CustomPropertyTransformation
+import com.android.compose.animation.scene.transformation.InterpolatedPropertyTransformation
import com.android.compose.animation.scene.transformation.PropertyTransformation
import com.android.compose.animation.scene.transformation.SharedElementTransformation
import com.android.compose.animation.scene.transformation.TransformationWithRange
@@ -1308,7 +1310,14 @@
checkNotNull(if (currentContent == toContent) toState else fromState)
val idleValue = contentValue(overscrollState)
val targetValue =
- with(propertySpec.transformation) {
+ with(
+ propertySpec.transformation.requireInterpolatedTransformation(
+ element,
+ transition,
+ ) {
+ "Custom transformations in overscroll specs should not be possible"
+ }
+ ) {
layoutImpl.propertyTransformationScope.transform(
currentContent,
element.key,
@@ -1390,7 +1399,7 @@
// fromContent or toContent during interruptions.
val content = contentState.content
- val transformation =
+ val transformationWithRange =
transformation(transition.transformationSpec.transformations(element.key, content))
val previewTransformation =
@@ -1403,7 +1412,14 @@
val idleValue = contentValue(contentState)
val isEntering = content == toContent
val previewTargetValue =
- with(previewTransformation.transformation) {
+ with(
+ previewTransformation.transformation.requireInterpolatedTransformation(
+ element,
+ transition,
+ ) {
+ "Custom transformations in preview specs should not be possible"
+ }
+ ) {
layoutImpl.propertyTransformationScope.transform(
content,
element.key,
@@ -1413,8 +1429,15 @@
}
val targetValueOrNull =
- transformation?.let { transformation ->
- with(transformation.transformation) {
+ transformationWithRange?.let { transformation ->
+ with(
+ transformation.transformation.requireInterpolatedTransformation(
+ element,
+ transition,
+ ) {
+ "Custom transformations are not allowed for properties with a preview"
+ }
+ ) {
layoutImpl.propertyTransformationScope.transform(
content,
element.key,
@@ -1461,7 +1484,7 @@
lerp(
lerp(previewTargetValue, targetValueOrNull ?: idleValue, previewRangeProgress),
idleValue,
- transformation?.range?.progress(transition.progress) ?: transition.progress,
+ transformationWithRange?.range?.progress(transition.progress) ?: transition.progress,
)
} else {
if (targetValueOrNull == null) {
@@ -1474,22 +1497,39 @@
lerp(
lerp(idleValue, previewTargetValue, previewRangeProgress),
targetValueOrNull,
- transformation.range?.progress(transition.progress) ?: transition.progress,
+ transformationWithRange.range?.progress(transition.progress)
+ ?: transition.progress,
)
}
}
}
- if (transformation == null) {
+ if (transformationWithRange == null) {
// 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()
}
+ val transformation = transformationWithRange.transformation
+ when (transformation) {
+ is CustomPropertyTransformation ->
+ return with(transformation) {
+ layoutImpl.propertyTransformationScope.transform(
+ content,
+ element.key,
+ transition,
+ transition.coroutineScope,
+ )
+ }
+ is InterpolatedPropertyTransformation -> {
+ /* continue */
+ }
+ }
+
val idleValue = contentValue(contentState)
val targetValue =
- with(transformation.transformation) {
+ with(transformation) {
layoutImpl.propertyTransformationScope.transform(
content,
element.key,
@@ -1506,7 +1546,7 @@
val progress = transition.progress
// TODO(b/290184746): Make sure that we don't overflow transformations associated to a range.
- val rangeProgress = transformation.range?.progress(progress) ?: progress
+ val rangeProgress = transformationWithRange.range?.progress(progress) ?: progress
// Interpolate between the value at rest and the value before entering/after leaving.
val isEntering =
@@ -1523,6 +1563,22 @@
}
}
+private inline fun <T> PropertyTransformation<T>.requireInterpolatedTransformation(
+ element: Element,
+ transition: TransitionState.Transition,
+ errorMessage: () -> String,
+): InterpolatedPropertyTransformation<T> {
+ return when (this) {
+ is InterpolatedPropertyTransformation -> this
+ is CustomPropertyTransformation -> {
+ val elem = element.key.debugName
+ val fromContent = transition.fromContent
+ val toContent = transition.toContent
+ error("${errorMessage()} (element=$elem fromContent=$fromContent toContent=$toContent)")
+ }
+ }
+}
+
private inline fun <T> interpolateSharedElement(
transition: TransitionState.Transition,
contentValue: (Element.State) -> T,
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
index 72b29ee..3c01dfe 100644
--- 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
@@ -32,6 +32,7 @@
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
@@ -466,9 +467,9 @@
return
}
- // Make sure that this transition settles in case it was force finished, for instance by
- // calling snapToScene().
- transition.freezeAndAnimateToCurrentState()
+ // Make sure that this transition is cancelled in case it was force finished, for instance
+ // if snapToScene() is called.
+ transition.coroutineScope.cancel()
val transitionStates = this.transitionStates
if (!transitionStates.contains(transition)) {
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
index b083f79..569593c 100644
--- 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
@@ -26,18 +26,18 @@
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.util.fastForEach
import com.android.compose.animation.scene.content.state.TransitionState
-import com.android.compose.animation.scene.transformation.AnchoredSize
-import com.android.compose.animation.scene.transformation.AnchoredTranslate
-import com.android.compose.animation.scene.transformation.DrawScale
-import com.android.compose.animation.scene.transformation.EdgeTranslate
-import com.android.compose.animation.scene.transformation.Fade
-import com.android.compose.animation.scene.transformation.OverscrollTranslate
+import com.android.compose.animation.scene.transformation.CustomAlphaTransformation
+import com.android.compose.animation.scene.transformation.CustomOffsetTransformation
+import com.android.compose.animation.scene.transformation.CustomScaleTransformation
+import com.android.compose.animation.scene.transformation.CustomSizeTransformation
+import com.android.compose.animation.scene.transformation.InterpolatedAlphaTransformation
+import com.android.compose.animation.scene.transformation.InterpolatedOffsetTransformation
+import com.android.compose.animation.scene.transformation.InterpolatedScaleTransformation
+import com.android.compose.animation.scene.transformation.InterpolatedSizeTransformation
import com.android.compose.animation.scene.transformation.PropertyTransformation
-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.TransformationWithRange
-import com.android.compose.animation.scene.transformation.Translate
/** The transitions configuration of a [SceneTransitionLayout]. */
class SceneTransitions
@@ -359,35 +359,34 @@
transformationWithRange
as TransformationWithRange<SharedElementTransformation>
}
- is Translate,
- is OverscrollTranslate,
- is EdgeTranslate,
- is AnchoredTranslate -> {
+ is InterpolatedOffsetTransformation,
+ is CustomOffsetTransformation -> {
throwIfNotNull(offset, element, name = "offset")
offset =
transformationWithRange
as TransformationWithRange<PropertyTransformation<Offset>>
}
- is ScaleSize,
- is AnchoredSize -> {
+ is InterpolatedSizeTransformation,
+ is CustomSizeTransformation -> {
throwIfNotNull(size, element, name = "size")
size =
transformationWithRange
as TransformationWithRange<PropertyTransformation<IntSize>>
}
- is DrawScale -> {
+ is InterpolatedScaleTransformation,
+ is CustomScaleTransformation -> {
throwIfNotNull(drawScale, element, name = "drawScale")
drawScale =
transformationWithRange
as TransformationWithRange<PropertyTransformation<Scale>>
}
- is Fade -> {
+ is InterpolatedAlphaTransformation,
+ is CustomAlphaTransformation -> {
throwIfNotNull(alpha, element, name = "alpha")
alpha =
transformationWithRange
as TransformationWithRange<PropertyTransformation<Float>>
}
- else -> error("Unknown transformation: $transformation")
}
}
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
index dc26b6b..1fdfca9 100644
--- 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
@@ -26,6 +26,7 @@
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.android.compose.animation.scene.content.state.TransitionState
+import com.android.compose.animation.scene.transformation.CustomPropertyTransformation
import kotlin.math.tanh
/** Define the [transitions][SceneTransitions] to be used with a [SceneTransitionLayout]. */
@@ -527,6 +528,16 @@
anchorWidth: Boolean = true,
anchorHeight: Boolean = true,
)
+
+ /**
+ * Apply a [CustomPropertyTransformation] to one or more elements.
+ *
+ * @see com.android.compose.animation.scene.transformation.CustomSizeTransformation
+ * @see com.android.compose.animation.scene.transformation.CustomOffsetTransformation
+ * @see com.android.compose.animation.scene.transformation.CustomAlphaTransformation
+ * @see com.android.compose.animation.scene.transformation.CustomScaleTransformation
+ */
+ fun transformation(transformation: CustomPropertyTransformation<*>)
}
/** This converter lets you change a linear progress into a function of your choice. */
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
index e461f9c..79f8cd4 100644
--- 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
@@ -30,6 +30,7 @@
import com.android.compose.animation.scene.content.state.TransitionState
import com.android.compose.animation.scene.transformation.AnchoredSize
import com.android.compose.animation.scene.transformation.AnchoredTranslate
+import com.android.compose.animation.scene.transformation.CustomPropertyTransformation
import com.android.compose.animation.scene.transformation.DrawScale
import com.android.compose.animation.scene.transformation.EdgeTranslate
import com.android.compose.animation.scene.transformation.Fade
@@ -173,7 +174,7 @@
range = null
}
- protected fun transformation(transformation: Transformation) {
+ protected fun addTransformation(transformation: Transformation) {
val transformationWithRange = TransformationWithRange(transformation, range)
transformations.add(
if (reversed) {
@@ -185,11 +186,11 @@
}
override fun fade(matcher: ElementMatcher) {
- transformation(Fade(matcher))
+ addTransformation(Fade(matcher))
}
override fun translate(matcher: ElementMatcher, x: Dp, y: Dp) {
- transformation(Translate(matcher, x, y))
+ addTransformation(Translate(matcher, x, y))
}
override fun translate(
@@ -197,19 +198,19 @@
edge: Edge,
startsOutsideLayoutBounds: Boolean,
) {
- transformation(EdgeTranslate(matcher, edge, startsOutsideLayoutBounds))
+ addTransformation(EdgeTranslate(matcher, edge, startsOutsideLayoutBounds))
}
override fun anchoredTranslate(matcher: ElementMatcher, anchor: ElementKey) {
- transformation(AnchoredTranslate(matcher, anchor))
+ addTransformation(AnchoredTranslate(matcher, anchor))
}
override fun scaleSize(matcher: ElementMatcher, width: Float, height: Float) {
- transformation(ScaleSize(matcher, width, height))
+ addTransformation(ScaleSize(matcher, width, height))
}
override fun scaleDraw(matcher: ElementMatcher, scaleX: Float, scaleY: Float, pivot: Offset) {
- transformation(DrawScale(matcher, scaleX, scaleY, pivot))
+ addTransformation(DrawScale(matcher, scaleX, scaleY, pivot))
}
override fun anchoredSize(
@@ -218,7 +219,12 @@
anchorWidth: Boolean,
anchorHeight: Boolean,
) {
- transformation(AnchoredSize(matcher, anchor, anchorWidth, anchorHeight))
+ addTransformation(AnchoredSize(matcher, anchor, anchorWidth, anchorHeight))
+ }
+
+ override fun transformation(transformation: CustomPropertyTransformation<*>) {
+ check(range == null) { "Custom transformations can not be applied inside a range" }
+ addTransformation(transformation)
}
}
@@ -257,7 +263,7 @@
"(${transition.toContent.debugName})"
}
- transformation(SharedElementTransformation(matcher, enabled, elevateInContent))
+ addTransformation(SharedElementTransformation(matcher, enabled, elevateInContent))
}
override fun timestampRange(
@@ -288,6 +294,6 @@
x: OverscrollScope.() -> Float,
y: OverscrollScope.() -> Float,
) {
- transformation(OverscrollTranslate(matcher, x, y))
+ addTransformation(OverscrollTranslate(matcher, x, y))
}
}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt
index 68f8491..8f7028b 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt
@@ -34,6 +34,8 @@
import com.android.compose.animation.scene.TransformationSpec
import com.android.compose.animation.scene.TransformationSpecImpl
import com.android.compose.animation.scene.TransitionKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
/** The state associated to a [SceneTransitionLayout] at some specific point in time. */
@@ -278,8 +280,24 @@
*/
private var interruptionDecay: Animatable<Float, AnimationVector1D>? = null
- /** Whether this transition was already started. */
- private var wasStarted = false
+ /**
+ * The coroutine scope associated to this transition.
+ *
+ * This coroutine scope can be used to launch animations associated to this transition,
+ * which will not finish until at least one animation/job is still running in the scope.
+ *
+ * Important: Make sure to never launch long-running jobs in this scope, otherwise the
+ * transition will never be considered as finished.
+ */
+ internal val coroutineScope: CoroutineScope
+ get() =
+ _coroutineScope
+ ?: error(
+ "Transition.coroutineScope can only be accessed once the transition was " +
+ "started "
+ )
+
+ private var _coroutineScope: CoroutineScope? = null
init {
check(fromContent != toContent)
@@ -340,10 +358,11 @@
abstract fun freezeAndAnimateToCurrentState()
internal suspend fun runInternal() {
- check(!wasStarted) { "A Transition can be started only once." }
- wasStarted = true
-
- run()
+ check(_coroutineScope == null) { "A Transition can be started only once." }
+ coroutineScope {
+ _coroutineScope = this
+ run()
+ }
}
internal fun updateOverscrollSpecs(
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
index 0ddeb7c..85bb533 100644
--- 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
@@ -28,7 +28,7 @@
private val anchor: ElementKey,
private val anchorWidth: Boolean,
private val anchorHeight: Boolean,
-) : PropertyTransformation<IntSize> {
+) : InterpolatedSizeTransformation {
override fun PropertyTransformationScope.transform(
content: ContentKey,
element: ElementKey,
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
index 47508b4..04cd683 100644
--- 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
@@ -26,7 +26,7 @@
internal class AnchoredTranslate(
override val matcher: ElementMatcher,
private val anchor: ElementKey,
-) : PropertyTransformation<Offset> {
+) : InterpolatedOffsetTransformation {
override fun PropertyTransformationScope.transform(
content: ContentKey,
element: ElementKey,
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/DrawScale.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/DrawScale.kt
index 8488ae5..45d6d40 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/DrawScale.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/DrawScale.kt
@@ -32,7 +32,7 @@
private val scaleX: Float,
private val scaleY: Float,
private val pivot: Offset = Offset.Unspecified,
-) : PropertyTransformation<Scale> {
+) : InterpolatedScaleTransformation {
override fun PropertyTransformationScope.transform(
content: ContentKey,
element: ElementKey,
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
index 884aae4b..21d66d7 100644
--- 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
@@ -28,7 +28,7 @@
override val matcher: ElementMatcher,
private val edge: Edge,
private val startsOutsideLayoutBounds: Boolean = true,
-) : PropertyTransformation<Offset> {
+) : InterpolatedOffsetTransformation {
override fun PropertyTransformationScope.transform(
content: ContentKey,
element: ElementKey,
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
index ef769e7..d942273 100644
--- 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
@@ -22,7 +22,7 @@
import com.android.compose.animation.scene.content.state.TransitionState
/** Fade an element in or out. */
-internal class Fade(override val matcher: ElementMatcher) : PropertyTransformation<Float> {
+internal class Fade(override val matcher: ElementMatcher) : InterpolatedAlphaTransformation {
override fun PropertyTransformationScope.transform(
content: ContentKey,
element: ElementKey,
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
index ef3654b..5f3cdab 100644
--- 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
@@ -31,7 +31,7 @@
override val matcher: ElementMatcher,
private val width: Float = 1f,
private val height: Float = 1f,
-) : PropertyTransformation<IntSize> {
+) : InterpolatedSizeTransformation {
override fun PropertyTransformationScope.transform(
content: ContentKey,
element: ElementKey,
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
index d9b131e..d5143d7 100644
--- 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
@@ -18,7 +18,9 @@
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.LinearEasing
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.util.fastCoerceAtLeast
import androidx.compose.ui.util.fastCoerceAtMost
@@ -27,7 +29,9 @@
import com.android.compose.animation.scene.ElementKey
import com.android.compose.animation.scene.ElementMatcher
import com.android.compose.animation.scene.ElementStateScope
+import com.android.compose.animation.scene.Scale
import com.android.compose.animation.scene.content.state.TransitionState
+import kotlinx.coroutines.CoroutineScope
/** A transformation applied to one or more elements during a transition. */
sealed interface Transformation {
@@ -44,7 +48,13 @@
) : Transformation
/** A transformation that changes the value of an element property, like its size or offset. */
-interface PropertyTransformation<T> : Transformation {
+sealed interface PropertyTransformation<T> : Transformation
+
+/**
+ * A transformation to a target/transformed value that is automatically interpolated using the
+ * transition progress and transformation range.
+ */
+sealed interface InterpolatedPropertyTransformation<T> : PropertyTransformation<T> {
/**
* Return the transformed value for the given property, i.e.:
* - the value at progress = 0% for elements that are entering the layout (i.e. elements in the
@@ -52,8 +62,8 @@
* - the value at progress = 100% for elements that are leaving the layout (i.e. elements in the
* content we are transitioning from).
*
- * The returned value will be interpolated using the [transition] progress and [idleValue], the
- * value of the property when we are idle.
+ * The returned value will be automatically interpolated using the [transition] progress, the
+ * transformation range and [idleValue], the value of the property when we are idle.
*/
fun PropertyTransformationScope.transform(
content: ContentKey,
@@ -63,6 +73,50 @@
): T
}
+/** An [InterpolatedPropertyTransformation] applied to the size of one or more elements. */
+interface InterpolatedSizeTransformation : InterpolatedPropertyTransformation<IntSize>
+
+/** An [InterpolatedPropertyTransformation] applied to the offset of one or more elements. */
+interface InterpolatedOffsetTransformation : InterpolatedPropertyTransformation<Offset>
+
+/** An [InterpolatedPropertyTransformation] applied to the alpha of one or more elements. */
+interface InterpolatedAlphaTransformation : InterpolatedPropertyTransformation<Float>
+
+/** An [InterpolatedPropertyTransformation] applied to the scale of one or more elements. */
+interface InterpolatedScaleTransformation : InterpolatedPropertyTransformation<Scale>
+
+sealed interface CustomPropertyTransformation<T> : PropertyTransformation<T> {
+ /**
+ * Return the value that the property should have in the current frame for the given [content]
+ * and [element].
+ *
+ * This transformation can use [transitionScope] to launch animations associated to
+ * [transition], which will not finish until at least one animation/job is still running in the
+ * scope.
+ *
+ * Important: Make sure to never launch long-running jobs in [transitionScope], otherwise
+ * [transition] will never be considered as finished.
+ */
+ fun PropertyTransformationScope.transform(
+ content: ContentKey,
+ element: ElementKey,
+ transition: TransitionState.Transition,
+ transitionScope: CoroutineScope,
+ ): T
+}
+
+/** A [CustomPropertyTransformation] applied to the size of one or more elements. */
+interface CustomSizeTransformation : CustomPropertyTransformation<IntSize>
+
+/** A [CustomPropertyTransformation] applied to the offset of one or more elements. */
+interface CustomOffsetTransformation : CustomPropertyTransformation<Offset>
+
+/** A [CustomPropertyTransformation] applied to the alpha of one or more elements. */
+interface CustomAlphaTransformation : CustomPropertyTransformation<Float>
+
+/** A [CustomPropertyTransformation] applied to the scale of one or more elements. */
+interface CustomScaleTransformation : CustomPropertyTransformation<Scale>
+
interface PropertyTransformationScope : Density, ElementStateScope {
/** The current [direction][LayoutDirection] of the layout. */
val layoutDirection: LayoutDirection
@@ -95,7 +149,7 @@
}
/** Reverse this range. */
- fun reversed() =
+ internal fun reversed() =
TransformationRange(start = reverseBound(end), end = reverseBound(start), easing = easing)
/** Get the progress of this range given the global [transitionProgress]. */
@@ -122,6 +176,6 @@
}
companion object {
- const val BoundUnspecified = Float.MIN_VALUE
+ internal 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
index 356ed99..d756c86 100644
--- 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
@@ -30,7 +30,7 @@
override val matcher: ElementMatcher,
private val x: Dp = 0.dp,
private val y: Dp = 0.dp,
-) : PropertyTransformation<Offset> {
+) : InterpolatedOffsetTransformation {
override fun PropertyTransformationScope.transform(
content: ContentKey,
element: ElementKey,
@@ -45,7 +45,7 @@
override val matcher: ElementMatcher,
val x: OverscrollScope.() -> Float = { 0f },
val y: OverscrollScope.() -> Float = { 0f },
-) : PropertyTransformation<Offset> {
+) : InterpolatedOffsetTransformation {
private val cachedOverscrollScope = CachedOverscrollScope()
override fun PropertyTransformationScope.transform(
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutStateTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutStateTest.kt
index 2b70908..ce1c8f8 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutStateTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutStateTest.kt
@@ -35,12 +35,15 @@
import com.android.compose.test.transition
import com.google.common.truth.Truth.assertThat
import kotlin.coroutines.cancellation.CancellationException
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertThrows
import org.junit.Rule
@@ -601,4 +604,47 @@
runBlocking { state.startTransition(transition) }
}
}
+
+ @Test
+ fun transitionFinishedWhenScopeIsEmpty() = runTest {
+ val state = MutableSceneTransitionLayoutState(SceneA)
+
+ // Start a transition.
+ val transition = transition(from = SceneA, to = SceneB)
+ state.startTransitionImmediately(backgroundScope, transition)
+ assertThat(state.transitionState).isSceneTransition()
+
+ // Start a job in the transition scope.
+ val jobCompletable = CompletableDeferred<Unit>()
+ transition.coroutineScope.launch { jobCompletable.await() }
+
+ // Finish the transition (i.e. make its #run() method return). The transition should not be
+ // considered as finished yet given that there is a job still running in its scope.
+ transition.finish()
+ runCurrent()
+ assertThat(state.transitionState).isSceneTransition()
+
+ // Finish the job in the scope. Now the transition should be considered as finished.
+ jobCompletable.complete(Unit)
+ runCurrent()
+ assertThat(state.transitionState).isIdle()
+ }
+
+ @Test
+ fun transitionScopeIsCancelledWhenTransitionIsForceFinished() = runTest {
+ val state = MutableSceneTransitionLayoutState(SceneA)
+
+ // Start a transition.
+ val transition = transition(from = SceneA, to = SceneB)
+ state.startTransitionImmediately(backgroundScope, transition)
+ assertThat(state.transitionState).isSceneTransition()
+
+ // Start a job in the transition scope that never finishes.
+ val job = transition.coroutineScope.launch { awaitCancellation() }
+
+ // Force snap state to SceneB to force finish all current transitions.
+ state.snapToScene(SceneB)
+ assertThat(state.transitionState).isIdle()
+ assertThat(job.isCancelled).isTrue()
+ }
}
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
index d317114..1f9ba9e 100644
--- 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
@@ -22,17 +22,22 @@
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.ui.unit.IntSize
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.compose.animation.scene.TestScenes.SceneA
import com.android.compose.animation.scene.TestScenes.SceneB
import com.android.compose.animation.scene.TestScenes.SceneC
import com.android.compose.animation.scene.content.state.TransitionState
+import com.android.compose.animation.scene.transformation.CustomSizeTransformation
import com.android.compose.animation.scene.transformation.OverscrollTranslate
+import com.android.compose.animation.scene.transformation.PropertyTransformationScope
import com.android.compose.animation.scene.transformation.TransformationRange
import com.android.compose.animation.scene.transformation.TransformationWithRange
import com.android.compose.test.transition
import com.google.common.truth.Correspondence
import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertThrows
import org.junit.Test
@@ -343,6 +348,33 @@
assertThat(transitionPassedToBuilder).isSameInstanceAs(transition)
}
+ @Test
+ fun customTransitionsAreNotSupportedInRanges() = runTest {
+ val transitions = transitions {
+ from(SceneA, to = SceneB) {
+ fractionRange {
+ transformation(
+ object : CustomSizeTransformation {
+ override val matcher: ElementMatcher = TestElements.Foo
+
+ override fun PropertyTransformationScope.transform(
+ content: ContentKey,
+ element: ElementKey,
+ transition: TransitionState.Transition,
+ transitionScope: CoroutineScope,
+ ): IntSize = IntSize.Zero
+ }
+ )
+ }
+ }
+ }
+
+ val state = MutableSceneTransitionLayoutState(SceneA, transitions)
+ assertThrows(IllegalStateException::class.java) {
+ runBlocking { state.startTransition(transition(from = SceneA, to = SceneB)) }
+ }
+ }
+
companion object {
private val TRANSFORMATION_RANGE =
Correspondence.transforming<TransformationWithRange<*>, TransformationRange?>(
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/CustomTransformationTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/CustomTransformationTest.kt
new file mode 100644
index 0000000..487b099
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/CustomTransformationTest.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2024 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.geometry.Offset
+import androidx.compose.ui.test.assertPositionInRootIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.ContentKey
+import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.ElementMatcher
+import com.android.compose.animation.scene.TestElements
+import com.android.compose.animation.scene.content.state.TransitionState
+import com.android.compose.animation.scene.testTransition
+import com.android.compose.test.assertSizeIsEqualTo
+import kotlinx.coroutines.CoroutineScope
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class CustomTransformationTest {
+ @get:Rule val rule = createComposeRule()
+
+ @Test
+ fun customSize() {
+ /** A size transformation that adds [add] to the size of the transformed element(s). */
+ class AddSizeTransformation(override val matcher: ElementMatcher, private val add: Dp) :
+ CustomSizeTransformation {
+ override fun PropertyTransformationScope.transform(
+ content: ContentKey,
+ element: ElementKey,
+ transition: TransitionState.Transition,
+ transitionScope: CoroutineScope,
+ ): IntSize {
+ val idleSize = checkNotNull(element.targetSize(content))
+ val progress = 1f - transition.progressTo(content)
+ val addPx = (add * progress).roundToPx()
+ return IntSize(width = idleSize.width + addPx, height = idleSize.height + addPx)
+ }
+ }
+
+ rule.testTransition(
+ fromSceneContent = { Box(Modifier.element(TestElements.Foo).size(40.dp, 20.dp)) },
+ toSceneContent = {},
+ transition = {
+ spec = tween(16 * 4, easing = LinearEasing)
+
+ // Add 80dp to the width and height of Foo.
+ transformation(AddSizeTransformation(TestElements.Foo, 80.dp))
+ },
+ ) {
+ before { onElement(TestElements.Foo).assertSizeIsEqualTo(40.dp, 20.dp) }
+ at(0) { onElement(TestElements.Foo).assertSizeIsEqualTo(40.dp, 20.dp) }
+ at(16) { onElement(TestElements.Foo).assertSizeIsEqualTo(60.dp, 40.dp) }
+ at(32) { onElement(TestElements.Foo).assertSizeIsEqualTo(80.dp, 60.dp) }
+ at(48) { onElement(TestElements.Foo).assertSizeIsEqualTo(100.dp, 80.dp) }
+ after { onElement(TestElements.Foo).assertDoesNotExist() }
+ }
+ }
+
+ @Test
+ fun customOffset() {
+ /** An offset transformation that adds [add] to the offset of the transformed element(s). */
+ class AddOffsetTransformation(override val matcher: ElementMatcher, private val add: Dp) :
+ CustomOffsetTransformation {
+ override fun PropertyTransformationScope.transform(
+ content: ContentKey,
+ element: ElementKey,
+ transition: TransitionState.Transition,
+ transitionScope: CoroutineScope,
+ ): Offset {
+ val idleOffset = checkNotNull(element.targetOffset(content))
+ val progress = 1f - transition.progressTo(content)
+ val addPx = (add * progress).toPx()
+ return Offset(x = idleOffset.x + addPx, y = idleOffset.y + addPx)
+ }
+ }
+
+ rule.testTransition(
+ fromSceneContent = { Box(Modifier.element(TestElements.Foo)) },
+ toSceneContent = {},
+ transition = {
+ spec = tween(16 * 4, easing = LinearEasing)
+
+ // Add 80dp to the offset of Foo.
+ transformation(AddOffsetTransformation(TestElements.Foo, 80.dp))
+ },
+ ) {
+ before { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(0.dp, 0.dp) }
+ at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(0.dp, 0.dp) }
+ at(16) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(20.dp, 20.dp) }
+ at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(40.dp, 40.dp) }
+ at(48) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(60.dp, 60.dp) }
+ after { onElement(TestElements.Foo).assertDoesNotExist() }
+ }
+ }
+}