• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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