Merge changes from topic "stl-full-distance-swipe" into main

* changes:
  Do not consider elements with alpha=0f when interrupting
  Add UserActionResult.requiresFullDistanceSwipe
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt
index cfaed41..da968ac 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt
@@ -395,10 +395,11 @@
             if (
                 distance != DistanceUnspecified &&
                     shouldCommitSwipe(
-                        offset,
-                        distance,
-                        velocity,
+                        offset = offset,
+                        distance = distance,
+                        velocity = velocity,
                         wasCommitted = swipeTransition._currentScene == toScene,
+                        requiresFullDistanceSwipe = swipeTransition.requiresFullDistanceSwipe,
                     )
             ) {
                 targetScene = toScene
@@ -472,7 +473,12 @@
         distance: Float,
         velocity: Float,
         wasCommitted: Boolean,
+        requiresFullDistanceSwipe: Boolean,
     ): Boolean {
+        if (requiresFullDistanceSwipe && !wasCommitted) {
+            return offset / distance >= 1f
+        }
+
         fun isCloserToTarget(): Boolean {
             return (offset - distance).absoluteValue < offset.absoluteValue
         }
@@ -530,6 +536,7 @@
         userActionDistanceScope = layoutImpl.userActionDistanceScope,
         orientation = orientation,
         isUpOrLeft = isUpOrLeft,
+        requiresFullDistanceSwipe = result.requiresFullDistanceSwipe,
     )
 }
 
@@ -545,6 +552,7 @@
             orientation = old.orientation,
             isUpOrLeft = old.isUpOrLeft,
             lastDistance = old.lastDistance,
+            requiresFullDistanceSwipe = old.requiresFullDistanceSwipe,
         )
         .apply {
             _currentScene = old._currentScene
@@ -562,6 +570,7 @@
     val userActionDistanceScope: UserActionDistanceScope,
     override val orientation: Orientation,
     override val isUpOrLeft: Boolean,
+    val requiresFullDistanceSwipe: Boolean,
     var lastDistance: Float = DistanceUnspecified,
 ) :
     TransitionState.Transition(_fromScene.key, _toScene.key),
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 09d11b7..d4f1ad1 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
@@ -306,7 +306,6 @@
         return layout(placeable.width, placeable.height) { place(transition, placeable) }
     }
 
-    @OptIn(ExperimentalComposeUiApi::class)
     private fun Placeable.PlacementScope.place(
         transition: TransitionState.Transition?,
         placeable: Placeable,
@@ -561,10 +560,20 @@
 }
 
 private fun Element.SceneState.selfUpdateValuesBeforeInterruption() {
-    offsetBeforeInterruption = lastOffset
     sizeBeforeInterruption = lastSize
-    scaleBeforeInterruption = lastScale
-    alphaBeforeInterruption = lastAlpha
+
+    if (lastAlpha > 0f) {
+        offsetBeforeInterruption = lastOffset
+        scaleBeforeInterruption = lastScale
+        alphaBeforeInterruption = lastAlpha
+    } else {
+        // Consider the element as not placed in this scene if it was fully transparent.
+        // TODO(b/290930950): Look into using derived state inside place() instead to not even place
+        // the element at all when alpha == 0f.
+        offsetBeforeInterruption = Offset.Unspecified
+        scaleBeforeInterruption = Scale.Unspecified
+        alphaBeforeInterruption = Element.AlphaUnspecified
+    }
 }
 
 private fun Element.SceneState.updateValuesBeforeInterruption(lastState: Element.SceneState) {
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 33063c8..7c8fce8 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
@@ -459,6 +459,13 @@
 
     /** The key of the transition that should be used. */
     val transitionKey: TransitionKey? = null,
+
+    /**
+     * If `true`, the swipe will be committed and we will settle to [toScene] if only if the user
+     * swiped at least the swipe distance, i.e. the transition progress was already equal to or
+     * bigger than 100% when the user released their finger. `
+     */
+    val requiresFullDistanceSwipe: Boolean = false,
 )
 
 fun interface UserActionDistance {
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
index be3bca7..c738ad3 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
@@ -1215,4 +1215,22 @@
         onDragStartedImmediately()
         assertTransition(fromScene = SceneA, toScene = SceneB, progress = 50f / 75f)
     }
