Merge changes I91a27f82,I62688e42 into main

* changes:
  Introduce CustomPropertyTransformation
  Remove Transformation.reversed()
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 e3118d67..75584ba 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
@@ -35,6 +35,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. */
@@ -279,8 +281,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)
@@ -341,10 +359,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 74a3ead..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 {
@@ -35,12 +39,6 @@
      * The matcher that should match the element(s) to which this transformation should be applied.
      */
     val matcher: ElementMatcher
-
-    /*
-     * 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 reversed(): Transformation = this
 }
 
 internal class SharedElementTransformation(
@@ -50,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
@@ -58,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,
@@ -69,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
@@ -101,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]. */
@@ -128,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() }
+        }
+    }
+}