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