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