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