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 package com.android.compose.animation.scene 18 19 import androidx.compose.animation.core.LinearEasing 20 import androidx.compose.animation.core.Spring 21 import androidx.compose.animation.core.spring 22 import androidx.compose.animation.core.tween 23 import androidx.compose.foundation.gestures.Orientation 24 import androidx.compose.foundation.gestures.rememberScrollableState 25 import androidx.compose.foundation.gestures.scrollable 26 import androidx.compose.foundation.layout.Box 27 import androidx.compose.foundation.layout.Column 28 import androidx.compose.foundation.layout.Row 29 import androidx.compose.foundation.layout.Spacer 30 import androidx.compose.foundation.layout.fillMaxSize 31 import androidx.compose.foundation.layout.offset 32 import androidx.compose.foundation.layout.size 33 import androidx.compose.foundation.overscroll 34 import androidx.compose.foundation.pager.HorizontalPager 35 import androidx.compose.foundation.pager.PagerState 36 import androidx.compose.material3.Text 37 import androidx.compose.runtime.Composable 38 import androidx.compose.runtime.LaunchedEffect 39 import androidx.compose.runtime.SideEffect 40 import androidx.compose.runtime.getValue 41 import androidx.compose.runtime.mutableFloatStateOf 42 import androidx.compose.runtime.mutableStateOf 43 import androidx.compose.runtime.rememberCoroutineScope 44 import androidx.compose.runtime.setValue 45 import androidx.compose.runtime.snapshotFlow 46 import androidx.compose.ui.Alignment 47 import androidx.compose.ui.Modifier 48 import androidx.compose.ui.geometry.Offset 49 import androidx.compose.ui.layout.approachLayout 50 import androidx.compose.ui.layout.layout 51 import androidx.compose.ui.platform.LocalDensity 52 import androidx.compose.ui.platform.LocalViewConfiguration 53 import androidx.compose.ui.platform.testTag 54 import androidx.compose.ui.test.assertIsDisplayed 55 import androidx.compose.ui.test.assertIsNotDisplayed 56 import androidx.compose.ui.test.assertPositionInRootIsEqualTo 57 import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo 58 import androidx.compose.ui.test.hasParent 59 import androidx.compose.ui.test.hasTestTag 60 import androidx.compose.ui.test.hasText 61 import androidx.compose.ui.test.junit4.createComposeRule 62 import androidx.compose.ui.test.onNodeWithTag 63 import androidx.compose.ui.test.onRoot 64 import androidx.compose.ui.test.performTouchInput 65 import androidx.compose.ui.unit.Density 66 import androidx.compose.ui.unit.Dp 67 import androidx.compose.ui.unit.DpOffset 68 import androidx.compose.ui.unit.DpSize 69 import androidx.compose.ui.unit.IntSize 70 import androidx.compose.ui.unit.dp 71 import androidx.compose.ui.unit.lerp 72 import androidx.compose.ui.util.lerp 73 import androidx.test.ext.junit.runners.AndroidJUnit4 74 import com.android.compose.animation.scene.TestScenes.SceneA 75 import com.android.compose.animation.scene.TestScenes.SceneB 76 import com.android.compose.animation.scene.TestScenes.SceneC 77 import com.android.compose.animation.scene.content.state.TransitionState 78 import com.android.compose.animation.scene.effect.OffsetOverscrollEffect 79 import com.android.compose.animation.scene.subjects.assertThat 80 import com.android.compose.test.assertSizeIsEqualTo 81 import com.android.compose.test.setContentAndCreateMainScope 82 import com.android.compose.test.transition 83 import com.google.common.truth.Truth.assertThat 84 import com.google.common.truth.Truth.assertWithMessage 85 import kotlinx.coroutines.CoroutineScope 86 import kotlinx.coroutines.launch 87 import org.junit.Assert.assertThrows 88 import org.junit.Ignore 89 import org.junit.Rule 90 import org.junit.Test 91 import org.junit.runner.RunWith 92 93 @RunWith(AndroidJUnit4::class) 94 class ElementTest { 95 @get:Rule val rule = createComposeRule() 96 97 @Composable 98 private fun ContentScope.Element( 99 key: ElementKey, 100 size: Dp, 101 offset: Dp, 102 modifier: Modifier = Modifier, 103 onLayout: () -> Unit = {}, 104 onPlacement: () -> Unit = {}, 105 ) { 106 Box( 107 modifier 108 .offset(offset) 109 .element(key) 110 .approachLayout( 111 isMeasurementApproachInProgress = { layoutState.isTransitioning() } 112 ) { measurable, constraints -> 113 onLayout() 114 val placement = measurable.measure(constraints) 115 layout(placement.width, placement.height) { 116 onPlacement() 117 placement.place(0, 0) 118 } 119 } 120 .size(size) 121 ) 122 } 123 124 @Test 125 fun staticElements_noLayout_noPlacement() { 126 val nFrames = 20 127 val layoutSize = 100.dp 128 val elementSize = 50.dp 129 val elementOffset = 20.dp 130 131 var fooLayouts = 0 132 var fooPlacements = 0 133 var barLayouts = 0 134 var barPlacements = 0 135 136 rule.testTransition( 137 fromSceneContent = { 138 Box(Modifier.size(layoutSize)) { 139 // Shared element. 140 Element( 141 TestElements.Foo, 142 elementSize, 143 elementOffset, 144 onLayout = { fooLayouts++ }, 145 onPlacement = { fooPlacements++ }, 146 ) 147 148 // Transformed element 149 Element( 150 TestElements.Bar, 151 elementSize, 152 elementOffset, 153 onLayout = { barLayouts++ }, 154 onPlacement = { barPlacements++ }, 155 ) 156 } 157 }, 158 toSceneContent = { 159 Box(Modifier.size(layoutSize)) { 160 // Shared element. 161 Element( 162 TestElements.Foo, 163 elementSize, 164 elementOffset, 165 onLayout = { fooLayouts++ }, 166 onPlacement = { fooPlacements++ }, 167 ) 168 } 169 }, 170 transition = { 171 spec = tween(nFrames * 16) 172 173 // no-op transformations. 174 translate(TestElements.Bar, x = 0.dp, y = 0.dp) 175 scaleSize(TestElements.Bar, width = 1f, height = 1f) 176 }, 177 ) { 178 var fooLayoutsAfterOneAnimationFrame = 0 179 var fooPlacementsAfterOneAnimationFrame = 0 180 var barLayoutsAfterOneAnimationFrame = 0 181 var barPlacementsAfterOneAnimationFrame = 0 182 183 fun assertNumberOfLayoutsAndPlacements() { 184 assertThat(fooLayouts).isEqualTo(fooLayoutsAfterOneAnimationFrame) 185 assertThat(fooPlacements).isEqualTo(fooPlacementsAfterOneAnimationFrame) 186 assertThat(barLayouts).isEqualTo(barLayoutsAfterOneAnimationFrame) 187 assertThat(barPlacements).isEqualTo(barPlacementsAfterOneAnimationFrame) 188 } 189 190 at(16) { 191 // Capture the number of layouts and placements that happened after 1 animation 192 // frame. 193 fooLayoutsAfterOneAnimationFrame = fooLayouts 194 fooPlacementsAfterOneAnimationFrame = fooPlacements 195 barLayoutsAfterOneAnimationFrame = barLayouts 196 barPlacementsAfterOneAnimationFrame = barPlacements 197 } 198 repeat(nFrames - 2) { i -> 199 // Ensure that all animation frames (except the final one) don't relayout or replace 200 // static (shared or transformed) elements. 201 at(32L + i * 16) { assertNumberOfLayoutsAndPlacements() } 202 } 203 } 204 } 205 206 @Test 207 fun elementsNotInTransition_shouldNotBeDrawn() { 208 val nFrames = 20 209 val frameDuration = 16L 210 val tween = tween<Float>(nFrames * frameDuration.toInt()) 211 val layoutSize = 100.dp 212 val elementSize = 50.dp 213 val elementOffset = 20.dp 214 215 val state = 216 rule.runOnUiThread { 217 MutableSceneTransitionLayoutState( 218 SceneA, 219 transitions { 220 from(SceneA, to = SceneB) { spec = tween } 221 from(SceneB, to = SceneC) { spec = tween } 222 }, 223 ) 224 } 225 226 lateinit var coroutineScope: CoroutineScope 227 rule.testTransition( 228 state = state, 229 to = SceneB, 230 transitionLayout = { state -> 231 coroutineScope = rememberCoroutineScope() 232 SceneTransitionLayout(state) { 233 scene(SceneA) { 234 Box(Modifier.size(layoutSize)) { 235 // Transformed element 236 Element(TestElements.Bar, elementSize, elementOffset) 237 } 238 } 239 scene(SceneB) { Box(Modifier.size(layoutSize)) } 240 scene(SceneC) { Box(Modifier.size(layoutSize)) } 241 } 242 }, 243 ) { 244 // Start transition from SceneA to SceneB 245 at(1 * frameDuration) { 246 onElement(TestElements.Bar).assertExists() 247 248 // Start transition from SceneB to SceneC 249 rule.runOnUiThread { 250 // We snap to scene B so that the transition A => B is removed from the list of 251 // transitions. 252 state.snapToScene(SceneB) 253 state.setTargetScene(SceneC, coroutineScope) 254 } 255 } 256 257 at(3 * frameDuration) { onElement(TestElements.Bar).assertIsNotDisplayed() } 258 259 at(4 * frameDuration) { onElement(TestElements.Bar).assertDoesNotExist() } 260 } 261 } 262 263 @Test 264 fun onlyMovingElements_noLayout_onlyPlacement() { 265 val nFrames = 20 266 val layoutSize = 100.dp 267 val elementSize = 50.dp 268 269 var fooLayouts = 0 270 var fooPlacements = 0 271 var barLayouts = 0 272 var barPlacements = 0 273 274 rule.testTransition( 275 fromSceneContent = { 276 Box(Modifier.size(layoutSize)) { 277 // Shared element. 278 Element( 279 TestElements.Foo, 280 elementSize, 281 offset = 0.dp, 282 onLayout = { fooLayouts++ }, 283 onPlacement = { fooPlacements++ }, 284 ) 285 286 // Transformed element 287 Element( 288 TestElements.Bar, 289 elementSize, 290 offset = 0.dp, 291 onLayout = { barLayouts++ }, 292 onPlacement = { barPlacements++ }, 293 ) 294 } 295 }, 296 toSceneContent = { 297 Box(Modifier.size(layoutSize)) { 298 // Shared element. 299 Element( 300 TestElements.Foo, 301 elementSize, 302 offset = 20.dp, 303 onLayout = { fooLayouts++ }, 304 onPlacement = { fooPlacements++ }, 305 ) 306 } 307 }, 308 transition = { 309 spec = tween(nFrames * 16) 310 311 // Only translate Bar. 312 translate(TestElements.Bar, x = 20.dp, y = 20.dp) 313 scaleSize(TestElements.Bar, width = 1f, height = 1f) 314 }, 315 ) { 316 var fooLayoutsAfterOneAnimationFrame = 0 317 var barLayoutsAfterOneAnimationFrame = 0 318 var lastFooPlacements = 0 319 var lastBarPlacements = 0 320 321 fun assertNumberOfLayoutsAndPlacements() { 322 // The number of layouts have not changed. 323 assertThat(fooLayouts).isEqualTo(fooLayoutsAfterOneAnimationFrame) 324 assertThat(barLayouts).isEqualTo(barLayoutsAfterOneAnimationFrame) 325 326 // The number of placements have increased. 327 assertThat(fooPlacements).isGreaterThan(lastFooPlacements) 328 assertThat(barPlacements).isGreaterThan(lastBarPlacements) 329 lastFooPlacements = fooPlacements 330 lastBarPlacements = barPlacements 331 } 332 333 at(16) { 334 // Capture the number of layouts and placements that happened after 1 animation 335 // frame. 336 fooLayoutsAfterOneAnimationFrame = fooLayouts 337 barLayoutsAfterOneAnimationFrame = barLayouts 338 lastFooPlacements = fooPlacements 339 lastBarPlacements = barPlacements 340 } 341 repeat(nFrames - 2) { i -> 342 // Ensure that all animation frames (except the final one) only replaced the 343 // elements. 344 at(32L + i * 16) { assertNumberOfLayoutsAndPlacements() } 345 } 346 } 347 } 348 349 @Test 350 fun elementIsReusedBetweenScenes() { 351 val state = rule.runOnUiThread { MutableSceneTransitionLayoutState(SceneA) } 352 var sceneCState by mutableStateOf(0) 353 val key = TestElements.Foo 354 var nullableLayoutImpl: SceneTransitionLayoutImpl? = null 355 356 lateinit var coroutineScope: CoroutineScope 357 rule.setContent { 358 coroutineScope = rememberCoroutineScope() 359 SceneTransitionLayoutForTesting( 360 state = state, 361 onLayoutImpl = { nullableLayoutImpl = it }, 362 ) { 363 scene(SceneA) { /* Nothing */ } 364 scene(SceneB) { Box(Modifier.element(key)) } 365 scene(SceneC) { 366 when (sceneCState) { 367 0 -> Row(Modifier.element(key)) {} 368 else -> { 369 /* Nothing */ 370 } 371 } 372 } 373 } 374 } 375 376 assertThat(nullableLayoutImpl).isNotNull() 377 val layoutImpl = nullableLayoutImpl!! 378 379 // Scene A: no elements in the elements map. 380 rule.waitForIdle() 381 assertThat(layoutImpl.elements).isEmpty() 382 383 // Scene B: element is in the map. 384 rule.runOnUiThread { state.setTargetScene(SceneB, coroutineScope) } 385 rule.waitForIdle() 386 387 assertThat(layoutImpl.elements.keys).containsExactly(key) 388 val element = layoutImpl.elements.getValue(key) 389 assertThat(element.stateByContent.keys).containsExactly(SceneB) 390 391 // Scene C, state 0: the same element is reused. 392 rule.runOnUiThread { state.setTargetScene(SceneC, coroutineScope) } 393 sceneCState = 0 394 rule.waitForIdle() 395 396 assertThat(layoutImpl.elements.keys).containsExactly(key) 397 assertThat(layoutImpl.elements.getValue(key)).isSameInstanceAs(element) 398 assertThat(element.stateByContent.keys).containsExactly(SceneC) 399 400 // Scene C, state 1: the element is removed from the map. 401 sceneCState = 1 402 rule.waitForIdle() 403 404 assertThat(element.stateByContent).isEmpty() 405 assertThat(layoutImpl.elements).isEmpty() 406 } 407 408 @Test 409 fun throwsExceptionWhenElementIsComposedMultipleTimes() { 410 val key = TestElements.Foo 411 412 assertThrows(IllegalStateException::class.java) { 413 rule.setContent { 414 TestContentScope { 415 Column { 416 Box(Modifier.element(key)) 417 Box(Modifier.element(key)) 418 } 419 } 420 } 421 } 422 } 423 424 @Test 425 fun throwsExceptionWhenElementIsComposedMultipleTimes_childModifier() { 426 val key = TestElements.Foo 427 428 assertThrows(IllegalStateException::class.java) { 429 rule.setContent { 430 TestContentScope { 431 Column { 432 val childModifier = Modifier.element(key) 433 Box(childModifier) 434 Box(childModifier) 435 } 436 } 437 } 438 } 439 } 440 441 @Test 442 fun throwsExceptionWhenElementIsComposedMultipleTimes_childModifier_laterDuplication() { 443 val key = TestElements.Foo 444 445 assertThrows(IllegalStateException::class.java) { 446 var nElements by mutableStateOf(1) 447 rule.setContent { 448 TestContentScope { 449 Column { 450 val childModifier = Modifier.element(key) 451 repeat(nElements) { Box(childModifier) } 452 } 453 } 454 } 455 456 nElements = 2 457 rule.waitForIdle() 458 } 459 } 460 461 @Test 462 fun throwsExceptionWhenElementIsComposedMultipleTimes_updatedNode() { 463 assertThrows(IllegalStateException::class.java) { 464 var key by mutableStateOf(TestElements.Foo) 465 rule.setContent { 466 TestContentScope { 467 Column { 468 Box(Modifier.element(key)) 469 Box(Modifier.element(TestElements.Bar)) 470 } 471 } 472 } 473 474 key = TestElements.Bar 475 rule.waitForIdle() 476 } 477 } 478 479 @Test 480 fun elementModifierSupportsUpdates() { 481 val state = rule.runOnUiThread { MutableSceneTransitionLayoutState(SceneA) } 482 var key by mutableStateOf(TestElements.Foo) 483 var nullableLayoutImpl: SceneTransitionLayoutImpl? = null 484 485 rule.setContent { 486 SceneTransitionLayoutForTesting( 487 state = state, 488 onLayoutImpl = { nullableLayoutImpl = it }, 489 ) { 490 scene(SceneA) { Box(Modifier.element(key)) } 491 } 492 } 493 494 assertThat(nullableLayoutImpl).isNotNull() 495 val layoutImpl = nullableLayoutImpl!! 496 497 // There is only Foo in the elements map. 498 assertThat(layoutImpl.elements.keys).containsExactly(TestElements.Foo) 499 val fooElement = layoutImpl.elements.getValue(TestElements.Foo) 500 assertThat(fooElement.stateByContent.keys).containsExactly(SceneA) 501 502 key = TestElements.Bar 503 504 // There is only Bar in the elements map and foo scene values was cleaned up. 505 rule.waitForIdle() 506 assertThat(layoutImpl.elements.keys).containsExactly(TestElements.Bar) 507 val barElement = layoutImpl.elements.getValue(TestElements.Bar) 508 assertThat(barElement.stateByContent.keys).containsExactly(SceneA) 509 assertThat(fooElement.stateByContent).isEmpty() 510 } 511 512 @Test 513 fun elementModifierNodeIsRecycledInLazyLayouts() { 514 val nPages = 2 515 val pagerState = PagerState(currentPage = 0) { nPages } 516 var nullableLayoutImpl: SceneTransitionLayoutImpl? = null 517 518 // This is how we scroll a pager inside a test, as explained in b/315457147#comment2. 519 lateinit var scrollScope: CoroutineScope 520 fun scrollToPage(page: Int) { 521 var animationFinished by mutableStateOf(false) 522 rule.runOnIdle { 523 scrollScope.launch { 524 pagerState.scrollToPage(page) 525 animationFinished = true 526 } 527 } 528 rule.waitUntil(timeoutMillis = 10_000) { animationFinished } 529 } 530 531 val state = rule.runOnUiThread { MutableSceneTransitionLayoutState(SceneA) } 532 rule.setContent { 533 scrollScope = rememberCoroutineScope() 534 535 SceneTransitionLayoutForTesting( 536 state = state, 537 onLayoutImpl = { nullableLayoutImpl = it }, 538 ) { 539 scene(SceneA) { 540 // The pages are full-size and beyondBoundsPageCount is 0, so at rest only one 541 // page should be composed. 542 HorizontalPager(pagerState, beyondViewportPageCount = 0) { page -> 543 when (page) { 544 0 -> Box(Modifier.element(TestElements.Foo).fillMaxSize()) 545 1 -> Box(Modifier.fillMaxSize()) 546 else -> error("page $page < nPages $nPages") 547 } 548 } 549 } 550 } 551 } 552 553 assertThat(nullableLayoutImpl).isNotNull() 554 val layoutImpl = nullableLayoutImpl!! 555 556 // There is only Foo in the elements map. 557 assertThat(layoutImpl.elements.keys).containsExactly(TestElements.Foo) 558 val element = layoutImpl.elements.getValue(TestElements.Foo) 559 val sceneValues = element.stateByContent 560 assertThat(sceneValues.keys).containsExactly(SceneA) 561 562 // Get the ElementModifier node that should be reused later on when coming back to this 563 // page. 564 val nodes = sceneValues.getValue(SceneA).nodes 565 assertThat(nodes).hasSize(1) 566 val node = nodes.single() 567 568 // Go to the second page. 569 scrollToPage(1) 570 rule.waitForIdle() 571 572 assertThat(nodes).isEmpty() 573 assertThat(sceneValues).isEmpty() 574 assertThat(layoutImpl.elements).isEmpty() 575 576 // Go back to the first page. 577 scrollToPage(0) 578 rule.waitForIdle() 579 580 assertThat(layoutImpl.elements.keys).containsExactly(TestElements.Foo) 581 val newElement = layoutImpl.elements.getValue(TestElements.Foo) 582 val newSceneValues = newElement.stateByContent 583 assertThat(newElement).isNotEqualTo(element) 584 assertThat(newSceneValues).isNotEqualTo(sceneValues) 585 assertThat(newSceneValues.keys).containsExactly(SceneA) 586 587 // The ElementModifier node should be the same as before. 588 val newNodes = newSceneValues.getValue(SceneA).nodes 589 assertThat(newNodes).hasSize(1) 590 val newNode = newNodes.single() 591 assertThat(newNode).isSameInstanceAs(node) 592 } 593 594 @Test 595 @Ignore("b/341072461") 596 fun existingElementsDontRecomposeWhenTransitionStateChanges() { 597 var fooCompositions = 0 598 599 rule.testTransition( 600 fromSceneContent = { 601 SideEffect { fooCompositions++ } 602 Box(Modifier.element(TestElements.Foo)) 603 }, 604 toSceneContent = {}, 605 transition = { 606 spec = tween(4 * 16) 607 608 scaleSize(TestElements.Foo, width = 2f, height = 0.5f) 609 translate(TestElements.Foo, x = 10.dp, y = 10.dp) 610 fade(TestElements.Foo) 611 }, 612 ) { 613 before { assertThat(fooCompositions).isEqualTo(1) } 614 at(16) { assertThat(fooCompositions).isEqualTo(1) } 615 at(32) { assertThat(fooCompositions).isEqualTo(1) } 616 at(48) { assertThat(fooCompositions).isEqualTo(1) } 617 after { assertThat(fooCompositions).isEqualTo(1) } 618 } 619 } 620 621 @Test 622 // TODO(b/341072461): Remove this test. 623 fun layoutGetsCurrentTransitionStateFromComposition() { 624 val state = 625 rule.runOnUiThread { 626 MutableSceneTransitionLayoutStateImpl( 627 SceneA, 628 transitions { 629 from(SceneA, to = SceneB) { 630 scaleSize(TestElements.Foo, width = 2f, height = 2f) 631 } 632 }, 633 ) 634 } 635 636 val scope = 637 rule.setContentAndCreateMainScope { 638 SceneTransitionLayout(state) { 639 scene(SceneA) { Box(Modifier.element(TestElements.Foo).size(20.dp)) } 640 scene(SceneB) {} 641 } 642 } 643 644 // Pause the clock to block recompositions. 645 rule.mainClock.autoAdvance = false 646 647 // Change the current transition. 648 scope.launch { 649 state.startTransition(transition(from = SceneA, to = SceneB, progress = { 0.5f })) 650 } 651 652 // The size of Foo should still be 20dp given that the new state was not composed yet. 653 rule.onNode(isElement(TestElements.Foo)).assertSizeIsEqualTo(20.dp, 20.dp) 654 } 655 656 private fun setupOverscrollScenario( 657 layoutWidth: Dp, 658 layoutHeight: Dp, 659 sceneTransitions: SceneTransitionsBuilder.() -> Unit, 660 firstScroll: Float, 661 animatedFloatRange: ClosedFloatingPointRange<Float>, 662 onAnimatedFloat: (Float) -> Unit, 663 ): MutableSceneTransitionLayoutStateImpl { 664 // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is 665 // detected as a drag event. 666 var touchSlop = 0f 667 668 val state = 669 rule.runOnUiThread { 670 MutableSceneTransitionLayoutState( 671 initialScene = SceneA, 672 transitions = transitions(sceneTransitions), 673 ) 674 as MutableSceneTransitionLayoutStateImpl 675 } 676 677 rule.setContent { 678 touchSlop = LocalViewConfiguration.current.touchSlop 679 SceneTransitionLayout( 680 state = state, 681 modifier = Modifier.size(layoutWidth, layoutHeight), 682 ) { 683 scene(key = SceneA, userActions = mapOf(Swipe.Down to SceneB)) { 684 animateContentFloatAsState( 685 value = animatedFloatRange.start, 686 key = TestValues.Value1, 687 false, 688 ) 689 Spacer(Modifier.fillMaxSize()) 690 } 691 scene(SceneB) { 692 val animatedFloat by 693 animateContentFloatAsState( 694 value = animatedFloatRange.endInclusive, 695 key = TestValues.Value1, 696 canOverflow = false, 697 ) 698 Spacer(Modifier.element(TestElements.Foo).fillMaxSize()) 699 LaunchedEffect(Unit) { 700 snapshotFlow { animatedFloat }.collect { onAnimatedFloat(it) } 701 } 702 } 703 } 704 } 705 706 assertThat(state.transitionState).isIdle() 707 708 // Swipe by half of verticalSwipeDistance. 709 rule.onRoot().performTouchInput { 710 val middleTop = Offset((layoutWidth / 2).toPx(), 0f) 711 down(middleTop) 712 val firstScrollHeight = layoutHeight.toPx() * firstScroll 713 moveBy(Offset(0f, touchSlop + firstScrollHeight), delayMillis = 1_000) 714 } 715 return state 716 } 717 718 @Test 719 fun elementTransitionDuringOverscrollWithOverscrollDSL() { 720 val layoutWidth = 200.dp 721 val layoutHeight = 400.dp 722 val overscrollTranslateY = 10.dp 723 var animatedFloat = 0f 724 725 val state = 726 setupOverscrollScenario( 727 layoutWidth = layoutWidth, 728 layoutHeight = layoutHeight, 729 sceneTransitions = { 730 overscroll(SceneB, Orientation.Vertical) { 731 progressConverter = ProgressConverter.linear() 732 // On overscroll 100% -> Foo should translate by overscrollTranslateY 733 translate(TestElements.Foo, y = overscrollTranslateY) 734 } 735 }, 736 firstScroll = 0.5f, // Scroll 50% 737 animatedFloatRange = 0f..100f, 738 onAnimatedFloat = { animatedFloat = it }, 739 ) 740 741 val fooElement = rule.onNodeWithTag(TestElements.Foo.testTag) 742 fooElement.assertTopPositionInRootIsEqualTo(0.dp) 743 val transition = assertThat(state.transitionState).isSceneTransition() 744 assertThat(transition).isNotNull() 745 assertThat(transition).hasProgress(0.5f) 746 assertThat(animatedFloat).isEqualTo(50f) 747 748 rule.onRoot().performTouchInput { 749 // Scroll another 100% 750 moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000) 751 } 752 753 // Scroll 150% (Scene B overscroll by 50%) 754 assertThat(transition).hasProgress(1.5f) 755 assertThat(transition).hasOverscrollSpec() 756 fooElement.assertTopPositionInRootIsEqualTo(overscrollTranslateY * 0.5f) 757 // animatedFloat cannot overflow (canOverflow = false) 758 assertThat(animatedFloat).isEqualTo(100f) 759 760 rule.onRoot().performTouchInput { 761 // Scroll another 100% 762 moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000) 763 } 764 765 // Scroll 250% (Scene B overscroll by 150%) 766 assertThat(transition).hasProgress(2.5f) 767 assertThat(transition).hasOverscrollSpec() 768 fooElement.assertTopPositionInRootIsEqualTo(overscrollTranslateY * 1.5f) 769 assertThat(animatedFloat).isEqualTo(100f) 770 } 771 772 private fun expectedOffset(currentOffset: Dp, density: Density): Dp { 773 return with(density) { 774 OffsetOverscrollEffect.computeOffset(density, currentOffset.toPx()).toDp() 775 } 776 } 777 778 @Test 779 fun elementTransitionDuringOverscroll() { 780 val layoutWidth = 200.dp 781 val layoutHeight = 400.dp 782 lateinit var density: Density 783 784 // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is 785 // detected as a drag event. 786 var touchSlop = 0f 787 val state = 788 rule.runOnUiThread { 789 MutableSceneTransitionLayoutState( 790 initialScene = SceneA, 791 transitions = transitions { overscrollDisabled(SceneB, Orientation.Vertical) }, 792 ) 793 } 794 rule.setContent { 795 density = LocalDensity.current 796 touchSlop = LocalViewConfiguration.current.touchSlop 797 SceneTransitionLayout(state, Modifier.size(layoutWidth, layoutHeight)) { 798 scene(key = SceneA, userActions = mapOf(Swipe.Down to SceneB)) { 799 Spacer(Modifier.fillMaxSize()) 800 } 801 scene(SceneB) { 802 Spacer( 803 Modifier.overscroll(verticalOverscrollEffect) 804 .fillMaxSize() 805 .element(TestElements.Foo) 806 ) 807 } 808 } 809 } 810 assertThat(state.transitionState).isIdle() 811 812 // Swipe by half of verticalSwipeDistance. 813 rule.onRoot().performTouchInput { 814 val middleTop = Offset((layoutWidth / 2).toPx(), 0f) 815 down(middleTop) 816 // Scroll 50%. 817 val firstScrollHeight = layoutHeight.toPx() * 0.5f 818 moveBy(Offset(0f, touchSlop + firstScrollHeight), delayMillis = 1_000) 819 } 820 821 rule.onNodeWithTag(TestElements.Foo.testTag).assertTopPositionInRootIsEqualTo(0.dp) 822 val transition = assertThat(state.transitionState).isSceneTransition() 823 assertThat(transition).isNotNull() 824 assertThat(transition).hasProgress(0.5f) 825 826 rule.onRoot().performTouchInput { 827 // Scroll another 100%. 828 moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000) 829 } 830 831 // Scroll 150% (Scene B overscroll by 50%). 832 assertThat(transition).hasProgress(1f) 833 834 rule 835 .onNodeWithTag(TestElements.Foo.testTag) 836 .assertTopPositionInRootIsEqualTo(expectedOffset(layoutHeight * 0.5f, density)) 837 } 838 839 @Test 840 fun elementTransitionOverscrollMultipleScenes() { 841 val layoutWidth = 200.dp 842 val layoutHeight = 400.dp 843 lateinit var density: Density 844 845 // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is 846 // detected as a drag event. 847 var touchSlop = 0f 848 val state = 849 rule.runOnUiThread { 850 MutableSceneTransitionLayoutState( 851 initialScene = SceneA, 852 transitions = 853 transitions { 854 overscrollDisabled(SceneA, Orientation.Vertical) 855 overscrollDisabled(SceneB, Orientation.Vertical) 856 }, 857 ) 858 } 859 rule.setContent { 860 density = LocalDensity.current 861 touchSlop = LocalViewConfiguration.current.touchSlop 862 SceneTransitionLayout(state, Modifier.size(layoutWidth, layoutHeight)) { 863 scene(key = SceneA, userActions = mapOf(Swipe.Down to SceneB)) { 864 Spacer( 865 Modifier.overscroll(verticalOverscrollEffect) 866 .fillMaxSize() 867 .element(TestElements.Foo) 868 ) 869 } 870 scene(SceneB) { 871 Spacer( 872 Modifier.overscroll(verticalOverscrollEffect) 873 .fillMaxSize() 874 .element(TestElements.Bar) 875 ) 876 } 877 } 878 } 879 assertThat(state.transitionState).isIdle() 880 881 // Swipe by half of verticalSwipeDistance. 882 rule.onRoot().performTouchInput { 883 val middleTop = Offset((layoutWidth / 2).toPx(), 0f) 884 down(middleTop) 885 val firstScrollHeight = layoutHeight.toPx() * 0.5f // Scroll 50% 886 moveBy(Offset(0f, touchSlop + firstScrollHeight), delayMillis = 1_000) 887 } 888 889 rule.onNodeWithTag(TestElements.Foo.testTag).assertTopPositionInRootIsEqualTo(0.dp) 890 rule.onNodeWithTag(TestElements.Bar.testTag).assertTopPositionInRootIsEqualTo(0.dp) 891 val transition = assertThat(state.transitionState).isSceneTransition() 892 assertThat(transition).isNotNull() 893 assertThat(transition).hasProgress(0.5f) 894 895 rule.onRoot().performTouchInput { 896 // Scroll another 100%. 897 moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000) 898 } 899 900 // Scroll 150% (Scene B overscroll by 50%). 901 assertThat(transition).hasProgress(1f) 902 903 rule.onNodeWithTag(TestElements.Foo.testTag).assertTopPositionInRootIsEqualTo(0.dp) 904 rule 905 .onNodeWithTag(TestElements.Bar.testTag) 906 .assertTopPositionInRootIsEqualTo(expectedOffset(layoutHeight * 0.5f, density)) 907 908 rule.onRoot().performTouchInput { 909 // Scroll another -30%. 910 moveBy(Offset(0f, layoutHeight.toPx() * -0.3f), delayMillis = 1_000) 911 } 912 913 // Scroll 120% (Scene B overscroll by 20%). 914 assertThat(transition).hasProgress(1f) 915 916 rule.onNodeWithTag(TestElements.Foo.testTag).assertTopPositionInRootIsEqualTo(0.dp) 917 rule 918 .onNodeWithTag(TestElements.Bar.testTag) 919 .assertTopPositionInRootIsEqualTo(expectedOffset(layoutHeight * 0.2f, density)) 920 rule.onRoot().performTouchInput { 921 // Scroll another -70% 922 moveBy(Offset(0f, layoutHeight.toPx() * -0.7f), delayMillis = 1_000) 923 } 924 925 // Scroll 50% (No overscroll). 926 assertThat(transition).hasProgress(0.5f) 927 928 rule.onNodeWithTag(TestElements.Foo.testTag).assertTopPositionInRootIsEqualTo(0.dp) 929 rule.onNodeWithTag(TestElements.Bar.testTag).assertTopPositionInRootIsEqualTo(0.dp) 930 931 rule.onRoot().performTouchInput { 932 // Scroll another -100%. 933 moveBy(Offset(0f, layoutHeight.toPx() * -1f), delayMillis = 1_000) 934 } 935 936 // Scroll -50% (Scene A overscroll by -50%). 937 assertThat(transition).hasProgress(0f) 938 rule 939 .onNodeWithTag(TestElements.Foo.testTag) 940 .assertTopPositionInRootIsEqualTo(expectedOffset(layoutHeight * -0.5f, density)) 941 rule.onNodeWithTag(TestElements.Bar.testTag).assertTopPositionInRootIsEqualTo(0.dp) 942 } 943 944 @Test 945 fun elementTransitionOverscroll() { 946 val layoutWidth = 200.dp 947 val layoutHeight = 400.dp 948 lateinit var density: Density 949 950 // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is 951 // detected as a drag event. 952 var touchSlop = 0f 953 val state = 954 rule.runOnUiThread { 955 MutableSceneTransitionLayoutState( 956 initialScene = SceneA, 957 transitions = 958 transitions { 959 defaultOverscrollProgressConverter = ProgressConverter.linear() 960 overscrollDisabled(SceneB, Orientation.Vertical) 961 }, 962 ) 963 } 964 rule.setContent { 965 density = LocalDensity.current 966 touchSlop = LocalViewConfiguration.current.touchSlop 967 SceneTransitionLayout(state, Modifier.size(layoutWidth, layoutHeight)) { 968 scene(key = SceneA, userActions = mapOf(Swipe.Down to SceneB)) { 969 Spacer(Modifier.fillMaxSize()) 970 } 971 scene(SceneB) { 972 Spacer( 973 Modifier.overscroll(verticalOverscrollEffect) 974 .element(TestElements.Foo) 975 .fillMaxSize() 976 ) 977 } 978 } 979 } 980 assertThat(state.transitionState).isIdle() 981 982 // Swipe by half of verticalSwipeDistance. 983 rule.onRoot().performTouchInput { 984 val middleTop = Offset((layoutWidth / 2).toPx(), 0f) 985 down(middleTop) 986 val firstScrollHeight = layoutHeight.toPx() * 0.5f // Scroll 50% 987 moveBy(Offset(0f, touchSlop + firstScrollHeight), delayMillis = 1_000) 988 } 989 990 val fooElement = rule.onNodeWithTag(TestElements.Foo.testTag) 991 fooElement.assertTopPositionInRootIsEqualTo(0.dp) 992 val transition = assertThat(state.transitionState).isSceneTransition() 993 assertThat(transition).isNotNull() 994 assertThat(transition).hasProgress(0.5f) 995 996 rule.onRoot().performTouchInput { 997 // Scroll another 100%. 998 moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000) 999 } 1000 1001 // Scroll 150% (Scene B overscroll by 50%). 1002 assertThat(transition).hasProgress(1f) 1003 1004 fooElement.assertTopPositionInRootIsEqualTo(expectedOffset(layoutHeight * 0.5f, density)) 1005 } 1006 1007 @Test 1008 fun elementTransitionDuringNestedScrollOverscroll() { 1009 // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is 1010 // detected as a drag event. 1011 var touchSlop = 0f 1012 val overscrollTranslateY = 10.dp 1013 val layoutWidth = 200.dp 1014 val layoutHeight = 400.dp 1015 1016 val state = 1017 rule.runOnUiThread { 1018 MutableSceneTransitionLayoutState( 1019 initialScene = SceneB, 1020 transitions = 1021 transitions { 1022 overscroll(SceneB, Orientation.Vertical) { 1023 progressConverter = ProgressConverter.linear() 1024 translate(TestElements.Foo, y = overscrollTranslateY) 1025 } 1026 }, 1027 ) 1028 as MutableSceneTransitionLayoutStateImpl 1029 } 1030 1031 rule.setContent { 1032 touchSlop = LocalViewConfiguration.current.touchSlop 1033 SceneTransitionLayout( 1034 state = state, 1035 modifier = Modifier.size(layoutWidth, layoutHeight), 1036 ) { 1037 scene(SceneA) { Spacer(Modifier.fillMaxSize()) } 1038 scene(SceneB, userActions = mapOf(Swipe.Up to SceneA)) { 1039 Box( 1040 Modifier 1041 // A scrollable that does not consume the scroll gesture 1042 .scrollable( 1043 rememberScrollableState(consumeScrollDelta = { 0f }), 1044 Orientation.Vertical, 1045 ) 1046 .fillMaxSize() 1047 ) { 1048 Spacer(Modifier.element(TestElements.Foo).fillMaxSize()) 1049 } 1050 } 1051 } 1052 } 1053 1054 assertThat(state.transitionState).isIdle() 1055 val fooElement = rule.onNodeWithTag(TestElements.Foo.testTag) 1056 fooElement.assertTopPositionInRootIsEqualTo(0.dp) 1057 1058 // Swipe by half of verticalSwipeDistance. 1059 rule.onRoot().performTouchInput { 1060 val middleTop = Offset((layoutWidth / 2).toPx(), 0f) 1061 down(middleTop) 1062 // Scroll 50% 1063 moveBy(Offset(0f, touchSlop + layoutHeight.toPx() * 0.5f), delayMillis = 1_000) 1064 } 1065 1066 val transition = assertThat(state.transitionState).isSceneTransition() 1067 assertThat(transition).hasOverscrollSpec() 1068 assertThat(transition).hasProgress(-0.5f) 1069 fooElement.assertTopPositionInRootIsEqualTo(overscrollTranslateY * 0.5f) 1070 1071 rule.onRoot().performTouchInput { 1072 // Scroll another 100% 1073 moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000) 1074 } 1075 1076 // Scroll 150% (Scene B overscroll by 50%) 1077 assertThat(transition).hasProgress(-1.5f) 1078 assertThat(transition).hasOverscrollSpec() 1079 fooElement.assertTopPositionInRootIsEqualTo(overscrollTranslateY * 1.5f) 1080 } 1081 1082 @Test 1083 fun elementTransitionDuringNestedScrollWith2Pointers() { 1084 // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is 1085 // detected as a drag event. 1086 var touchSlop = 0f 1087 val translateY = 10.dp 1088 val layoutWidth = 200.dp 1089 val layoutHeight = 400.dp 1090 1091 val state = 1092 rule.runOnUiThread { 1093 MutableSceneTransitionLayoutState( 1094 initialScene = SceneA, 1095 transitions = 1096 transitions { 1097 from(SceneA, to = SceneB) { 1098 translate(TestElements.Foo, y = translateY) 1099 } 1100 }, 1101 ) 1102 as MutableSceneTransitionLayoutStateImpl 1103 } 1104 1105 rule.setContent { 1106 touchSlop = LocalViewConfiguration.current.touchSlop 1107 SceneTransitionLayout( 1108 state = state, 1109 modifier = Modifier.size(layoutWidth, layoutHeight), 1110 ) { 1111 scene(SceneA, userActions = mapOf(Swipe.Down(pointerCount = 2) to SceneB)) { 1112 Box( 1113 Modifier 1114 // A scrollable that does not consume the scroll gesture 1115 .scrollable( 1116 rememberScrollableState(consumeScrollDelta = { 0f }), 1117 Orientation.Vertical, 1118 ) 1119 .fillMaxSize() 1120 ) { 1121 Spacer(Modifier.element(TestElements.Foo).fillMaxSize()) 1122 } 1123 } 1124 scene(SceneB) { Spacer(Modifier.fillMaxSize()) } 1125 } 1126 } 1127 1128 assertThat(state.transitionState).isIdle() 1129 val fooElement = rule.onNodeWithTag(TestElements.Foo.testTag) 1130 fooElement.assertTopPositionInRootIsEqualTo(0.dp) 1131 1132 // Swipe down with 2 pointers by half of verticalSwipeDistance. 1133 rule.onRoot().performTouchInput { 1134 val middleTop = Offset((layoutWidth / 2).toPx(), 0f) 1135 repeat(2) { i -> down(pointerId = i, middleTop) } 1136 repeat(2) { i -> 1137 // Scroll 50% 1138 moveBy( 1139 pointerId = i, 1140 delta = Offset(0f, touchSlop + layoutHeight.toPx() * 0.5f), 1141 delayMillis = 1_000, 1142 ) 1143 } 1144 } 1145 1146 val transition = assertThat(state.transitionState).isSceneTransition() 1147 assertThat(transition).hasProgress(0.5f) 1148 fooElement.assertTopPositionInRootIsEqualTo(translateY * 0.5f) 1149 } 1150 1151 @Test 1152 fun elementTransitionWithDistanceDuringOverscroll() { 1153 val layoutWidth = 200.dp 1154 val layoutHeight = 400.dp 1155 var animatedFloat = 0f 1156 val state = 1157 setupOverscrollScenario( 1158 layoutWidth = layoutWidth, 1159 layoutHeight = layoutHeight, 1160 sceneTransitions = { 1161 overscroll(SceneB, Orientation.Vertical) { 1162 progressConverter = ProgressConverter.linear() 1163 // On overscroll 100% -> Foo should translate by layoutHeight 1164 translate(TestElements.Foo, y = { absoluteDistance }) 1165 } 1166 }, 1167 firstScroll = 1f, // 100% scroll 1168 animatedFloatRange = 0f..100f, 1169 onAnimatedFloat = { animatedFloat = it }, 1170 ) 1171 1172 val fooElement = rule.onNodeWithTag(TestElements.Foo.testTag) 1173 fooElement.assertTopPositionInRootIsEqualTo(0.dp) 1174 assertThat(animatedFloat).isEqualTo(100f) 1175 1176 rule.onRoot().performTouchInput { 1177 // Scroll another 50% 1178 moveBy(Offset(0f, layoutHeight.toPx() * 0.5f), delayMillis = 1_000) 1179 } 1180 1181 val transition = assertThat(state.transitionState).isSceneTransition() 1182 assertThat(animatedFloat).isEqualTo(100f) 1183 1184 // Scroll 150% (100% scroll + 50% overscroll) 1185 assertThat(transition).hasProgress(1.5f) 1186 assertThat(transition).hasOverscrollSpec() 1187 fooElement.assertTopPositionInRootIsEqualTo(layoutHeight * 0.5f) 1188 assertThat(animatedFloat).isEqualTo(100f) 1189 1190 rule.onRoot().performTouchInput { 1191 // Scroll another 100% 1192 moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000) 1193 } 1194 1195 // Scroll 250% (100% scroll + 150% overscroll) 1196 assertThat(transition).hasProgress(2.5f) 1197 assertThat(transition).hasOverscrollSpec() 1198 fooElement.assertTopPositionInRootIsEqualTo(layoutHeight * 1.5f) 1199 assertThat(animatedFloat).isEqualTo(100f) 1200 } 1201 1202 @Test 1203 fun elementTransitionWithDistanceDuringOverscrollWithDefaultProgressConverter() { 1204 val layoutWidth = 200.dp 1205 val layoutHeight = 400.dp 1206 var animatedFloat = 0f 1207 val state = 1208 setupOverscrollScenario( 1209 layoutWidth = layoutWidth, 1210 layoutHeight = layoutHeight, 1211 sceneTransitions = { 1212 // Overscroll progress will be halved 1213 defaultOverscrollProgressConverter = ProgressConverter { it / 2f } 1214 1215 overscroll(SceneB, Orientation.Vertical) { 1216 // On overscroll 100% -> Foo should translate by layoutHeight 1217 translate(TestElements.Foo, y = { absoluteDistance }) 1218 } 1219 }, 1220 firstScroll = 1f, // 100% scroll 1221 animatedFloatRange = 0f..100f, 1222 onAnimatedFloat = { animatedFloat = it }, 1223 ) 1224 1225 val fooElement = rule.onNodeWithTag(TestElements.Foo.testTag) 1226 fooElement.assertTopPositionInRootIsEqualTo(0.dp) 1227 assertThat(animatedFloat).isEqualTo(100f) 1228 1229 rule.onRoot().performTouchInput { 1230 // Scroll another 100% 1231 moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000) 1232 } 1233 1234 val transition = assertThat(state.transitionState).isSceneTransition() 1235 assertThat(animatedFloat).isEqualTo(100f) 1236 1237 // Scroll 200% (100% scroll + 100% overscroll) 1238 assertThat(transition).hasProgress(2f) 1239 assertThat(transition).hasOverscrollSpec() 1240 1241 // Overscroll progress is halved, we are at 50% of the overscroll progress. 1242 fooElement.assertTopPositionInRootIsEqualTo(layoutHeight * 0.5f) 1243 assertThat(animatedFloat).isEqualTo(100f) 1244 } 1245 1246 @Test 1247 fun elementTransitionWithDistanceDuringOverscrollWithOverrideDefaultProgressConverter() { 1248 val layoutWidth = 200.dp 1249 val layoutHeight = 400.dp 1250 var animatedFloat = 0f 1251 val state = 1252 setupOverscrollScenario( 1253 layoutWidth = layoutWidth, 1254 layoutHeight = layoutHeight, 1255 sceneTransitions = { 1256 // Overscroll progress will be linear (by default) 1257 defaultOverscrollProgressConverter = ProgressConverter.linear() 1258 1259 overscroll(SceneB, Orientation.Vertical) { 1260 // This override the defaultOverscrollProgressConverter 1261 // Overscroll progress will be halved 1262 progressConverter = ProgressConverter { it / 2f } 1263 // On overscroll 100% -> Foo should translate by layoutHeight 1264 translate(TestElements.Foo, y = { absoluteDistance }) 1265 } 1266 }, 1267 firstScroll = 1f, // 100% scroll 1268 animatedFloatRange = 0f..100f, 1269 onAnimatedFloat = { animatedFloat = it }, 1270 ) 1271 1272 val fooElement = rule.onNodeWithTag(TestElements.Foo.testTag) 1273 fooElement.assertTopPositionInRootIsEqualTo(0.dp) 1274 assertThat(animatedFloat).isEqualTo(100f) 1275 1276 rule.onRoot().performTouchInput { 1277 // Scroll another 100% 1278 moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000) 1279 } 1280 1281 val transition = assertThat(state.transitionState).isSceneTransition() 1282 assertThat(animatedFloat).isEqualTo(100f) 1283 1284 // Scroll 200% (100% scroll + 100% overscroll) 1285 assertThat(transition).hasProgress(2f) 1286 assertThat(transition).hasOverscrollSpec() 1287 1288 // Overscroll progress is halved, we are at 50% of the overscroll progress. 1289 fooElement.assertTopPositionInRootIsEqualTo(layoutHeight * 0.5f) 1290 assertThat(animatedFloat).isEqualTo(100f) 1291 } 1292 1293 @Test 1294 fun elementTransitionWithDistanceDuringOverscrollWithProgressConverter() { 1295 val layoutWidth = 200.dp 1296 val layoutHeight = 400.dp 1297 var animatedFloat = 0f 1298 val state = 1299 setupOverscrollScenario( 1300 layoutWidth = layoutWidth, 1301 layoutHeight = layoutHeight, 1302 sceneTransitions = { 1303 overscroll(SceneB, Orientation.Vertical) { 1304 // Overscroll progress will be halved 1305 progressConverter = ProgressConverter { it / 2f } 1306 1307 // On overscroll 100% -> Foo should translate by layoutHeight 1308 translate(TestElements.Foo, y = { absoluteDistance }) 1309 } 1310 }, 1311 firstScroll = 1f, // 100% scroll 1312 animatedFloatRange = 0f..100f, 1313 onAnimatedFloat = { animatedFloat = it }, 1314 ) 1315 1316 val fooElement = rule.onNodeWithTag(TestElements.Foo.testTag) 1317 fooElement.assertTopPositionInRootIsEqualTo(0.dp) 1318 assertThat(animatedFloat).isEqualTo(100f) 1319 1320 rule.onRoot().performTouchInput { 1321 // Scroll another 100% 1322 moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000) 1323 } 1324 1325 val transition = assertThat(state.transitionState).isSceneTransition() 1326 assertThat(animatedFloat).isEqualTo(100f) 1327 1328 // Scroll 200% (100% scroll + 100% overscroll) 1329 assertThat(transition).hasProgress(2f) 1330 assertThat(transition).hasOverscrollSpec() 1331 1332 // Overscroll progress is halved, we are at 50% of the overscroll progress. 1333 fooElement.assertTopPositionInRootIsEqualTo(layoutHeight * 0.5f) 1334 assertThat(animatedFloat).isEqualTo(100f) 1335 1336 rule.onRoot().performTouchInput { 1337 // Scroll another 100% 1338 moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000) 1339 } 1340 1341 // Scroll 300% (100% scroll + 200% overscroll) 1342 assertThat(transition).hasProgress(3f) 1343 assertThat(transition).hasOverscrollSpec() 1344 1345 // Overscroll progress is halved, we are at 100% of the overscroll progress. 1346 fooElement.assertTopPositionInRootIsEqualTo(layoutHeight) 1347 assertThat(animatedFloat).isEqualTo(100f) 1348 } 1349 1350 @Test 1351 fun elementTransitionWithDistanceDuringOverscrollBouncing() { 1352 val layoutWidth = 200.dp 1353 val layoutHeight = 400.dp 1354 var animatedFloat = 0f 1355 val state = 1356 setupOverscrollScenario( 1357 layoutWidth = layoutWidth, 1358 layoutHeight = layoutHeight, 1359 sceneTransitions = { 1360 defaultSwipeSpec = 1361 spring( 1362 dampingRatio = Spring.DampingRatioMediumBouncy, 1363 stiffness = Spring.StiffnessLow, 1364 ) 1365 1366 overscroll(SceneB, Orientation.Vertical) { 1367 progressConverter = ProgressConverter.linear() 1368 // On overscroll 100% -> Foo should translate by layoutHeight 1369 translate(TestElements.Foo, y = { absoluteDistance }) 1370 } 1371 }, 1372 firstScroll = 1f, // 100% scroll 1373 animatedFloatRange = 0f..100f, 1374 onAnimatedFloat = { animatedFloat = it }, 1375 ) 1376 1377 val fooElement = rule.onNodeWithTag(TestElements.Foo.testTag) 1378 fooElement.assertTopPositionInRootIsEqualTo(0.dp) 1379 assertThat(animatedFloat).isEqualTo(100f) 1380 1381 rule.onRoot().performTouchInput { 1382 // Scroll another 50% 1383 moveBy(Offset(0f, layoutHeight.toPx() * 0.5f), delayMillis = 1_000) 1384 } 1385 1386 val transition = assertThat(state.transitionState).isSceneTransition() 1387 1388 // Scroll 150% (100% scroll + 50% overscroll) 1389 assertThat(transition).hasProgress(1.5f) 1390 assertThat(transition).hasOverscrollSpec() 1391 fooElement.assertTopPositionInRootIsEqualTo(layoutHeight * (transition.progress - 1f)) 1392 assertThat(animatedFloat).isEqualTo(100f) 1393 1394 // finger raised 1395 rule.onRoot().performTouchInput { up() } 1396 1397 // The target value is 1f, but the spring (defaultSwipeSpec) allows you to go to a lower 1398 // value. 1399 rule.waitUntil(timeoutMillis = 10_000) { transition.progress < 1f } 1400 1401 assertThat(transition.progress).isLessThan(1f) 1402 assertThat(transition).hasOverscrollSpec() 1403 assertThat(transition).hasBouncingContent(transition.toContent) 1404 assertThat(animatedFloat).isEqualTo(100f) 1405 } 1406 1407 @Test 1408 fun elementIsUsingLastTransition() { 1409 // 4 frames of animation. 1410 val duration = 4 * 16 1411 1412 val state = 1413 rule.runOnUiThread { 1414 MutableSceneTransitionLayoutState( 1415 SceneA, 1416 transitions { 1417 // Foo is at the top left corner of scene A. We make it disappear during A 1418 // => B 1419 // to the right edge so it translates to the right. 1420 from(SceneA, to = SceneB) { 1421 spec = tween(duration, easing = LinearEasing) 1422 translate( 1423 TestElements.Foo, 1424 edge = Edge.Right, 1425 startsOutsideLayoutBounds = false, 1426 ) 1427 } 1428 1429 // Bar is at the top right corner of scene C. We make it appear during B => 1430 // C 1431 // from the left edge so it translates to the right at same time as Foo. 1432 from(SceneB, to = SceneC) { 1433 spec = tween(duration, easing = LinearEasing) 1434 translate( 1435 TestElements.Bar, 1436 edge = Edge.Left, 1437 startsOutsideLayoutBounds = false, 1438 ) 1439 } 1440 }, 1441 ) 1442 } 1443 1444 val layoutSize = 150.dp 1445 val elemSize = 50.dp 1446 lateinit var coroutineScope: CoroutineScope 1447 rule.setContent { 1448 coroutineScope = rememberCoroutineScope() 1449 1450 SceneTransitionLayout(state) { 1451 scene(SceneA) { 1452 Box(Modifier.size(layoutSize)) { 1453 Box( 1454 Modifier.align(Alignment.TopStart) 1455 .element(TestElements.Foo) 1456 .size(elemSize) 1457 ) 1458 } 1459 } 1460 scene(SceneB) { 1461 // Empty scene. 1462 Box(Modifier.size(layoutSize)) 1463 } 1464 scene(SceneC) { 1465 Box(Modifier.size(layoutSize)) { 1466 Box( 1467 Modifier.align(Alignment.BottomEnd) 1468 .element(TestElements.Bar) 1469 .size(elemSize) 1470 ) 1471 } 1472 } 1473 } 1474 } 1475 1476 rule.mainClock.autoAdvance = false 1477 1478 // Trigger A => B then directly B => C so that Foo and Bar move together to the right edge. 1479 rule.runOnUiThread { 1480 state.setTargetScene(SceneB, coroutineScope) 1481 state.setTargetScene(SceneC, coroutineScope) 1482 } 1483 1484 val transitions = state.currentTransitions 1485 assertThat(transitions).hasSize(2) 1486 val firstTransition = assertThat(transitions[0]).isSceneTransition() 1487 assertThat(firstTransition).hasFromScene(SceneA) 1488 assertThat(firstTransition).hasToScene(SceneB) 1489 assertThat(firstTransition).hasProgress(0f) 1490 1491 val secondTransition = assertThat(transitions[1]).isSceneTransition() 1492 assertThat(secondTransition).hasFromScene(SceneB) 1493 assertThat(secondTransition).hasToScene(SceneC) 1494 assertThat(secondTransition).hasProgress(0f) 1495 1496 // First frame: both are at x = 0dp. For the whole transition, Foo is at y = 0dp and Bar is 1497 // at y = layoutSize - elementSoze = 100dp. 1498 rule.mainClock.advanceTimeByFrame() 1499 rule.waitForIdle() 1500 rule.onNode(isElement(TestElements.Foo)).assertPositionInRootIsEqualTo(0.dp, 0.dp) 1501 rule.onNode(isElement(TestElements.Bar)).assertPositionInRootIsEqualTo(0.dp, 100.dp) 1502 1503 // Advance to the second frame (25% of the transition): they are both translating 1504 // horizontally to the final target (x = layoutSize - elemSize = 100dp), so they should now 1505 // be at x = 25dp. 1506 rule.mainClock.advanceTimeByFrame() 1507 rule.waitForIdle() 1508 rule.onNode(isElement(TestElements.Foo)).assertPositionInRootIsEqualTo(25.dp, 0.dp) 1509 rule.onNode(isElement(TestElements.Bar)).assertPositionInRootIsEqualTo(25.dp, 100.dp) 1510 1511 // Advance to the second frame (50% of the transition): they should now be at x = 50dp. 1512 rule.mainClock.advanceTimeByFrame() 1513 rule.waitForIdle() 1514 rule.onNode(isElement(TestElements.Foo)).assertPositionInRootIsEqualTo(50.dp, 0.dp) 1515 rule.onNode(isElement(TestElements.Bar)).assertPositionInRootIsEqualTo(50.dp, 100.dp) 1516 1517 // Advance to the third frame (75% of the transition): they should now be at x = 75dp. 1518 rule.mainClock.advanceTimeByFrame() 1519 rule.waitForIdle() 1520 rule.onNode(isElement(TestElements.Foo)).assertPositionInRootIsEqualTo(75.dp, 0.dp) 1521 rule.onNode(isElement(TestElements.Bar)).assertPositionInRootIsEqualTo(75.dp, 100.dp) 1522 1523 // Advance to the end of the animation. We can't really test the fourth frame because when 1524 // pausing the clock, the layout/drawing code will still run (so elements will have their 1525 // size/offset when there is no more transition running) but composition will not (so 1526 // elements that should not be composed anymore will still be composed). 1527 rule.mainClock.autoAdvance = true 1528 rule.waitForIdle() 1529 assertThat(state.currentTransitions).isEmpty() 1530 rule.onNode(isElement(TestElements.Foo)).assertDoesNotExist() 1531 rule.onNode(isElement(TestElements.Bar)).assertPositionInRootIsEqualTo(100.dp, 100.dp) 1532 } 1533 1534 @Test 1535 fun interruption() { 1536 // 4 frames of animation. 1537 val duration = 4 * 16 1538 1539 val state = 1540 rule.runOnUiThread { 1541 MutableSceneTransitionLayoutStateImpl( 1542 SceneA, 1543 transitions { 1544 from(SceneA, to = SceneB) { spec = tween(duration, easing = LinearEasing) } 1545 from(SceneB, to = SceneC) { spec = tween(duration, easing = LinearEasing) } 1546 }, 1547 ) 1548 } 1549 1550 val layoutSize = DpSize(200.dp, 100.dp) 1551 val lastValues = mutableMapOf<ContentKey, Float>() 1552 1553 @Composable 1554 fun ContentScope.Foo(size: Dp, value: Float, modifier: Modifier = Modifier) { 1555 val contentKey = this.contentKey 1556 Element(TestElements.Foo, modifier.size(size)) { 1557 val animatedValue = animateElementFloatAsState(value, TestValues.Value1) 1558 LaunchedEffect(animatedValue) { 1559 snapshotFlow { animatedValue.value }.collect { lastValues[contentKey] = it } 1560 } 1561 } 1562 } 1563 1564 // The size of Foo when idle in A, B or C. 1565 val sizeInA = 10.dp 1566 val sizeInB = 30.dp 1567 val sizeInC = 50.dp 1568 1569 // The target value when idle in A, B, or C. 1570 val valueInA = 0f 1571 val valueInB = 100f 1572 val valueInC = 200f 1573 1574 lateinit var layoutImpl: SceneTransitionLayoutImpl 1575 val scope = 1576 rule.setContentAndCreateMainScope { 1577 SceneTransitionLayoutForTesting( 1578 state, 1579 Modifier.size(layoutSize), 1580 onLayoutImpl = { layoutImpl = it }, 1581 ) { 1582 // In scene A, Foo is aligned at the TopStart. 1583 scene(SceneA) { 1584 Box(Modifier.fillMaxSize()) { 1585 Foo(sizeInA, valueInA, Modifier.align(Alignment.TopStart)) 1586 } 1587 } 1588 1589 // In scene C, Foo is aligned at the BottomEnd, so it moves vertically when 1590 // coming 1591 // from B. We put it before (below) scene B so that we can check that 1592 // interruptions 1593 // values and deltas are properly cleared once all transitions are done. 1594 scene(SceneC) { 1595 Box(Modifier.fillMaxSize()) { 1596 Foo(sizeInC, valueInC, Modifier.align(Alignment.BottomEnd)) 1597 } 1598 } 1599 1600 // In scene B, Foo is aligned at the TopEnd, so it moves horizontally when 1601 // coming 1602 // from A. 1603 scene(SceneB) { 1604 Box(Modifier.fillMaxSize()) { 1605 Foo(sizeInB, valueInB, Modifier.align(Alignment.TopEnd)) 1606 } 1607 } 1608 } 1609 } 1610 1611 // The offset of Foo when idle in A, B or C. 1612 val offsetInA = DpOffset.Zero 1613 val offsetInB = DpOffset(layoutSize.width - sizeInB, 0.dp) 1614 val offsetInC = DpOffset(layoutSize.width - sizeInC, layoutSize.height - sizeInC) 1615 1616 // Initial state (idle in A). 1617 rule 1618 .onNode(isElement(TestElements.Foo, SceneA)) 1619 .assertSizeIsEqualTo(sizeInA) 1620 .assertPositionInRootIsEqualTo(offsetInA.x, offsetInA.y) 1621 1622 assertThat(lastValues[SceneA]).isWithin(0.001f).of(valueInA) 1623 assertThat(lastValues[SceneB]).isNull() 1624 assertThat(lastValues[SceneC]).isNull() 1625 1626 // Current transition is A => B at 50%. 1627 val aToBProgress = 0.5f 1628 val aToB = 1629 transition( 1630 from = SceneA, 1631 to = SceneB, 1632 progress = { aToBProgress }, 1633 onFreezeAndAnimate = { /* never finish */ }, 1634 ) 1635 val offsetInAToB = lerp(offsetInA, offsetInB, aToBProgress) 1636 val sizeInAToB = lerp(sizeInA, sizeInB, aToBProgress) 1637 val valueInAToB = lerp(valueInA, valueInB, aToBProgress) 1638 scope.launch { state.startTransition(aToB) } 1639 rule 1640 .onNode(isElement(TestElements.Foo, SceneB)) 1641 .assertSizeIsEqualTo(sizeInAToB) 1642 .assertPositionInRootIsEqualTo(offsetInAToB.x, offsetInAToB.y) 1643 1644 assertThat(lastValues[SceneA]).isWithin(0.001f).of(valueInAToB) 1645 assertThat(lastValues[SceneB]).isWithin(0.001f).of(valueInAToB) 1646 assertThat(lastValues[SceneC]).isNull() 1647 1648 // Start B => C at 0%. 1649 var bToCProgress by mutableFloatStateOf(0f) 1650 var interruptionProgress by mutableFloatStateOf(1f) 1651 val bToC = 1652 transition( 1653 from = SceneB, 1654 to = SceneC, 1655 progress = { bToCProgress }, 1656 interruptionProgress = { interruptionProgress }, 1657 ) 1658 scope.launch { state.startTransition(bToC) } 1659 1660 // The interruption deltas, which will be multiplied by the interruption progress then added 1661 // to the current transition offset and size. 1662 val offsetInterruptionDelta = offsetInAToB - offsetInB 1663 val sizeInterruptionDelta = sizeInAToB - sizeInB 1664 val valueInterruptionDelta = valueInAToB - valueInB 1665 1666 assertThat(offsetInterruptionDelta).isNotEqualTo(DpOffset.Zero) 1667 assertThat(sizeInterruptionDelta).isNotEqualTo(0.dp) 1668 assertThat(valueInterruptionDelta).isNotEqualTo(0f) 1669 1670 // Interruption progress is at 100% and bToC is at 0%, so Foo should be at the same offset 1671 // and size as right before the interruption. 1672 rule 1673 .onNode(isElement(TestElements.Foo, SceneB)) 1674 .assertPositionInRootIsEqualTo(offsetInAToB.x, offsetInAToB.y) 1675 .assertSizeIsEqualTo(sizeInAToB) 1676 1677 assertThat(lastValues[SceneA]).isWithin(0.001f).of(valueInAToB) 1678 assertThat(lastValues[SceneB]).isWithin(0.001f).of(valueInAToB) 1679 assertThat(lastValues[SceneC]).isWithin(0.001f).of(valueInAToB) 1680 1681 // Move the transition forward at 30% and set the interruption progress to 50%. 1682 bToCProgress = 0.3f 1683 interruptionProgress = 0.5f 1684 val offsetInBToC = lerp(offsetInB, offsetInC, bToCProgress) 1685 val sizeInBToC = lerp(sizeInB, sizeInC, bToCProgress) 1686 val valueInBToC = lerp(valueInB, valueInC, bToCProgress) 1687 val offsetInBToCWithInterruption = 1688 offsetInBToC + 1689 DpOffset( 1690 offsetInterruptionDelta.x * interruptionProgress, 1691 offsetInterruptionDelta.y * interruptionProgress, 1692 ) 1693 val sizeInBToCWithInterruption = sizeInBToC + sizeInterruptionDelta * interruptionProgress 1694 val valueInBToCWithInterruption = 1695 valueInBToC + valueInterruptionDelta * interruptionProgress 1696 1697 rule.waitForIdle() 1698 rule 1699 .onNode(isElement(TestElements.Foo, SceneB)) 1700 .assertPositionInRootIsEqualTo( 1701 offsetInBToCWithInterruption.x, 1702 offsetInBToCWithInterruption.y, 1703 ) 1704 .assertSizeIsEqualTo(sizeInBToCWithInterruption) 1705 1706 assertThat(lastValues[SceneA]).isWithin(0.001f).of(valueInBToCWithInterruption) 1707 assertThat(lastValues[SceneB]).isWithin(0.001f).of(valueInBToCWithInterruption) 1708 assertThat(lastValues[SceneC]).isWithin(0.001f).of(valueInBToCWithInterruption) 1709 1710 // Finish the transition and interruption. 1711 bToCProgress = 1f 1712 interruptionProgress = 0f 1713 rule 1714 .onNode(isElement(TestElements.Foo, SceneB)) 1715 .assertPositionInRootIsEqualTo(offsetInC.x, offsetInC.y) 1716 .assertSizeIsEqualTo(sizeInC) 1717 1718 // Manually finish the transition. 1719 aToB.finish() 1720 bToC.finish() 1721 rule.waitForIdle() 1722 assertThat(state.transitionState).isIdle() 1723 1724 // The interruption values should be unspecified and deltas should be set to zero. 1725 val foo = layoutImpl.elements.getValue(TestElements.Foo) 1726 assertThat(foo.stateByContent.keys).containsExactly(SceneC) 1727 val stateInC = foo.stateByContent.getValue(SceneC) 1728 assertThat(stateInC.offsetBeforeInterruption).isEqualTo(Offset.Unspecified) 1729 assertThat(stateInC.sizeBeforeInterruption).isEqualTo(Element.SizeUnspecified) 1730 assertThat(stateInC.scaleBeforeInterruption).isEqualTo(Scale.Unspecified) 1731 assertThat(stateInC.alphaBeforeInterruption).isEqualTo(Element.AlphaUnspecified) 1732 assertThat(stateInC.offsetInterruptionDelta).isEqualTo(Offset.Zero) 1733 assertThat(stateInC.sizeInterruptionDelta).isEqualTo(IntSize.Zero) 1734 assertThat(stateInC.scaleInterruptionDelta).isEqualTo(Scale.Zero) 1735 assertThat(stateInC.alphaInterruptionDelta).isEqualTo(0f) 1736 } 1737 1738 @Test 1739 fun interruption_sharedTransitionDisabled() { 1740 // 4 frames of animation. 1741 val duration = 4 * 16 1742 val layoutSize = DpSize(200.dp, 100.dp) 1743 val fooSize = 100.dp 1744 val state = 1745 rule.runOnUiThread { 1746 MutableSceneTransitionLayoutStateImpl( 1747 SceneA, 1748 transitions { 1749 from(SceneA, to = SceneB) { spec = tween(duration, easing = LinearEasing) } 1750 1751 // Disable the shared transition during B => C. 1752 from(SceneB, to = SceneC) { 1753 spec = tween(duration, easing = LinearEasing) 1754 sharedElement(TestElements.Foo, enabled = false) 1755 } 1756 }, 1757 ) 1758 } 1759 1760 @Composable 1761 fun ContentScope.Foo(modifier: Modifier = Modifier) { 1762 Box(modifier.element(TestElements.Foo).size(fooSize)) 1763 } 1764 1765 val scope = 1766 rule.setContentAndCreateMainScope { 1767 SceneTransitionLayout(state, Modifier.size(layoutSize)) { 1768 scene(SceneA) { 1769 Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopStart)) } 1770 } 1771 1772 scene(SceneB) { 1773 Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopEnd)) } 1774 } 1775 1776 scene(SceneC) { 1777 Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.BottomEnd)) } 1778 } 1779 } 1780 } 1781 1782 // The offset of Foo when idle in A, B or C. 1783 val offsetInA = DpOffset.Zero 1784 val offsetInB = DpOffset(layoutSize.width - fooSize, 0.dp) 1785 val offsetInC = DpOffset(layoutSize.width - fooSize, layoutSize.height - fooSize) 1786 1787 // State is a transition A => B at 50% interrupted by B => C at 30%. 1788 val aToB = 1789 transition( 1790 from = SceneA, 1791 to = SceneB, 1792 progress = { 0.5f }, 1793 onFreezeAndAnimate = { /* never finish */ }, 1794 ) 1795 var bToCInterruptionProgress by mutableStateOf(1f) 1796 val bToC = 1797 transition( 1798 from = SceneB, 1799 to = SceneC, 1800 progress = { 0.3f }, 1801 interruptionProgress = { bToCInterruptionProgress }, 1802 onFreezeAndAnimate = { /* never finish */ }, 1803 ) 1804 scope.launch { state.startTransition(aToB) } 1805 rule.waitForIdle() 1806 scope.launch { state.startTransition(bToC) } 1807 1808 // Foo is placed in both B and C given that the shared transition is disabled. In B, its 1809 // offset is impacted by the interruption but in C it is not. 1810 val offsetInAToB = lerp(offsetInA, offsetInB, 0.5f) 1811 val interruptionDelta = offsetInAToB - offsetInB 1812 assertThat(interruptionDelta).isNotEqualTo(Offset.Zero) 1813 rule 1814 .onNode(isElement(TestElements.Foo, SceneB)) 1815 .assertPositionInRootIsEqualTo( 1816 offsetInB.x + interruptionDelta.x, 1817 offsetInB.y + interruptionDelta.y, 1818 ) 1819 1820 rule 1821 .onNode(isElement(TestElements.Foo, SceneC)) 1822 .assertPositionInRootIsEqualTo(offsetInC.x, offsetInC.y) 1823 1824 // Manually finish A => B so only B => C is remaining. 1825 bToCInterruptionProgress = 0f 1826 aToB.finish() 1827 1828 rule 1829 .onNode(isElement(TestElements.Foo, SceneB)) 1830 .assertPositionInRootIsEqualTo(offsetInB.x, offsetInB.y) 1831 rule 1832 .onNode(isElement(TestElements.Foo, SceneC)) 1833 .assertPositionInRootIsEqualTo(offsetInC.x, offsetInC.y) 1834 1835 // Interrupt B => C by B => A, starting directly at 70% 1836 val bToA = 1837 transition( 1838 from = SceneB, 1839 to = SceneA, 1840 progress = { 0.7f }, 1841 interruptionProgress = { 1f }, 1842 ) 1843 scope.launch { state.startTransition(bToA) } 1844 1845 // Foo should have the position it had in B right before the interruption. 1846 rule 1847 .onNode(isElement(TestElements.Foo, SceneB)) 1848 .assertPositionInRootIsEqualTo(offsetInB.x, offsetInB.y) 1849 } 1850 1851 @Test 1852 fun targetStateIsSetEvenWhenNotPlaced() { 1853 // Start directly at A => B but with progress < 0f to overscroll on A. 1854 val state = 1855 rule.runOnUiThread { 1856 MutableSceneTransitionLayoutStateImpl( 1857 SceneA, 1858 transitions { overscrollDisabled(SceneA, Orientation.Horizontal) }, 1859 ) 1860 } 1861 1862 lateinit var layoutImpl: SceneTransitionLayoutImpl 1863 val scope = 1864 rule.setContentAndCreateMainScope { 1865 SceneTransitionLayoutForTesting( 1866 state, 1867 Modifier.size(100.dp), 1868 onLayoutImpl = { layoutImpl = it }, 1869 ) { 1870 scene(SceneA) {} 1871 scene(SceneB) { Box(Modifier.element(TestElements.Foo)) } 1872 } 1873 } 1874 1875 scope.launch { 1876 state.startTransition( 1877 transition( 1878 from = SceneA, 1879 to = SceneB, 1880 progress = { -1f }, 1881 orientation = Orientation.Horizontal, 1882 ) 1883 ) 1884 } 1885 rule.waitForIdle() 1886 1887 assertThat(layoutImpl.elements).containsKey(TestElements.Foo) 1888 val foo = layoutImpl.elements.getValue(TestElements.Foo) 1889 1890 assertThat(foo.stateByContent).containsKey(SceneB) 1891 val bState = foo.stateByContent.getValue(SceneB) 1892 1893 assertThat(bState.targetSize).isNotEqualTo(Element.SizeUnspecified) 1894 assertThat(bState.targetOffset).isNotEqualTo(Offset.Unspecified) 1895 } 1896 1897 @Test 1898 fun lastAlphaIsNotSetByOutdatedLayer() { 1899 val state = 1900 rule.runOnUiThread { 1901 MutableSceneTransitionLayoutStateImpl( 1902 SceneA, 1903 transitions { from(SceneA, to = SceneB) { fade(TestElements.Foo) } }, 1904 ) 1905 } 1906 1907 lateinit var layoutImpl: SceneTransitionLayoutImpl 1908 val scope = 1909 rule.setContentAndCreateMainScope { 1910 SceneTransitionLayoutForTesting(state, onLayoutImpl = { layoutImpl = it }) { 1911 scene(SceneA) {} 1912 scene(SceneB) { Box(Modifier.element(TestElements.Foo)) } 1913 scene(SceneC) { Box(Modifier.element(TestElements.Foo)) } 1914 } 1915 } 1916 1917 // Start A => B at 0.5f. 1918 var aToBProgress by mutableStateOf(0.5f) 1919 scope.launch { 1920 state.startTransition( 1921 transition( 1922 from = SceneA, 1923 to = SceneB, 1924 progress = { aToBProgress }, 1925 onFreezeAndAnimate = { /* never finish */ }, 1926 ) 1927 ) 1928 } 1929 rule.waitForIdle() 1930 1931 val foo = checkNotNull(layoutImpl.elements[TestElements.Foo]) 1932 assertThat(foo.stateByContent[SceneA]).isNull() 1933 1934 val fooInB = foo.stateByContent[SceneB] 1935 assertThat(fooInB).isNotNull() 1936 assertThat(fooInB!!.lastAlpha).isEqualTo(0.5f) 1937 1938 // Move the progress of A => B to 0.7f. 1939 aToBProgress = 0.7f 1940 rule.waitForIdle() 1941 assertThat(fooInB.lastAlpha).isEqualTo(0.7f) 1942 1943 // Start B => C at 0.3f. 1944 scope.launch { 1945 state.startTransition(transition(from = SceneB, to = SceneC, progress = { 0.3f })) 1946 } 1947 rule.waitForIdle() 1948 val fooInC = foo.stateByContent[SceneC] 1949 assertThat(fooInC).isNotNull() 1950 assertThat(fooInC!!.lastAlpha).isEqualTo(1f) 1951 assertThat(fooInB.lastAlpha).isEqualTo(Element.AlphaUnspecified) 1952 1953 // Move the progress of A => B to 0.9f. This shouldn't change anything given that B => C is 1954 // now the transition applied to Foo. 1955 aToBProgress = 0.9f 1956 rule.waitForIdle() 1957 assertThat(fooInC.lastAlpha).isEqualTo(1f) 1958 assertThat(fooInB.lastAlpha).isEqualTo(Element.AlphaUnspecified) 1959 } 1960 1961 @Test 1962 fun fadingElementsDontAppearInstantly() { 1963 val state = 1964 rule.runOnUiThread { 1965 MutableSceneTransitionLayoutStateImpl( 1966 SceneA, 1967 transitions { from(SceneA, to = SceneB) { fade(TestElements.Foo) } }, 1968 ) 1969 } 1970 1971 lateinit var layoutImpl: SceneTransitionLayoutImpl 1972 val scope = 1973 rule.setContentAndCreateMainScope { 1974 SceneTransitionLayoutForTesting(state, onLayoutImpl = { layoutImpl = it }) { 1975 scene(SceneA) {} 1976 scene(SceneB) { Box(Modifier.element(TestElements.Foo)) } 1977 } 1978 } 1979 1980 // Start A => B at 60%. 1981 var interruptionProgress by mutableStateOf(1f) 1982 scope.launch { 1983 state.startTransition( 1984 transition( 1985 from = SceneA, 1986 to = SceneB, 1987 progress = { 0.6f }, 1988 interruptionProgress = { interruptionProgress }, 1989 ) 1990 ) 1991 } 1992 rule.waitForIdle() 1993 1994 // Alpha of Foo should be 0f at interruption progress 100%. 1995 val fooInB = layoutImpl.elements.getValue(TestElements.Foo).stateByContent.getValue(SceneB) 1996 assertThat(fooInB.lastAlpha).isEqualTo(0f) 1997 1998 // Alpha of Foo should be 0.6f at interruption progress 0%. 1999 interruptionProgress = 0f 2000 rule.waitForIdle() 2001 assertThat(fooInB.lastAlpha).isEqualTo(0.6f) 2002 2003 // Alpha of Foo should be 0.3f at interruption progress 50%. 2004 interruptionProgress = 0.5f 2005 rule.waitForIdle() 2006 assertThat(fooInB.lastAlpha).isEqualTo(0.3f) 2007 } 2008 2009 @Test 2010 fun sharedElementIsOnlyPlacedInOverscrollingScene() { 2011 val state = 2012 rule.runOnUiThread { 2013 MutableSceneTransitionLayoutStateImpl( 2014 SceneA, 2015 transitions { 2016 overscrollDisabled(SceneA, Orientation.Horizontal) 2017 overscrollDisabled(SceneB, Orientation.Horizontal) 2018 }, 2019 ) 2020 } 2021 2022 @Composable 2023 fun ContentScope.Foo() { 2024 Box(Modifier.element(TestElements.Foo).size(10.dp)) 2025 } 2026 2027 val scope = 2028 rule.setContentAndCreateMainScope { 2029 SceneTransitionLayout(state) { 2030 scene(SceneA) { Foo() } 2031 scene(SceneB) { Foo() } 2032 } 2033 } 2034 2035 rule.onNode(isElement(TestElements.Foo, SceneA)).assertIsDisplayed() 2036 rule.onNode(isElement(TestElements.Foo, SceneB)).assertDoesNotExist() 2037 2038 // A => B while overscrolling at scene B. 2039 var progress by mutableStateOf(2f) 2040 scope.launch { 2041 state.startTransition(transition(from = SceneA, to = SceneB, progress = { progress })) 2042 } 2043 rule.waitForIdle() 2044 2045 // Foo should only be placed in scene B. 2046 rule.onNode(isElement(TestElements.Foo, SceneA)).assertExists().assertIsNotDisplayed() 2047 rule.onNode(isElement(TestElements.Foo, SceneB)).assertIsDisplayed() 2048 2049 // Overscroll at scene A. 2050 progress = -1f 2051 rule.waitForIdle() 2052 2053 // Foo should only be placed in scene A. 2054 rule.onNode(isElement(TestElements.Foo, SceneA)).assertIsDisplayed() 2055 rule.onNode(isElement(TestElements.Foo, SceneB)).assertExists().assertIsNotDisplayed() 2056 } 2057 2058 @Test 2059 fun sharedMovableElementIsOnlyComposedInOverscrollingScene() { 2060 val state = 2061 rule.runOnUiThread { 2062 MutableSceneTransitionLayoutStateImpl( 2063 SceneA, 2064 transitions { 2065 overscrollDisabled(SceneA, Orientation.Horizontal) 2066 overscrollDisabled(SceneB, Orientation.Horizontal) 2067 }, 2068 ) 2069 } 2070 2071 val fooInA = "fooInA" 2072 val fooInB = "fooInB" 2073 2074 val key = MovableElementKey("Foo", contents = setOf(SceneA, SceneB)) 2075 2076 @Composable 2077 fun ContentScope.MovableFoo(text: String, modifier: Modifier = Modifier) { 2078 MovableElement(key, modifier) { content { Text(text) } } 2079 } 2080 2081 val scope = 2082 rule.setContentAndCreateMainScope { 2083 SceneTransitionLayout(state) { 2084 scene(SceneA) { MovableFoo(text = fooInA) } 2085 scene(SceneB) { MovableFoo(text = fooInB) } 2086 } 2087 } 2088 2089 rule.onNode(hasText(fooInA)).assertIsDisplayed() 2090 rule.onNode(hasText(fooInB)).assertDoesNotExist() 2091 2092 // A => B while overscrolling at scene B. 2093 var progress by mutableStateOf(2f) 2094 scope.launch { 2095 state.startTransition(transition(from = SceneA, to = SceneB, progress = { progress })) 2096 } 2097 rule.waitForIdle() 2098 2099 // Foo content should only be composed in scene B. 2100 rule.onNode(hasText(fooInA)).assertDoesNotExist() 2101 rule.onNode(hasText(fooInB)).assertIsDisplayed() 2102 2103 // Overscroll at scene A. 2104 progress = -1f 2105 rule.waitForIdle() 2106 2107 // Foo content should only be composed in scene A. 2108 rule.onNode(hasText(fooInA)).assertIsDisplayed() 2109 rule.onNode(hasText(fooInB)).assertDoesNotExist() 2110 } 2111 2112 @Test 2113 fun interruptionThenOverscroll() { 2114 val state = 2115 rule.runOnUiThread { 2116 MutableSceneTransitionLayoutStateImpl( 2117 SceneA, 2118 transitions { 2119 overscroll(SceneB, Orientation.Vertical) { 2120 progressConverter = ProgressConverter.linear() 2121 translate(TestElements.Foo, y = 15.dp) 2122 } 2123 }, 2124 ) 2125 } 2126 2127 @Composable 2128 fun ContentScope.SceneWithFoo(offset: DpOffset, modifier: Modifier = Modifier) { 2129 Box(modifier.fillMaxSize()) { 2130 Box(Modifier.offset(offset.x, offset.y).element(TestElements.Foo).size(100.dp)) 2131 } 2132 } 2133 2134 val scope = 2135 rule.setContentAndCreateMainScope { 2136 SceneTransitionLayout(state, Modifier.size(200.dp)) { 2137 scene(SceneA) { SceneWithFoo(offset = DpOffset.Zero) } 2138 scene(SceneB) { SceneWithFoo(offset = DpOffset(x = 40.dp, y = 0.dp)) } 2139 scene(SceneC) { SceneWithFoo(offset = DpOffset(x = 40.dp, y = 40.dp)) } 2140 } 2141 } 2142 2143 // Start A => B at 75%. 2144 scope.launch { 2145 state.startTransition( 2146 transition( 2147 from = SceneA, 2148 to = SceneB, 2149 progress = { 0.75f }, 2150 onFreezeAndAnimate = { /* never finish */ }, 2151 ) 2152 ) 2153 } 2154 2155 // Foo should be at offset (30dp, 0dp) and placed in scene B. 2156 rule.onNode(isElement(TestElements.Foo, SceneA)).assertIsNotDisplayed() 2157 rule.onNode(isElement(TestElements.Foo, SceneB)).assertPositionInRootIsEqualTo(30.dp, 0.dp) 2158 rule.onNode(isElement(TestElements.Foo, SceneC)).assertIsNotDisplayed() 2159 2160 // Interrupt A => B with B => C at 0%. 2161 var progress by mutableStateOf(0f) 2162 var interruptionProgress by mutableStateOf(1f) 2163 scope.launch { 2164 state.startTransition( 2165 transition( 2166 from = SceneB, 2167 to = SceneC, 2168 progress = { progress }, 2169 interruptionProgress = { interruptionProgress }, 2170 orientation = Orientation.Vertical, 2171 onFreezeAndAnimate = { /* never finish */ }, 2172 ) 2173 ) 2174 } 2175 2176 // Because interruption progress is at 100M, Foo should still be at offset (30dp, 0dp) but 2177 // placed in scene C. 2178 rule.onNode(isElement(TestElements.Foo, SceneA)).assertIsNotDisplayed() 2179 rule.onNode(isElement(TestElements.Foo, SceneB)).assertIsNotDisplayed() 2180 rule.onNode(isElement(TestElements.Foo, SceneC)).assertPositionInRootIsEqualTo(30.dp, 0.dp) 2181 2182 // Overscroll B => C on scene B at -100%. Because overscrolling on B => C translates Foo 2183 // vertically by -15dp and that interruptionProgress is still 100%, we should now be at 2184 // (30dp, -15dp) 2185 progress = -1f 2186 rule.onNode(isElement(TestElements.Foo, SceneA)).assertIsNotDisplayed() 2187 rule 2188 .onNode(isElement(TestElements.Foo, SceneB)) 2189 .assertPositionInRootIsEqualTo(30.dp, -15.dp) 2190 rule.onNode(isElement(TestElements.Foo, SceneC)).assertIsNotDisplayed() 2191 2192 // Finish the interruption, we should now be at (40dp, -15dp), still on scene B. 2193 interruptionProgress = 0f 2194 rule.onNode(isElement(TestElements.Foo, SceneA)).assertIsNotDisplayed() 2195 rule 2196 .onNode(isElement(TestElements.Foo, SceneB)) 2197 .assertPositionInRootIsEqualTo(40.dp, -15.dp) 2198 rule.onNode(isElement(TestElements.Foo, SceneC)).assertIsNotDisplayed() 2199 2200 // Finish the transition, we should be at the final position (40dp, 40dp) on scene C. 2201 progress = 1f 2202 rule.onNode(isElement(TestElements.Foo, SceneA)).assertIsNotDisplayed() 2203 rule.onNode(isElement(TestElements.Foo, SceneB)).assertIsNotDisplayed() 2204 rule.onNode(isElement(TestElements.Foo, SceneC)).assertPositionInRootIsEqualTo(40.dp, 40.dp) 2205 } 2206 2207 @Test 2208 fun lastPlacementValuesAreClearedOnNestedElements() { 2209 val state = rule.runOnIdle { MutableSceneTransitionLayoutStateImpl(SceneA) } 2210 2211 @Composable 2212 fun ContentScope.NestedFooBar() { 2213 Box(Modifier.element(TestElements.Foo)) { 2214 Box(Modifier.element(TestElements.Bar).size(10.dp)) 2215 } 2216 } 2217 2218 lateinit var layoutImpl: SceneTransitionLayoutImpl 2219 val scope = 2220 rule.setContentAndCreateMainScope { 2221 SceneTransitionLayoutForTesting(state, onLayoutImpl = { layoutImpl = it }) { 2222 scene(SceneA) { NestedFooBar() } 2223 scene(SceneB) { NestedFooBar() } 2224 } 2225 } 2226 2227 // Idle on A: composed and placed only in B. 2228 rule.onNode(isElement(TestElements.Foo, SceneA)).assertIsDisplayed() 2229 rule.onNode(isElement(TestElements.Bar, SceneA)).assertIsDisplayed() 2230 rule.onNode(isElement(TestElements.Foo, SceneB)).assertDoesNotExist() 2231 rule.onNode(isElement(TestElements.Bar, SceneB)).assertDoesNotExist() 2232 2233 assertThat(layoutImpl.elements).containsKey(TestElements.Foo) 2234 assertThat(layoutImpl.elements).containsKey(TestElements.Bar) 2235 val foo = layoutImpl.elements.getValue(TestElements.Foo) 2236 val bar = layoutImpl.elements.getValue(TestElements.Bar) 2237 2238 assertThat(foo.stateByContent).containsKey(SceneA) 2239 assertThat(bar.stateByContent).containsKey(SceneA) 2240 assertThat(foo.stateByContent).doesNotContainKey(SceneB) 2241 assertThat(bar.stateByContent).doesNotContainKey(SceneB) 2242 2243 val fooInA = foo.stateByContent.getValue(SceneA) 2244 val barInA = bar.stateByContent.getValue(SceneA) 2245 assertThat(fooInA.lastOffset).isNotEqualTo(Offset.Unspecified) 2246 assertThat(fooInA.lastAlpha).isNotEqualTo(Element.AlphaUnspecified) 2247 assertThat(fooInA.lastScale).isNotEqualTo(Scale.Unspecified) 2248 2249 assertThat(barInA.lastOffset).isNotEqualTo(Offset.Unspecified) 2250 assertThat(barInA.lastAlpha).isNotEqualTo(Element.AlphaUnspecified) 2251 assertThat(barInA.lastScale).isNotEqualTo(Scale.Unspecified) 2252 2253 // A => B: composed in both and placed only in B. 2254 scope.launch { state.startTransition(transition(from = SceneA, to = SceneB)) } 2255 rule.onNode(isElement(TestElements.Foo, SceneA)).assertExists().assertIsNotDisplayed() 2256 rule.onNode(isElement(TestElements.Bar, SceneA)).assertExists().assertIsNotDisplayed() 2257 rule.onNode(isElement(TestElements.Foo, SceneB)).assertIsDisplayed() 2258 rule.onNode(isElement(TestElements.Bar, SceneB)).assertIsDisplayed() 2259 2260 assertThat(foo.stateByContent).containsKey(SceneB) 2261 assertThat(bar.stateByContent).containsKey(SceneB) 2262 2263 val fooInB = foo.stateByContent.getValue(SceneB) 2264 val barInB = bar.stateByContent.getValue(SceneB) 2265 assertThat(fooInA.lastOffset).isEqualTo(Offset.Unspecified) 2266 assertThat(fooInA.lastAlpha).isEqualTo(Element.AlphaUnspecified) 2267 assertThat(fooInA.lastScale).isEqualTo(Scale.Unspecified) 2268 assertThat(fooInB.lastOffset).isNotEqualTo(Offset.Unspecified) 2269 assertThat(fooInB.lastAlpha).isNotEqualTo(Element.AlphaUnspecified) 2270 assertThat(fooInB.lastScale).isNotEqualTo(Scale.Unspecified) 2271 2272 assertThat(barInA.lastOffset).isEqualTo(Offset.Unspecified) 2273 assertThat(barInA.lastAlpha).isEqualTo(Element.AlphaUnspecified) 2274 assertThat(barInA.lastScale).isEqualTo(Scale.Unspecified) 2275 assertThat(barInB.lastOffset).isNotEqualTo(Offset.Unspecified) 2276 assertThat(barInB.lastAlpha).isNotEqualTo(Element.AlphaUnspecified) 2277 assertThat(barInB.lastScale).isNotEqualTo(Scale.Unspecified) 2278 } 2279 2280 @Test 2281 fun currentTransitionSceneIsUsedToComputeElementValues() { 2282 val state = 2283 rule.runOnIdle { 2284 MutableSceneTransitionLayoutStateImpl( 2285 SceneA, 2286 transitions { 2287 from(SceneB, to = SceneC) { 2288 scaleSize(TestElements.Foo, width = 2f, height = 3f) 2289 } 2290 }, 2291 ) 2292 } 2293 2294 @Composable 2295 fun ContentScope.Foo() { 2296 Box(Modifier.testTag("fooParentIn${contentKey.debugName}")) { 2297 Box(Modifier.element(TestElements.Foo).size(20.dp)) 2298 } 2299 } 2300 2301 val scope = 2302 rule.setContentAndCreateMainScope { 2303 SceneTransitionLayout(state, Modifier.size(200.dp)) { 2304 scene(SceneA) { Foo() } 2305 scene(SceneB) {} 2306 scene(SceneC) { Foo() } 2307 } 2308 } 2309 2310 // We have 2 transitions: 2311 // - A => B at 100% 2312 // - B => C at 0% 2313 // So Foo should have a size of (40dp, 60dp) in both A and C given that it is scaling its 2314 // size in B => C. 2315 scope.launch { 2316 state.startTransition( 2317 transition( 2318 from = SceneA, 2319 to = SceneB, 2320 progress = { 1f }, 2321 onFreezeAndAnimate = { /* never finish */ }, 2322 ) 2323 ) 2324 } 2325 scope.launch { 2326 state.startTransition(transition(from = SceneB, to = SceneC, progress = { 0f })) 2327 } 2328 2329 rule.onNode(hasTestTag("fooParentInSceneA")).assertSizeIsEqualTo(40.dp, 60.dp) 2330 rule.onNode(hasTestTag("fooParentInSceneC")).assertSizeIsEqualTo(40.dp, 60.dp) 2331 } 2332 2333 @Test 2334 fun interruptionDeltasAreProperlyCleaned() { 2335 val state = rule.runOnIdle { MutableSceneTransitionLayoutStateImpl(SceneA) } 2336 2337 @Composable 2338 fun ContentScope.Foo(offset: Dp) { 2339 Box(Modifier.fillMaxSize()) { 2340 Box(Modifier.offset(offset, offset).element(TestElements.Foo).size(20.dp)) 2341 } 2342 } 2343 2344 val scope = 2345 rule.setContentAndCreateMainScope { 2346 SceneTransitionLayout(state, Modifier.size(200.dp)) { 2347 scene(SceneA) { Foo(offset = 0.dp) } 2348 scene(SceneB) { Foo(offset = 20.dp) } 2349 scene(SceneC) { Foo(offset = 40.dp) } 2350 } 2351 } 2352 2353 // Start A => B at 50%. 2354 val aToB = 2355 transition( 2356 from = SceneA, 2357 to = SceneB, 2358 progress = { 0.5f }, 2359 onFreezeAndAnimate = { /* never finish */ }, 2360 ) 2361 scope.launch { state.startTransition(aToB) } 2362 rule.onNode(isElement(TestElements.Foo, SceneB)).assertPositionInRootIsEqualTo(10.dp, 10.dp) 2363 2364 // Start B => C at 0%. This will compute an interruption delta of (-10dp, -10dp) so that the 2365 // position of Foo is unchanged and converges to (20dp, 20dp). 2366 var interruptionProgress by mutableStateOf(1f) 2367 val bToC = 2368 transition( 2369 from = SceneB, 2370 to = SceneC, 2371 current = { SceneB }, 2372 progress = { 0f }, 2373 interruptionProgress = { interruptionProgress }, 2374 onFreezeAndAnimate = { /* never finish */ }, 2375 ) 2376 scope.launch { state.startTransition(bToC) } 2377 rule.onNode(isElement(TestElements.Foo, SceneC)).assertPositionInRootIsEqualTo(10.dp, 10.dp) 2378 2379 // Finish the interruption and leave the transition progress at 0f. We should be at the same 2380 // state as in B. 2381 interruptionProgress = 0f 2382 rule.onNode(isElement(TestElements.Foo, SceneC)).assertPositionInRootIsEqualTo(20.dp, 20.dp) 2383 2384 // Finish both transitions but directly start a new one B => A with interruption progress 2385 // 100%. We should be at (20dp, 20dp), unless the interruption deltas have not been 2386 // correctly cleaned. 2387 aToB.finish() 2388 bToC.finish() 2389 scope.launch { 2390 state.startTransition( 2391 transition( 2392 from = SceneB, 2393 to = SceneA, 2394 progress = { 0f }, 2395 interruptionProgress = { 1f }, 2396 ) 2397 ) 2398 } 2399 rule.onNode(isElement(TestElements.Foo, SceneB)).assertPositionInRootIsEqualTo(20.dp, 20.dp) 2400 } 2401 2402 @Test 2403 fun lastSizeIsUnspecifiedWhenOverscrollingOtherScene() { 2404 val state = 2405 rule.runOnIdle { 2406 MutableSceneTransitionLayoutStateImpl( 2407 SceneA, 2408 transitions { overscrollDisabled(SceneA, Orientation.Horizontal) }, 2409 ) 2410 } 2411 2412 @Composable 2413 fun ContentScope.Foo() { 2414 Box(Modifier.element(TestElements.Foo).size(10.dp)) 2415 } 2416 2417 lateinit var layoutImpl: SceneTransitionLayoutImpl 2418 val scope = 2419 rule.setContentAndCreateMainScope { 2420 SceneTransitionLayoutForTesting(state, onLayoutImpl = { layoutImpl = it }) { 2421 scene(SceneA) { Foo() } 2422 scene(SceneB) { Foo() } 2423 } 2424 } 2425 2426 // Overscroll A => B on A. 2427 scope.launch { 2428 state.startTransition( 2429 transition( 2430 from = SceneA, 2431 to = SceneB, 2432 progress = { -1f }, 2433 onFreezeAndAnimate = { /* never finish */ }, 2434 ) 2435 ) 2436 } 2437 rule.waitForIdle() 2438 2439 assertThat( 2440 layoutImpl.elements 2441 .getValue(TestElements.Foo) 2442 .stateByContent 2443 .getValue(SceneB) 2444 .lastSize 2445 ) 2446 .isEqualTo(Element.SizeUnspecified) 2447 } 2448 2449 @Test 2450 fun transparentElementIsNotImpactingInterruption() { 2451 val state = 2452 rule.runOnIdle { 2453 MutableSceneTransitionLayoutStateImpl( 2454 SceneA, 2455 transitions { 2456 from(SceneA, to = SceneB) { 2457 // In A => B, Foo is not shared and first fades out from A then fades in 2458 // B. 2459 sharedElement(TestElements.Foo, enabled = false) 2460 fractionRange(end = 0.5f) { fade(TestElements.Foo.inScene(SceneA)) } 2461 fractionRange(start = 0.5f) { fade(TestElements.Foo.inScene(SceneB)) } 2462 } 2463 2464 from(SceneB, to = SceneA) { 2465 // In B => A, Foo is shared. 2466 sharedElement(TestElements.Foo, enabled = true) 2467 } 2468 }, 2469 ) 2470 } 2471 2472 @Composable 2473 fun ContentScope.Foo(modifier: Modifier = Modifier) { 2474 Box(modifier.element(TestElements.Foo).size(10.dp)) 2475 } 2476 2477 val scope = 2478 rule.setContentAndCreateMainScope { 2479 SceneTransitionLayout(state) { 2480 scene(SceneB) { Foo(Modifier.offset(40.dp, 60.dp)) } 2481 2482 // Define A after B so that Foo is placed in A during A <=> B. 2483 scene(SceneA) { Foo() } 2484 } 2485 } 2486 2487 // Start A => B at 70%. 2488 scope.launch { 2489 state.startTransition( 2490 transition( 2491 from = SceneA, 2492 to = SceneB, 2493 progress = { 0.7f }, 2494 onFreezeAndAnimate = { /* never finish */ }, 2495 ) 2496 ) 2497 } 2498 2499 rule.onNode(isElement(TestElements.Foo, SceneA)).assertPositionInRootIsEqualTo(0.dp, 0.dp) 2500 rule.onNode(isElement(TestElements.Foo, SceneB)).assertPositionInRootIsEqualTo(40.dp, 60.dp) 2501 2502 // Start B => A at 50% with interruptionProgress = 100%. Foo is placed in A and should still 2503 // be at (40dp, 60dp) given that it was fully transparent in A before the interruption. 2504 var interruptionProgress by mutableStateOf(1f) 2505 scope.launch { 2506 state.startTransition( 2507 transition( 2508 from = SceneB, 2509 to = SceneA, 2510 progress = { 0.5f }, 2511 interruptionProgress = { interruptionProgress }, 2512 onFreezeAndAnimate = { /* never finish */ }, 2513 ) 2514 ) 2515 } 2516 2517 rule.onNode(isElement(TestElements.Foo, SceneA)).assertPositionInRootIsEqualTo(40.dp, 60.dp) 2518 rule.onNode(isElement(TestElements.Foo, SceneB)).assertIsNotDisplayed() 2519 2520 // Set the interruption progress to 0%. Foo should be at (20dp, 30dp) given that B => is at 2521 // 50%. 2522 interruptionProgress = 0f 2523 rule.onNode(isElement(TestElements.Foo, SceneA)).assertPositionInRootIsEqualTo(20.dp, 30.dp) 2524 rule.onNode(isElement(TestElements.Foo, SceneB)).assertIsNotDisplayed() 2525 } 2526 2527 @Test 2528 fun replacedTransitionDoesNotTriggerInterruption() { 2529 val state = rule.runOnIdle { MutableSceneTransitionLayoutStateImpl(SceneA) } 2530 2531 @Composable 2532 fun ContentScope.Foo(modifier: Modifier = Modifier) { 2533 Box(modifier.element(TestElements.Foo).size(10.dp)) 2534 } 2535 2536 val scope = 2537 rule.setContentAndCreateMainScope { 2538 SceneTransitionLayout(state) { 2539 scene(SceneA) { Foo() } 2540 scene(SceneB) { Foo(Modifier.offset(40.dp, 60.dp)) } 2541 } 2542 } 2543 2544 // Start A => B at 50%. 2545 val aToB1 = 2546 transition( 2547 from = SceneA, 2548 to = SceneB, 2549 progress = { 0.5f }, 2550 onFreezeAndAnimate = { /* never finish */ }, 2551 ) 2552 scope.launch { state.startTransition(aToB1) } 2553 rule.onNode(isElement(TestElements.Foo, SceneA)).assertIsNotDisplayed() 2554 rule.onNode(isElement(TestElements.Foo, SceneB)).assertPositionInRootIsEqualTo(20.dp, 30.dp) 2555 2556 // Replace A => B by another A => B at 100%. Even with interruption progress at 100%, Foo 2557 // should be at (40dp, 60dp) given that aToB1 was replaced by aToB2. 2558 val aToB2 = 2559 transition( 2560 from = SceneA, 2561 to = SceneB, 2562 progress = { 1f }, 2563 interruptionProgress = { 1f }, 2564 replacedTransition = aToB1, 2565 ) 2566 scope.launch { state.startTransition(aToB2) } 2567 rule.onNode(isElement(TestElements.Foo, SceneA)).assertIsNotDisplayed() 2568 rule.onNode(isElement(TestElements.Foo, SceneB)).assertPositionInRootIsEqualTo(40.dp, 60.dp) 2569 } 2570 2571 @Test 2572 fun previewInterpolation_previewStage() { 2573 val exiting1 = ElementKey("exiting1") 2574 val exiting2 = ElementKey("exiting2") 2575 val exiting3 = ElementKey("exiting3") 2576 val entering1 = ElementKey("entering1") 2577 val entering2 = ElementKey("entering2") 2578 val entering3 = ElementKey("entering3") 2579 2580 val layoutImpl = 2581 testPreviewTransformation( 2582 from = SceneB, 2583 to = SceneA, 2584 exitingElements = listOf(exiting1, exiting2, exiting3), 2585 enteringElements = listOf(entering1, entering2, entering3), 2586 preview = { 2587 scaleDraw(exiting1, scaleX = 0.8f, scaleY = 0.8f) 2588 translate(exiting2, x = 20.dp) 2589 scaleDraw(entering1, scaleX = 0f, scaleY = 0f) 2590 translate(entering2, y = 30.dp) 2591 }, 2592 transition = { 2593 translate(exiting2, x = 30.dp) 2594 scaleSize(exiting3, width = 0.8f, height = 0.8f) 2595 scaleDraw(entering1, scaleX = 0.5f, scaleY = 0.5f) 2596 scaleSize(entering3, width = 0.2f, height = 0.2f) 2597 }, 2598 previewProgress = 0.5f, 2599 progress = 0f, 2600 isInPreviewStage = true, 2601 ) 2602 2603 // verify that preview transition for exiting elements is halfway played from 2604 // current-scene-value -> preview-target-value 2605 val exiting1InB = layoutImpl.elements.getValue(exiting1).stateByContent.getValue(SceneB) 2606 // e.g. exiting1 is half scaled... 2607 assertThat(exiting1InB.lastScale).isEqualTo(Scale(0.9f, 0.9f, Offset.Unspecified)) 2608 // ...and exiting2 is halfway translated from 0.dp to 20.dp... 2609 rule.onNode(isElement(exiting2)).assertPositionInRootIsEqualTo(10.dp, 0.dp) 2610 // ...whereas exiting3 remains in its original size because it is only affected by the 2611 // second phase of the transition 2612 rule.onNode(isElement(exiting3)).assertSizeIsEqualTo(100.dp, 100.dp) 2613 2614 // verify that preview transition for entering elements is halfway played from 2615 // preview-target-value -> transition-target-value (or target-scene-value if no 2616 // transition-target-value defined). 2617 val entering1InA = layoutImpl.elements.getValue(entering1).stateByContent.getValue(SceneA) 2618 // e.g. entering1 is half scaled between 0f and 0.5f -> 0.25f... 2619 assertThat(entering1InA.lastScale).isEqualTo(Scale(0.25f, 0.25f, Offset.Unspecified)) 2620 // ...and entering2 is half way translated between 30.dp and 0.dp 2621 rule.onNode(isElement(entering2)).assertPositionInRootIsEqualTo(0.dp, 15.dp) 2622 // ...and entering3 is still at its start size of 0.2f * 100.dp, because it is unaffected 2623 // by the preview phase 2624 rule.onNode(isElement(entering3)).assertSizeIsEqualTo(20.dp, 20.dp) 2625 } 2626 2627 @Test 2628 fun previewInterpolation_transitionStage() { 2629 val exiting1 = ElementKey("exiting1") 2630 val exiting2 = ElementKey("exiting2") 2631 val exiting3 = ElementKey("exiting3") 2632 val entering1 = ElementKey("entering1") 2633 val entering2 = ElementKey("entering2") 2634 val entering3 = ElementKey("entering3") 2635 2636 val layoutImpl = 2637 testPreviewTransformation( 2638 from = SceneB, 2639 to = SceneA, 2640 exitingElements = listOf(exiting1, exiting2, exiting3), 2641 enteringElements = listOf(entering1, entering2, entering3), 2642 preview = { 2643 scaleDraw(exiting1, scaleX = 0.8f, scaleY = 0.8f) 2644 translate(exiting2, x = 20.dp) 2645 scaleDraw(entering1, scaleX = 0f, scaleY = 0f) 2646 translate(entering2, y = 30.dp) 2647 }, 2648 transition = { 2649 translate(exiting2, x = 30.dp) 2650 scaleSize(exiting3, width = 0.8f, height = 0.8f) 2651 scaleDraw(entering1, scaleX = 0.5f, scaleY = 0.5f) 2652 scaleSize(entering3, width = 0.2f, height = 0.2f) 2653 }, 2654 previewProgress = 0.5f, 2655 progress = 0.5f, 2656 isInPreviewStage = false, 2657 ) 2658 2659 // verify that exiting elements remain in the preview-end state if no further transition is 2660 // defined for them in the second stage 2661 val exiting1InB = layoutImpl.elements.getValue(exiting1).stateByContent.getValue(SceneB) 2662 // i.e. exiting1 remains half scaled 2663 assertThat(exiting1InB.lastScale).isEqualTo(Scale(0.9f, 0.9f, Offset.Unspecified)) 2664 // in case there is an additional transition defined for the second stage, verify that the 2665 // animation is seamlessly taken over from the preview-end-state, e.g. the translation of 2666 // exiting2 is at 10.dp after the preview phase. After half of the second phase, it 2667 // should be half-way between 10.dp and the target-value of 30.dp -> 20.dp 2668 rule.onNode(isElement(exiting2)).assertPositionInRootIsEqualTo(20.dp, 0.dp) 2669 // if the element is only modified by the second phase transition, verify it's in the middle 2670 // of start-scene-state and target-scene-state, i.e. exiting3 is halfway between 100.dp and 2671 // 80.dp 2672 rule.onNode(isElement(exiting3)).assertSizeIsEqualTo(90.dp, 90.dp) 2673 2674 // verify that entering elements animate seamlessly to their target state 2675 val entering1InA = layoutImpl.elements.getValue(entering1).stateByContent.getValue(SceneA) 2676 // e.g. entering1, which was scaled from 0f to 0.25f during the preview phase, should now be 2677 // half way scaled between 0.25f and its target-state of 1f -> 0.625f 2678 assertThat(entering1InA.lastScale).isEqualTo(Scale(0.625f, 0.625f, Offset.Unspecified)) 2679 // entering2, which was translated from y=30.dp to y=15.dp should now be half way 2680 // between 15.dp and its target state of 0.dp... 2681 rule.onNode(isElement(entering2)).assertPositionInRootIsEqualTo(0.dp, 7.5.dp) 2682 // entering3, which isn't affected by the preview transformation should be half scaled 2683 // between start size (20.dp) and target size (100.dp) -> 60.dp 2684 rule.onNode(isElement(entering3)).assertSizeIsEqualTo(60.dp, 60.dp) 2685 } 2686 2687 private fun testPreviewTransformation( 2688 from: SceneKey, 2689 to: SceneKey, 2690 exitingElements: List<ElementKey> = listOf(), 2691 enteringElements: List<ElementKey> = listOf(), 2692 preview: (TransitionBuilder.() -> Unit)? = null, 2693 transition: TransitionBuilder.() -> Unit, 2694 progress: Float = 0f, 2695 previewProgress: Float = 0.5f, 2696 isInPreviewStage: Boolean = true, 2697 ): SceneTransitionLayoutImpl { 2698 val state = 2699 rule.runOnIdle { 2700 MutableSceneTransitionLayoutStateImpl( 2701 from, 2702 transitions { from(from, to = to, preview = preview, builder = transition) }, 2703 ) 2704 } 2705 2706 @Composable 2707 fun ContentScope.Foo(elementKey: ElementKey) { 2708 Box(Modifier.element(elementKey).size(100.dp)) 2709 } 2710 2711 lateinit var layoutImpl: SceneTransitionLayoutImpl 2712 val scope = 2713 rule.setContentAndCreateMainScope { 2714 SceneTransitionLayoutForTesting(state, onLayoutImpl = { layoutImpl = it }) { 2715 scene(from) { Box { exitingElements.forEach { Foo(it) } } } 2716 scene(to) { Box { enteringElements.forEach { Foo(it) } } } 2717 } 2718 } 2719 2720 val bToA = 2721 transition( 2722 from = from, 2723 to = to, 2724 progress = { progress }, 2725 previewProgress = { previewProgress }, 2726 isInPreviewStage = { isInPreviewStage }, 2727 ) 2728 scope.launch { state.startTransition(bToA) } 2729 rule.waitForIdle() 2730 return layoutImpl 2731 } 2732 2733 @Test 2734 fun elementComposableShouldPropagateMinConstraints() { 2735 val contentTestTag = "content" 2736 val movable = MovableElementKey("movable", contents = setOf(SceneA)) 2737 rule.setContent { 2738 TestContentScope(currentScene = SceneA) { 2739 Column { 2740 Element(TestElements.Foo, Modifier.size(40.dp)) { 2741 content { 2742 // Modifier.size() sets a preferred size and this should be ignored 2743 // because of the previously set 40dp size. 2744 Box(Modifier.testTag(contentTestTag).size(20.dp)) 2745 } 2746 } 2747 2748 MovableElement(movable, Modifier.size(40.dp)) { 2749 content { Box(Modifier.testTag(contentTestTag).size(20.dp)) } 2750 } 2751 } 2752 } 2753 } 2754 2755 rule 2756 .onNode(hasTestTag(contentTestTag) and hasParent(isElement(TestElements.Foo))) 2757 .assertSizeIsEqualTo(40.dp) 2758 rule 2759 .onNode(hasTestTag(contentTestTag) and hasParent(isElement(movable))) 2760 .assertSizeIsEqualTo(40.dp) 2761 } 2762 2763 @Test 2764 fun placeAllCopies() { 2765 val foo = ElementKey("Foo", placeAllCopies = true) 2766 2767 @Composable 2768 fun SceneScope.Foo(size: Dp, modifier: Modifier = Modifier) { 2769 Box(modifier.element(foo).size(size)) 2770 } 2771 2772 rule.testTransition( 2773 fromSceneContent = { Box(Modifier.size(100.dp)) { Foo(size = 10.dp) } }, 2774 toSceneContent = { 2775 Box(Modifier.size(100.dp)) { 2776 Foo(size = 50.dp, Modifier.align(Alignment.BottomEnd)) 2777 } 2778 }, 2779 transition = { spec = tween(4 * 16, easing = LinearEasing) }, 2780 ) { 2781 before { 2782 onElement(foo, SceneA) 2783 .assertSizeIsEqualTo(10.dp) 2784 .assertPositionInRootIsEqualTo(0.dp, 0.dp) 2785 onElement(foo, SceneB).assertDoesNotExist() 2786 } 2787 2788 at(16) { 2789 onElement(foo, SceneA) 2790 .assertSizeIsEqualTo(20.dp) 2791 .assertPositionInRootIsEqualTo(12.5.dp, 12.5.dp) 2792 onElement(foo, SceneB) 2793 .assertSizeIsEqualTo(20.dp) 2794 .assertPositionInRootIsEqualTo(12.5.dp, 12.5.dp) 2795 } 2796 2797 at(32) { 2798 onElement(foo, SceneA) 2799 .assertSizeIsEqualTo(30.dp) 2800 .assertPositionInRootIsEqualTo(25.dp, 25.dp) 2801 onElement(foo, SceneB) 2802 .assertSizeIsEqualTo(30.dp) 2803 .assertPositionInRootIsEqualTo(25.dp, 25.dp) 2804 } 2805 2806 at(48) { 2807 onElement(foo, SceneA) 2808 .assertSizeIsEqualTo(40.dp) 2809 .assertPositionInRootIsEqualTo(37.5.dp, 37.5.dp) 2810 onElement(foo, SceneB) 2811 .assertSizeIsEqualTo(40.dp) 2812 .assertPositionInRootIsEqualTo(37.5.dp, 37.5.dp) 2813 } 2814 2815 after { 2816 onElement(foo, SceneA).assertDoesNotExist() 2817 onElement(foo, SceneB) 2818 .assertSizeIsEqualTo(50.dp) 2819 .assertPositionInRootIsEqualTo(50.dp, 50.dp) 2820 } 2821 } 2822 } 2823 2824 @Test 2825 fun staticSharedElementShouldNotRemeasureOrReplaceDuringOverscrollableTransition() { 2826 val size = 30.dp 2827 var numberOfMeasurements = 0 2828 var numberOfPlacements = 0 2829 2830 // Foo is a simple element that does not move or resize during the transition. 2831 @Composable 2832 fun SceneScope.Foo(modifier: Modifier = Modifier) { 2833 Box( 2834 modifier 2835 .element(TestElements.Foo) 2836 .layout { measurable, constraints -> 2837 numberOfMeasurements++ 2838 measurable.measure(constraints).run { 2839 numberOfPlacements++ 2840 layout(width, height) { place(0, 0) } 2841 } 2842 } 2843 .size(size) 2844 ) 2845 } 2846 2847 val state = rule.runOnUiThread { MutableSceneTransitionLayoutState(SceneA) } 2848 val scope = 2849 rule.setContentAndCreateMainScope { 2850 SceneTransitionLayout(state) { 2851 scene(SceneA) { Box(Modifier.fillMaxSize()) { Foo() } } 2852 scene(SceneB) { Box(Modifier.fillMaxSize()) { Foo() } } 2853 } 2854 } 2855 2856 // Start an overscrollable transition driven by progress. 2857 var progress by mutableFloatStateOf(0f) 2858 val transition = transition(from = SceneA, to = SceneB, progress = { progress }) 2859 assertThat(transition).isInstanceOf(TransitionState.HasOverscrollProperties::class.java) 2860 scope.launch { state.startTransition(transition) } 2861 2862 // Reset the counters after the first animation frame. 2863 rule.waitForIdle() 2864 numberOfMeasurements = 0 2865 numberOfPlacements = 0 2866 2867 // Change the progress a bunch of times. 2868 val nFrames = 20 2869 repeat(nFrames) { i -> 2870 progress = i / nFrames.toFloat() 2871 rule.waitForIdle() 2872 2873 // We shouldn't have remeasured or replaced Foo. 2874 assertWithMessage("Frame $i didn't remeasure Foo") 2875 .that(numberOfMeasurements) 2876 .isEqualTo(0) 2877 assertWithMessage("Frame $i didn't replace Foo").that(numberOfPlacements).isEqualTo(0) 2878 } 2879 } 2880 2881 @Test 2882 fun interruption_considerPreviousUniqueState() { 2883 @Composable 2884 fun SceneScope.Foo(modifier: Modifier = Modifier) { 2885 Box(modifier.element(TestElements.Foo).size(50.dp)) 2886 } 2887 2888 val state = rule.runOnUiThread { MutableSceneTransitionLayoutState(SceneA) } 2889 val scope = 2890 rule.setContentAndCreateMainScope { 2891 SceneTransitionLayout(state) { 2892 scene(SceneA) { Box(Modifier.fillMaxSize()) { Foo() } } 2893 scene(SceneB) { Box(Modifier.fillMaxSize()) } 2894 scene(SceneC) { 2895 Box(Modifier.fillMaxSize()) { Foo(Modifier.offset(x = 100.dp, y = 100.dp)) } 2896 } 2897 } 2898 } 2899 2900 // During A => B, Foo disappears and stays in its original position. 2901 scope.launch { state.startTransition(transition(SceneA, SceneB)) } 2902 rule 2903 .onNode(isElement(TestElements.Foo)) 2904 .assertSizeIsEqualTo(50.dp) 2905 .assertPositionInRootIsEqualTo(0.dp, 0.dp) 2906 2907 // Interrupt A => B by B => C. 2908 var interruptionProgress by mutableFloatStateOf(1f) 2909 scope.launch { 2910 state.startTransition( 2911 transition(SceneB, SceneC, interruptionProgress = { interruptionProgress }) 2912 ) 2913 } 2914 2915 // During B => C, Foo appears again. It is still at (0, 0) when the interruption progress is 2916 // 100%, and converges to its position (100, 100) in C. 2917 rule 2918 .onNode(isElement(TestElements.Foo)) 2919 .assertSizeIsEqualTo(50.dp) 2920 .assertPositionInRootIsEqualTo(0.dp, 0.dp) 2921 2922 interruptionProgress = 0.5f 2923 rule 2924 .onNode(isElement(TestElements.Foo)) 2925 .assertSizeIsEqualTo(50.dp) 2926 .assertPositionInRootIsEqualTo(50.dp, 50.dp) 2927 2928 interruptionProgress = 0f 2929 rule 2930 .onNode(isElement(TestElements.Foo)) 2931 .assertSizeIsEqualTo(50.dp) 2932 .assertPositionInRootIsEqualTo(100.dp, 100.dp) 2933 } 2934 } 2935