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