+
+    @Test
+    fun requireFullDistanceSwipe() = runGestureTest {
+        mutableUserActionsA[Swipe.Up] = UserActionResult(SceneB, requiresFullDistanceSwipe = true)
+
+        val controller = onDragStarted(overSlop = up(fractionOfScreen = 0.9f))
+        assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.9f)
+
+        controller.onDragStopped(velocity = 0f)
+        advanceUntilIdle()
+        assertIdle(SceneA)
+
+        val otherController = onDragStarted(overSlop = up(fractionOfScreen = 1f))
+        assertTransition(fromScene = SceneA, toScene = SceneB, progress = 1f)
+        otherController.onDragStopped(velocity = 0f)
+        advanceUntilIdle()
+        assertIdle(SceneB)
+    }
 }
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 615a42f..7c20a97 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
@@ -851,11 +851,12 @@
             rule.runOnUiThread {
                 MutableSceneTransitionLayoutState(
                     initialScene = SceneA,
-                    transitions = transitions {
-                        from(SceneA, to = SceneB) {
-                            translate(TestElements.Foo, y = translateY)
-                        }
-                    },
+                    transitions =
+                        transitions {
+                            from(SceneA, to = SceneB) {
+                                translate(TestElements.Foo, y = translateY)
+                            }
+                        },
                 )
                     as MutableSceneTransitionLayoutStateImpl
             }
@@ -2010,4 +2011,81 @@
             )
             .isEqualTo(Element.SizeUnspecified)
     }
+
+    @Test
+    fun transparentElementIsNotImpactingInterruption() = runTest {
+        val state =
+            rule.runOnIdle {
+                MutableSceneTransitionLayoutStateImpl(
+                    SceneA,
+                    transitions {
+                        from(SceneA, to = SceneB) {
+                            // In A => B, Foo is not shared and first fades out from A then fades in
+                            // B.
+                            sharedElement(TestElements.Foo, enabled = false)
+                            fractionRange(end = 0.5f) { fade(TestElements.Foo.inScene(SceneA)) }
+                            fractionRange(start = 0.5f) { fade(TestElements.Foo.inScene(SceneB)) }
+                        }
+
+                        from(SceneB, to = SceneA) {
+                            // In B => A, Foo is shared.
+                            sharedElement(TestElements.Foo, enabled = true)
+                        }
+                    }
+                )
+            }
+
+        @Composable
+        fun SceneScope.Foo(modifier: Modifier = Modifier) {
+            Box(modifier.element(TestElements.Foo).size(10.dp))
+        }
+
+        rule.setContent {
+            SceneTransitionLayout(state) {
+                scene(SceneB) { Foo(Modifier.offset(40.dp, 60.dp)) }
+
+                // Define A after B so that Foo is placed in A during A <=> B.
+                scene(SceneA) { Foo() }
+            }
+        }
+
+        // Start A => B at 70%.
+        rule.runOnUiThread {
+            state.startTransition(
+                transition(
+                    from = SceneA,
+                    to = SceneB,
+                    progress = { 0.7f },
+                    onFinish = neverFinish(),
+                )
+            )
+        }
+
+        rule.onNode(isElement(TestElements.Foo, SceneA)).assertPositionInRootIsEqualTo(0.dp, 0.dp)
+        rule.onNode(isElement(TestElements.Foo, SceneB)).assertPositionInRootIsEqualTo(40.dp, 60.dp)
+
+        // Start B => A at 50% with interruptionProgress = 100%. Foo is placed in A and should still
+        // be at (40dp, 60dp) given that it was fully transparent in A before the interruption.
+        var interruptionProgress by mutableStateOf(1f)
+        rule.runOnUiThread {
+            state.startTransition(
+                transition(
+                    from = SceneB,
+                    to = SceneA,
+                    progress = { 0.5f },
+                    interruptionProgress = { interruptionProgress },
+                    onFinish = neverFinish(),
+                )
+            )
+        }
+
+        rule.onNode(isElement(TestElements.Foo, SceneA)).assertPositionInRootIsEqualTo(40.dp, 60.dp)
+        rule.onNode(isElement(TestElements.Foo, SceneB)).assertIsNotDisplayed()
+
+        // Set the interruption progress to 0%. Foo should be at (20dp, 30dp) given that B => is at
+        // 50%.
+        interruptionProgress = 0f
+        rule.onNode(isElement(TestElements.Foo, SceneA)).assertPositionInRootIsEqualTo(20.dp, 30.dp)
+        rule.onNode(isElement(TestElements.Foo, SceneB)).assertIsNotDisplayed()
+    }
 }