1 /*
<lambda>null2  * Copyright (C) 2022 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 androidx.constraintlayout.compose
18 
19 import androidx.compose.animation.core.animateFloatAsState
20 import androidx.compose.foundation.Image
21 import androidx.compose.foundation.background
22 import androidx.compose.foundation.border
23 import androidx.compose.foundation.layout.Box
24 import androidx.compose.foundation.layout.Column
25 import androidx.compose.foundation.layout.IntrinsicSize
26 import androidx.compose.foundation.layout.fillMaxWidth
27 import androidx.compose.foundation.layout.height
28 import androidx.compose.foundation.layout.size
29 import androidx.compose.foundation.layout.width
30 import androidx.compose.foundation.layout.wrapContentHeight
31 import androidx.compose.foundation.shape.CircleShape
32 import androidx.compose.material.LocalTextStyle
33 import androidx.compose.material.Text
34 import androidx.compose.material.icons.Icons
35 import androidx.compose.material.icons.filled.Person
36 import androidx.compose.runtime.Composable
37 import androidx.compose.runtime.CompositionLocalProvider
38 import androidx.compose.runtime.MutableState
39 import androidx.compose.runtime.derivedStateOf
40 import androidx.compose.runtime.getValue
41 import androidx.compose.runtime.mutableStateListOf
42 import androidx.compose.runtime.mutableStateOf
43 import androidx.compose.runtime.remember
44 import androidx.compose.runtime.setValue
45 import androidx.compose.ui.Modifier
46 import androidx.compose.ui.draw.clip
47 import androidx.compose.ui.geometry.Offset
48 import androidx.compose.ui.geometry.Rect
49 import androidx.compose.ui.graphics.Color
50 import androidx.compose.ui.graphics.toArgb
51 import androidx.compose.ui.layout.boundsInParent
52 import androidx.compose.ui.layout.layoutId
53 import androidx.compose.ui.layout.onGloballyPositioned
54 import androidx.compose.ui.layout.positionInParent
55 import androidx.compose.ui.layout.positionInRoot
56 import androidx.compose.ui.platform.LocalContext
57 import androidx.compose.ui.platform.LocalDensity
58 import androidx.compose.ui.test.getUnclippedBoundsInRoot
59 import androidx.compose.ui.test.junit4.createComposeRule
60 import androidx.compose.ui.test.onNodeWithTag
61 import androidx.compose.ui.test.onNodeWithText
62 import androidx.compose.ui.text.PlatformTextStyle
63 import androidx.compose.ui.text.TextStyle
64 import androidx.compose.ui.text.font.FontFamily
65 import androidx.compose.ui.text.font.FontWeight
66 import androidx.compose.ui.unit.Density
67 import androidx.compose.ui.unit.IntOffset
68 import androidx.compose.ui.unit.IntRect
69 import androidx.compose.ui.unit.IntSize
70 import androidx.compose.ui.unit.dp
71 import androidx.compose.ui.unit.height
72 import androidx.compose.ui.unit.round
73 import androidx.compose.ui.unit.roundToIntRect
74 import androidx.compose.ui.unit.size
75 import androidx.compose.ui.unit.sp
76 import androidx.compose.ui.unit.width
77 import androidx.constraintlayout.compose.test.R
78 import androidx.test.ext.junit.runners.AndroidJUnit4
79 import androidx.test.filters.MediumTest
80 import kotlin.math.roundToInt
81 import kotlin.test.assertEquals
82 import kotlin.test.assertNotEquals
83 import kotlin.test.assertTrue
84 import org.junit.Rule
85 import org.junit.Test
86 import org.junit.runner.RunWith
87 
88 @OptIn(ExperimentalMotionApi::class)
89 @MediumTest
90 @RunWith(AndroidJUnit4::class)
91 internal class MotionLayoutTest {
92     @get:Rule val rule = createComposeRule()
93 
94     /**
95      * Tests that [MotionLayoutScope.customFontSize] works as expected.
96      *
97      * See custom_text_size_scene.json5
98      */
99     @Test
100     fun testCustomTextSize() {
101         var animateToEnd by mutableStateOf(false)
102         rule.setContent {
103             val progress by animateFloatAsState(targetValue = if (animateToEnd) 1.0f else 0f)
104             CustomTextSize(modifier = Modifier.size(200.dp), progress = progress)
105         }
106         rule.waitForIdle()
107 
108         var usernameSize = rule.onNodeWithTag("username").getUnclippedBoundsInRoot().size
109 
110         // TextSize is 18sp at the start. Since getting the resulting dimensions of the text is not
111         // straightforward, the values were obtained by running the test
112         assertEquals(55.dp.value, usernameSize.width.value, absoluteTolerance = 0.5f)
113         assertEquals(25.dp.value, usernameSize.height.value, absoluteTolerance = 0.5f)
114 
115         animateToEnd = true
116         rule.waitForIdle()
117 
118         usernameSize = rule.onNodeWithTag("username").getUnclippedBoundsInRoot().size
119 
120         // TextSize is 12sp at the end. Results in approx. 66% of the original text height
121         assertEquals(35.dp.value, usernameSize.width.value, absoluteTolerance = 0.5f)
122         assertEquals(17.dp.value, usernameSize.height.value, absoluteTolerance = 0.5f)
123     }
124 
125     @Test
126     fun testCustomKeyFrameAttributes() {
127         val progress: MutableState<Float> = mutableStateOf(0f)
128         rule.setContent {
129             MotionLayout(
130                 motionScene =
131                     MotionScene {
132                         val element = createRefFor("element")
133                         defaultTransition(
134                             from =
135                                 constraintSet {
136                                     constrain(element) {
137                                         customColor("color", Color.White)
138                                         customDistance("distance", 0.dp)
139                                         customFontSize("fontSize", 0.sp)
140                                         customInt("int", 0)
141                                     }
142                                 },
143                             to =
144                                 constraintSet {
145                                     constrain(element) {
146                                         customColor("color", Color.Black)
147                                         customDistance("distance", 10.dp)
148                                         customFontSize("fontSize", 20.sp)
149                                         customInt("int", 30)
150                                     }
151                                 }
152                         ) {
153                             keyAttributes(element) {
154                                 frame(50) {
155                                     // Also tests interpolating to a transparent color
156                                     customColor("color", Color(0x00ff0000))
157                                     customDistance("distance", 20.dp)
158                                     customFontSize("fontSize", 30.sp)
159                                     customInt("int", 40)
160                                 }
161                             }
162                         }
163                     },
164                 progress = progress.value,
165                 modifier = Modifier.size(200.dp)
166             ) {
167                 val props = customProperties(id = "element")
168                 Column(Modifier.layoutId("element")) {
169                     Text(text = "1) Color: #${props.color("color").toHexString()}")
170                     Text(text = "2) Distance: ${props.distance("distance")}")
171                     Text(text = "3) FontSize: ${props.fontSize("fontSize")}")
172                     Text(text = "4) Int: ${props.int("int")}")
173 
174                     // Missing properties
175                     Text(text = "5) Color: #${props.color("a").toHexString()}")
176                     Text(text = "6) Distance: ${props.distance("b")}")
177                     Text(text = "7) FontSize: ${props.fontSize("c")}")
178                     Text(text = "8) Int: ${props.int("d")}")
179                 }
180             }
181         }
182         rule.waitForIdle()
183 
184         progress.value = 0.25f
185         rule.waitForIdle()
186         rule.onNodeWithText("1) Color: #7fffbaba").assertExists()
187         rule.onNodeWithText("2) Distance: 10.0.dp").assertExists()
188         rule.onNodeWithText("3) FontSize: 15.0.sp").assertExists()
189         rule.onNodeWithText("4) Int: 20").assertExists()
190 
191         // Undefined custom properties
192         rule.onNodeWithText("5) Color: #0").assertExists()
193         rule.onNodeWithText("6) Distance: Dp.Unspecified").assertExists()
194         rule.onNodeWithText("7) FontSize: NaN.sp").assertExists()
195         rule.onNodeWithText("8) Int: 0").assertExists()
196 
197         progress.value = 0.75f
198         rule.waitForIdle()
199         rule.onNodeWithText("1) Color: #7fba0000").assertExists()
200         rule.onNodeWithText("2) Distance: 15.0.dp").assertExists()
201         rule.onNodeWithText("3) FontSize: 25.0.sp").assertExists()
202         rule.onNodeWithText("4) Int: 35").assertExists()
203 
204         // Undefined custom properties
205         rule.onNodeWithText("5) Color: #0").assertExists()
206         rule.onNodeWithText("6) Distance: Dp.Unspecified").assertExists()
207         rule.onNodeWithText("7) FontSize: NaN.sp").assertExists()
208         rule.onNodeWithText("8) Int: 0").assertExists()
209     }
210 
211     @Test
212     fun testMotionLayout_withParentIntrinsics() =
213         with(rule.density) {
214             val constraintSet = ConstraintSet {
215                 val (one, two) = createRefsFor("one", "two")
216                 val horChain = createHorizontalChain(one, two, chainStyle = ChainStyle.Packed(0f))
217                 constrain(horChain) {
218                     start.linkTo(parent.start)
219                     end.linkTo(parent.end)
220                 }
221                 constrain(one) {
222                     top.linkTo(parent.top)
223                     bottom.linkTo(parent.bottom)
224                 }
225                 constrain(two) {
226                     width = Dimension.preferredWrapContent
227                     top.linkTo(parent.top)
228                     bottom.linkTo(parent.bottom)
229                 }
230             }
231 
232             val rootBoxWidth = 200
233             val box1Size = 40
234             val box2Size = 70
235 
236             var rootSize = IntSize.Zero
237             var mlSize = IntSize.Zero
238             var box1Position = IntOffset.Zero
239             var box2Position = IntOffset.Zero
240 
241             rule.setContent {
242                 Box(
243                     modifier =
244                         Modifier.width(rootBoxWidth.toDp())
245                             .height(IntrinsicSize.Max)
246                             .background(Color.LightGray)
247                             .onGloballyPositioned { rootSize = it.size }
248                 ) {
249                     MotionLayout(
250                         start = constraintSet,
251                         end = constraintSet,
252                         transition = Transition {},
253                         progress = 0f, // We're not testing the animation
254                         modifier =
255                             Modifier.fillMaxWidth()
256                                 .wrapContentHeight()
257                                 .background(Color.Yellow)
258                                 .onGloballyPositioned { mlSize = it.size }
259                     ) {
260                         Box(
261                             Modifier.size(box1Size.toDp())
262                                 .background(Color.Green)
263                                 .layoutId("one")
264                                 .onGloballyPositioned { box1Position = it.positionInRoot().round() }
265                         )
266                         Box(
267                             Modifier.size(box2Size.toDp())
268                                 .background(Color.Red)
269                                 .layoutId("two")
270                                 .onGloballyPositioned { box2Position = it.positionInRoot().round() }
271                         )
272                     }
273                 }
274             }
275 
276             val expectedSize = IntSize(rootBoxWidth, box2Size)
277             val expectedBox1Y = ((box2Size / 2f) - (box1Size / 2f)).roundToInt()
278             rule.runOnIdle {
279                 assertEquals(expectedSize, rootSize)
280                 assertEquals(expectedSize, mlSize)
281                 assertEquals(IntOffset(0, expectedBox1Y), box1Position)
282                 assertEquals(IntOffset(box1Size, 0), box2Position)
283             }
284         }
285 
286     @Test
287     fun testTransitionChange_hasCorrectStartAndEnd() =
288         with(rule.density) {
289             val rootWidthPx = 200
290             val rootHeightPx = 50
291 
292             val scene = MotionScene {
293                 val circleRef = createRefFor("circle")
294                 val aCSetRef = constraintSet {
295                     constrain(circleRef) {
296                         width = rootHeightPx.toDp().asDimension()
297                         height = rootHeightPx.toDp().asDimension()
298                         centerVerticallyTo(parent)
299                         start.linkTo(parent.start)
300                     }
301                 }
302                 val bCSetRef = constraintSet {
303                     constrain(circleRef) {
304                         width = Dimension.fillToConstraints
305                         height = rootHeightPx.toDp().asDimension()
306                         centerTo(parent)
307                     }
308                 }
309                 val cCSetRef =
310                     constraintSet(extendConstraintSet = aCSetRef) {
311                         constrain(circleRef) {
312                             clearHorizontal()
313                             end.linkTo(parent.end)
314                         }
315                     }
316                 transition(from = aCSetRef, to = bCSetRef, name = "part1") {}
317                 transition(from = bCSetRef, to = cCSetRef, name = "part2") {}
318             }
319 
320             val progress = mutableStateOf(0f)
321             var bounds = IntRect.Zero
322 
323             rule.setContent {
324                 MotionLayout(
325                     motionScene = scene,
326                     progress =
327                         if (progress.value < 0.5) progress.value * 2 else progress.value * 2 - 1,
328                     transitionName = if (progress.value < 0.5f) "part1" else "part2",
329                     modifier =
330                         Modifier.size(width = rootWidthPx.toDp(), height = rootHeightPx.toDp())
331                 ) {
332                     Box(
333                         modifier =
334                             Modifier.layoutId("circle").background(Color.Red).onGloballyPositioned {
335                                 bounds = it.boundsInParent().roundToIntRect()
336                             }
337                     )
338                 }
339             }
340 
341             rule.runOnIdle {
342                 assertEquals(
343                     expected = IntRect(IntOffset(0, 0), IntSize(rootHeightPx, rootHeightPx)),
344                     actual = bounds
345                 )
346             }
347 
348             // Offset attributed to the default non-linear interpolator
349             val offset = 25
350 
351             progress.value = 0.25f
352             rule.runOnIdle {
353                 assertEquals(
354                     expected =
355                         IntRect(
356                             offset = IntOffset(0, 0),
357                             size = IntSize(rootWidthPx / 2 + offset, rootHeightPx)
358                         ),
359                     actual = bounds
360                 )
361             }
362 
363             progress.value = 0.75f
364             rule.runOnIdle {
365                 assertEquals(
366                     expected =
367                         IntRect(
368                             offset = IntOffset(rootWidthPx / 2 - offset, 0),
369                             size = IntSize(rootWidthPx / 2 + offset, rootHeightPx)
370                         ),
371                     actual = bounds
372                 )
373             }
374         }
375 
376     @Test
377     fun testStartAndEndBoundsModifier() =
378         with(rule.density) {
379             val rootSizePx = 100
380             val boxHeight = 10
381             val boxWidthStartPx = 10
382             val boxWidthEndPx = 70
383 
384             val boxId = "box"
385 
386             var startBoundsOfBox = Rect.Zero
387             var endBoundsOfBox = Rect.Zero
388             var globallyPositionedBounds = IntRect.Zero
389 
390             var boundsProvidedCount = 0
391 
392             val progress = mutableStateOf(0f)
393 
394             rule.setContent {
395                 MotionLayout(
396                     motionScene =
397                         MotionScene {
398                             val box = createRefFor(boxId)
399                             defaultTransition(
400                                 from =
401                                     constraintSet {
402                                         constrain(box) {
403                                             width = boxWidthStartPx.toDp().asDimension()
404                                             height = boxHeight.toDp().asDimension()
405 
406                                             top.linkTo(parent.top)
407                                             centerHorizontallyTo(parent)
408                                         }
409                                     },
410                                 to =
411                                     constraintSet {
412                                         constrain(box) {
413                                             width = boxWidthEndPx.toDp().asDimension()
414                                             height = boxHeight.toDp().asDimension()
415 
416                                             centerHorizontallyTo(parent)
417                                             bottom.linkTo(parent.bottom)
418                                         }
419                                     }
420                             )
421                         },
422                     progress = progress.value,
423                     modifier = Modifier.size(rootSizePx.toDp())
424                 ) {
425                     Box(
426                         modifier =
427                             Modifier.layoutId(boxId)
428                                 .background(Color.Red)
429                                 .onStartEndBoundsChanged(boxId) { startBounds, endBounds ->
430                                     boundsProvidedCount++
431                                     startBoundsOfBox = startBounds
432                                     endBoundsOfBox = endBounds
433                                 }
434                                 .onGloballyPositioned {
435                                     globallyPositionedBounds = it.boundsInParent().roundToIntRect()
436                                 }
437                     )
438                 }
439             }
440             rule.waitForIdle()
441 
442             rule.runOnIdle {
443                 // Values should only be assigned once, to prove that they are stable
444                 assertEquals(1, boundsProvidedCount)
445                 assertEquals(
446                     expected =
447                         IntRect(
448                             offset = IntOffset((rootSizePx - boxWidthStartPx) / 2, 0),
449                             size = IntSize(boxWidthStartPx, boxHeight)
450                         ),
451                     actual = globallyPositionedBounds
452                 )
453                 assertEquals(globallyPositionedBounds, startBoundsOfBox.roundToIntRect())
454             }
455 
456             progress.value = 1f
457 
458             rule.runOnIdle {
459                 // Values should only be assigned once, to prove that they are stable
460                 assertEquals(1, boundsProvidedCount)
461                 assertEquals(
462                     expected =
463                         IntRect(
464                             offset =
465                                 IntOffset((rootSizePx - boxWidthEndPx) / 2, rootSizePx - boxHeight),
466                             size = IntSize(boxWidthEndPx, boxHeight)
467                         ),
468                     actual = globallyPositionedBounds
469                 )
470                 assertEquals(globallyPositionedBounds, endBoundsOfBox.roundToIntRect())
471             }
472         }
473 
474     @Test
475     fun testStaggeredAndCustomWeights() =
476         with(rule.density) {
477             val rootSizePx = 100
478             val boxSizePx = 10
479             val progress = mutableStateOf(0f)
480             val staggeredValue = mutableStateOf(0.31f)
481             val weights = mutableStateListOf(Float.NaN, Float.NaN, Float.NaN)
482 
483             val ids = IntArray(3) { it }
484             val positions = mutableMapOf<Int, IntOffset>()
485 
486             rule.setContent {
487                 MotionLayout(
488                     motionScene =
489                         remember {
490                                 derivedStateOf {
491                                     MotionScene {
492                                         val refs = ids.map { createRefFor(it) }.toTypedArray()
493                                         defaultTransition(
494                                             from =
495                                                 constraintSet {
496                                                     createVerticalChain(
497                                                         *refs,
498                                                         chainStyle = ChainStyle.Packed(0.0f)
499                                                     )
500                                                     refs.forEachIndexed { index, ref ->
501                                                         constrain(ref) {
502                                                             staggeredWeight = weights[index]
503                                                         }
504                                                     }
505                                                 },
506                                             to =
507                                                 constraintSet {
508                                                     createVerticalChain(
509                                                         *refs,
510                                                         chainStyle = ChainStyle.Packed(0.0f)
511                                                     )
512                                                     constrain(*refs) { end.linkTo(parent.end) }
513                                                 }
514                                         ) {
515                                             maxStaggerDelay = staggeredValue.value
516                                         }
517                                     }
518                                 }
519                             }
520                             .value,
521                     progress = progress.value,
522                     modifier = Modifier.size(rootSizePx.toDp())
523                 ) {
524                     for (id in ids) {
525                         Box(
526                             Modifier.size(boxSizePx.toDp()).layoutId(id).onGloballyPositioned {
527                                 positions[id] = it.positionInParent().round()
528                             }
529                         )
530                     }
531                 }
532             }
533 
534             // Set the progress to just before the stagger value (0.31f)
535             progress.value = 0.3f
536 
537             rule.runOnIdle {
538                 assertEquals(0, positions[0]!!.x)
539                 assertNotEquals(0, positions[1]!!.x)
540                 assertNotEquals(0, positions[2]!!.x)
541 
542                 // Widget 2 has higher weight since it's laid out further towards the bottom
543                 assertTrue(positions[2]!!.x > positions[1]!!.x)
544             }
545 
546             // Invert the staggering order
547             staggeredValue.value = -(staggeredValue.value)
548 
549             rule.runOnIdle {
550                 assertNotEquals(0, positions[0]!!.x)
551                 assertNotEquals(0, positions[1]!!.x)
552                 assertEquals(0, positions[2]!!.x)
553 
554                 // While inverted, widget 0 has the higher weight
555                 assertTrue(positions[0]!!.x > positions[1]!!.x)
556             }
557 
558             // Set the widget in the middle to have the lowest weight
559             weights[0] = 3f
560             weights[1] = 1f
561             weights[2] = 2f
562 
563             // Set the staggering order back to normal
564             staggeredValue.value = -(staggeredValue.value)
565 
566             rule.runOnIdle {
567                 assertNotEquals(0, positions[0]!!.x)
568                 assertEquals(0, positions[1]!!.x)
569                 assertNotEquals(0, positions[2]!!.x)
570 
571                 // Widget 0 has higher weight, starts earlier
572                 assertTrue(positions[0]!!.x > positions[2]!!.x)
573             }
574         }
575 
576     @Test
577     fun testRemeasureOnContentChanged() {
578         val progress = mutableStateOf(0f)
579         val textContent = mutableStateOf("Foo")
580 
581         rule.setContent {
582             WithConsistentTextStyle {
583                 MotionLayout(
584                     modifier = Modifier.size(300.dp).background(Color.LightGray),
585                     motionScene =
586                         MotionScene {
587                             // Text at wrap_content, animated from top of the layout to the bottom
588                             val textRef = createRefFor("text")
589                             defaultTransition(
590                                 from =
591                                     constraintSet {
592                                         constrain(textRef) {
593                                             centerHorizontallyTo(parent)
594                                             centerVerticallyTo(parent, 0f)
595                                         }
596                                     },
597                                 to =
598                                     constraintSet {
599                                         constrain(textRef) {
600                                             centerHorizontallyTo(parent)
601                                             centerVerticallyTo(parent, 1f)
602                                         }
603                                     }
604                             )
605                         },
606                     progress = progress.value
607                 ) {
608                     Text(
609                         text = textContent.value,
610                         fontSize = 10.sp,
611                         modifier = Modifier.layoutTestId("text")
612                     )
613                 }
614             }
615         }
616 
617         rule.waitForIdle()
618         var actualTextSize = rule.onNodeWithTag("text").getUnclippedBoundsInRoot()
619         assertEquals(18, actualTextSize.width.value.roundToInt())
620         assertEquals(14, actualTextSize.height.value.roundToInt())
621 
622         progress.value = 0.5f
623         rule.waitForIdle()
624         actualTextSize = rule.onNodeWithTag("text").getUnclippedBoundsInRoot()
625         assertEquals(18, actualTextSize.width.value.roundToInt())
626         assertEquals(14, actualTextSize.height.value.roundToInt())
627 
628         textContent.value = "FooBar"
629         rule.waitForIdle()
630         actualTextSize = rule.onNodeWithTag("text").getUnclippedBoundsInRoot()
631         assertEquals(36, actualTextSize.width.value.roundToInt())
632         assertEquals(14, actualTextSize.height.value.roundToInt())
633     }
634 
635     @Test
636     fun testOnSwipe_withLimitBounds() =
637         with(rule.density) {
638             val rootSizePx = 300
639             val boxSizePx = 30
640             val boxId = "box"
641             var boxPosition = IntOffset.Zero
642 
643             rule.setContent {
644                 MotionLayout(
645                     motionScene =
646                         remember {
647                             createCornerToCornerMotionScene(boxId = boxId, boxSizePx = boxSizePx) {
648                                 boxRef ->
649                                 onSwipe =
650                                     OnSwipe(
651                                         anchor = boxRef,
652                                         side = SwipeSide.End,
653                                         direction = SwipeDirection.End,
654                                         limitBoundsTo = boxRef
655                                     )
656                             }
657                         },
658                     progress = 0f,
659                     modifier = Modifier.layoutTestId("MyMotion").size(rootSizePx.toDp())
660                 ) {
661                     Box(
662                         Modifier.background(Color.Red).layoutTestId(boxId).onGloballyPositioned {
663                             boxPosition = it.positionInParent().round()
664                         }
665                     )
666                 }
667             }
668             rule.waitForIdle()
669             val motionSemantic = rule.onNodeWithTag("MyMotion")
670             motionSemantic
671                 .assertExists()
672                 // The first swipe will completely miss the Box, so it shouldn't move
673                 .performSwipe(
674                     from = { Offset(left + boxSizePx / 2, centerY) },
675                     to = { Offset(right * 0.9f, centerY) }
676                 )
677             // Wait a frame for the Touch Up animation to start
678             rule.mainClock.advanceTimeByFrame()
679             // Then wait for it to end
680             rule.waitForIdle()
681             // Box didn't move since the swipe didn't start within the box
682             assertEquals(IntOffset.Zero, boxPosition)
683 
684             motionSemantic
685                 .assertExists()
686                 // The second swipe will start within the Box
687                 .performSwipe(
688                     from = { Offset(left + boxSizePx / 2, top + boxSizePx / 2) },
689                     to = { Offset(right * 0.9f, centerY) }
690                 )
691             // Wait a frame for the Touch Up animation to start
692             rule.mainClock.advanceTimeByFrame()
693             // Then wait for it to end
694             rule.waitForIdle()
695             // Box moved to end
696             assertEquals(IntOffset(rootSizePx - boxSizePx, rootSizePx - boxSizePx), boxPosition)
697         }
698 
699     @Test
700     fun testInvalidationStrategy_onObservedStateChange() =
701         with(rule.density) {
702             val rootSizePx = 200
703             val progress = mutableStateOf(0f)
704             val textContent = mutableStateOf("Foo")
705             val optimizeCorrectly = mutableStateOf(false)
706             val textId = "text"
707 
708             rule.setContent {
709                 WithConsistentTextStyle {
710                     MotionLayout(
711                         motionScene =
712                             remember {
713                                 MotionScene {
714                                     val textRef = createRefFor(textId)
715 
716                                     defaultTransition(
717                                         from =
718                                             constraintSet {
719                                                 constrain(textRef) { centerTo(parent) }
720                                             },
721                                         to =
722                                             constraintSet {
723                                                 constrain(textRef) { centerTo(parent) }
724                                             }
725                                     )
726                                 }
727                             },
728                         progress = progress.value,
729                         modifier = Modifier.size(rootSizePx.toDp()),
730                         invalidationStrategy =
731                             remember(optimizeCorrectly.value) {
732                                 if (optimizeCorrectly.value) {
733                                     InvalidationStrategy { textContent.value }
734                                 } else {
735                                     InvalidationStrategy {
736                                         // Do not invalidate on recomposition
737                                     }
738                                 }
739                             }
740                     ) {
741                         Text(
742                             text = textContent.value,
743                             fontSize = 10.sp,
744                             modifier = Modifier.layoutTestId(textId)
745                         )
746                     }
747                 }
748             }
749 
750             rule.waitForIdle()
751             var actualTextSize = rule.onNodeWithTag(textId).getUnclippedBoundsInRoot()
752             assertEquals(18, actualTextSize.width.value.roundToInt())
753             assertEquals(14, actualTextSize.height.value.roundToInt())
754 
755             textContent.value = "Foo\nBar"
756 
757             // Because we are optimizing "incorrectly" the text layout remains unchanged
758             rule.waitForIdle()
759             actualTextSize = rule.onNodeWithTag(textId).getUnclippedBoundsInRoot()
760             assertEquals(18, actualTextSize.width.value.roundToInt())
761             assertEquals(14, actualTextSize.height.value.roundToInt())
762 
763             textContent.value = "Foo"
764             optimizeCorrectly.value = true
765 
766             // We change the text back and update the optimization strategy to be correct, text
767             // should
768             // be the same as in its initial state
769             rule.waitForIdle()
770             actualTextSize = rule.onNodeWithTag(textId).getUnclippedBoundsInRoot()
771             assertEquals(18, actualTextSize.width.value.roundToInt())
772             assertEquals(14, actualTextSize.height.value.roundToInt())
773 
774             textContent.value = "Foo\nBar"
775 
776             // With the appropriate optimization strategy, the layout is invalidated when the text
777             // changes
778             rule.waitForIdle()
779             actualTextSize = rule.onNodeWithTag(textId).getUnclippedBoundsInRoot()
780             assertEquals(18, actualTextSize.width.value.roundToInt())
781             assertEquals(25, actualTextSize.height.value.roundToInt())
782         }
783 
784     @Test
785     fun testOnSwipe_withDragScale() =
786         with(rule.density) {
787             val rootSizePx = 300
788             val boxSizePx = 30
789             val boxId = "box"
790             val dragScale = 3f
791             var boxPosition = IntOffset.Zero
792 
793             rule.setContent {
794                 MotionLayout(
795                     motionScene =
796                         remember {
797                             createCornerToCornerMotionScene(boxId = boxId, boxSizePx = boxSizePx) {
798                                 boxRef ->
799                                 onSwipe =
800                                     OnSwipe(
801                                         anchor = boxRef,
802                                         side = SwipeSide.Middle,
803                                         direction = SwipeDirection.Down,
804                                         onTouchUp = SwipeTouchUp.ToStart,
805                                         dragScale = dragScale
806                                     )
807                             }
808                         },
809                     progress = 0f,
810                     modifier = Modifier.layoutTestId("MyMotion").size(rootSizePx.toDp())
811                 ) {
812                     Box(
813                         Modifier.background(Color.Red).layoutTestId(boxId).onGloballyPositioned {
814                             boxPosition = it.positionInParent().round()
815                         }
816                     )
817                 }
818             }
819             rule.waitForIdle()
820             val motionSemantic = rule.onNodeWithTag("MyMotion")
821 
822             motionSemantic
823                 .assertExists()
824                 .performSwipe(
825                     from = { Offset(center.x, top + (boxSizePx / 2f)) },
826                     to = {
827                         // Move only half-way, with a dragScale of 1f, it would be forced to
828                         // return to the start position
829                         val off = ((bottom - (boxSizePx / 2f)) - (top + (boxSizePx / 2f))) * 0.5f
830                         Offset(center.x, (top + (boxSizePx / 2f)) + off)
831                     }
832                 )
833             // Wait a frame for the Touch Up animation to start
834             rule.mainClock.advanceTimeByFrame()
835             // Then wait for it to end
836             rule.waitForIdle()
837             // Box is at the ending position because of the increased dragScale
838             assertEquals(IntOffset(rootSizePx - boxSizePx, rootSizePx - boxSizePx), boxPosition)
839         }
840 
841     private fun Color.toHexString(): String = toArgb().toUInt().toString(16)
842 }
843 
844 @OptIn(ExperimentalMotionApi::class)
845 @Composable
CustomTextSizenull846 private fun CustomTextSize(modifier: Modifier, progress: Float) {
847     val context = LocalContext.current
848     WithConsistentTextStyle {
849         MotionLayout(
850             motionScene =
851                 MotionScene(
852                     content =
853                         context.resources
854                             .openRawResource(R.raw.custom_text_size_scene)
855                             .readBytes()
856                             .decodeToString()
857                 ),
858             progress = progress,
859             modifier = modifier
860         ) {
861             val profilePicProperties = customProperties(id = "profile_pic")
862             Box(modifier = Modifier.layoutTestId("box").background(Color.DarkGray))
863             Image(
864                 imageVector = Icons.Default.Person,
865                 contentDescription = null,
866                 modifier =
867                     Modifier.clip(CircleShape)
868                         .border(
869                             width = 2.dp,
870                             color = profilePicProperties.color("background"),
871                             shape = CircleShape
872                         )
873                         .layoutTestId("profile_pic")
874             )
875             Text(
876                 text = "Hello",
877                 fontSize = customFontSize("username", "textSize"),
878                 modifier = Modifier.layoutTestId("username"),
879                 color = profilePicProperties.color("background")
880             )
881         }
882     }
883 }
884 
885 /**
886  * Provides composition locals that help making Text produce consistent measurements across multiple
887  * devices.
888  *
889  * Be aware that this makes it so that 1.dp = 1px. So the layout will look significantly different
890  * than expected.
891  */
892 @Composable
WithConsistentTextStylenull893 private fun WithConsistentTextStyle(content: @Composable () -> Unit) {
894     CompositionLocalProvider(
895         LocalDensity provides Density(1f, 1f),
896         LocalTextStyle provides
897             TextStyle(
898                 fontFamily = FontFamily.Monospace,
899                 fontWeight = FontWeight.Normal,
900                 platformStyle = PlatformTextStyle(includeFontPadding = true)
901             ),
902         content = content
903     )
904 }
905 
906 @OptIn(ExperimentalMotionApi::class)
createCornerToCornerMotionScenenull907 private fun Density.createCornerToCornerMotionScene(
908     boxId: String,
909     boxSizePx: Int,
910     transitionContent: TransitionScope.(boxRef: ConstrainedLayoutReference) -> Unit
911 ) = MotionScene {
912     val boxRef = createRefFor(boxId)
913 
914     defaultTransition(
915         from =
916             constraintSet {
917                 constrain(boxRef) {
918                     width = boxSizePx.toDp().asDimension()
919                     height = boxSizePx.toDp().asDimension()
920 
921                     top.linkTo(parent.top)
922                     start.linkTo(parent.start)
923                 }
924             },
925         to =
926             constraintSet {
927                 constrain(boxRef) {
928                     width = boxSizePx.toDp().asDimension()
929                     height = boxSizePx.toDp().asDimension()
930 
931                     bottom.linkTo(parent.bottom)
932                     end.linkTo(parent.end)
933                 }
934             }
935     ) {
936         transitionContent(boxRef)
937     }
938 }
939