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