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