Merge "Add SceneScope.nestedScrollToScene() modifier" into main
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt
index ae7d8f5..216608a 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt
@@ -3,11 +3,6 @@
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 
-interface GestureHandler {
-    val draggable: DraggableHandler
-    val nestedScroll: NestedScrollHandler
-}
-
 interface DraggableHandler {
     fun onDragStarted(startedPosition: Offset, pointersDown: Int = 1)
     fun onDelta(pixels: Float)
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt
new file mode 100644
index 0000000..658b45f
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.compose.animation.scene
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+
+/**
+ * Defines the behavior of the [SceneTransitionLayout] when a scrollable component is scrolled.
+ *
+ * By default, scrollable elements within the scene have priority during the user's gesture and are
+ * not consumed by the [SceneTransitionLayout] unless specifically requested via
+ * [nestedScrollToScene].
+ */
+enum class NestedScrollBehavior(val canStartOnPostFling: Boolean) {
+    /**
+     * During scene transitions, scroll events are consumed by the [SceneTransitionLayout] instead
+     * of the scrollable component.
+     */
+    DuringTransitionBetweenScenes(canStartOnPostFling = false),
+
+    /**
+     * Overscroll will only be used by the [SceneTransitionLayout] to move to the next scene if the
+     * gesture begins at the edge of the scrollable component (so that a scroll in that direction
+     * can no longer be consumed). If the gesture is partially consumed by the scrollable component,
+     * there will be NO overscroll effect between scenes.
+     *
+     * In addition, during scene transitions, scroll events are consumed by the
+     * [SceneTransitionLayout] instead of the scrollable component.
+     */
+    EdgeNoOverscroll(canStartOnPostFling = false),
+
+    /**
+     * Overscroll will only be used by the [SceneTransitionLayout] to move to the next scene if the
+     * gesture begins at the edge of the scrollable component. If the gesture is partially consumed
+     * by the scrollable component, there will be an overscroll effect between scenes.
+     *
+     * In addition, during scene transitions, scroll events are consumed by the
+     * [SceneTransitionLayout] instead of the scrollable component.
+     */
+    EdgeWithOverscroll(canStartOnPostFling = true),
+
+    /**
+     * Any overscroll will be used by the [SceneTransitionLayout] to move to the next scene.
+     *
+     * In addition, during scene transitions, scroll events are consumed by the
+     * [SceneTransitionLayout] instead of the scrollable component.
+     */
+    Always(canStartOnPostFling = true),
+}
+
+internal fun Modifier.nestedScrollToScene(
+    layoutImpl: SceneTransitionLayoutImpl,
+    orientation: Orientation,
+    startBehavior: NestedScrollBehavior,
+    endBehavior: NestedScrollBehavior,
+): Modifier = composed {
+    val connection =
+        remember(layoutImpl, orientation, startBehavior, endBehavior) {
+            scenePriorityNestedScrollConnection(
+                layoutImpl = layoutImpl,
+                orientation = orientation,
+                startBehavior = startBehavior,
+                endBehavior = endBehavior
+            )
+        }
+
+    // Make sure we reset the scroll connection when this modifier is removed from composition
+    DisposableEffect(connection) { onDispose { connection.reset() } }
+
+    nestedScroll(connection = connection)
+}
+
+private fun scenePriorityNestedScrollConnection(
+    layoutImpl: SceneTransitionLayoutImpl,
+    orientation: Orientation,
+    startBehavior: NestedScrollBehavior,
+    endBehavior: NestedScrollBehavior,
+) =
+    SceneNestedScrollHandler(
+            gestureHandler = layoutImpl.gestureHandler(orientation = orientation),
+            startBehavior = startBehavior,
+            endBehavior = endBehavior,
+        )
+        .connection
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 2e50a71..eb5168b 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
@@ -16,6 +16,7 @@
 
 package com.android.compose.animation.scene
 
+import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.layout.Box
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
@@ -68,6 +69,18 @@
         return element(layoutImpl, scene, key)
     }
 
