• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * 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 @file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
18 
19 package com.android.compose.animation.scene
20 
21 import androidx.compose.animation.SplineBasedFloatDecayAnimationSpec
22 import androidx.compose.animation.core.Spring
23 import androidx.compose.animation.core.generateDecayAnimationSpec
24 import androidx.compose.animation.core.spring
25 import androidx.compose.foundation.overscroll
26 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
27 import androidx.compose.material3.MotionScheme
28 import androidx.compose.material3.Text
29 import androidx.compose.ui.Modifier
30 import androidx.compose.ui.geometry.Offset
31 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
32 import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.UserInput
33 import androidx.compose.ui.input.pointer.PointerType
34 import androidx.compose.ui.unit.Density
35 import androidx.compose.ui.unit.IntSize
36 import androidx.compose.ui.unit.LayoutDirection
37 import androidx.compose.ui.unit.Velocity
38 import androidx.test.ext.junit.runners.AndroidJUnit4
39 import com.android.compose.animation.scene.TestOverlays.OverlayA
40 import com.android.compose.animation.scene.TestOverlays.OverlayB
41 import com.android.compose.animation.scene.TestScenes.SceneA
42 import com.android.compose.animation.scene.TestScenes.SceneB
43 import com.android.compose.animation.scene.TestScenes.SceneC
44 import com.android.compose.animation.scene.content.state.TransitionState
45 import com.android.compose.animation.scene.content.state.TransitionState.Companion.DistanceUnspecified
46 import com.android.compose.animation.scene.content.state.TransitionState.Transition
47 import com.android.compose.animation.scene.subjects.assertThat
48 import com.android.compose.gesture.NestedDraggable
49 import com.android.compose.gesture.effect.OffsetOverscrollEffectFactory
50 import com.android.compose.test.MonotonicClockTestScope
51 import com.android.compose.test.runMonotonicClockTest
52 import com.android.mechanics.spec.InputDirection
53 import com.google.common.truth.Truth.assertThat
54 import kotlin.math.nextUp
55 import kotlin.math.sign
56 import kotlinx.coroutines.CoroutineScope
57 import kotlinx.coroutines.Deferred
58 import kotlinx.coroutines.async
59 import kotlinx.coroutines.launch
60 import org.junit.Test
61 import org.junit.runner.RunWith
62 
63 private const val SCREEN_SIZE = 100f
64 private val LAYOUT_SIZE = IntSize(SCREEN_SIZE.toInt(), SCREEN_SIZE.toInt())
65 
66 @RunWith(AndroidJUnit4::class)
67 class DraggableHandlerTest {
68     private class TestGestureScope(val testScope: MonotonicClockTestScope) {
69         var canChangeScene: (SceneKey) -> Boolean = { true }
70         val layoutState =
71             MutableSceneTransitionLayoutStateForTests(
72                 SceneA,
73                 EmptyTestTransitions,
74                 canChangeScene = { canChangeScene(it) },
75             )
76 
77         val defaultEffectFactory =
78             OffsetOverscrollEffectFactory(testScope, MotionScheme.standard().defaultSpatialSpec())
79 
80         var layoutDirection = LayoutDirection.Rtl
81             set(value) {
82                 field = value
83                 layoutImpl.updateContents(scenesBuilder, layoutDirection, defaultEffectFactory)
84             }
85 
86         var mutableUserActionsA = mapOf(Swipe.Up to SceneB, Swipe.Down to SceneC)
87             set(value) {
88                 field = value
89                 layoutImpl.updateContents(scenesBuilder, layoutDirection, defaultEffectFactory)
90             }
91 
92         var mutableUserActionsB = mapOf(Swipe.Up to SceneC, Swipe.Down to SceneA)
93             set(value) {
94                 field = value
95                 layoutImpl.updateContents(scenesBuilder, layoutDirection, defaultEffectFactory)
96             }
97 
98         private val scenesBuilder: SceneTransitionLayoutScope<ContentScope>.() -> Unit = {
99             scene(key = SceneA, userActions = mutableUserActionsA) { Text("SceneA") }
100             scene(key = SceneB, userActions = mutableUserActionsB) { Text("SceneB") }
101             scene(
102                 key = SceneC,
103                 userActions =
104                     mapOf(Swipe.Up to SceneB, Swipe.Up(fromSource = Edge.Bottom) to SceneA),
105             ) {
106                 Text("SceneC", Modifier.overscroll(verticalOverscrollEffect))
107             }
108             overlay(
109                 key = OverlayA,
110                 userActions =
111                     mapOf(
112                         Swipe.Up to UserActionResult.HideOverlay(OverlayA),
113                         Swipe.Down to UserActionResult.ReplaceByOverlay(OverlayB),
114                     ),
115             ) {
116                 Text("OverlayA")
117             }
118             overlay(key = OverlayB) { Text("OverlayB") }
119         }
120 
121         val transitionInterceptionThreshold = 0.05f
122         val directionChangeSlop = 10f
123 
124         private val density = Density(1f)
125         private val layoutImpl =
126             SceneTransitionLayoutImpl(
127                     state = layoutState,
128                     density = density,
129                     layoutDirection = LayoutDirection.Ltr,
130                     swipeSourceDetector = DefaultEdgeDetector,
131                     swipeDetector = DefaultSwipeDetector,
132                     transitionInterceptionThreshold = transitionInterceptionThreshold,
133                     builder = scenesBuilder,
134 
135                     // Use testScope and not backgroundScope here because backgroundScope does not
136                     // work well with advanceUntilIdle(), which is used by some tests.
137                     animationScope = testScope,
138                     directionChangeSlop = directionChangeSlop,
139                     defaultEffectFactory = defaultEffectFactory,
140                     decayAnimationSpec =
141                         SplineBasedFloatDecayAnimationSpec(density).generateDecayAnimationSpec(),
142                 )
143                 .apply { setContentsAndLayoutTargetSizeForTest(LAYOUT_SIZE) }
144 
145         val draggableHandler = layoutImpl.verticalDraggableHandler
146         val horizontalDraggableHandler = layoutImpl.horizontalDraggableHandler
147         val velocityThreshold = draggableHandler.velocityThreshold
148 
149         fun down(fractionOfScreen: Float) =
150             if (fractionOfScreen < 0f) error("use up()") else SCREEN_SIZE * fractionOfScreen
151 
152         fun up(fractionOfScreen: Float) =
153             if (fractionOfScreen < 0f) error("use down()") else -down(fractionOfScreen)
154 
155         fun downOffset(fractionOfScreen: Float) =
156             if (fractionOfScreen < 0f) {
157                 error("use upOffset()")
158             } else {
159                 Offset(x = 0f, y = down(fractionOfScreen))
160             }
161 
162         fun upOffset(fractionOfScreen: Float) =
163             if (fractionOfScreen < 0f) {
164                 error("use downOffset()")
165             } else {
166                 Offset(x = 0f, y = up(fractionOfScreen))
167             }
168 
169         val transitionState: TransitionState
170             get() = layoutState.transitionState
171 
172         val progress: Float
173             get() = (transitionState as Transition).progress
174 
175         val isUserInputOngoing: Boolean
176             get() = (transitionState as Transition).isUserInputOngoing
177 
178         fun advanceUntilIdle() {
179             testScope.testScheduler.advanceUntilIdle()
180         }
181 
182         fun runCurrent() {
183             testScope.testScheduler.runCurrent()
184         }
185 
186         fun assertIdle(currentScene: SceneKey) {
187             assertThat(transitionState).isIdle()
188             assertThat(transitionState).hasCurrentScene(currentScene)
189         }
190 
191         fun assertTransition(
192             currentScene: SceneKey? = null,
193             fromScene: SceneKey? = null,
194             toScene: SceneKey? = null,
195             progress: Float? = null,
196             previewProgress: Float? = null,
197             isInPreviewStage: Boolean? = null,
198             isUserInputOngoing: Boolean? = null,
199         ): Transition {
200             val transition = assertThat(transitionState).isSceneTransition()
201             currentScene?.let { assertThat(transition).hasCurrentScene(it) }
202             fromScene?.let { assertThat(transition).hasFromScene(it) }
203             toScene?.let { assertThat(transition).hasToScene(it) }
204             progress?.let { assertThat(transition).hasProgress(it) }
205             previewProgress?.let { assertThat(transition).hasPreviewProgress(it) }
206             isInPreviewStage?.let {
207                 assertThat(transition).run { if (it) isInPreviewStage() else isNotInPreviewStage() }
208             }
209             isUserInputOngoing?.let { assertThat(transition).hasIsUserInputOngoing(it) }
210             return transition
211         }
212 
213         fun onDragStarted(
214             overSlop: Float,
215             position: Offset = Offset.Zero,
216             pointersDown: Int = 1,
217             pointerType: PointerType? = PointerType.Touch,
218             expectedConsumedOverSlop: Float = overSlop,
219         ): NestedDraggable.Controller {
220             return onDragStarted(
221                 draggableHandler = draggableHandler,
222                 overSlop = overSlop,
223                 position = position,
224                 pointersDown = pointersDown,
225                 pointerType = pointerType,
226                 expectedConsumedOverSlop = expectedConsumedOverSlop,
227             )
228         }
229 
230         fun onDragStarted(
231             draggableHandler: NestedDraggable,
232             overSlop: Float,
233             position: Offset = Offset.Zero,
234             pointersDown: Int = 1,
235             pointerType: PointerType? = PointerType.Touch,
236             expectedConsumedOverSlop: Float = overSlop,
237         ): NestedDraggable.Controller {
238             // overSlop should be 0f only if the drag gesture starts with startDragImmediately.
239             if (overSlop == 0f) error("Consider using onDragStartedImmediately()")
240 
241             val dragController =
242                 draggableHandler.onDragStarted(position, overSlop.sign, pointersDown, pointerType)
243 
244             // MultiPointerDraggable will always call onDelta with the initial overSlop right after.
245             dragController.onDragDelta(pixels = overSlop, expectedConsumedOverSlop)
246 
247             return dragController
248         }
249 
250         fun NestedDraggable.Controller.onDragDelta(
251             pixels: Float,
252             expectedConsumed: Float = pixels,
253         ) {
254             val consumed = onDrag(delta = pixels)
255             assertThat(consumed).isEqualTo(expectedConsumed)
256         }
257 
258         suspend fun NestedDraggable.Controller.onDragStoppedAnimateNow(
259             velocity: Float,
260             onAnimationStart: () -> Unit,
261             onAnimationEnd: (Float) -> Unit,
262         ) {
263             val velocityConsumed = onDragStoppedAnimateLater(velocity)
264             onAnimationStart()
265             onAnimationEnd(velocityConsumed.await())
266         }
267 
268         suspend fun NestedDraggable.Controller.onDragStoppedAnimateNow(
269             velocity: Float,
270             onAnimationStart: () -> Unit,
271         ) =
272             onDragStoppedAnimateNow(
273                 velocity = velocity,
274                 onAnimationStart = onAnimationStart,
275                 onAnimationEnd = {},
276             )
277 
278         fun NestedDraggable.Controller.onDragStoppedAnimateLater(velocity: Float): Deferred<Float> {
279             val velocityConsumed = testScope.async { onDragStopped(velocity, awaitFling = {}) }
280             testScope.testScheduler.runCurrent()
281             return velocityConsumed
282         }
283 
284         fun NestedScrollConnection.scroll(
285             available: Offset,
286             consumedByScroll: Offset = Offset.Zero,
287         ) {
288             val consumedByPreScroll = onPreScroll(available = available, source = UserInput)
289             val consumed = consumedByPreScroll + consumedByScroll
290 
291             onPostScroll(consumed = consumed, available = available - consumed, source = UserInput)
292         }
293 
294         fun NestedScrollConnection.preFling(
295             available: Velocity,
296             coroutineScope: CoroutineScope = testScope,
297         ) {
298             // onPreFling is a suspend function that returns the consumed velocity once it finishes
299             // consuming it. In the current scenario, it returns after completing the animation.
300             // To return immediately, we can initiate a job that allows us to check the status
301             // before the animation starts.
302             coroutineScope.launch { onPreFling(available = available) }
303             runCurrent()
304         }
305     }
306 
307     private fun runGestureTest(block: suspend TestGestureScope.() -> Unit) {
308         runMonotonicClockTest {
309             val testGestureScope = TestGestureScope(testScope = this)
310 
311             try {
312                 // Run the test.
313                 testGestureScope.block()
314             } finally {
315                 // Make sure we stop the last transition if it was not explicitly stopped, otherwise
316                 // tests will time out after 10s given that the transitions are now started on the
317                 // test scope. We don't use backgroundScope when starting the test transitions
318                 // because coroutines started on the background scope don't work well with
319                 // advanceUntilIdle(), which is used in a few tests.
320                 if (testGestureScope.draggableHandler.isDrivingTransition) {
321                     (testGestureScope.layoutState.transitionState as Transition)
322                         .freezeAndAnimateToCurrentState()
323                 }
324             }
325         }
326     }
327 
328     @Test fun testPreconditions() = runGestureTest { assertIdle(currentScene = SceneA) }
329 
330     @Test
331     fun onDragStarted_shouldStartATransition() = runGestureTest {
332         onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
333         assertTransition(currentScene = SceneA)
334     }
335 
336     @Test
337     fun afterSceneTransitionIsStarted_interceptDragEvents() = runGestureTest {
338         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
339         assertTransition(currentScene = SceneA)
340         assertThat(progress).isEqualTo(0.1f)
341 
342         dragController.onDragDelta(pixels = down(fractionOfScreen = 0.1f))
343         assertThat(progress).isEqualTo(0.2f)
344     }
345 
346     @Test
347     fun onDragStoppedAfterDrag_velocityLowerThanThreshold_remainSameScene() = runGestureTest {
348         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
349         assertTransition(currentScene = SceneA)
350 
351         dragController.onDragStoppedAnimateNow(
352             velocity = velocityThreshold - 0.01f,
353             onAnimationStart = { assertTransition(currentScene = SceneA) },
354         )
355 
356         assertIdle(currentScene = SceneA)
357     }
358 
359     @Test
360     fun onDragStoppedAfterDrag_velocityLowerThanThreshold_remainSameScene_previewAnimated() =
361         runGestureTest {
362             layoutState.transitions = transitions {
363                 // set a preview for the transition
364                 from(SceneA, to = SceneC, preview = {}) {}
365             }
366             val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
367             assertTransition(currentScene = SceneA)
368 
369             dragController.onDragStoppedAnimateNow(
370                 velocity = velocityThreshold - 0.01f,
371                 onAnimationStart = {
372                     // verify that transition remains in preview stage and animates back to
373                     // fromScene
374                     assertTransition(
375                         currentScene = SceneA,
376                         isInPreviewStage = true,
377                         previewProgress = 0.1f,
378                         progress = 0f,
379                     )
380                 },
381             )
382 
383             assertIdle(currentScene = SceneA)
384         }
385 
386     @Test
387     fun onDragStoppedAfterDrag_velocityAtLeastThreshold_goToNextScene() = runGestureTest {
388         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
389         assertTransition(currentScene = SceneA)
390 
391         dragController.onDragStoppedAnimateNow(
392             velocity = velocityThreshold,
393             onAnimationStart = { assertTransition(currentScene = SceneC) },
394         )
395         assertIdle(currentScene = SceneC)
396     }
397 
398     @Test
399     fun onDragStoppedAfterStarted_returnToIdle() = runGestureTest {
400         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
401         assertTransition(currentScene = SceneA)
402 
403         dragController.onDragStoppedAnimateNow(
404             velocity = 0f,
405             onAnimationStart = { assertTransition(currentScene = SceneA) },
406         )
407         assertIdle(currentScene = SceneA)
408     }
409 
410     @Test
411     fun onDragStartedWithoutActionsInBothDirections_stayIdle() = runGestureTest {
412         onDragStarted(
413             horizontalDraggableHandler,
414             overSlop = up(fractionOfScreen = 0.3f),
415             expectedConsumedOverSlop = 0f,
416         )
417         assertIdle(currentScene = SceneA)
418 
419         onDragStarted(
420             horizontalDraggableHandler,
421             overSlop = down(fractionOfScreen = 0.3f),
422             expectedConsumedOverSlop = 0f,
423         )
424         assertIdle(currentScene = SceneA)
425     }
426 
427     @Test
428     fun onDragIntoNoAction_overscrolls() = runGestureTest {
429         navigateToSceneC()
430 
431         // We are on SceneC which has no action in Down direction, we still start a transition so
432         // that we can overscroll on that scene.
433         onDragStarted(overSlop = 10f, expectedConsumedOverSlop = 0f)
434         assertTransition(fromScene = SceneC, toScene = SceneB, progress = 0f)
435     }
436 
437     @Test
438     fun onDragWithActionsInBothDirections_dragToOppositeDirectionNotReplaceable() = runGestureTest {
439         // We are on SceneA. UP -> B, DOWN-> C. The up swipe is not replaceable though.
440         mutableUserActionsA = mapOf(Swipe.Up to UserActionResult(SceneB), Swipe.Down to SceneC)
441         val dragController =
442             onDragStarted(
443                 position = Offset(SCREEN_SIZE * 0.5f, SCREEN_SIZE * 0.5f),
444                 overSlop = up(fractionOfScreen = 0.2f),
445             )
446         assertTransition(
447             currentScene = SceneA,
448             fromScene = SceneA,
449             toScene = SceneB,
450             progress = 0.2f,
451         )
452 
453         // Reverse drag direction, it does not replace the previous transition.
454         dragController.onDragDelta(
455             pixels = down(fractionOfScreen = 0.5f),
456             expectedConsumed = down(0.2f),
457         )
458         assertTransition(currentScene = SceneA, fromScene = SceneA, toScene = SceneB, progress = 0f)
459     }
460 
461     @Test
462     fun onDragFromEdge_startTransitionToEdgeAction() = runGestureTest {
463         navigateToSceneC()
464 
465         // Start dragging from the bottom
466         onDragStarted(
467             position = Offset(SCREEN_SIZE * 0.5f, SCREEN_SIZE),
468             overSlop = up(fractionOfScreen = 0.1f),
469         )
470         assertTransition(
471             currentScene = SceneC,
472             fromScene = SceneC,
473             toScene = SceneA,
474             progress = 0.1f,
475         )
476     }
477 
478     @Test
479     fun onDragToExactlyZero_toSceneIsSet() = runGestureTest {
480         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.3f))
481         assertTransition(
482             currentScene = SceneA,
483             fromScene = SceneA,
484             toScene = SceneC,
485             progress = 0.3f,
486         )
487         dragController.onDragDelta(pixels = up(fractionOfScreen = 0.3f))
488         assertTransition(
489             currentScene = SceneA,
490             fromScene = SceneA,
491             toScene = SceneC,
492             progress = 0.0f,
493         )
494     }
495 
496     private suspend fun TestGestureScope.navigateToSceneC() {
497         assertIdle(currentScene = SceneA)
498         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 1f))
499         assertTransition(currentScene = SceneA, fromScene = SceneA, toScene = SceneC)
500         dragController.onDragStoppedAnimateNow(
501             velocity = 0f,
502             onAnimationStart = {
503                 assertTransition(currentScene = SceneC, fromScene = SceneA, toScene = SceneC)
504             },
505         )
506         assertIdle(currentScene = SceneC)
507     }
508 
509     @Test
510     fun onDragTargetsChanged_targetStaysTheSame() = runGestureTest {
511         val dragController1 = onDragStarted(overSlop = up(fractionOfScreen = 0.1f))
512         assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.1f)
513 
514         mutableUserActionsA += Swipe.Up to UserActionResult(SceneC)
515         dragController1.onDragDelta(pixels = up(fractionOfScreen = 0.1f))
516         // target stays B even though UserActions changed
517         assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.2f)
518 
519         dragController1.onDragStoppedAnimateNow(
520             velocity = down(fractionOfScreen = 0.1f),
521             onAnimationStart = {
522                 assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.2f)
523             },
524         )
525         assertIdle(SceneA)
526 
527         // now target changed to C for new drag
528         onDragStarted(overSlop = up(fractionOfScreen = 0.1f))
529         assertTransition(fromScene = SceneA, toScene = SceneC, progress = 0.1f)
530     }
531 
532     @Test
533     fun onDragTargetsChanged_targetsChangeWhenStartingNewDrag() = runGestureTest {
534         val dragController1 = onDragStarted(overSlop = up(fractionOfScreen = 0.1f))
535         assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.1f)
536 
537         mutableUserActionsA += Swipe.Up to UserActionResult(SceneC)
538         dragController1.onDragDelta(pixels = up(fractionOfScreen = 0.1f))
539         dragController1.onDragStoppedAnimateLater(velocity = down(fractionOfScreen = 0.1f))
540 
541         // now target changed to C for new drag that started before previous drag settled to Idle
542         onDragStarted(overSlop = up(fractionOfScreen = 0.1f))
543         assertTransition(fromScene = SceneA, toScene = SceneC, progress = 0.1f)
544     }
545 
546     @Test
547     fun startGestureDuringAnimatingOffset_shouldImmediatelyStopTheAnimation() = runGestureTest {
548         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
549         assertTransition(currentScene = SceneA)
550 
551         dragController.onDragStoppedAnimateLater(velocity = velocityThreshold)
552         runCurrent()
553 
554         assertTransition(currentScene = SceneC)
555         assertThat(isUserInputOngoing).isFalse()
556 
557         // Start a new gesture while the offset is animating
558         onDragStarted(overSlop = up(fractionOfScreen = 0.1f))
559         assertThat(isUserInputOngoing).isTrue()
560     }
561 
562     @Test
563     fun freezeAndAnimateToCurrentState() = runGestureTest {
564         // Start at scene C.
565         navigateToSceneC()
566 
567         // Swipe up from the middle to transition to scene B.
568         onDragStarted(position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f), overSlop = up(0.1f))
569         assertTransition(fromScene = SceneC, toScene = SceneB, isUserInputOngoing = true)
570 
571         // Freeze the transition.
572         val transition = transitionState as Transition
573         transition.freezeAndAnimateToCurrentState()
574         runCurrent()
575         assertTransition(isUserInputOngoing = false)
576         advanceUntilIdle()
577         assertIdle(SceneC)
578     }
579 
580     @Test
581     fun blockTransition() = runGestureTest {
582         assertIdle(SceneA)
583 
584         // Swipe up to scene B.
585         val dragController = onDragStarted(overSlop = up(0.1f))
586         assertTransition(currentScene = SceneA, fromScene = SceneA, toScene = SceneB)
587 
588         // Block the transition when the user release their finger.
589         canChangeScene = { false }
590         dragController.onDragStoppedAnimateNow(
591             velocity = -velocityThreshold,
592             onAnimationStart = { assertTransition(fromScene = SceneA, toScene = SceneB) },
593         )
594         assertIdle(SceneA)
595     }
596 
597     @Test
598     fun blockTransition_animated() = runGestureTest {
599         assertIdle(SceneA)
600 
601         // Swipe up to scene B. Overscroll 50%.
602         val dragController = onDragStarted(overSlop = up(1.5f), expectedConsumedOverSlop = up(1.0f))
603         assertTransition(currentScene = SceneA, fromScene = SceneA, toScene = SceneB, progress = 1f)
604 
605         // Block the transition when the user release their finger.
606         canChangeScene = { false }
607         val velocityConsumed =
608             dragController.onDragStoppedAnimateLater(velocity = -velocityThreshold)
609 
610         // Start an animation: overscroll and from 1f to 0f.
611         assertTransition(currentScene = SceneA, fromScene = SceneA, toScene = SceneB, progress = 1f)
612 
613         val consumed = velocityConsumed.await()
614         assertThat(consumed).isNotEqualTo(0f)
615         assertIdle(SceneA)
616     }
617 
618     @Test
619     fun transitionIsImmediatelyUpdatedWhenReleasingFinger() = runGestureTest {
620         // Swipe up from the middle to transition to scene B.
621         val dragController =
622             onDragStarted(
623                 position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
624                 overSlop = up(0.1f),
625             )
626         assertTransition(fromScene = SceneA, toScene = SceneB, isUserInputOngoing = true)
627 
628         dragController.onDragStoppedAnimateLater(velocity = 0f)
629         assertTransition(isUserInputOngoing = false)
630     }
631 
632     @Test
633     fun emptyOverscrollAbortsSettleAnimationAndExposeTheConsumedVelocity() = runGestureTest {
634         // Swipe up to scene B at progress = 200%.
635         val dragController =
636             onDragStarted(
637                 position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
638                 overSlop = up(0.99f),
639             )
640         assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.99f)
641 
642         // Release the finger.
643         dragController.onDragStoppedAnimateNow(
644             velocity = -velocityThreshold,
645             onAnimationStart = { assertTransition(fromScene = SceneA, toScene = SceneB) },
646             onAnimationEnd = { consumedVelocity ->
647                 // Our progress value was 0.99f and it is coerced in `[0..1]`.
648                 // Some of the velocity will be used for animation, but not all of it.
649                 assertThat(consumedVelocity).isLessThan(0f)
650                 assertThat(consumedVelocity).isGreaterThan(-velocityThreshold)
651             },
652         )
653     }
654 
655     @Test
656     fun overscroll_releaseBetween0And100Percent_up() = runGestureTest {
657         // Make scene B overscrollable.
658         layoutState.transitions = transitions { from(SceneA, to = SceneB) {} }
659 
660         val dragController =
661             onDragStarted(
662                 position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
663                 overSlop = up(0.5f),
664             )
665         val transition = assertThat(transitionState).isSceneTransition()
666         assertThat(transition).hasFromScene(SceneA)
667         assertThat(transition).hasToScene(SceneB)
668         assertThat(transition).hasProgress(0.5f)
669 
670         // Release to B.
671         dragController.onDragStoppedAnimateNow(
672             velocity = -velocityThreshold,
673             onAnimationStart = {
674                 assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.5f)
675             },
676         )
677 
678         // We didn't overscroll at the end of the transition.
679         assertIdle(SceneB)
680         assertThat(transition).hasProgress(1f)
681     }
682 
683     @Test
684     fun overscroll_releaseBetween0And100Percent_down() = runGestureTest {
685         // Make scene C overscrollable.
686         layoutState.transitions = transitions { from(SceneA, to = SceneC) {} }
687 
688         val dragController =
689             onDragStarted(
690                 position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
691                 overSlop = down(0.5f),
692             )
693         val transition = assertThat(transitionState).isSceneTransition()
694         assertThat(transition).hasFromScene(SceneA)
695         assertThat(transition).hasToScene(SceneC)
696         assertThat(transition).hasProgress(0.5f)
697 
698         // Release to C.
699         dragController.onDragStoppedAnimateNow(
700             velocity = velocityThreshold,
701             onAnimationStart = {
702                 assertTransition(fromScene = SceneA, toScene = SceneC, progress = 0.5f)
703             },
704         )
705 
706         // We didn't overscroll at the end of the transition.
707         assertIdle(SceneC)
708         assertThat(transition).hasProgress(1f)
709     }
710 
711     @Test
712     fun overscroll_releaseAt150Percent_up() = runGestureTest {
713         // Make scene B overscrollable.
714         layoutState.transitions = transitions {
715             from(SceneA, to = SceneB) { spec = spring(dampingRatio = Spring.DampingRatioNoBouncy) }
716         }
717 
718         val dragController =
719             onDragStarted(
720                 position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
721                 overSlop = up(1.5f),
722                 expectedConsumedOverSlop = up(1f),
723             )
724         val transition = assertThat(transitionState).isSceneTransition()
725         assertThat(transition).hasFromScene(SceneA)
726         assertThat(transition).hasToScene(SceneB)
727         assertThat(transition).hasProgress(1f)
728 
729         // Release to B.
730         dragController.onDragStoppedAnimateNow(
731             velocity = 0f,
732             onAnimationStart = {
733                 assertTransition(fromScene = SceneA, toScene = SceneB, progress = 1f)
734             },
735         )
736 
737         // We kept the overscroll at 100% so that the placement logic didn't change at the end of
738         // the animation.
739         assertIdle(SceneB)
740         assertThat(transition).hasProgress(1f)
741     }
742 
743     @Test
744     fun overscroll_releaseAt150Percent_down() = runGestureTest {
745         // Make scene C overscrollable.
746         layoutState.transitions = transitions {
747             from(SceneA, to = SceneC) { spec = spring(dampingRatio = Spring.DampingRatioNoBouncy) }
748         }
749 
750         val dragController =
751             onDragStarted(
752                 position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
753                 overSlop = down(1.5f),
754                 expectedConsumedOverSlop = down(1f),
755             )
756         val transition = assertThat(transitionState).isSceneTransition()
757         assertThat(transition).hasFromScene(SceneA)
758         assertThat(transition).hasToScene(SceneC)
759         assertThat(transition).hasProgress(1f)
760 
761         // Release to C.
762         dragController.onDragStoppedAnimateNow(
763             velocity = 0f,
764             onAnimationStart = {
765                 assertTransition(fromScene = SceneA, toScene = SceneC, progress = 1f)
766             },
767         )
768 
769         // We kept the overscroll at 100% so that the placement logic didn't change at the end of
770         // the animation.
771         assertIdle(SceneC)
772         assertThat(transition).hasProgress(1f)
773     }
774 
775     @Test
776     fun startSwipeAnimationFromBound() = runGestureTest {
777         // Swipe down to go to SceneC.
778         mutableUserActionsA = mapOf(Swipe.Down to SceneC)
779 
780         val dragController =
781             onDragStarted(
782                 position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
783                 // Swipe up.
784                 overSlop = up(0.5f),
785                 // Should be ignored.
786                 expectedConsumedOverSlop = 0f,
787             )
788 
789         val transition = assertThat(transitionState).isSceneTransition()
790         assertThat(transition).hasFromScene(SceneA)
791         assertThat(transition).hasToScene(SceneC)
792         assertThat(transition).hasProgress(0f)
793 
794         // Swipe down, but not enough to go to SceneC.
795         dragController.onDragStoppedAnimateNow(
796             velocity = velocityThreshold - 0.01f,
797             onAnimationStart = {
798                 assertTransition(fromScene = SceneA, toScene = SceneC, progress = 0f)
799             },
800         )
801 
802         assertIdle(SceneA)
803     }
804 
805     @Test
806     fun requireFullDistanceSwipe() = runGestureTest {
807         mutableUserActionsA +=
808             Swipe.Up to UserActionResult(SceneB, requiresFullDistanceSwipe = true)
809 
810         val controller = onDragStarted(overSlop = up(fractionOfScreen = 0.9f))
811         assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.9f)
812 
813         controller.onDragStoppedAnimateNow(
814             velocity = 0f,
815             onAnimationStart = {
816                 assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.9f)
817             },
818         )
819         assertIdle(SceneA)
820 
821         val otherController = onDragStarted(overSlop = up(fractionOfScreen = 1f))
822         assertTransition(fromScene = SceneA, toScene = SceneB, progress = 1f)
823         otherController.onDragStoppedAnimateNow(
824             velocity = 0f,
825             onAnimationStart = {
826                 assertTransition(fromScene = SceneA, toScene = SceneB, progress = 1f)
827             },
828         )
829         assertIdle(SceneB)
830     }
831 
832     @Test
833     fun animateWhenDistanceUnspecified() = runGestureTest {
834         layoutState.transitions = transitions {
835             from(SceneA, to = SceneB) {
836                 distance = UserActionDistance { _, _, _ -> DistanceUnspecified }
837             }
838         }
839 
840         val controller = onDragStarted(overSlop = up(fractionOfScreen = 0.9f))
841 
842         // The distance is not computed yet, so we don't know the "progress" value yet.
843         assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.0f)
844 
845         controller.onDragStoppedAnimateNow(
846             // We are animating from SceneA to SceneA, when the distance is still unspecified.
847             velocity = velocityThreshold,
848             onAnimationStart = {
849                 assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.0f)
850             },
851         )
852         assertIdle(SceneA)
853     }
854 
855     @Test
856     fun showOverlay() = runGestureTest {
857         mutableUserActionsA = mapOf(Swipe.Down to UserActionResult.ShowOverlay(OverlayA))
858 
859         // Initial state.
860         assertThat(layoutState.transitionState).isIdle()
861         assertThat(layoutState.transitionState).hasCurrentScene(SceneA)
862         assertThat(layoutState.transitionState).hasCurrentOverlays(/* empty */ )
863 
864         // Swipe down to show overlay A.
865         val controller = onDragStarted(overSlop = down(0.1f))
866         val transition = assertThat(layoutState.transitionState).isShowOrHideOverlayTransition()
867         assertThat(transition).hasCurrentScene(SceneA)
868         assertThat(transition).hasFromOrToScene(SceneA)
869         assertThat(transition).hasOverlay(OverlayA)
870         assertThat(transition).hasCurrentOverlays(/* empty, gesture not committed yet. */ )
871         assertThat(transition).hasProgress(0.1f)
872 
873         // Commit the gesture. The overlay is instantly added in the set of current overlays.
874         controller.onDragStoppedAnimateNow(
875             velocity = velocityThreshold,
876             onAnimationStart = { assertThat(transition).hasCurrentOverlays(OverlayA) },
877         )
878         assertThat(layoutState.transitionState).isIdle()
879         assertThat(layoutState.transitionState).hasCurrentScene(SceneA)
880         assertThat(layoutState.transitionState).hasCurrentOverlays(OverlayA)
881     }
882 
883     @Test
884     fun hideOverlay() = runGestureTest {
885         layoutState.showOverlay(OverlayA, animationScope = testScope)
886         advanceUntilIdle()
887 
888         // Initial state.
889         assertThat(layoutState.transitionState).isIdle()
890         assertThat(layoutState.transitionState).hasCurrentScene(SceneA)
891         assertThat(layoutState.transitionState).hasCurrentOverlays(OverlayA)
892 
893         // Swipe up to hide overlay A.
894         val controller = onDragStarted(overSlop = up(0.1f))
895         val transition = assertThat(layoutState.transitionState).isShowOrHideOverlayTransition()
896         assertThat(transition).hasCurrentScene(SceneA)
897         assertThat(transition).hasFromOrToScene(SceneA)
898         assertThat(transition).hasOverlay(OverlayA)
899         assertThat(transition).hasCurrentOverlays(OverlayA)
900         assertThat(transition).hasProgress(0.1f)
901 
902         // Commit the gesture. The overlay is instantly removed from the set of current overlays.
903         controller.onDragStoppedAnimateNow(
904             velocity = -velocityThreshold,
905             onAnimationStart = { assertThat(transition).hasCurrentOverlays(/* empty */ ) },
906         )
907         assertThat(layoutState.transitionState).isIdle()
908         assertThat(layoutState.transitionState).hasCurrentScene(SceneA)
909         assertThat(layoutState.transitionState).hasCurrentOverlays(/* empty */ )
910     }
911 
912     @Test
913     fun replaceOverlay() = runGestureTest {
914         layoutState.showOverlay(OverlayA, animationScope = testScope)
915         advanceUntilIdle()
916 
917         // Initial state.
918         assertThat(layoutState.transitionState).isIdle()
919         assertThat(layoutState.transitionState).hasCurrentScene(SceneA)
920         assertThat(layoutState.transitionState).hasCurrentOverlays(OverlayA)
921 
922         // Swipe down to replace overlay A by overlay B.
923         val controller = onDragStarted(overSlop = down(0.1f))
924         val transition = assertThat(layoutState.transitionState).isReplaceOverlayTransition()
925         assertThat(transition).hasCurrentScene(SceneA)
926         assertThat(transition).hasFromOverlay(OverlayA)
927         assertThat(transition).hasToOverlay(OverlayB)
928         assertThat(transition).hasCurrentOverlays(OverlayA)
929         assertThat(transition).hasProgress(0.1f)
930 
931         // Commit the gesture. The overlays are instantly swapped in the set of current overlays.
932         controller.onDragStoppedAnimateNow(
933             velocity = velocityThreshold,
934             onAnimationStart = { assertThat(transition).hasCurrentOverlays(OverlayB) },
935         )
936         assertThat(layoutState.transitionState).isIdle()
937         assertThat(layoutState.transitionState).hasCurrentScene(SceneA)
938         assertThat(layoutState.transitionState).hasCurrentOverlays(OverlayB)
939     }
940 
941     @Test
942     fun gestureContext_dragOffset_matchesOverSlopAtBeginning() = runGestureTest {
943         val overSlop = down(fractionOfScreen = 0.1f)
944         onDragStarted(overSlop = overSlop)
945 
946         val gestureContext = assertThat(transitionState).hasGestureContext()
947         assertThat(gestureContext.dragOffset).isEqualTo(overSlop)
948     }
949 
950     @Test
951     fun gestureContext_dragOffset_getsUpdatedOnEachDragEvent() = runGestureTest {
952         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
953 
954         val gestureContext = assertThat(transitionState).hasGestureContext()
955         val initialDragOffset = gestureContext.dragOffset
956 
957         dragController.onDragDelta(pixels = 3.5f)
958         assertThat(gestureContext.dragOffset).isEqualTo(initialDragOffset + 3.5f)
959 
960         dragController.onDragDelta(pixels = -2f)
961         assertThat(gestureContext.dragOffset).isEqualTo(initialDragOffset + 3.5f - 2f)
962     }
963 
964     @Test
965     fun gestureContext_direction_swipeDown_startsWithMaxDirection() = runGestureTest {
966         onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
967 
968         val gestureContext = assertThat(transitionState).hasGestureContext()
969         assertThat(gestureContext.direction).isEqualTo(InputDirection.Max)
970     }
971 
972     @Test
973     fun gestureContext_direction_swipeUp_startsWithMinDirection() = runGestureTest {
974         onDragStarted(overSlop = up(fractionOfScreen = 0.1f))
975 
976         val gestureContext = assertThat(transitionState).hasGestureContext()
977         assertThat(gestureContext.direction).isEqualTo(InputDirection.Min)
978     }
979 
980     @Test
981     fun gestureContext_direction_withinDirectionSlop_staysSame() = runGestureTest {
982         val dragController = onDragStarted(overSlop = up(fractionOfScreen = .2f))
983 
984         val gestureContext = assertThat(transitionState).hasGestureContext()
985         assertThat(gestureContext.direction).isEqualTo(InputDirection.Min)
986 
987         dragController.onDragDelta(pixels = directionChangeSlop)
988         assertThat(gestureContext.direction).isEqualTo(InputDirection.Min)
989     }
990 
991     @Test
992     fun gestureContext_direction_overDirectionSlop_isChanged() = runGestureTest {
993         val dragController = onDragStarted(overSlop = up(fractionOfScreen = .2f))
994 
995         val gestureContext = assertThat(transitionState).hasGestureContext()
996         assertThat(gestureContext.direction).isEqualTo(InputDirection.Min)
997 
998         dragController.onDragDelta(pixels = directionChangeSlop.nextUp())
999         assertThat(gestureContext.direction).isEqualTo(InputDirection.Max)
1000     }
1001 }
1002