1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.compose.animation.scene 18 19 import androidx.compose.animation.core.FastOutSlowInEasing 20 import androidx.compose.animation.core.FiniteAnimationSpec 21 import androidx.compose.animation.core.LinearEasing 22 import androidx.compose.animation.core.spring 23 import androidx.compose.animation.core.tween 24 import androidx.compose.foundation.background 25 import androidx.compose.foundation.layout.Box 26 import androidx.compose.foundation.layout.Spacer 27 import androidx.compose.foundation.layout.fillMaxSize 28 import androidx.compose.foundation.layout.offset 29 import androidx.compose.foundation.layout.size 30 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 31 import androidx.compose.material3.MaterialTheme 32 import androidx.compose.material3.MotionScheme 33 import androidx.compose.material3.Text 34 import androidx.compose.runtime.Composable 35 import androidx.compose.runtime.getValue 36 import androidx.compose.runtime.mutableStateOf 37 import androidx.compose.runtime.remember 38 import androidx.compose.runtime.rememberCoroutineScope 39 import androidx.compose.runtime.setValue 40 import androidx.compose.ui.Alignment 41 import androidx.compose.ui.Modifier 42 import androidx.compose.ui.graphics.Color 43 import androidx.compose.ui.platform.LocalViewConfiguration 44 import androidx.compose.ui.platform.testTag 45 import androidx.compose.ui.test.SemanticsNodeInteraction 46 import androidx.compose.ui.test.assertHeightIsEqualTo 47 import androidx.compose.ui.test.assertIsDisplayed 48 import androidx.compose.ui.test.assertIsNotDisplayed 49 import androidx.compose.ui.test.assertPositionInRootIsEqualTo 50 import androidx.compose.ui.test.assertWidthIsEqualTo 51 import androidx.compose.ui.test.junit4.createComposeRule 52 import androidx.compose.ui.test.onChild 53 import androidx.compose.ui.test.onNodeWithTag 54 import androidx.compose.ui.test.onNodeWithText 55 import androidx.compose.ui.test.onRoot 56 import androidx.compose.ui.test.performTouchInput 57 import androidx.compose.ui.test.swipeDown 58 import androidx.compose.ui.unit.Dp 59 import androidx.compose.ui.unit.DpOffset 60 import androidx.compose.ui.unit.IntOffset 61 import androidx.compose.ui.unit.dp 62 import androidx.test.ext.junit.runners.AndroidJUnit4 63 import com.android.compose.animation.scene.TestScenes.SceneA 64 import com.android.compose.animation.scene.TestScenes.SceneB 65 import com.android.compose.animation.scene.TestScenes.SceneC 66 import com.android.compose.animation.scene.subjects.assertThat 67 import com.android.compose.test.assertSizeIsEqualTo 68 import com.android.compose.test.setContentAndCreateMainScope 69 import com.android.compose.test.subjects.DpOffsetSubject 70 import com.android.compose.test.subjects.assertThat 71 import com.android.compose.test.transition 72 import com.google.common.truth.Truth.assertThat 73 import kotlinx.coroutines.CoroutineScope 74 import kotlinx.coroutines.launch 75 import org.junit.Assert.assertThrows 76 import org.junit.Rule 77 import org.junit.Test 78 import org.junit.runner.RunWith 79 80 @RunWith(AndroidJUnit4::class) 81 class SceneTransitionLayoutTest { 82 companion object { 83 private val LayoutSize = 300.dp 84 } 85 86 private lateinit var coroutineScope: CoroutineScope 87 private lateinit var layoutState: MutableSceneTransitionLayoutState 88 private var currentScene: SceneKey 89 get() = layoutState.transitionState.currentScene 90 set(value) { <lambda>null91 rule.runOnUiThread { layoutState.setTargetScene(value, coroutineScope) } 92 } 93 94 @get:Rule val rule = createComposeRule() 95 96 /** The content under test. */ 97 @Composable TestContentnull98 private fun TestContent() { 99 coroutineScope = rememberCoroutineScope() 100 layoutState = remember { 101 MutableSceneTransitionLayoutStateForTests(SceneA, EmptyTestTransitions) 102 } 103 104 SceneTransitionLayoutForTesting(state = layoutState, modifier = Modifier.size(LayoutSize)) { 105 scene(SceneA, userActions = mapOf(Back to SceneB)) { 106 Box(Modifier.fillMaxSize()) { 107 SharedFoo(size = 50.dp, childOffset = 0.dp, Modifier.align(Alignment.TopEnd)) 108 Text("SceneA") 109 } 110 } 111 scene(SceneB) { 112 Box(Modifier.fillMaxSize()) { 113 SharedFoo( 114 size = 100.dp, 115 childOffset = 50.dp, 116 Modifier.align(Alignment.TopStart), 117 ) 118 Text("SceneB") 119 } 120 } 121 scene(SceneC) { 122 Box(Modifier.fillMaxSize()) { 123 SharedFoo( 124 size = 150.dp, 125 childOffset = 100.dp, 126 Modifier.align(Alignment.BottomStart), 127 ) 128 Text("SceneC") 129 } 130 } 131 } 132 } 133 134 @Composable ContentScopenull135 private fun ContentScope.SharedFoo(size: Dp, childOffset: Dp, modifier: Modifier = Modifier) { 136 ElementWithValues(TestElements.Foo, modifier.size(size).background(Color.Red)) { 137 // Offset the single child of Foo by some animated shared offset. 138 val offset by animateElementDpAsState(childOffset, TestValues.Value1) 139 140 content { 141 Box( 142 Modifier.offset { 143 val pxOffset = offset.roundToPx() 144 IntOffset(pxOffset, pxOffset) 145 } 146 .size(30.dp) 147 .background(Color.Blue) 148 .testTag(TestElements.Bar.debugName) 149 ) 150 } 151 } 152 } 153 154 @Test testOnlyCurrentSceneIsDisplayednull155 fun testOnlyCurrentSceneIsDisplayed() { 156 rule.setContent { TestContent() } 157 158 // Only scene A is displayed. 159 rule.onNodeWithText("SceneA").assertIsDisplayed() 160 rule.onNodeWithText("SceneB").assertDoesNotExist() 161 rule.onNodeWithText("SceneC").assertDoesNotExist() 162 assertThat(layoutState.transitionState).isIdle() 163 assertThat(layoutState.transitionState).hasCurrentScene(SceneA) 164 165 // Change to scene B. Only that scene is displayed. 166 currentScene = SceneB 167 rule.onNodeWithText("SceneA").assertDoesNotExist() 168 rule.onNodeWithText("SceneB").assertIsDisplayed() 169 rule.onNodeWithText("SceneC").assertDoesNotExist() 170 assertThat(layoutState.transitionState).isIdle() 171 assertThat(layoutState.transitionState).hasCurrentScene(SceneB) 172 } 173 174 @Test testTransitionStatenull175 fun testTransitionState() { 176 rule.setContent { TestContent() } 177 assertThat(layoutState.transitionState).isIdle() 178 assertThat(layoutState.transitionState).hasCurrentScene(SceneA) 179 180 // We will advance the clock manually. 181 rule.mainClock.autoAdvance = false 182 183 // Change the current scene. 184 currentScene = SceneB 185 val transition = assertThat(layoutState.transitionState).isSceneTransition() 186 assertThat(transition).hasFromScene(SceneA) 187 assertThat(transition).hasToScene(SceneB) 188 assertThat(transition).hasProgress(0f) 189 190 // Then, on the next frame, the animator we started gets its initial value and clock 191 // starting time. We are still at progress = 0f. 192 rule.mainClock.advanceTimeByFrame() 193 assertThat(transition).hasProgress(0f) 194 195 // The test transition lasts 480ms. 240ms after the start of the transition, we are at 196 // progress = 0.5f. 197 rule.mainClock.advanceTimeBy(TestTransitionDuration / 2) 198 assertThat(transition).hasProgress(0.5f) 199 200 // (240-16) ms later, i.e. one frame before the transition is finished, we are at 201 // progress=(480-16)/480. 202 rule.mainClock.advanceTimeBy(TestTransitionDuration / 2 - 16) 203 assertThat(transition).hasProgress((TestTransitionDuration - 16) / 480f) 204 205 // one frame (16ms) later, the transition is finished and we are in the idle state in scene 206 // B. 207 rule.mainClock.advanceTimeByFrame() 208 assertThat(layoutState.transitionState).isIdle() 209 assertThat(layoutState.transitionState).hasCurrentScene(SceneB) 210 } 211 212 @Test testSharedElementnull213 fun testSharedElement() { 214 rule.setContent { TestContent() } 215 216 // In scene A, the shared element SharedFoo() is at the top end of the layout and has a size 217 // of 50.dp. 218 var sharedFoo = rule.onNodeWithTag(TestElements.Foo.testTag, useUnmergedTree = true) 219 sharedFoo.assertWidthIsEqualTo(50.dp) 220 sharedFoo.assertHeightIsEqualTo(50.dp) 221 sharedFoo.assertPositionInRootIsEqualTo( 222 expectedTop = 0.dp, 223 expectedLeft = LayoutSize - 50.dp, 224 ) 225 226 // The shared offset of the single child of SharedFoo() is 0dp in scene A. 227 assertThat(sharedFoo.onChild().offsetRelativeTo(sharedFoo)).isEqualTo(DpOffset(0.dp, 0.dp)) 228 229 // Pause animations to test the state mid-transition. 230 rule.mainClock.autoAdvance = false 231 232 // Go to scene B and let the animation start. 233 currentScene = SceneB 234 rule.mainClock.advanceTimeByFrame() 235 236 // Advance to the middle of the animation. 237 rule.mainClock.advanceTimeBy(TestTransitionDuration / 2) 238 239 // Foo is shared between Scene A and Scene B, and is therefore placed/drawn in Scene B given 240 // that B has a higher zIndex than A. 241 sharedFoo = rule.onNode(isElement(TestElements.Foo, SceneB)) 242 243 // In scene B, foo is at the top start (x = 0, y = 0) of the layout and has a size of 244 // 100.dp. We pause at the middle of the transition, so it should now be 75.dp given that we 245 // use a linear interpolator. Foo was at (x = layoutSize - 50dp, y = 0) in SceneA and is 246 // going to (x = 0, y = 0), so the offset should now be half what it was. 247 var transition = assertThat(layoutState.transitionState).isSceneTransition() 248 assertThat(transition).hasProgress(0.5f) 249 sharedFoo.assertWidthIsEqualTo(75.dp) 250 sharedFoo.assertHeightIsEqualTo(75.dp) 251 sharedFoo.assertPositionInRootIsEqualTo( 252 expectedTop = 0.dp, 253 expectedLeft = (LayoutSize - 50.dp) / 2, 254 ) 255 256 // The shared offset of the single child of SharedFoo() is 50dp in scene B and 0dp in Scene 257 // A, so it should be 25dp now. 258 assertThat(sharedFoo.onChild().offsetRelativeTo(sharedFoo)) 259 .isWithin(DpOffsetSubject.DefaultTolerance) 260 .of(DpOffset(25.dp, 25.dp)) 261 262 // Finish the transition. 263 rule.mainClock.advanceTimeBy(TestTransitionDuration / 2) 264 265 // Animate to scene C, let the animation start then go to the middle of the transition. 266 currentScene = SceneC 267 rule.mainClock.advanceTimeByFrame() 268 rule.mainClock.advanceTimeBy(TestTransitionDuration / 2) 269 270 // In Scene C, foo is at the bottom start of the layout and has a size of 150.dp. The 271 // transition scene B => scene C is using a FastOutSlowIn interpolator. 272 val interpolatedProgress = FastOutSlowInEasing.transform(0.5f) 273 val expectedTop = (LayoutSize - 150.dp) * interpolatedProgress 274 val expectedLeft = 0.dp 275 val expectedSize = 100.dp + (150.dp - 100.dp) * interpolatedProgress 276 277 sharedFoo = rule.onNode(isElement(TestElements.Foo, SceneC)) 278 transition = assertThat(layoutState.transitionState).isSceneTransition() 279 assertThat(transition).hasProgress(interpolatedProgress) 280 sharedFoo.assertWidthIsEqualTo(expectedSize) 281 sharedFoo.assertHeightIsEqualTo(expectedSize) 282 sharedFoo.assertPositionInRootIsEqualTo(expectedLeft, expectedTop) 283 284 // The shared offset of the single child of SharedFoo() is 50dp in scene B and 100dp in 285 // Scene C. 286 val expectedOffset = 50.dp + (100.dp - 50.dp) * interpolatedProgress 287 assertThat(sharedFoo.onChild().offsetRelativeTo(sharedFoo)) 288 .isWithin(DpOffsetSubject.DefaultTolerance) 289 .of(DpOffset(expectedOffset, expectedOffset)) 290 291 // Wait for the transition to C to finish. 292 rule.mainClock.advanceTimeBy(TestTransitionDuration) 293 assertThat(layoutState.transitionState).isIdle() 294 assertThat(layoutState.transitionState).hasCurrentScene(SceneC) 295 296 // Go back to scene A. This should happen instantly (once the animation started, i.e. after 297 // 2 frames) given that we use a snap() animation spec. 298 currentScene = SceneA 299 rule.mainClock.advanceTimeByFrame() 300 rule.mainClock.advanceTimeByFrame() 301 assertThat(layoutState.transitionState).isIdle() 302 assertThat(layoutState.transitionState).hasCurrentScene(SceneA) 303 } 304 305 @Test layoutSizeIsAnimatednull306 fun layoutSizeIsAnimated() { 307 val layoutTag = "layout" 308 rule.testTransition( 309 fromSceneContent = { Box(Modifier.size(200.dp, 100.dp)) }, 310 toSceneContent = { Box(Modifier.size(120.dp, 140.dp)) }, 311 transition = { 312 // 4 frames of animation. 313 spec = tween(4 * 16, easing = LinearEasing) 314 }, 315 layoutModifier = Modifier.testTag(layoutTag), 316 ) { 317 before { rule.onNodeWithTag(layoutTag).assertSizeIsEqualTo(200.dp, 100.dp) } 318 at(16) { rule.onNodeWithTag(layoutTag).assertSizeIsEqualTo(180.dp, 110.dp) } 319 at(32) { rule.onNodeWithTag(layoutTag).assertSizeIsEqualTo(160.dp, 120.dp) } 320 at(48) { rule.onNodeWithTag(layoutTag).assertSizeIsEqualTo(140.dp, 130.dp) } 321 after { rule.onNodeWithTag(layoutTag).assertSizeIsEqualTo(120.dp, 140.dp) } 322 } 323 } 324 325 @Test multipleTransitionsWillComposeMultipleScenesnull326 fun multipleTransitionsWillComposeMultipleScenes() { 327 val duration = 10 * 16L 328 329 val state = 330 rule.runOnUiThread { 331 MutableSceneTransitionLayoutStateForTests( 332 SceneA, 333 transitions { 334 from(SceneA, to = SceneB) { 335 spec = tween(duration.toInt(), easing = LinearEasing) 336 } 337 from(SceneB, to = SceneC) { 338 spec = tween(duration.toInt(), easing = LinearEasing) 339 } 340 }, 341 ) 342 } 343 344 lateinit var coroutineScope: CoroutineScope 345 rule.setContent { 346 coroutineScope = rememberCoroutineScope() 347 SceneTransitionLayout(state) { 348 scene(SceneA) { Box(Modifier.testTag("aRoot").fillMaxSize()) } 349 scene(SceneB) { Box(Modifier.testTag("bRoot").fillMaxSize()) } 350 scene(SceneC) { Box(Modifier.testTag("cRoot").fillMaxSize()) } 351 } 352 } 353 354 // Initial state: only A is composed. 355 rule.onNodeWithTag("aRoot").assertExists() 356 rule.onNodeWithTag("bRoot").assertDoesNotExist() 357 rule.onNodeWithTag("cRoot").assertDoesNotExist() 358 359 // Pause the clock so we can manually advance it. 360 rule.waitForIdle() 361 rule.mainClock.autoAdvance = false 362 363 // Start A => B and go to the middle of the transition. 364 rule.runOnUiThread { state.setTargetScene(SceneB, coroutineScope) } 365 366 // We need to tick 1 frames after changing [currentScene] before the animation actually 367 // starts. 368 rule.mainClock.advanceTimeByFrame() 369 rule.mainClock.advanceTimeBy(duration / 2) 370 rule.waitForIdle() 371 372 var transition = assertThat(state.transitionState).isSceneTransition() 373 assertThat(transition).hasProgress(0.5f) 374 375 // A and B are composed. 376 rule.onNodeWithTag("aRoot").assertExists() 377 rule.onNodeWithTag("bRoot").assertExists() 378 rule.onNodeWithTag("cRoot").assertDoesNotExist() 379 380 // Start B => C. 381 rule.runOnUiThread { state.setTargetScene(SceneC, coroutineScope) } 382 rule.mainClock.advanceTimeByFrame() 383 rule.waitForIdle() 384 385 transition = assertThat(state.transitionState).isSceneTransition() 386 assertThat(transition).hasProgress(0f) 387 388 // A, B and C are composed. 389 rule.onNodeWithTag("aRoot").assertExists() 390 rule.onNodeWithTag("bRoot").assertExists() 391 rule.onNodeWithTag("cRoot").assertExists() 392 393 // Let A => B finish. 394 rule.mainClock.advanceTimeBy(duration / 2L) 395 assertThat(transition).hasProgress(0.5f) 396 rule.waitForIdle() 397 398 // A, B and C are still composed given that B => C is not finished yet. 399 rule.onNodeWithTag("aRoot").assertExists() 400 rule.onNodeWithTag("bRoot").assertExists() 401 rule.onNodeWithTag("cRoot").assertExists() 402 403 // Let B => C finish. 404 rule.mainClock.advanceTimeBy(duration / 2L) 405 rule.mainClock.advanceTimeByFrame() 406 rule.waitForIdle() 407 assertThat(state.transitionState).isIdle() 408 409 // Only C is composed. 410 rule.onNodeWithTag("aRoot").assertDoesNotExist() 411 rule.onNodeWithTag("bRoot").assertDoesNotExist() 412 rule.onNodeWithTag("cRoot").assertExists() 413 } 414 SemanticsNodeInteractionnull415 private fun SemanticsNodeInteraction.offsetRelativeTo( 416 other: SemanticsNodeInteraction 417 ): DpOffset { 418 val node = fetchSemanticsNode() 419 val bounds = node.boundsInRoot 420 val otherBounds = other.fetchSemanticsNode().boundsInRoot 421 return with(node.layoutInfo.density) { 422 DpOffset( 423 x = (bounds.left - otherBounds.left).toDp(), 424 y = (bounds.top - otherBounds.top).toDp(), 425 ) 426 } 427 } 428 429 @Test userActionFromSceneAToSceneA_throwsNotSupportednull430 fun userActionFromSceneAToSceneA_throwsNotSupported() { 431 val exception: IllegalStateException = 432 assertThrows(IllegalStateException::class.java) { 433 rule.setContent { 434 SceneTransitionLayout( 435 state = remember { MutableSceneTransitionLayoutStateForTests(SceneA) }, 436 modifier = Modifier.size(LayoutSize), 437 ) { 438 // from SceneA to SceneA 439 scene(SceneA, userActions = mapOf(Back to SceneA), content = {}) 440 } 441 } 442 } 443 444 assertThat(exception).hasMessageThat().contains(Back.toString()) 445 assertThat(exception).hasMessageThat().contains(SceneA.debugName) 446 } 447 448 @Test sceneKeyInScopenull449 fun sceneKeyInScope() { 450 val state = rule.runOnUiThread { MutableSceneTransitionLayoutStateForTests(SceneA) } 451 452 var keyInA: ContentKey? = null 453 var keyInB: ContentKey? = null 454 var keyInC: ContentKey? = null 455 rule.setContent { 456 SceneTransitionLayout(state) { 457 scene(SceneA) { keyInA = contentKey } 458 scene(SceneB) { keyInB = contentKey } 459 scene(SceneC) { keyInC = contentKey } 460 } 461 } 462 463 // Snap to B then C to compose these scenes at least once. 464 rule.runOnUiThread { state.snapTo(SceneB) } 465 rule.waitForIdle() 466 rule.runOnUiThread { state.snapTo(SceneC) } 467 rule.waitForIdle() 468 469 assertThat(keyInA).isEqualTo(SceneA) 470 assertThat(keyInB).isEqualTo(SceneB) 471 assertThat(keyInC).isEqualTo(SceneC) 472 } 473 474 @Test overlaysMapIsNotAllocatedWhenNoOverlayIsDefinednull475 fun overlaysMapIsNotAllocatedWhenNoOverlayIsDefined() { 476 lateinit var layoutImpl: SceneTransitionLayoutImpl 477 rule.setContent { 478 SceneTransitionLayoutForTesting( 479 remember { MutableSceneTransitionLayoutStateForTests(SceneA) }, 480 onLayoutImpl = { layoutImpl = it }, 481 ) { 482 scene(SceneA) { Box(Modifier.fillMaxSize()) } 483 } 484 } 485 486 assertThat(layoutImpl.overlaysOrNullForTest()).isNull() 487 } 488 489 @Test transitionProgressBoundedBetween0And1null490 fun transitionProgressBoundedBetween0And1() { 491 val layoutWidth = 200.dp 492 val layoutHeight = 400.dp 493 494 // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is 495 // detected as a drag event. 496 var touchSlop = 0f 497 val state = 498 rule.runOnUiThread { MutableSceneTransitionLayoutStateForTests(initialScene = SceneA) } 499 rule.setContent { 500 touchSlop = LocalViewConfiguration.current.touchSlop 501 SceneTransitionLayout(state, Modifier.size(layoutWidth, layoutHeight)) { 502 scene(SceneA, userActions = mapOf(Swipe.Down to SceneB)) { 503 Spacer(Modifier.fillMaxSize()) 504 } 505 scene(SceneB) { Spacer(Modifier.fillMaxSize()) } 506 } 507 } 508 assertThat(state.transitionState).isIdle() 509 510 rule.mainClock.autoAdvance = false 511 512 // Swipe the verticalSwipeDistance. 513 rule.onRoot().performTouchInput { 514 swipeDown(endY = bottom + touchSlop, durationMillis = 50) 515 } 516 517 rule.mainClock.advanceTimeBy(16) 518 val transition = assertThat(state.transitionState).isSceneTransition() 519 assertThat(transition).isNotNull() 520 assertThat(transition).hasProgress(1f, tolerance = 0.01f) 521 522 rule.mainClock.advanceTimeBy(16) 523 // Fling animation, we are overscrolling now. Progress should always be between [0, 1]. 524 assertThat(transition).hasProgress(1f) 525 } 526 527 @OptIn(ExperimentalMaterial3ExpressiveApi::class) 528 @Test motionSchemeArePassedToSTLStatenull529 fun motionSchemeArePassedToSTLState() { 530 // Implementation inspired by MotionScheme.standard() 531 @Suppress("UNCHECKED_CAST") 532 fun motionScheme(animationSpec: FiniteAnimationSpec<Any>) = 533 object : MotionScheme { 534 override fun <T> defaultEffectsSpec() = animationSpec as FiniteAnimationSpec<T> 535 536 override fun <T> defaultSpatialSpec() = animationSpec as FiniteAnimationSpec<T> 537 538 override fun <T> fastEffectsSpec() = animationSpec as FiniteAnimationSpec<T> 539 540 override fun <T> fastSpatialSpec() = animationSpec as FiniteAnimationSpec<T> 541 542 override fun <T> slowEffectsSpec() = animationSpec as FiniteAnimationSpec<T> 543 544 override fun <T> slowSpatialSpec() = animationSpec as FiniteAnimationSpec<T> 545 } 546 547 lateinit var state1: MutableSceneTransitionLayoutState 548 lateinit var state2: MutableSceneTransitionLayoutState 549 550 lateinit var motionScheme1: MotionScheme 551 var motionScheme2 by mutableStateOf(motionScheme(animationSpec = tween(500))) 552 rule.setContent { 553 motionScheme1 = MaterialTheme.motionScheme 554 state1 = rememberMutableSceneTransitionLayoutState(initialScene = SceneA) 555 SceneTransitionLayout(state1) { 556 scene(SceneA, userActions = mapOf(Swipe.Down to SceneB)) { 557 Spacer(Modifier.fillMaxSize()) 558 } 559 } 560 561 MaterialTheme(motionScheme = motionScheme2) { 562 // Important: we should read this state inside the MaterialTheme composable. 563 state2 = rememberMutableSceneTransitionLayoutState(initialScene = SceneA) 564 SceneTransitionLayout(state2) { 565 scene(SceneA, userActions = mapOf(Swipe.Down to SceneB)) { 566 Spacer(Modifier.fillMaxSize()) 567 } 568 } 569 } 570 } 571 572 assertThat(motionScheme1).isNotNull() 573 assertThat(motionScheme1).isNotEqualTo(motionScheme2) 574 575 assertThat((state1 as MutableSceneTransitionLayoutStateImpl).motionScheme) 576 .isEqualTo(motionScheme1) 577 578 assertThat((state2 as MutableSceneTransitionLayoutStateImpl).motionScheme) 579 .isEqualTo(motionScheme2) 580 581 // Update the MaterialTheme's MotionScheme configuration. 582 motionScheme2 = motionScheme(animationSpec = spring()) 583 584 // We just updated the motionScheme2 state, wait for a recomposition. 585 rule.waitForIdle() 586 assertThat((state2 as MutableSceneTransitionLayoutStateImpl).motionScheme) 587 .isEqualTo(motionScheme2) 588 } 589 590 @Test alwaysComposenull591 fun alwaysCompose() { 592 val state = rule.runOnUiThread { MutableSceneTransitionLayoutStateForTests(SceneA) } 593 val scope = 594 rule.setContentAndCreateMainScope { 595 SceneTransitionLayoutForTesting(state) { 596 scene(SceneA) { Box(Modifier.element(TestElements.Foo).size(20.dp)) } 597 scene(SceneB, alwaysCompose = true) { 598 Box(Modifier.element(TestElements.Bar).size(40.dp)) 599 } 600 } 601 } 602 603 // Idle(A): Foo is displayed and Bar exists given that SceneB is always composed but it is 604 // not displayed. 605 rule.onNode(isElement(TestElements.Foo)).assertIsDisplayed().assertSizeIsEqualTo(20.dp) 606 rule.onNode(isElement(TestElements.Bar)).assertExists().assertIsNotDisplayed() 607 608 // Transition(A => B): Foo and Bar are both displayed 609 val aToB = transition(SceneA, SceneB) 610 scope.launch { state.startTransition(aToB) } 611 rule.onNode(isElement(TestElements.Foo)).assertIsDisplayed().assertSizeIsEqualTo(20.dp) 612 rule.onNode(isElement(TestElements.Bar)).assertIsDisplayed().assertSizeIsEqualTo(40.dp) 613 614 // Idle(B): Foo does not exist and Bar is displayed. 615 aToB.finish() 616 rule.onNode(isElement(TestElements.Foo)).assertDoesNotExist() 617 rule.onNode(isElement(TestElements.Bar)).assertIsDisplayed().assertSizeIsEqualTo(40.dp) 618 } 619 } 620