1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.compose.animation.scene
18 
19 import androidx.compose.animation.core.Spring
20 import androidx.compose.animation.core.spring
21 import androidx.compose.foundation.gestures.Orientation
22 import androidx.compose.material3.Text
23 import androidx.compose.ui.geometry.Offset
24 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
25 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
26 import androidx.compose.ui.unit.Density
27 import androidx.compose.ui.unit.IntSize
28 import androidx.compose.ui.unit.Velocity
29 import androidx.test.ext.junit.runners.AndroidJUnit4
30 import com.android.compose.animation.scene.NestedScrollBehavior.DuringTransitionBetweenScenes
31 import com.android.compose.animation.scene.NestedScrollBehavior.EdgeAlways
32 import com.android.compose.animation.scene.NestedScrollBehavior.EdgeNoPreview
33 import com.android.compose.animation.scene.NestedScrollBehavior.EdgeWithPreview
34 import com.android.compose.animation.scene.TestScenes.SceneA
35 import com.android.compose.animation.scene.TestScenes.SceneB
36 import com.android.compose.animation.scene.TestScenes.SceneC
37 import com.android.compose.animation.scene.TransitionState.Transition
38 import com.android.compose.animation.scene.subjects.assertThat
39 import com.android.compose.test.MonotonicClockTestScope
40 import com.android.compose.test.runMonotonicClockTest
41 import com.google.common.truth.Truth.assertThat
42 import kotlinx.coroutines.CoroutineScope
43 import kotlinx.coroutines.cancelAndJoin
44 import kotlinx.coroutines.launch
45 import org.junit.Test
46 import org.junit.runner.RunWith
47 
48 private const val SCREEN_SIZE = 100f
49 private val LAYOUT_SIZE = IntSize(SCREEN_SIZE.toInt(), SCREEN_SIZE.toInt())
50 
51 @RunWith(AndroidJUnit4::class)
52 class DraggableHandlerTest {
53     private class TestGestureScope(
54         private val testScope: MonotonicClockTestScope,
55     ) {
<lambda>null56         var canChangeScene: (SceneKey) -> Boolean = { true }
57         val layoutState =
58             MutableSceneTransitionLayoutStateImpl(
59                 SceneA,
60                 EmptyTestTransitions,
<lambda>null61                 canChangeScene = { canChangeScene(it) },
62             )
63 
64         val mutableUserActionsA = mutableMapOf(Swipe.Up to SceneB, Swipe.Down to SceneC)
65         val mutableUserActionsB = mutableMapOf(Swipe.Up to SceneC, Swipe.Down to SceneA)
<lambda>null66         private val scenesBuilder: SceneTransitionLayoutScope.() -> Unit = {
67             scene(
68                 key = SceneA,
69                 userActions = mutableUserActionsA,
70             ) {
71                 Text("SceneA")
72             }
73             scene(
74                 key = SceneB,
75                 userActions = mutableUserActionsB,
76             ) {
77                 Text("SceneB")
78             }
79             scene(
80                 key = SceneC,
81                 userActions =
82                     mapOf(
83                         Swipe.Up to SceneB,
84                         Swipe(SwipeDirection.Up, fromSource = Edge.Bottom) to SceneA
85                     ),
86             ) {
87                 Text("SceneC")
88             }
89         }
90 
91         val transitionInterceptionThreshold = 0.05f
92 
93         private val layoutImpl =
94             SceneTransitionLayoutImpl(
95                     state = layoutState,
96                     density = Density(1f),
97                     swipeSourceDetector = DefaultEdgeDetector,
98                     transitionInterceptionThreshold = transitionInterceptionThreshold,
99                     builder = scenesBuilder,
100                     coroutineScope = testScope,
101                 )
<lambda>null102                 .apply { setScenesTargetSizeForTest(LAYOUT_SIZE) }
103 
104         val draggableHandler = layoutImpl.draggableHandler(Orientation.Vertical)
105         val horizontalDraggableHandler = layoutImpl.draggableHandler(Orientation.Horizontal)
106 
nestedScrollConnectionnull107         fun nestedScrollConnection(
108             nestedScrollBehavior: NestedScrollBehavior,
109             isExternalOverscrollGesture: Boolean = false
110         ) =
111             NestedScrollHandlerImpl(
112                     layoutImpl = layoutImpl,
113                     orientation = draggableHandler.orientation,
114                     topOrLeftBehavior = nestedScrollBehavior,
115                     bottomOrRightBehavior = nestedScrollBehavior,
116                     isExternalOverscrollGesture = { isExternalOverscrollGesture }
117                 )
118                 .connection
119 
120         val velocityThreshold = draggableHandler.velocityThreshold
121 
downnull122         fun down(fractionOfScreen: Float) =
123             if (fractionOfScreen < 0f) error("use up()") else SCREEN_SIZE * fractionOfScreen
124 
125         fun up(fractionOfScreen: Float) =
126             if (fractionOfScreen < 0f) error("use down()") else -down(fractionOfScreen)
127 
128         fun downOffset(fractionOfScreen: Float) =
129             if (fractionOfScreen < 0f) {
130                 error("upOffset() is required, not implemented yet")
131             } else {
132                 Offset(x = 0f, y = down(fractionOfScreen))
133             }
134 
135         val transitionState: TransitionState
136             get() = layoutState.transitionState
137 
138         val progress: Float
139             get() = (transitionState as Transition).progress
140 
141         val isUserInputOngoing: Boolean
142             get() = (transitionState as Transition).isUserInputOngoing
143 
advanceUntilIdlenull144         fun advanceUntilIdle() {
145             testScope.testScheduler.advanceUntilIdle()
146         }
147 
runCurrentnull148         fun runCurrent() {
149             testScope.testScheduler.runCurrent()
150         }
151 
assertIdlenull152         fun assertIdle(currentScene: SceneKey) {
153             assertThat(transitionState).isIdle()
154             assertThat(transitionState).hasCurrentScene(currentScene)
155         }
156 
assertTransitionnull157         fun assertTransition(
158             currentScene: SceneKey? = null,
159             fromScene: SceneKey? = null,
160             toScene: SceneKey? = null,
161             progress: Float? = null,
162             isUserInputOngoing: Boolean? = null
163         ): Transition {
164             val transition = assertThat(transitionState).isTransition()
165             currentScene?.let { assertThat(transition).hasCurrentScene(it) }
166             fromScene?.let { assertThat(transition).hasFromScene(it) }
167             toScene?.let { assertThat(transition).hasToScene(it) }
168             progress?.let { assertThat(transition).hasProgress(it) }
169             isUserInputOngoing?.let { assertThat(transition).hasIsUserInputOngoing(it) }
170             return transition
171         }
172 
onDragStartednull173         fun onDragStarted(
174             startedPosition: Offset = Offset.Zero,
175             overSlop: Float,
176             pointersDown: Int = 1,
177         ): DragController {
178             // overSlop should be 0f only if the drag gesture starts with startDragImmediately
179             if (overSlop == 0f) error("Consider using onDragStartedImmediately()")
180             return onDragStarted(draggableHandler, startedPosition, overSlop, pointersDown)
181         }
182 
onDragStartedImmediatelynull183         fun onDragStartedImmediately(
184             startedPosition: Offset = Offset.Zero,
185             pointersDown: Int = 1,
186         ): DragController {
187             return onDragStarted(draggableHandler, startedPosition, overSlop = 0f, pointersDown)
188         }
189 
onDragStartednull190         fun onDragStarted(
191             draggableHandler: DraggableHandler,
192             startedPosition: Offset = Offset.Zero,
193             overSlop: Float = 0f,
194             pointersDown: Int = 1
195         ): DragController {
196             val dragController =
197                 draggableHandler.onDragStarted(
198                     startedPosition = startedPosition,
199                     overSlop = overSlop,
200                     pointersDown = pointersDown,
201                 )
202 
203             // MultiPointerDraggable will always call onDelta with the initial overSlop right after
204             dragController.onDragDelta(pixels = overSlop)
205 
206             return dragController
207         }
208 
DragControllernull209         fun DragController.onDragDelta(pixels: Float) {
210             onDrag(delta = pixels)
211         }
212 
onDragStoppednull213         fun DragController.onDragStopped(velocity: Float, canChangeScene: Boolean = true) {
214             onStop(velocity, canChangeScene)
215         }
216 
NestedScrollConnectionnull217         fun NestedScrollConnection.scroll(
218             available: Offset,
219             consumedByScroll: Offset = Offset.Zero,
220         ) {
221             val consumedByPreScroll =
222                 onPreScroll(
223                     available = available,
224                     source = NestedScrollSource.Drag,
225                 )
226             val consumed = consumedByPreScroll + consumedByScroll
227 
228             onPostScroll(
229                 consumed = consumed,
230                 available = available - consumed,
231                 source = NestedScrollSource.Drag
232             )
233         }
234 
NestedScrollConnectionnull235         fun NestedScrollConnection.preFling(
236             available: Velocity,
237             coroutineScope: CoroutineScope = testScope,
238         ) {
239             // onPreFling is a suspend function that returns the consumed velocity once it finishes
240             // consuming it. In the current scenario, it returns after completing the animation.
241             // To return immediately, we can initiate a job that allows us to check the status
242             // before the animation starts.
243             coroutineScope.launch { onPreFling(available = available) }
244             runCurrent()
245         }
246     }
247 
runGestureTestnull248     private fun runGestureTest(block: suspend TestGestureScope.() -> Unit) {
249         runMonotonicClockTest {
250             val testGestureScope = TestGestureScope(testScope = this)
251 
252             // run the test
253             testGestureScope.block()
254         }
255     }
256 
<lambda>null257     @Test fun testPreconditions() = runGestureTest { assertIdle(currentScene = SceneA) }
258 
259     @Test
<lambda>null260     fun onDragStarted_shouldStartATransition() = runGestureTest {
261         onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
262         assertTransition(currentScene = SceneA)
263     }
264 
265     @Test
<lambda>null266     fun afterSceneTransitionIsStarted_interceptDragEvents() = runGestureTest {
267         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
268         assertTransition(currentScene = SceneA)
269         assertThat(progress).isEqualTo(0.1f)
270 
271         dragController.onDragDelta(pixels = down(fractionOfScreen = 0.1f))
272         assertThat(progress).isEqualTo(0.2f)
273     }
274 
275     @Test
<lambda>null276     fun onDragStoppedAfterDrag_velocityLowerThanThreshold_remainSameScene() = runGestureTest {
277         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
278         assertTransition(currentScene = SceneA)
279 
280         dragController.onDragStopped(velocity = velocityThreshold - 0.01f)
281         assertTransition(currentScene = SceneA)
282 
283         // wait for the stop animation
284         advanceUntilIdle()
285         assertIdle(currentScene = SceneA)
286     }
287 
288     @Test
<lambda>null289     fun onDragStoppedAfterDrag_velocityAtLeastThreshold_goToNextScene() = runGestureTest {
290         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
291         assertTransition(currentScene = SceneA)
292 
293         dragController.onDragStopped(velocity = velocityThreshold)
294         assertTransition(currentScene = SceneC)
295 
296         // wait for the stop animation
297         advanceUntilIdle()
298         assertIdle(currentScene = SceneC)
299     }
300 
301     @Test
<lambda>null302     fun onDragStoppedAfterStarted_returnToIdle() = runGestureTest {
303         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
304         assertTransition(currentScene = SceneA)
305 
306         dragController.onDragStopped(velocity = 0f)
307         advanceUntilIdle()
308         assertIdle(currentScene = SceneA)
309     }
310 
311     @Test
onDragReversedDirection_changeToScenenull312     fun onDragReversedDirection_changeToScene() = runGestureTest {
313         // Drag A -> B with progress 0.6
314         val dragController = onDragStarted(overSlop = -60f)
315         assertTransition(
316             currentScene = SceneA,
317             fromScene = SceneA,
318             toScene = SceneB,
319             progress = 0.6f
320         )
321 
322         // Reverse direction such that A -> C now with 0.4
323         dragController.onDragDelta(pixels = 100f)
324         assertTransition(
325             currentScene = SceneA,
326             fromScene = SceneA,
327             toScene = SceneC,
328             progress = 0.4f
329         )
330 
331         // After the drag stopped scene C should be committed
332         dragController.onDragStopped(velocity = velocityThreshold)
333         assertTransition(currentScene = SceneC, fromScene = SceneA, toScene = SceneC)
334 
335         // wait for the stop animation
336         advanceUntilIdle()
337         assertIdle(currentScene = SceneC)
338     }
339 
340     @Test
<lambda>null341     fun onDragStartedWithoutActionsInBothDirections_stayIdle() = runGestureTest {
342         onDragStarted(horizontalDraggableHandler, overSlop = up(fractionOfScreen = 0.3f))
343         assertIdle(currentScene = SceneA)
344 
345         onDragStarted(horizontalDraggableHandler, overSlop = down(fractionOfScreen = 0.3f))
346         assertIdle(currentScene = SceneA)
347     }
348 
349     @Test
<lambda>null350     fun onDragIntoNoAction_startTransitionToOppositeDirection() = runGestureTest {
351         navigateToSceneC()
352 
353         // We are on SceneC which has no action in Down direction
354         val dragController = onDragStarted(overSlop = 10f)
355         assertTransition(
356             currentScene = SceneC,
357             fromScene = SceneC,
358             toScene = SceneB,
359             progress = -0.1f
360         )
361 
362         // Reverse drag direction, it will consume the previous drag
363         dragController.onDragDelta(pixels = -10f)
364         assertTransition(
365             currentScene = SceneC,
366             fromScene = SceneC,
367             toScene = SceneB,
368             progress = 0.0f
369         )
370 
371         // Continue reverse drag direction, it should record progress to Scene B
372         dragController.onDragDelta(pixels = -10f)
373         assertTransition(
374             currentScene = SceneC,
375             fromScene = SceneC,
376             toScene = SceneB,
377             progress = 0.1f
378         )
379     }
380 
381     @Test
<lambda>null382     fun onDragFromEdge_startTransitionToEdgeAction() = runGestureTest {
383         navigateToSceneC()
384 
385         // Start dragging from the bottom
386         onDragStarted(
387             startedPosition = Offset(SCREEN_SIZE * 0.5f, SCREEN_SIZE),
388             overSlop = up(fractionOfScreen = 0.1f)
389         )
390         assertTransition(
391             currentScene = SceneC,
392             fromScene = SceneC,
393             toScene = SceneA,
394             progress = 0.1f
395         )
396     }
397 
398     @Test
<lambda>null399     fun onDragToExactlyZero_toSceneIsSet() = runGestureTest {
400         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.3f))
401         assertTransition(
402             currentScene = SceneA,
403             fromScene = SceneA,
404             toScene = SceneC,
405             progress = 0.3f
406         )
407         dragController.onDragDelta(pixels = up(fractionOfScreen = 0.3f))
408         assertTransition(
409             currentScene = SceneA,
410             fromScene = SceneA,
411             toScene = SceneC,
412             progress = 0.0f
413         )
414     }
415 
TestGestureScopenull416     private fun TestGestureScope.navigateToSceneC() {
417         assertIdle(currentScene = SceneA)
418         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 1f))
419         dragController.onDragStopped(velocity = 0f)
420         advanceUntilIdle()
421         assertIdle(currentScene = SceneC)
422     }
423 
424     @Test
<lambda>null425     fun onAccelaratedScroll_scrollToThirdScene() = runGestureTest {
426         // Drag A -> B with progress 0.2
427         val dragController1 = onDragStarted(overSlop = up(fractionOfScreen = 0.2f))
428         assertTransition(
429             currentScene = SceneA,
430             fromScene = SceneA,
431             toScene = SceneB,
432             progress = 0.2f
433         )
434 
435         // Start animation A -> B with progress 0.2 -> 1.0
436         dragController1.onDragStopped(velocity = -velocityThreshold)
437         assertTransition(currentScene = SceneB, fromScene = SceneA, toScene = SceneB)
438 
439         // While at A -> B do a 100% screen drag (progress 1.2). This should go past B and change
440         // the transition to B -> C with progress 0.2
441         val dragController2 = onDragStartedImmediately()
442         dragController2.onDragDelta(pixels = up(fractionOfScreen = 1f))
443         assertTransition(
444             currentScene = SceneB,
445             fromScene = SceneB,
446             toScene = SceneC,
447             progress = 0.2f
448         )
449 
450         // After the drag stopped scene C should be committed
451         dragController2.onDragStopped(velocity = -velocityThreshold)
452         assertTransition(currentScene = SceneC, fromScene = SceneB, toScene = SceneC)
453 
454         // wait for the stop animation
455         advanceUntilIdle()
456         assertIdle(currentScene = SceneC)
457     }
458 
459     @Test
onAccelaratedScrollBothTargetsBecomeNull_settlesToIdlenull460     fun onAccelaratedScrollBothTargetsBecomeNull_settlesToIdle() = runGestureTest {
461         val dragController1 = onDragStarted(overSlop = up(fractionOfScreen = 0.2f))
462         dragController1.onDragDelta(pixels = up(fractionOfScreen = 0.2f))
463         dragController1.onDragStopped(velocity = -velocityThreshold)
464         assertTransition(currentScene = SceneB, fromScene = SceneA, toScene = SceneB)
465 
466         mutableUserActionsA.remove(Swipe.Up)
467         mutableUserActionsA.remove(Swipe.Down)
468         mutableUserActionsB.remove(Swipe.Up)
469         mutableUserActionsB.remove(Swipe.Down)
470 
471         // start accelaratedScroll and scroll over to B -> null
472         val dragController2 = onDragStartedImmediately()
473         dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f))
474         dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f))
475 
476         // here onDragStopped is already triggered, but subsequent onDelta/onDragStopped calls may
477         // still be called. Make sure that they don't crash or change the scene
478         dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f))
479         dragController2.onDragStopped(velocity = 0f)
480 
481         advanceUntilIdle()
482         assertIdle(SceneB)
483 
484         // These events can still come in after the animation has settled
485         dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f))
486         dragController2.onDragStopped(velocity = 0f)
487         assertIdle(SceneB)
488     }
489 
490     @Test
<lambda>null491     fun onDragTargetsChanged_targetStaysTheSame() = runGestureTest {
492         val dragController1 = onDragStarted(overSlop = up(fractionOfScreen = 0.1f))
493         assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.1f)
494 
495         mutableUserActionsA[Swipe.Up] = UserActionResult(SceneC)
496         dragController1.onDragDelta(pixels = up(fractionOfScreen = 0.1f))
497         // target stays B even though UserActions changed
498         assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.2f)
499         dragController1.onDragStopped(velocity = down(fractionOfScreen = 0.1f))
500         advanceUntilIdle()
501 
502         // now target changed to C for new drag
503         onDragStarted(overSlop = up(fractionOfScreen = 0.1f))
504         assertTransition(fromScene = SceneA, toScene = SceneC, progress = 0.1f)
505     }
506 
507     @Test
<lambda>null508     fun onDragTargetsChanged_targetsChangeWhenStartingNewDrag() = runGestureTest {
509         val dragController1 = onDragStarted(overSlop = up(fractionOfScreen = 0.1f))
510         assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.1f)
511 
512         mutableUserActionsA[Swipe.Up] = UserActionResult(SceneC)
513         dragController1.onDragDelta(pixels = up(fractionOfScreen = 0.1f))
514         dragController1.onDragStopped(velocity = down(fractionOfScreen = 0.1f))
515 
516         // now target changed to C for new drag that started before previous drag settled to Idle
517         val dragController2 = onDragStartedImmediately()
518         dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.1f))
519         assertTransition(fromScene = SceneA, toScene = SceneC, progress = 0.3f)
520     }
521 
522     @Test
<lambda>null523     fun startGestureDuringAnimatingOffset_shouldImmediatelyStopTheAnimation() = runGestureTest {
524         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
525         assertTransition(currentScene = SceneA)
526 
527         dragController.onDragStopped(velocity = velocityThreshold)
528         runCurrent()
529 
530         assertTransition(currentScene = SceneC)
531         assertThat(isUserInputOngoing).isFalse()
532 
533         // Start a new gesture while the offset is animating
534         onDragStartedImmediately()
535         assertThat(isUserInputOngoing).isTrue()
536     }
537 
538     @Test
<lambda>null539     fun onInitialPreScroll_EdgeWithOverscroll_doNotChangeState() = runGestureTest {
540         val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithPreview)
541         nestedScroll.onPreScroll(
542             available = downOffset(fractionOfScreen = 0.1f),
543             source = NestedScrollSource.Drag
544         )
545         assertIdle(currentScene = SceneA)
546     }
547 
548     @Test
<lambda>null549     fun onPostScrollWithNothingAvailable_EdgeWithOverscroll_doNotChangeState() = runGestureTest {
550         val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithPreview)
551         val consumed =
552             nestedScroll.onPostScroll(
553                 consumed = Offset.Zero,
554                 available = Offset.Zero,
555                 source = NestedScrollSource.Drag
556             )
557 
558         assertIdle(currentScene = SceneA)
559         assertThat(consumed).isEqualTo(Offset.Zero)
560     }
561 
562     @Test
<lambda>null563     fun onPostScrollWithSomethingAvailable_startSceneTransition() = runGestureTest {
564         val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithPreview)
565         val consumed =
566             nestedScroll.onPostScroll(
567                 consumed = Offset.Zero,
568                 available = downOffset(fractionOfScreen = 0.1f),
569                 source = NestedScrollSource.Drag
570             )
571 
572         assertTransition(currentScene = SceneA)
573         assertThat(progress).isEqualTo(0.1f)
574         assertThat(consumed).isEqualTo(downOffset(fractionOfScreen = 0.1f))
575     }
576 
577     @Test
<lambda>null578     fun afterSceneTransitionIsStarted_interceptPreScrollEvents() = runGestureTest {
579         val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithPreview)
580         nestedScroll.scroll(available = downOffset(fractionOfScreen = 0.1f))
581         assertTransition(currentScene = SceneA)
582 
583         assertThat(progress).isEqualTo(0.1f)
584 
585         // start intercept preScroll
586         val consumed =
587             nestedScroll.onPreScroll(
588                 available = downOffset(fractionOfScreen = 0.1f),
589                 source = NestedScrollSource.Drag
590             )
591         assertThat(progress).isEqualTo(0.2f)
592 
593         // do nothing on postScroll
594         nestedScroll.onPostScroll(
595             consumed = consumed,
596             available = Offset.Zero,
597             source = NestedScrollSource.Drag
598         )
599         assertThat(progress).isEqualTo(0.2f)
600 
601         nestedScroll.scroll(available = downOffset(fractionOfScreen = 0.1f))
602         assertThat(progress).isEqualTo(0.3f)
603         assertTransition(currentScene = SceneA)
604     }
605 
preScrollAfterSceneTransitionnull606     private fun TestGestureScope.preScrollAfterSceneTransition(
607         firstScroll: Float,
608         secondScroll: Float
609     ) {
610         val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithPreview)
611         // start scene transition
612         nestedScroll.scroll(available = Offset(0f, firstScroll))
613 
614         // stop scene transition (start the "stop animation")
615         nestedScroll.preFling(available = Velocity.Zero)
616 
617         // a pre scroll event, that could be intercepted by DraggableHandlerImpl
618         nestedScroll.onPreScroll(
619             available = Offset(0f, secondScroll),
620             source = NestedScrollSource.Drag
621         )
622     }
623 
624     @Test
scrollAndFling_scrollLessThanInterceptable_goToIdleOnCurrentScenenull625     fun scrollAndFling_scrollLessThanInterceptable_goToIdleOnCurrentScene() = runGestureTest {
626         val firstScroll = (transitionInterceptionThreshold - 0.0001f) * SCREEN_SIZE
627         val secondScroll = 1f
628 
629         preScrollAfterSceneTransition(firstScroll = firstScroll, secondScroll = secondScroll)
630 
631         assertIdle(SceneA)
632     }
633 
634     @Test
<lambda>null635     fun scrollAndFling_scrollMinInterceptable_interceptPreScrollEvents() = runGestureTest {
636         val firstScroll = (transitionInterceptionThreshold + 0.0001f) * SCREEN_SIZE
637         val secondScroll = 1f
638 
639         preScrollAfterSceneTransition(firstScroll = firstScroll, secondScroll = secondScroll)
640 
641         assertTransition(progress = (firstScroll + secondScroll) / SCREEN_SIZE)
642     }
643 
644     @Test
scrollAndFling_scrollMaxInterceptable_interceptPreScrollEventsnull645     fun scrollAndFling_scrollMaxInterceptable_interceptPreScrollEvents() = runGestureTest {
646         val firstScroll = -(1f - transitionInterceptionThreshold - 0.0001f) * SCREEN_SIZE
647         val secondScroll = -1f
648 
649         preScrollAfterSceneTransition(firstScroll = firstScroll, secondScroll = secondScroll)
650 
651         assertTransition(progress = -(firstScroll + secondScroll) / SCREEN_SIZE)
652     }
653 
654     @Test
<lambda>null655     fun scrollAndFling_scrollMoreThanInterceptable_goToIdleOnNextScene() = runGestureTest {
656         val firstScroll = -(1f - transitionInterceptionThreshold + 0.0001f) * SCREEN_SIZE
657         val secondScroll = -0.01f
658 
659         preScrollAfterSceneTransition(firstScroll = firstScroll, secondScroll = secondScroll)
660 
661         advanceUntilIdle()
662         assertIdle(SceneB)
663     }
664 
665     @Test
<lambda>null666     fun onPreFling_velocityLowerThanThreshold_remainSameScene() = runGestureTest {
667         val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithPreview)
668         nestedScroll.scroll(available = downOffset(fractionOfScreen = 0.1f))
669         assertTransition(currentScene = SceneA)
670 
671         nestedScroll.preFling(available = Velocity.Zero)
672         assertTransition(currentScene = SceneA)
673 
674         // wait for the stop animation
675         advanceUntilIdle()
676         assertIdle(currentScene = SceneA)
677     }
678 
flingAfterScrollnull679     private fun TestGestureScope.flingAfterScroll(
680         use: NestedScrollBehavior,
681         idleAfterScroll: Boolean,
682     ) {
683         val nestedScroll = nestedScrollConnection(nestedScrollBehavior = use)
684         nestedScroll.scroll(available = downOffset(fractionOfScreen = 0.1f))
685         if (idleAfterScroll) assertIdle(SceneA) else assertTransition(SceneA)
686 
687         nestedScroll.preFling(available = Velocity(0f, velocityThreshold))
688     }
689 
690     @Test
<lambda>null691     fun flingAfterScroll_DuringTransitionBetweenScenes_doNothing() = runGestureTest {
692         flingAfterScroll(use = DuringTransitionBetweenScenes, idleAfterScroll = true)
693 
694         assertIdle(currentScene = SceneA)
695     }
696 
697     @Test
<lambda>null698     fun flingAfterScroll_EdgeNoOverscroll_goToNextScene() = runGestureTest {
699         flingAfterScroll(use = EdgeNoPreview, idleAfterScroll = false)
700 
701         assertTransition(currentScene = SceneC)
702 
703         // wait for the stop animation
704         advanceUntilIdle()
705         assertIdle(currentScene = SceneC)
706     }
707 
708     @Test
<lambda>null709     fun flingAfterScroll_EdgeWithOverscroll_goToNextScene() = runGestureTest {
710         flingAfterScroll(use = EdgeWithPreview, idleAfterScroll = false)
711 
712         assertTransition(currentScene = SceneC)
713 
714         // wait for the stop animation
715         advanceUntilIdle()
716         assertIdle(currentScene = SceneC)
717     }
718 
719     @Test
<lambda>null720     fun flingAfterScroll_Always_goToNextScene() = runGestureTest {
721         flingAfterScroll(use = EdgeAlways, idleAfterScroll = false)
722 
723         assertTransition(currentScene = SceneC)
724 
725         // wait for the stop animation
726         advanceUntilIdle()
727         assertIdle(currentScene = SceneC)
728     }
729 
730     /** we started the scroll in the scene, then fling with the velocityThreshold */
flingAfterScrollStartedInScenenull731     private fun TestGestureScope.flingAfterScrollStartedInScene(
732         use: NestedScrollBehavior,
733         idleAfterScroll: Boolean,
734     ) {
735         val nestedScroll = nestedScrollConnection(nestedScrollBehavior = use)
736         // scroll consumed in child
737         nestedScroll.scroll(
738             available = downOffset(fractionOfScreen = 0.1f),
739             consumedByScroll = downOffset(fractionOfScreen = 0.1f)
740         )
741 
742         // scroll offsetY10 is all available for parents
743         nestedScroll.scroll(available = downOffset(fractionOfScreen = 0.1f))
744         if (idleAfterScroll) assertIdle(SceneA) else assertTransition(SceneA)
745 
746         nestedScroll.preFling(available = Velocity(0f, velocityThreshold))
747     }
748 
749     @Test
<lambda>null750     fun flingAfterScrollStartedInScene_DuringTransitionBetweenScenes_doNothing() = runGestureTest {
751         flingAfterScrollStartedInScene(use = DuringTransitionBetweenScenes, idleAfterScroll = true)
752 
753         assertIdle(currentScene = SceneA)
754     }
755 
756     @Test
<lambda>null757     fun flingAfterScrollStartedInScene_EdgeNoOverscroll_doNothing() = runGestureTest {
758         flingAfterScrollStartedInScene(use = EdgeNoPreview, idleAfterScroll = true)
759 
760         assertIdle(currentScene = SceneA)
761     }
762 
763     @Test
<lambda>null764     fun flingAfterScrollStartedInScene_EdgeWithOverscroll_doOverscrollAnimation() = runGestureTest {
765         flingAfterScrollStartedInScene(use = EdgeWithPreview, idleAfterScroll = false)
766 
767         assertTransition(currentScene = SceneA)
768 
769         // wait for the stop animation
770         advanceUntilIdle()
771         assertIdle(currentScene = SceneA)
772     }
773 
774     @Test
<lambda>null775     fun flingAfterScrollStartedInScene_Always_goToNextScene() = runGestureTest {
776         flingAfterScrollStartedInScene(use = EdgeAlways, idleAfterScroll = false)
777 
778         assertTransition(currentScene = SceneC)
779 
780         // wait for the stop animation
781         advanceUntilIdle()
782         assertIdle(currentScene = SceneC)
783     }
784 
785     @Test
<lambda>null786     fun flingAfterScrollStartedByExternalOverscrollGesture() = runGestureTest {
787         val nestedScroll =
788             nestedScrollConnection(
789                 nestedScrollBehavior = EdgeWithPreview,
790                 isExternalOverscrollGesture = true
791             )
792 
793         // scroll not consumed in child
794         nestedScroll.scroll(
795             available = downOffset(fractionOfScreen = 0.1f),
796         )
797 
798         // scroll offsetY10 is all available for parents
799         nestedScroll.scroll(available = downOffset(fractionOfScreen = 0.1f))
800         assertTransition(SceneA)
801 
802         nestedScroll.preFling(available = Velocity(0f, velocityThreshold))
803     }
804 
805     @Test
<lambda>null806     fun beforeNestedScrollStart_stop_shouldBeIgnored() = runGestureTest {
807         val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithPreview)
808         nestedScroll.preFling(available = Velocity(0f, velocityThreshold))
809         assertIdle(currentScene = SceneA)
810     }
811 
812     @Test
<lambda>null813     fun startNestedScrollWhileDragging() = runGestureTest {
814         val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeAlways)
815 
816         val offsetY10 = downOffset(fractionOfScreen = 0.1f)
817 
818         // Start a drag and then stop it, given that
819         val dragController = onDragStarted(overSlop = up(0.1f))
820 
821         assertTransition(currentScene = SceneA)
822         assertThat(progress).isEqualTo(0.1f)
823 
824         // now we can intercept the scroll events
825         nestedScroll.scroll(available = -offsetY10)
826         assertThat(progress).isEqualTo(0.2f)
827 
828         // this should be ignored, we are scrolling now!
829         dragController.onDragStopped(-velocityThreshold)
830         assertTransition(currentScene = SceneA)
831 
832         nestedScroll.scroll(available = -offsetY10)
833         assertThat(progress).isEqualTo(0.3f)
834 
835         nestedScroll.scroll(available = -offsetY10)
836         assertThat(progress).isEqualTo(0.4f)
837 
838         nestedScroll.preFling(available = Velocity(0f, -velocityThreshold))
839         assertTransition(currentScene = SceneB)
840 
841         // wait for the stop animation
842         advanceUntilIdle()
843         assertIdle(currentScene = SceneB)
844     }
845 
846     @Test
<lambda>null847     fun interceptTransition() = runGestureTest {
848         // Start at scene C.
849         navigateToSceneC()
850 
851         // Swipe up from the middle to transition to scene B.
852         val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
853         onDragStarted(startedPosition = middle, overSlop = up(0.1f))
854         assertTransition(
855             currentScene = SceneC,
856             fromScene = SceneC,
857             toScene = SceneB,
858             progress = 0.1f,
859             isUserInputOngoing = true,
860         )
861 
862         val firstTransition = transitionState
863 
864         // During the current gesture, start a new gesture, still in the middle of the screen. We
865         // should intercept it. Because it is intercepted, the overSlop passed to onDragStarted()
866         // should be 0f.
867         assertThat(draggableHandler.shouldImmediatelyIntercept(middle)).isTrue()
868         onDragStartedImmediately(startedPosition = middle)
869 
870         // We should have intercepted the transition, so the transition should be the same object.
871         assertTransition(
872             currentScene = SceneC,
873             fromScene = SceneC,
874             toScene = SceneB,
875             progress = 0.1f,
876             isUserInputOngoing = true,
877         )
878         // We should have a new transition
879         assertThat(transitionState).isNotSameInstanceAs(firstTransition)
880 
881         // Start a new gesture from the bottom of the screen. Because swiping up from the bottom of
882         // C leads to scene A (and not B), the previous transitions is *not* intercepted and we
883         // instead animate from C to A.
884         val bottom = Offset(SCREEN_SIZE / 2, SCREEN_SIZE)
885         assertThat(draggableHandler.shouldImmediatelyIntercept(bottom)).isFalse()
886         onDragStarted(startedPosition = bottom, overSlop = up(0.1f))
887 
888         assertTransition(
889             currentScene = SceneC,
890             fromScene = SceneC,
891             toScene = SceneA,
892             isUserInputOngoing = true,
893         )
894         assertThat(transitionState).isNotSameInstanceAs(firstTransition)
895     }
896 
897     @Test
<lambda>null898     fun finish() = runGestureTest {
899         // Start at scene C.
900         navigateToSceneC()
901 
902         // Swipe up from the middle to transition to scene B.
903         val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
904         onDragStarted(startedPosition = middle, overSlop = up(0.1f))
905         assertTransition(fromScene = SceneC, toScene = SceneB, isUserInputOngoing = true)
906 
907         // The current transition can be intercepted.
908         assertThat(draggableHandler.shouldImmediatelyIntercept(middle)).isTrue()
909 
910         // Finish the transition.
911         val transition = transitionState as Transition
912         val job = transition.finish()
913         assertTransition(isUserInputOngoing = false)
914 
915         // The current transition can not be intercepted anymore.
916         assertThat(draggableHandler.shouldImmediatelyIntercept(middle)).isFalse()
917 
918         // Calling finish() multiple times returns the same Job.
919         assertThat(transition.finish()).isSameInstanceAs(job)
920         assertThat(transition.finish()).isSameInstanceAs(job)
921         assertThat(transition.finish()).isSameInstanceAs(job)
922 
923         // We can join the job to wait for the animation to end.
924         assertTransition()
925         job.join()
926         assertIdle(SceneC)
927     }
928 
929     @Test
<lambda>null930     fun finish_cancelled() = runGestureTest {
931         // Swipe up from the middle to transition to scene B.
932         val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
933         onDragStarted(startedPosition = middle, overSlop = up(0.1f))
934         assertTransition(fromScene = SceneA, toScene = SceneB)
935 
936         // Finish the transition and cancel the returned job.
937         (transitionState as Transition).finish().cancelAndJoin()
938         assertIdle(SceneA)
939     }
940 
941     @Test
<lambda>null942     fun blockTransition() = runGestureTest {
943         assertIdle(SceneA)
944 
945         // Swipe up to scene B.
946         val dragController = onDragStarted(overSlop = up(0.1f))
947         assertTransition(currentScene = SceneA, fromScene = SceneA, toScene = SceneB)
948 
949         // Block the transition when the user release their finger.
950         canChangeScene = { false }
951         dragController.onDragStopped(velocity = -velocityThreshold)
952         advanceUntilIdle()
953         assertIdle(SceneA)
954     }
955 
956     @Test
<lambda>null957     fun blockInterceptedTransition() = runGestureTest {
958         assertIdle(SceneA)
959 
960         // Swipe up to B.
961         val dragController1 = onDragStarted(overSlop = up(0.1f))
962         assertTransition(currentScene = SceneA, fromScene = SceneA, toScene = SceneB)
963         dragController1.onDragStopped(velocity = -velocityThreshold)
964         assertTransition(currentScene = SceneB, fromScene = SceneA, toScene = SceneB)
965 
966         // Intercept the transition and swipe down back to scene A.
967         assertThat(draggableHandler.shouldImmediatelyIntercept(startedPosition = null)).isTrue()
968         val dragController2 = onDragStartedImmediately()
969 
970         // Block the transition when the user release their finger.
971         canChangeScene = { false }
972         dragController2.onDragStopped(velocity = velocityThreshold)
973 
974         advanceUntilIdle()
975         assertIdle(SceneB)
976     }
977 
978     @Test
<lambda>null979     fun scrollFromIdleWithNoTargetScene_shouldUseOverscrollSpecIfAvailable() = runGestureTest {
980         layoutState.transitions = transitions {
981             overscroll(SceneC, Orientation.Vertical) { fade(TestElements.Foo) }
982         }
983         // Start at scene C.
984         navigateToSceneC()
985 
986         val scene = layoutState.transitionState.currentScene
987         // We should have overscroll spec for scene C
988         assertThat(layoutState.transitions.overscrollSpec(scene, Orientation.Vertical)).isNotNull()
989         assertThat(layoutState.currentTransition?.currentOverscrollSpec).isNull()
990 
991         val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeAlways)
992         nestedScroll.scroll(available = downOffset(fractionOfScreen = 0.1f))
993 
994         // We scrolled down, under scene C there is nothing, so we can use the overscroll spec
995         assertThat(layoutState.currentTransition?.currentOverscrollSpec).isNotNull()
996         assertThat(layoutState.currentTransition?.currentOverscrollSpec?.scene).isEqualTo(SceneC)
997         val transition = layoutState.currentTransition
998         assertThat(transition).isNotNull()
999         assertThat(transition!!.progress).isEqualTo(-0.1f)
1000     }
1001 
1002     @Test
<lambda>null1003     fun transitionIsImmediatelyUpdatedWhenReleasingFinger() = runGestureTest {
1004         // Swipe up from the middle to transition to scene B.
1005         val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
1006         val dragController = onDragStarted(startedPosition = middle, overSlop = up(0.1f))
1007         assertTransition(fromScene = SceneA, toScene = SceneB, isUserInputOngoing = true)
1008 
1009         dragController.onDragStopped(velocity = 0f)
1010         assertTransition(isUserInputOngoing = false)
1011     }
1012 
1013     @Test
<lambda>null1014     fun emptyOverscrollImmediatelyAbortsSettleAnimationWhenOverProgress() = runGestureTest {
1015         // Overscrolling on scene B does nothing.
1016         layoutState.transitions = transitions { overscroll(SceneB, Orientation.Vertical) {} }
1017 
1018         // Swipe up to scene B at progress = 200%.
1019         val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
1020         val dragController = onDragStarted(startedPosition = middle, overSlop = up(2f))
1021         val transition = assertTransition(fromScene = SceneA, toScene = SceneB, progress = 2f)
1022 
1023         // Release the finger.
1024         dragController.onDragStopped(velocity = -velocityThreshold)
1025 
1026         // Exhaust all coroutines *without advancing the clock*. Given that we are at progress >=
1027         // 100% and that the overscroll on scene B is doing nothing, we are already idle.
1028         runCurrent()
1029         assertIdle(SceneB)
1030 
1031         // Progress is snapped to 100%.
1032         assertThat(transition).hasProgress(1f)
1033     }
1034 
1035     @Test
<lambda>null1036     fun overscroll_releaseBetween0And100Percent_up() = runGestureTest {
1037         // Make scene B overscrollable.
1038         layoutState.transitions = transitions {
1039             from(SceneA, to = SceneB) { spec = spring(dampingRatio = Spring.DampingRatioNoBouncy) }
1040             overscroll(SceneB, Orientation.Vertical) { fade(TestElements.Foo) }
1041         }
1042 
1043         val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
1044 
1045         val dragController = onDragStarted(startedPosition = middle, overSlop = up(0.5f))
1046         val transition = assertThat(transitionState).isTransition()
1047         assertThat(transition).hasFromScene(SceneA)
1048         assertThat(transition).hasToScene(SceneB)
1049         assertThat(transition).hasProgress(0.5f)
1050 
1051         // Release to B.
1052         dragController.onDragStopped(velocity = -velocityThreshold)
1053         advanceUntilIdle()
1054 
1055         // We didn't overscroll at the end of the transition.
1056         assertIdle(SceneB)
1057         assertThat(transition).hasProgress(1f)
1058         assertThat(transition).hasNoOverscrollSpec()
1059     }
1060 
1061     @Test
<lambda>null1062     fun overscroll_releaseBetween0And100Percent_down() = runGestureTest {
1063         // Make scene C overscrollable.
1064         layoutState.transitions = transitions {
1065             from(SceneA, to = SceneC) { spec = spring(dampingRatio = Spring.DampingRatioNoBouncy) }
1066             overscroll(SceneC, Orientation.Vertical) { fade(TestElements.Foo) }
1067         }
1068 
1069         val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
1070 
1071         val dragController = onDragStarted(startedPosition = middle, overSlop = down(0.5f))
1072         val transition = assertThat(transitionState).isTransition()
1073         assertThat(transition).hasFromScene(SceneA)
1074         assertThat(transition).hasToScene(SceneC)
1075         assertThat(transition).hasProgress(0.5f)
1076 
1077         // Release to C.
1078         dragController.onDragStopped(velocity = velocityThreshold)
1079         advanceUntilIdle()
1080 
1081         // We didn't overscroll at the end of the transition.
1082         assertIdle(SceneC)
1083         assertThat(transition).hasProgress(1f)
1084         assertThat(transition).hasNoOverscrollSpec()
1085     }
1086 
1087     @Test
<lambda>null1088     fun overscroll_releaseAt150Percent_up() = runGestureTest {
1089         // Make scene B overscrollable.
1090         layoutState.transitions = transitions {
1091             from(SceneA, to = SceneB) { spec = spring(dampingRatio = Spring.DampingRatioNoBouncy) }
1092             overscroll(SceneB, Orientation.Vertical) { fade(TestElements.Foo) }
1093         }
1094 
1095         val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
1096 
1097         val dragController = onDragStarted(startedPosition = middle, overSlop = up(1.5f))
1098         val transition = assertThat(transitionState).isTransition()
1099         assertThat(transition).hasFromScene(SceneA)
1100         assertThat(transition).hasToScene(SceneB)
1101         assertThat(transition).hasProgress(1.5f)
1102 
1103         // Release to B.
1104         dragController.onDragStopped(velocity = 0f)
1105         advanceUntilIdle()
1106 
1107         // We kept the overscroll at 100% so that the placement logic didn't change at the end of
1108         // the animation.
1109         assertIdle(SceneB)
1110         assertThat(transition).hasProgress(1f)
1111         assertThat(transition).hasOverscrollSpec()
1112     }
1113 
1114     @Test
<lambda>null1115     fun overscroll_releaseAt150Percent_down() = runGestureTest {
1116         // Make scene C overscrollable.
1117         layoutState.transitions = transitions {
1118             from(SceneA, to = SceneC) { spec = spring(dampingRatio = Spring.DampingRatioNoBouncy) }
1119             overscroll(SceneC, Orientation.Vertical) { fade(TestElements.Foo) }
1120         }
1121 
1122         val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
1123 
1124         val dragController = onDragStarted(startedPosition = middle, overSlop = down(1.5f))
1125         val transition = assertThat(transitionState).isTransition()
1126         assertThat(transition).hasFromScene(SceneA)
1127         assertThat(transition).hasToScene(SceneC)
1128         assertThat(transition).hasProgress(1.5f)
1129 
1130         // Release to C.
1131         dragController.onDragStopped(velocity = 0f)
1132         advanceUntilIdle()
1133 
1134         // We kept the overscroll at 100% so that the placement logic didn't change at the end of
1135         // the animation.
1136         assertIdle(SceneC)
1137         assertThat(transition).hasProgress(1f)
1138         assertThat(transition).hasOverscrollSpec()
1139     }
1140 
1141     @Test
<lambda>null1142     fun overscroll_releaseAtNegativePercent_up() = runGestureTest {
1143         // Make Scene A overscrollable.
1144         layoutState.transitions = transitions {
1145             from(SceneA, to = SceneB) { spec = spring(dampingRatio = Spring.DampingRatioNoBouncy) }
1146             overscroll(SceneA, Orientation.Vertical) { fade(TestElements.Foo) }
1147         }
1148 
1149         mutableUserActionsA.clear()
1150         mutableUserActionsA[Swipe.Up] = UserActionResult(SceneB)
1151 
1152         val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
1153         val dragController = onDragStarted(startedPosition = middle, overSlop = down(1f))
1154         val transition = assertThat(transitionState).isTransition()
1155         assertThat(transition).hasFromScene(SceneA)
1156         assertThat(transition).hasToScene(SceneB)
1157         assertThat(transition).hasProgress(-1f)
1158 
1159         // Release to A.
1160         dragController.onDragStopped(velocity = 0f)
1161         advanceUntilIdle()
1162 
1163         // We kept the overscroll at 100% so that the placement logic didn't change at the end of
1164         // the animation.
1165         assertIdle(SceneA)
1166         assertThat(transition).hasProgress(0f)
1167         assertThat(transition).hasOverscrollSpec()
1168     }
1169 
1170     @Test
<lambda>null1171     fun overscroll_releaseAtNegativePercent_down() = runGestureTest {
1172         // Make Scene A overscrollable.
1173         layoutState.transitions = transitions {
1174             from(SceneA, to = SceneC) { spec = spring(dampingRatio = Spring.DampingRatioNoBouncy) }
1175             overscroll(SceneA, Orientation.Vertical) { fade(TestElements.Foo) }
1176         }
1177 
1178         mutableUserActionsA.clear()
1179         mutableUserActionsA[Swipe.Down] = UserActionResult(SceneC)
1180 
1181         val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
1182         val dragController = onDragStarted(startedPosition = middle, overSlop = up(1f))
1183         val transition = assertThat(transitionState).isTransition()
1184         assertThat(transition).hasFromScene(SceneA)
1185         assertThat(transition).hasToScene(SceneC)
1186         assertThat(transition).hasProgress(-1f)
1187 
1188         // Release to A.
1189         dragController.onDragStopped(velocity = 0f)
1190         advanceUntilIdle()
1191 
1192         // We kept the overscroll at 100% so that the placement logic didn't change at the end of
1193         // the animation.
1194         assertIdle(SceneA)
1195         assertThat(transition).hasProgress(0f)
1196         assertThat(transition).hasOverscrollSpec()
1197     }
1198 }
1199