+    override fun Modifier.nestedScrollToScene(
+        orientation: Orientation,
+        startBehavior: NestedScrollBehavior,
+        endBehavior: NestedScrollBehavior,
+    ): Modifier =
+        nestedScrollToScene(
+            layoutImpl = layoutImpl,
+            orientation = orientation,
+            startBehavior = startBehavior,
+            endBehavior = endBehavior,
+        )
+
     @Composable
     override fun <T> animateSharedValueAsState(
         value: T,
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt
index 7563e27..9a3a0ae 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt
@@ -1,3 +1,19 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
 package com.android.compose.animation.scene
 
 import android.util.Log
@@ -25,10 +41,8 @@
     private val layoutImpl: SceneTransitionLayoutImpl,
     internal val orientation: Orientation,
     private val coroutineScope: CoroutineScope,
-) : GestureHandler {
-    override val draggable: DraggableHandler = SceneDraggableHandler(this)
-
-    override val nestedScroll: SceneNestedScrollHandler = SceneNestedScrollHandler(this)
+) {
+    val draggable: DraggableHandler = SceneDraggableHandler(this)
 
     private var transitionState
         get() = layoutImpl.state.transitionState
@@ -521,6 +535,8 @@
 @VisibleForTesting
 class SceneNestedScrollHandler(
     private val gestureHandler: SceneGestureHandler,
+    private val startBehavior: NestedScrollBehavior,
+    private val endBehavior: NestedScrollBehavior,
 ) : NestedScrollHandler {
     override val connection: PriorityNestedScrollConnection = nestedScrollConnection()
 
@@ -543,15 +559,9 @@
         }
 
     private fun nestedScrollConnection(): PriorityNestedScrollConnection {
-        // The next potential scene is calculated during the canStart
-        var nextScene: SceneKey? = null
-
-        // This is the scene on which we will have priority during the scroll gesture.
-        var priorityScene: SceneKey? = null
-
         // If we performed a long gesture before entering priority mode, we would have to avoid
         // moving on to the next scene.
-        var gestureStartedOnNestedChild = false
+        var canChangeScene = false
 
         val actionUpOrLeft =
             Swipe(
@@ -573,51 +583,70 @@
                 pointerCount = 1,
             )
 
-        fun findNextScene(amount: Float): SceneKey? {
+        fun hasNextScene(amount: Float): Boolean {
             val fromScene = gestureHandler.currentScene
-            return when {
-                amount < 0f -> fromScene.userActions[actionUpOrLeft]
-                amount > 0f -> fromScene.userActions[actionDownOrRight]
-                else -> null
-            }
+            val nextScene =
+                when {
+                    amount < 0f -> fromScene.userActions[actionUpOrLeft]
+                    amount > 0f -> fromScene.userActions[actionDownOrRight]
+                    else -> null
+                }
+            return nextScene != null
         }
 
         return PriorityNestedScrollConnection(
             canStartPreScroll = { offsetAvailable, offsetBeforeStart ->
-                gestureStartedOnNestedChild = offsetBeforeStart != Offset.Zero
-
-                val canInterceptPreScroll =
-                    gestureHandler.isDrivingTransition &&
-                        !gestureStartedOnNestedChild &&
-                        offsetAvailable.toAmount() != 0f
-
-                if (!canInterceptPreScroll) return@PriorityNestedScrollConnection false
-
-                nextScene = gestureHandler.swipeTransitionToScene.key
-
-                true
+                canChangeScene = offsetBeforeStart == Offset.Zero
+                gestureHandler.isDrivingTransition &&
+                    canChangeScene &&
+                    offsetAvailable.toAmount() != 0f
             },
             canStartPostScroll = { offsetAvailable, offsetBeforeStart ->
                 val amount = offsetAvailable.toAmount()
-                if (amount == 0f) return@PriorityNestedScrollConnection false
+                val behavior: NestedScrollBehavior =
+                    when {
+                        amount > 0 -> startBehavior
+                        amount < 0 -> endBehavior
+                        else -> return@PriorityNestedScrollConnection false
+                    }
 
-                gestureStartedOnNestedChild = offsetBeforeStart != Offset.Zero
-                nextScene = findNextScene(amount)
-                nextScene != null
+                val isZeroOffset = offsetBeforeStart == Offset.Zero
+
+                when (behavior) {
+                    NestedScrollBehavior.DuringTransitionBetweenScenes -> {
+                        canChangeScene = false // unused: added for consistency
+                        false
+                    }
+                    NestedScrollBehavior.EdgeNoOverscroll -> {
+                        canChangeScene = isZeroOffset
+                        isZeroOffset && hasNextScene(amount)
+                    }
+                    NestedScrollBehavior.EdgeWithOverscroll -> {
+                        canChangeScene = isZeroOffset
+                        hasNextScene(amount)
+                    }
+                    NestedScrollBehavior.Always -> {
+                        canChangeScene = true
+                        hasNextScene(amount)
+                    }
+                }
             },
             canStartPostFling = { velocityAvailable ->
                 val amount = velocityAvailable.toAmount()
-                if (amount == 0f) return@PriorityNestedScrollConnection false
+                val behavior: NestedScrollBehavior =
+                    when {
+                        amount > 0 -> startBehavior
+                        amount < 0 -> endBehavior
+                        else -> return@PriorityNestedScrollConnection false
+                    }
 
                 // We could start an overscroll animation
-                gestureStartedOnNestedChild = true
-                nextScene = findNextScene(amount)
-                nextScene != null
+                canChangeScene = false
+                behavior.canStartOnPostFling && hasNextScene(amount)
             },
-            canContinueScroll = { priorityScene == gestureHandler.swipeTransitionToScene.key },
+            canContinueScroll = { true },
             onStart = {
                 gestureHandler.gestureWithPriority = this
-                priorityScene = nextScene
                 gestureHandler.onDragStarted(pointersDown = 1, startedPosition = null)
             },
             onScroll = { offsetAvailable ->
@@ -638,11 +667,9 @@
                     return@PriorityNestedScrollConnection Velocity.Zero
                 }
 
-                priorityScene = null
-
                 gestureHandler.onDragStopped(
                     velocity = velocityAvailable.toAmount(),
-                    canChangeScene = !gestureStartedOnNestedChild
+                    canChangeScene = canChangeScene
                 )
 
                 // The onDragStopped animation consumes any remaining velocity.
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 35d1f90..efdfe7a 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
@@ -20,7 +20,9 @@
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.platform.LocalDensity
 
 /**
@@ -52,14 +54,16 @@
     scenes: SceneTransitionLayoutScope.() -> Unit,
 ) {
     val density = LocalDensity.current
+    val coroutineScope = rememberCoroutineScope()
     val layoutImpl = remember {
         SceneTransitionLayoutImpl(
-            onChangeScene,
-            scenes,
-            transitions,
-            state,
-            density,
-            edgeDetector,
+            onChangeScene = onChangeScene,
+            builder = scenes,
+            transitions = transitions,
+            state = state,
+            density = density,
+            edgeDetector = edgeDetector,
+            coroutineScope = coroutineScope,
         )
     }
 
@@ -120,6 +124,20 @@
     fun Modifier.element(key: ElementKey): Modifier
 
     /**
+     * Adds a [NestedScrollConnection] to intercept scroll events not handled by the scrollable
+     * component.
+     *
+     * @param orientation is used to determine if we handle top/bottom or left/right events.
+     * @param startBehavior when we should perform the overscroll animation at the top/left.
+     * @param endBehavior when we should perform the overscroll animation at the bottom/right.
+     */
+    fun Modifier.nestedScrollToScene(
+        orientation: Orientation,
+        startBehavior: NestedScrollBehavior = NestedScrollBehavior.EdgeNoOverscroll,
+        endBehavior: NestedScrollBehavior = NestedScrollBehavior.EdgeNoOverscroll,
+    ): Modifier
+
+    /**
      * Create a *movable* element identified by [key].
      *
      * This creates an element that will be automatically shared when present in multiple scenes and
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 6618eb0..6edd1b6 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
@@ -37,6 +37,7 @@
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.util.fastForEach
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.channels.Channel
 
 @VisibleForTesting
@@ -47,6 +48,7 @@
     internal val state: SceneTransitionLayoutState,
     density: Density,
     edgeDetector: EdgeDetector,
+    coroutineScope: CoroutineScope,
 ) {
     internal val scenes = SnapshotStateMap<SceneKey, Scene>()
     internal val elements = SnapshotStateMap<ElementKey, Element>()
@@ -59,6 +61,9 @@
     internal var density: Density by mutableStateOf(density)
     internal var edgeDetector by mutableStateOf(edgeDetector)
 
+    private val horizontalGestureHandler: SceneGestureHandler
+    private val verticalGestureHandler: SceneGestureHandler
+
     /**
      * The size of this layout. Note that this could be [IntSize.Zero] if this layour does not have
      * any scene configured or right before the first measure pass of the layout.
@@ -67,8 +72,30 @@
 
     init {
         setScenes(builder)
+
+        // SceneGestureHandler must wait for the scenes to be initialized, in order to access the
+        // current scene (required for SwipeTransition).
+        horizontalGestureHandler =
+            SceneGestureHandler(
+                layoutImpl = this,
+                orientation = Orientation.Horizontal,
+                coroutineScope = coroutineScope,
+            )
+
+        verticalGestureHandler =
+            SceneGestureHandler(
+                layoutImpl = this,
+                orientation = Orientation.Vertical,
+                coroutineScope = coroutineScope,
+            )
     }
 
+    internal fun gestureHandler(orientation: Orientation): SceneGestureHandler =
+        when (orientation) {
+            Orientation.Vertical -> verticalGestureHandler
+            Orientation.Horizontal -> horizontalGestureHandler
+        }
+
     internal fun scene(key: SceneKey): Scene {
         return scenes[key] ?: error("Scene $key is not configured")
     }
@@ -131,18 +158,13 @@
 
     @Composable
     internal fun Content(modifier: Modifier) {
-        val horizontalGestureHandler =
-            rememberSceneGestureHandler(layoutImpl = this, Orientation.Horizontal)
-        val verticalGestureHandler =
-            rememberSceneGestureHandler(layoutImpl = this, Orientation.Vertical)
-
         Box(
             modifier
                 // Handle horizontal and vertical swipes on this layout.
                 // Note: order here is important and will give a slight priority to the vertical
                 // swipes.
-                .swipeToScene(horizontalGestureHandler)
-                .swipeToScene(verticalGestureHandler)
+                .swipeToScene(gestureHandler(Orientation.Horizontal))
+                .swipeToScene(gestureHandler(Orientation.Vertical))
                 .onSizeChanged { size = it }
         ) {
             LookaheadScope {
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
index 8980df8..2c78dee 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
@@ -17,12 +17,7 @@
 package com.android.compose.animation.scene
 
 import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.nestedScroll
 
 /**
  * Configures the swipeable behavior of a [SceneTransitionLayout] depending on the current state.
@@ -43,38 +38,18 @@
             }
         )
 
-    return nestedScroll(connection = gestureHandler.nestedScroll.connection)
-        .multiPointerDraggable(
-            orientation = orientation,
-            enabled = gestureHandler.isDrivingTransition || canSwipe,
-            // Immediately start the drag if this our [transition] is currently animating to a scene
-            // (i.e. the user released their input pointer after swiping in this orientation) and
-            // the user can't swipe in the other direction.
-            startDragImmediately =
-                gestureHandler.isDrivingTransition &&
-                    gestureHandler.isAnimatingOffset &&
-                    !canOppositeSwipe,
-            onDragStarted = gestureHandler.draggable::onDragStarted,
-            onDragDelta = gestureHandler.draggable::onDelta,
-            onDragStopped = gestureHandler.draggable::onDragStopped,
-        )
-}
-
-@Composable
-internal fun rememberSceneGestureHandler(
-    layoutImpl: SceneTransitionLayoutImpl,
-    orientation: Orientation,
-): SceneGestureHandler {
-    val coroutineScope = rememberCoroutineScope()
-
-    val gestureHandler =
-        remember(layoutImpl, orientation, coroutineScope) {
-            SceneGestureHandler(layoutImpl, orientation, coroutineScope)
-        }
-
-    // Make sure we reset the scroll connection when this handler is removed from composition
-    val connection = gestureHandler.nestedScroll.connection
-    DisposableEffect(connection) { onDispose { connection.reset() } }
-
-    return gestureHandler
+    return multiPointerDraggable(
+        orientation = orientation,
+        enabled = gestureHandler.isDrivingTransition || canSwipe,
+        // Immediately start the drag if this our [transition] is currently animating to a scene
+        // (i.e. the user released their input pointer after swiping in this orientation) and the
+        // user can't swipe in the other direction.
+        startDragImmediately =
+            gestureHandler.isDrivingTransition &&
+                gestureHandler.isAnimatingOffset &&
+                !canOppositeSwipe,
+        onDragStarted = gestureHandler.draggable::onDragStarted,
+        onDragDelta = gestureHandler.draggable::onDelta,
+        onDragStopped = gestureHandler.draggable::onDragStopped,
+    )
 }
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt
index 1eb3392..1e3d011 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt
@@ -1,3 +1,19 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
 package com.android.compose.animation.scene
 
 import androidx.compose.foundation.gestures.Orientation
@@ -6,12 +22,17 @@
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
 import androidx.compose.ui.test.ExperimentalTestApi
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.Velocity
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.NestedScrollBehavior.Always
+import com.android.compose.animation.scene.NestedScrollBehavior.DuringTransitionBetweenScenes
+import com.android.compose.animation.scene.NestedScrollBehavior.EdgeNoOverscroll
+import com.android.compose.animation.scene.NestedScrollBehavior.EdgeWithOverscroll
 import com.android.compose.animation.scene.TestScenes.SceneA
 import com.android.compose.animation.scene.TestScenes.SceneB
 import com.android.compose.animation.scene.TestScenes.SceneC
@@ -57,6 +78,7 @@
                             state = layoutState,
                             density = Density(1f),
                             edgeDetector = DefaultEdgeDetector,
+                            coroutineScope = coroutineScope,
                         )
                         .also { it.size = IntSize(SCREEN_SIZE.toInt(), SCREEN_SIZE.toInt()) },
                 orientation = Orientation.Vertical,
@@ -65,7 +87,13 @@
 
         val draggable = sceneGestureHandler.draggable
 
-        val nestedScroll = sceneGestureHandler.nestedScroll.connection
+        fun nestedScrollConnection(nestedScrollBehavior: NestedScrollBehavior) =
+            SceneNestedScrollHandler(
+                    gestureHandler = sceneGestureHandler,
+                    startBehavior = nestedScrollBehavior,
+                    endBehavior = nestedScrollBehavior,
+                )
+                .connection
 
         val velocityThreshold = sceneGestureHandler.velocityThreshold
 
@@ -194,13 +222,15 @@
     }
 
     @Test
-    fun onInitialPreScroll_doNotChangeState() = runGestureTest {
+    fun onInitialPreScroll_EdgeWithOverscroll_doNotChangeState() = runGestureTest {
+        val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
         nestedScroll.onPreScroll(available = offsetY10, source = NestedScrollSource.Drag)
         assertScene(currentScene = SceneA, isIdle = true)
     }
 
     @Test
-    fun onPostScrollWithNothingAvailable_doNotChangeState() = runGestureTest {
+    fun onPostScrollWithNothingAvailable_EdgeWithOverscroll_doNotChangeState() = runGestureTest {
+        val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
         val consumed =
             nestedScroll.onPostScroll(
                 consumed = Offset.Zero,
@@ -214,6 +244,7 @@
 
     @Test
     fun onPostScrollWithSomethingAvailable_startSceneTransition() = runGestureTest {
+        val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
         val consumed =
             nestedScroll.onPostScroll(
                 consumed = Offset.Zero,
@@ -227,14 +258,14 @@
         assertThat(consumed).isEqualTo(offsetY10)
     }
 
-    private fun TestGestureScope.nestedScrollEvents(
+    private fun NestedScrollConnection.scroll(
         available: Offset,
         consumedByScroll: Offset = Offset.Zero,
     ) {
         val consumedByPreScroll =
-            nestedScroll.onPreScroll(available = available, source = NestedScrollSource.Drag)
+            onPreScroll(available = available, source = NestedScrollSource.Drag)
         val consumed = consumedByPreScroll + consumedByScroll
-        nestedScroll.onPostScroll(
+        onPostScroll(
             consumed = consumed,
             available = available - consumed,
             source = NestedScrollSource.Drag
@@ -243,7 +274,8 @@
 
     @Test
     fun afterSceneTransitionIsStarted_interceptPreScrollEvents() = runGestureTest {
-        nestedScrollEvents(available = offsetY10)
+        val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
+        nestedScroll.scroll(available = offsetY10)
         assertScene(currentScene = SceneA, isIdle = false)
 
         val transition = transitionState as Transition
@@ -262,14 +294,15 @@
         )
         assertThat(transition.progress).isEqualTo(0.2f)
 
-        nestedScrollEvents(available = offsetY10)
+        nestedScroll.scroll(available = offsetY10)
         assertThat(transition.progress).isEqualTo(0.3f)
         assertScene(currentScene = SceneA, isIdle = false)
     }
 
     @Test
     fun onPreFling_velocityLowerThanThreshold_remainSameScene() = runGestureTest {
-        nestedScrollEvents(available = offsetY10)
+        val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
+        nestedScroll.scroll(available = offsetY10)
         assertScene(currentScene = SceneA, isIdle = false)
 
         nestedScroll.onPreFling(available = Velocity.Zero)
@@ -280,12 +313,28 @@
         assertScene(currentScene = SceneA, isIdle = true)
     }
 
-    @Test
-    fun onPreFling_velocityAtLeastThreshold_goToNextScene() = runGestureTest {
-        nestedScrollEvents(available = offsetY10)
-        assertScene(currentScene = SceneA, isIdle = false)
+    private suspend fun TestGestureScope.flingAfterScroll(
+        use: NestedScrollBehavior,
+        idleAfterScroll: Boolean,
+    ) {
+        val nestedScroll = nestedScrollConnection(nestedScrollBehavior = use)
+        nestedScroll.scroll(available = offsetY10)
+        assertScene(currentScene = SceneA, isIdle = idleAfterScroll)
 
         nestedScroll.onPreFling(available = Velocity(0f, velocityThreshold))
+    }
+
+    @Test
+    fun flingAfterScroll_DuringTransitionBetweenScenes_doNothing() = runGestureTest {
+        flingAfterScroll(use = DuringTransitionBetweenScenes, idleAfterScroll = true)
+
+        assertScene(currentScene = SceneA, isIdle = true)
+    }
+
+    @Test
+    fun flingAfterScroll_EdgeNoOverscroll_goToNextScene() = runGestureTest {
+        flingAfterScroll(use = EdgeNoOverscroll, idleAfterScroll = false)
+
         assertScene(currentScene = SceneC, isIdle = false)
 
         // wait for the stop animation
@@ -294,16 +343,61 @@
     }
 
     @Test
-    fun scrollStartedInScene_doOverscrollAnimation() = runGestureTest {
-        // we started the scroll in the scene
-        nestedScrollEvents(available = offsetY10, consumedByScroll = offsetY10)
+    fun flingAfterScroll_EdgeWithOverscroll_goToNextScene() = runGestureTest {
+        flingAfterScroll(use = EdgeWithOverscroll, idleAfterScroll = false)
 
-        // now we can intercept the scroll events
-        nestedScrollEvents(available = offsetY10)
-        assertScene(currentScene = SceneA, isIdle = false)
+        assertScene(currentScene = SceneC, isIdle = false)
+
+        // wait for the stop animation
+        advanceUntilIdle()
+        assertScene(currentScene = SceneC, isIdle = true)
+    }
+
+    @Test
+    fun flingAfterScroll_Always_goToNextScene() = runGestureTest {
+        flingAfterScroll(use = Always, idleAfterScroll = false)
+
+        assertScene(currentScene = SceneC, isIdle = false)
+
+        // wait for the stop animation
+        advanceUntilIdle()
+        assertScene(currentScene = SceneC, isIdle = true)
+    }
+
+    /** we started the scroll in the scene, then fling with the velocityThreshold */
+    private suspend fun TestGestureScope.flingAfterScrollStartedInScene(
+        use: NestedScrollBehavior,
+        idleAfterScroll: Boolean,
+    ) {
+        val nestedScroll = nestedScrollConnection(nestedScrollBehavior = use)
+        // scroll consumed in child
+        nestedScroll.scroll(available = offsetY10, consumedByScroll = offsetY10)
+
+        // scroll offsetY10 is all available for parents
+        nestedScroll.scroll(available = offsetY10)
+        assertScene(currentScene = SceneA, isIdle = idleAfterScroll)
 
         nestedScroll.onPreFling(available = Velocity(0f, velocityThreshold))
-        // should start an overscroll animation (the gesture started in the scene)
+    }
+
+    @Test
+    fun flingAfterScrollStartedInScene_DuringTransitionBetweenScenes_doNothing() = runGestureTest {
+        flingAfterScrollStartedInScene(use = DuringTransitionBetweenScenes, idleAfterScroll = true)
+
+        assertScene(currentScene = SceneA, isIdle = true)
+    }
+
+    @Test
+    fun flingAfterScrollStartedInScene_EdgeNoOverscroll_doNothing() = runGestureTest {
+        flingAfterScrollStartedInScene(use = EdgeNoOverscroll, idleAfterScroll = true)
+
+        assertScene(currentScene = SceneA, isIdle = true)
+    }
+
+    @Test
+    fun flingAfterScrollStartedInScene_EdgeWithOverscroll_doOverscrollAnimation() = runGestureTest {
+        flingAfterScrollStartedInScene(use = EdgeWithOverscroll, idleAfterScroll = false)
+
         assertScene(currentScene = SceneA, isIdle = false)
 
         // wait for the stop animation
@@ -312,6 +406,17 @@
     }
 
     @Test
+    fun flingAfterScrollStartedInScene_Always_goToNextScene() = runGestureTest {
+        flingAfterScrollStartedInScene(use = Always, idleAfterScroll = false)
+
+        assertScene(currentScene = SceneC, isIdle = false)
+
+        // wait for the stop animation
+        advanceUntilIdle()
+        assertScene(currentScene = SceneC, isIdle = true)
+    }
+
+    @Test
     fun beforeDraggableStart_drag_shouldBeIgnored() = runGestureTest {
         draggable.onDelta(deltaInPixels10)
         assertScene(currentScene = SceneA, isIdle = true)
@@ -324,12 +429,14 @@
 
     @Test
     fun beforeNestedScrollStart_stop_shouldBeIgnored() = runGestureTest {
+        val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
         nestedScroll.onPreFling(Velocity(0f, velocityThreshold))
         assertScene(currentScene = SceneA, isIdle = true)
     }
 
     @Test
     fun startNestedScrollWhileDragging() = runGestureTest {
+        val nestedScroll = nestedScrollConnection(nestedScrollBehavior = Always)
         draggable.onDragStarted(Offset.Zero)
         assertScene(currentScene = SceneA, isIdle = false)
         val transition = transitionState as Transition
@@ -338,17 +445,17 @@
         assertThat(transition.progress).isEqualTo(0.1f)
 
         // now we can intercept the scroll events
-        nestedScrollEvents(available = offsetY10)
+        nestedScroll.scroll(available = offsetY10)
         assertThat(transition.progress).isEqualTo(0.2f)
 
         // this should be ignored, we are scrolling now!
         draggable.onDragStopped(velocityThreshold)
         assertScene(currentScene = SceneA, isIdle = false)
 
-        nestedScrollEvents(available = offsetY10)
+        nestedScroll.scroll(available = offsetY10)
         assertThat(transition.progress).isEqualTo(0.3f)
 
-        nestedScrollEvents(available = offsetY10)
+        nestedScroll.scroll(available = offsetY10)
         assertThat(transition.progress).isEqualTo(0.4f)
 
         nestedScroll.onPreFling(available = Velocity(0f, velocityThreshold))