Merge changes from topic "stl-no-modifier-transformation" into main

* changes:
  Migrate Modifier.multiPointerDraggable to the Node Modifier API
  Annotate Stable classes/interfaces to optimize compositions
  Move Element drawing logic in ElementNode
  Remove Modifier transformations (1/2)
  Move PunchHole.kt to the animation/scene/ directory
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromLockscreenToShadeTransition.kt b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromLockscreenToShadeTransition.kt
index fadbdce..22dc0ae 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromLockscreenToShadeTransition.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/transitions/FromLockscreenToShadeTransition.kt
@@ -10,7 +10,6 @@
 fun TransitionBuilder.lockscreenToShadeTransition() {
     spec = tween(durationMillis = 500)
 
-    punchHole(Shade.Elements.QuickSettings, bounds = Shade.Elements.Scrim, Shade.Shapes.Scrim)
     translate(Shade.Elements.Scrim, Edge.Top, startsOutsideLayoutBounds = false)
     fractionRange(end = 0.5f) {
         fade(Shade.Elements.ScrimBackground)
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 3b999e30..2b11952 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
@@ -17,6 +17,7 @@
 package com.android.compose.animation.scene
 
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.movableContentOf
 import androidx.compose.runtime.mutableStateOf
@@ -25,16 +26,17 @@
 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.isUnspecified
 import androidx.compose.ui.geometry.lerp
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
 import androidx.compose.ui.graphics.drawscope.scale
 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.node.DrawModifierNode
 import androidx.compose.ui.node.ModifierNodeElement
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.unit.Constraints
@@ -46,6 +48,7 @@
 import kotlinx.coroutines.launch
 
 /** An element on screen, that can be composed in one or more scenes. */
+@Stable
 internal class Element(val key: ElementKey) {
     /**
      * The last values of this element, coming from any scene. Note that this value will be unstable
@@ -90,6 +93,7 @@
     }
 
     /** The target values of this element in a given scene. */
+    @Stable
     class TargetValues(val scene: SceneKey) {
         val lastValues = Values()
 
@@ -107,6 +111,7 @@
     }
 
     /** A shared value of this element. */
+    @Stable
     class SharedValue<T>(val key: ValueKey, initialValue: T) {
         var value by mutableStateOf(initialValue)
     }
@@ -126,6 +131,7 @@
 
 /** The implementation of [SceneScope.element]. */
 @OptIn(ExperimentalComposeUiApi::class)
+@Stable
 internal fun Modifier.element(
     layoutImpl: SceneTransitionLayoutImpl,
     scene: Scene,
@@ -144,24 +150,9 @@
                 ?: Element.TargetValues(scene.key).also { element.sceneValues[scene.key] = it }
     }
 
-    return this.then(ElementModifier(layoutImpl, element, sceneValues))
-        .drawWithContent {
-            if (shouldDrawElement(layoutImpl, scene, element)) {
-                val drawScale = getDrawScale(layoutImpl, element, scene, sceneValues)
-                if (drawScale == Scale.Default) {
-                    drawContent()
-                } else {
-                    scale(
-                        drawScale.scaleX,
-                        drawScale.scaleY,
-                        if (drawScale.pivot.isUnspecified) center else drawScale.pivot,
-                    ) {
-                        this@drawWithContent.drawContent()
-                    }
-                }
-            }
-        }
-        .modifierTransformations(layoutImpl, scene, element, sceneValues)
+    return this.then(ElementModifier(layoutImpl, scene, element, sceneValues))
+        // TODO(b/311132415): Move this into ElementNode once we can create a delegate
+        // IntermediateLayoutModifierNode.
         .intermediateLayout { measurable, constraints ->
             val placeable =
                 measure(layoutImpl, scene, element, sceneValues, measurable, constraints)
@@ -178,22 +169,25 @@
  */
 private data class ElementModifier(
     private val layoutImpl: SceneTransitionLayoutImpl,
+    private val scene: Scene,
     private val element: Element,
     private val sceneValues: Element.TargetValues,
 ) : ModifierNodeElement<ElementNode>() {
-    override fun create(): ElementNode = ElementNode(layoutImpl, element, sceneValues)
+    override fun create(): ElementNode = ElementNode(layoutImpl, scene, element, sceneValues)
 
     override fun update(node: ElementNode) {
-        node.update(layoutImpl, element, sceneValues)
+        node.update(layoutImpl, scene, element, sceneValues)
     }
 }
 
 internal class ElementNode(
     layoutImpl: SceneTransitionLayoutImpl,
+    scene: Scene,
     element: Element,
     sceneValues: Element.TargetValues,
-) : Modifier.Node() {
+) : Modifier.Node(), DrawModifierNode {
     private var layoutImpl: SceneTransitionLayoutImpl = layoutImpl
+    private var scene: Scene = scene
     private var element: Element = element
     private var sceneValues: Element.TargetValues = sceneValues
 
@@ -239,15 +233,34 @@
 
     fun update(
         layoutImpl: SceneTransitionLayoutImpl,
+        scene: Scene,
         element: Element,
         sceneValues: Element.TargetValues,
     ) {
         removeNodeFromSceneValues()
         this.layoutImpl = layoutImpl
+        this.scene = scene
         this.element = element
         this.sceneValues = sceneValues
         addNodeToSceneValues()
     }
+
+    override fun ContentDrawScope.draw() {
+        if (shouldDrawElement(layoutImpl, scene, element)) {
+            val drawScale = getDrawScale(layoutImpl, element, scene, sceneValues)
+            if (drawScale == Scale.Default) {
+                drawContent()
+            } else {
+                scale(
+                    drawScale.scaleX,
+                    drawScale.scaleY,
+                    if (drawScale.pivot.isUnspecified) center else drawScale.pivot,
+                ) {
+                    this@draw.drawContent()
+                }
+            }
+        }
+    }
 }
 
 private fun shouldDrawElement(
@@ -332,39 +345,6 @@
 }
 
 /**
- * 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)
-                    }
-                }
-        }
-    }
-}
-
-/**
  * Whether the element is opaque or not.
  *
  * Important: The logic here should closely match the logic in [elementAlpha]. Note that we don't
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
index 5b752eb..84d3b86 100644
--- 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
@@ -17,11 +17,13 @@
 package com.android.compose.animation.scene
 
 import androidx.annotation.VisibleForTesting
+import androidx.compose.runtime.Stable
 
 /**
  * A base class to create unique keys, associated to an [identity] that is used to check the
  * equality of two key instances.
  */
+@Stable
 sealed class Key(val debugName: String, val identity: Any) {
     override fun equals(other: Any?): Boolean {
         if (this === other) return true
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt
index d48781a..a0fba80 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt
@@ -23,20 +23,25 @@
 import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation
 import androidx.compose.foundation.gestures.horizontalDrag
 import androidx.compose.foundation.gestures.verticalDrag
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.composed
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.PointerEvent
 import androidx.compose.ui.input.pointer.PointerEventPass
 import androidx.compose.ui.input.pointer.PointerId
 import androidx.compose.ui.input.pointer.PointerInputChange
 import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
 import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.input.pointer.positionChange
 import androidx.compose.ui.input.pointer.util.VelocityTracker
 import androidx.compose.ui.input.pointer.util.addPointerInputChange
+import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
+import androidx.compose.ui.node.DelegatingNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.PointerInputModifierNode
+import androidx.compose.ui.node.currentValueOf
 import androidx.compose.ui.platform.LocalViewConfiguration
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.Velocity
@@ -56,7 +61,7 @@
  * dragged) and a second pointer is down and dragged. This is an implementation detail that might
  * change in the future.
  */
-// TODO(b/291055080): Migrate to the Modifier.Node API.
+@Stable
 internal fun Modifier.multiPointerDraggable(
     orientation: Orientation,
     enabled: Boolean,
@@ -64,22 +69,88 @@
     onDragStarted: (layoutSize: IntSize, startedPosition: Offset, pointersDown: Int) -> Unit,
     onDragDelta: (Float) -> Unit,
     onDragStopped: (velocity: Float) -> Unit,
-): Modifier = composed {
-    val onDragStarted by rememberUpdatedState(onDragStarted)
-    val onDragStopped by rememberUpdatedState(onDragStopped)
-    val onDragDelta by rememberUpdatedState(onDragDelta)
-    val startDragImmediately by rememberUpdatedState(startDragImmediately)
+): Modifier =
+    this.then(
+        MultiPointerDraggableElement(
+            orientation,
+            enabled,
+            startDragImmediately,
+            onDragStarted,
+            onDragDelta,
+            onDragStopped,
+        )
+    )
 
-    val velocityTracker = remember { VelocityTracker() }
-    val maxFlingVelocity =
-        LocalViewConfiguration.current.maximumFlingVelocity.let { max ->
-            val maxF = max.toFloat()
-            Velocity(maxF, maxF)
+private data class MultiPointerDraggableElement(
+    private val orientation: Orientation,
+    private val enabled: Boolean,
+    private val startDragImmediately: Boolean,
+    private val onDragStarted:
+        (layoutSize: IntSize, startedPosition: Offset, pointersDown: Int) -> Unit,
+    private val onDragDelta: (Float) -> Unit,
+    private val onDragStopped: (velocity: Float) -> Unit,
+) : ModifierNodeElement<MultiPointerDraggableNode>() {
+    override fun create(): MultiPointerDraggableNode =
+        MultiPointerDraggableNode(
+            orientation = orientation,
+            enabled = enabled,
+            startDragImmediately = startDragImmediately,
+            onDragStarted = onDragStarted,
+            onDragDelta = onDragDelta,
+            onDragStopped = onDragStopped,
+        )
+
+    override fun update(node: MultiPointerDraggableNode) {
+        node.orientation = orientation
+        node.enabled = enabled
+        node.startDragImmediately = startDragImmediately
+        node.onDragStarted = onDragStarted
+        node.onDragDelta = onDragDelta
+        node.onDragStopped = onDragStopped
+    }
+}
+
+private class MultiPointerDraggableNode(
+    orientation: Orientation,
+    enabled: Boolean,
+    var startDragImmediately: Boolean,
+    var onDragStarted: (layoutSize: IntSize, startedPosition: Offset, pointersDown: Int) -> Unit,
+    var onDragDelta: (Float) -> Unit,
+    var onDragStopped: (velocity: Float) -> Unit,
+) : PointerInputModifierNode, DelegatingNode(), CompositionLocalConsumerModifierNode {
+    private val pointerInputHandler: suspend PointerInputScope.() -> Unit = { pointerInput() }
+    private val delegate = delegate(SuspendingPointerInputModifierNode(pointerInputHandler))
+    private val velocityTracker = VelocityTracker()
+
+    var enabled: Boolean = enabled
+        set(value) {
+            // Reset the pointer input whenever enabled changed.
+            if (value != field) {
+                field = value
+                delegate.resetPointerInputHandler()
+            }
         }
 
-    pointerInput(enabled, orientation, maxFlingVelocity) {
+    var orientation: Orientation = orientation
+        set(value) {
+            // Reset the pointer input whenever enabled orientation.
+            if (value != field) {
+                field = value
+                delegate.resetPointerInputHandler()
+            }
+        }
+
+    override fun onCancelPointerInput() = delegate.onCancelPointerInput()
+
+    override fun onPointerEvent(
+        pointerEvent: PointerEvent,
+        pass: PointerEventPass,
+        bounds: IntSize
+    ) = delegate.onPointerEvent(pointerEvent, pass, bounds)
+
+    private suspend fun PointerInputScope.pointerInput() {
         if (!enabled) {
-            return@pointerInput
+            return
         }
 
         val onDragStart: (Offset, Int) -> Unit = { startedPosition, pointersDown ->
@@ -90,6 +161,12 @@
         val onDragCancel: () -> Unit = { onDragStopped(/* velocity= */ 0f) }
 
         val onDragEnd: () -> Unit = {
+            val maxFlingVelocity =
+                currentValueOf(LocalViewConfiguration).maximumFlingVelocity.let { max ->
+                    val maxF = max.toFloat()
+                    Velocity(maxF, maxF)
+                }
+
             val velocity = velocityTracker.calculateVelocity(maxFlingVelocity)
             onDragStopped(
                 when (orientation) {
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/PunchHole.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/PunchHole.kt
new file mode 100644
index 0000000..560e92b
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/PunchHole.kt
@@ -0,0 +1,120 @@
+/*
+ * 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.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.geometry.toRect
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Outline
+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.ContentDrawScope
+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.node.DrawModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.toSize
+
+internal fun Modifier.punchHole(
+    layoutImpl: SceneTransitionLayoutImpl,
+    element: ElementKey,
+    bounds: ElementKey,
+    shape: Shape,
+): Modifier = this.then(PunchHoleElement(layoutImpl, element, bounds, shape))
+
+private data class PunchHoleElement(
+    private val layoutImpl: SceneTransitionLayoutImpl,
+    private val element: ElementKey,
+    private val bounds: ElementKey,
+    private val shape: Shape,
+) : ModifierNodeElement<PunchHoleNode>() {
+    override fun create(): PunchHoleNode = PunchHoleNode(layoutImpl, element, bounds, shape)
+
+    override fun update(node: PunchHoleNode) {
+        node.layoutImpl = layoutImpl
+        node.element = element
+        node.bounds = bounds
+        node.shape = shape
+    }
+}
+
+private class PunchHoleNode(
+    var layoutImpl: SceneTransitionLayoutImpl,
+    var element: ElementKey,
+    var bounds: ElementKey,
+    var shape: Shape,
+) : Modifier.Node(), DrawModifierNode {
+    private var lastSize: Size = Size.Unspecified
+    private var lastLayoutDirection: LayoutDirection = LayoutDirection.Ltr
+    private var lastOutline: Outline? = null
+
+    override fun ContentDrawScope.draw() {
+        val bounds = layoutImpl.elements[bounds]
+
+        if (
+            bounds == null ||
+                bounds.lastSharedValues.size == Element.SizeUnspecified ||
+                bounds.lastSharedValues.offset == Offset.Unspecified
+        ) {
+            drawContent()
+            return
+        }
+
+        val element = layoutImpl.elements.getValue(element)
+        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
+        }
+
+        val outline =
+            if (boundsSize == lastSize && layoutDirection == lastLayoutDirection) {
+                lastOutline!!
+            } else {
+                val newOutline = shape.createOutline(boundsSize, layoutDirection, this)
+                lastSize = boundsSize
+                lastLayoutDirection = layoutDirection
+                lastOutline = newOutline
+                newOutline
+            }
+
+        drawOutline(
+            outline,
+            Color.Black,
+            blendMode = BlendMode.DstOut,
+        )
+    }
+}
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
index 857a596..f5561cb 100644
--- 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
@@ -19,20 +19,24 @@
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.layout.Box
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
 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.runtime.snapshots.Snapshot
 import androidx.compose.runtime.snapshots.SnapshotStateMap
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.layout.intermediateLayout
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.zIndex
 
 /** A scene in a [SceneTransitionLayout]. */
+@Stable
 internal class Scene(
     val key: SceneKey,
     layoutImpl: SceneTransitionLayoutImpl,
@@ -104,11 +108,13 @@
     ): State<T> {
         val element =
             element?.let { key ->
-                layoutImpl.elements[key]
-                    ?: error(
-                        "Element $key is not composed. Make sure to call animateSharedXAsState " +
-                            "*after* Modifier.element(key)."
-                    )
+                Snapshot.withoutReadObservation {
+                    layoutImpl.elements[key]
+                        ?: error(
+                            "Element $key is not composed. Make sure to call " +
+                                "animateSharedXAsState *after* Modifier.element(key)."
+                        )
+                }
             }
 
         return animateSharedValueAsState(
@@ -130,4 +136,10 @@
     ) {
         MovableElement(layoutImpl, scene, key, modifier, content)
     }
+
+    override fun Modifier.punchHole(
+        element: ElementKey,
+        bounds: ElementKey,
+        shape: Shape
+    ): Modifier = punchHole(layoutImpl, element, bounds, shape)
 }
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
index 30d13df..07add77 100644
--- 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
@@ -19,10 +19,12 @@
 import androidx.annotation.FloatRange
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.platform.LocalDensity
 
@@ -94,6 +96,7 @@
 @DslMarker annotation class ElementDsl
 
 @ElementDsl
+@Stable
 interface SceneScope {
     /** The state of the [SceneTransitionLayout] in which this scene is contained. */
     val layoutState: SceneTransitionLayoutState
@@ -177,6 +180,18 @@
         lerp: (start: T, stop: T, fraction: Float) -> T,
         canOverflow: Boolean,
     ): State<T>
+
+    /**
+     * Punch a hole in this [element] using the bounds of [bounds] in [scene] and 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 Modifier.punchHole(element: ElementKey, bounds: ElementKey, shape: Shape): Modifier
 }
 
 // TODO(b/291053742): Add animateSharedValueAsState(targetValue) without any ValueKey and ElementKey
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
index 60f385a..02ddccb 100644
--- 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
@@ -23,6 +23,7 @@
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.key
 import androidx.compose.runtime.mutableStateOf
@@ -41,6 +42,7 @@
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.channels.Channel
 
+@Stable
 internal class SceneTransitionLayoutImpl(
     onChangeScene: (SceneKey) -> Unit,
     builder: SceneTransitionLayoutScope.() -> Unit,
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 64c9775..f48e914 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
@@ -16,11 +16,13 @@
 
 package com.android.compose.animation.scene
 
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
 
 /** The state of a [SceneTransitionLayout]. */
+@Stable
 class SceneTransitionLayoutState(initialScene: SceneKey) {
     /**
      * The current [TransitionState]. All values read here are backed by the Snapshot system.
@@ -29,7 +31,6 @@
      * [SceneTransitionLayoutState.observableTransitionState] instead.
      */
     var transitionState: TransitionState by mutableStateOf(TransitionState.Idle(initialScene))
-        internal set
 
     /**
      * Whether we are transitioning, optionally restricting the check to the transition between
@@ -46,8 +47,15 @@
         return (from == null || transition.fromScene == from) &&
             (to == null || transition.toScene == to)
     }
+
+    /** Whether we are transitioning from [scene] to [other], or from [other] to [scene]. */
+    fun isTransitioningBetween(scene: SceneKey, other: SceneKey): Boolean {
+        return isTransitioning(from = scene, to = other) ||
+            isTransitioning(from = other, to = scene)
+    }
 }
 
+@Stable
 sealed interface TransitionState {
     /**
      * The current effective scene. If a new transition was triggered, it would start from this
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 2172ed3..f91895b 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
@@ -18,6 +18,7 @@
 
 import androidx.compose.animation.core.AnimationSpec
 import androidx.compose.animation.core.snap
+import androidx.compose.runtime.Stable
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.util.fastForEach
@@ -27,7 +28,6 @@
 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.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
@@ -93,6 +93,7 @@
 }
 
 /** The definition of a transition between [from] and [to]. */
+@Stable
 data class TransitionSpec(
     val from: SceneKey?,
     val to: SceneKey?,
@@ -122,7 +123,6 @@
         scene: SceneKey,
     ): ElementTransformations {
         var shared: SharedElementTransformation? = null
-        val modifier = mutableListOf<ModifierTransformation>()
         var offset: PropertyTransformation<Offset>? = null
         var size: PropertyTransformation<IntSize>? = null
         var drawScale: PropertyTransformation<Scale>? = null
@@ -166,12 +166,11 @@
                     throwIfNotNull(shared, element, name = "shared")
                     shared = transformation
                 }
-                is ModifierTransformation -> modifier.add(transformation)
                 is PropertyTransformation<*> -> onPropertyTransformation(transformation)
             }
         }
 
-        return ElementTransformations(shared, modifier, offset, size, drawScale, alpha)
+        return ElementTransformations(shared, offset, size, drawScale, alpha)
     }
 
     private fun throwIfNotNull(
@@ -188,7 +187,6 @@
 /** 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 drawScale: PropertyTransformation<Scale>?,
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 ca66dff5..f820074 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
@@ -18,8 +18,6 @@
 
 import androidx.compose.animation.core.AnimationSpec
 import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.RectangleShape
-import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 
@@ -131,19 +129,6 @@
     )
 
     /**
-     * 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`.
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 d490989..8c0a5a3 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
@@ -22,7 +22,6 @@
 import androidx.compose.animation.core.VectorConverter
 import androidx.compose.animation.core.spring
 import androidx.compose.ui.geometry.Offset
-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
@@ -30,7 +29,6 @@
 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
@@ -93,10 +91,6 @@
         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()
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
deleted file mode 100644
index 984086b..0000000
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/PunchHole.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- * 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.Size
-import androidx.compose.ui.geometry.toRect
-import androidx.compose.ui.graphics.BlendMode
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.Outline
-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.LayoutDirection
-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 {
-
-    private var lastSize: Size = Size.Unspecified
-    private var lastLayoutDirection: LayoutDirection = LayoutDirection.Ltr
-    private var lastOutline: Outline? = null
-
-    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
-        }
-
-        val outline =
-            if (boundsSize == lastSize && layoutDirection == lastLayoutDirection) {
-                lastOutline!!
-            } else {
-                val newOutline = shape.createOutline(boundsSize, layoutDirection, this)
-                lastSize = boundsSize
-                lastLayoutDirection = layoutDirection
-                lastOutline = newOutline
-                newOutline
-            }
-
-        drawOutline(
-            outline,
-            Color.Black,
-            blendMode = BlendMode.DstOut,
-        )
-    }
-}
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 0db8469..2069355 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
@@ -16,7 +16,6 @@
 
 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
@@ -52,19 +51,6 @@
     internal val scenePicker: SharedElementScenePicker,
 ) : 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 {
     /**
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt
index cc7a0b8..ce3e1db 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt
@@ -23,6 +23,7 @@
 import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.size
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -427,4 +428,30 @@
         assertThat(barElement.sceneValues.keys).containsExactly(TestScenes.SceneA)
         assertThat(fooElement.sceneValues).isEmpty()
     }
+
+    @Test
+    fun existingElementsDontRecomposeWhenTransitionStateChanges() {
+        var fooCompositions = 0
+
+        rule.testTransition(
+            fromSceneContent = {
+                SideEffect { fooCompositions++ }
+                Box(Modifier.element(TestElements.Foo))
+            },
+            toSceneContent = {},
+            transition = {
+                spec = tween(4 * 16)
+
+                scaleSize(TestElements.Foo, width = 2f, height = 0.5f)
+                translate(TestElements.Foo, x = 10.dp, y = 10.dp)
+                fade(TestElements.Foo)
+            }
+        ) {
+            before { assertThat(fooCompositions).isEqualTo(1) }
+            at(16) { assertThat(fooCompositions).isEqualTo(1) }
+            at(32) { assertThat(fooCompositions).isEqualTo(1) }
+            at(48) { assertThat(fooCompositions).isEqualTo(1) }
+            after { assertThat(fooCompositions).isEqualTo(1) }
+        }
+    }
 }