1 /*
<lambda>null2 * Copyright 2021 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 android.os.Build
20 import android.view.View
21 import androidx.annotation.RequiresApi
22 import androidx.compose.animation.core.Animatable
23 import androidx.compose.animation.core.AnimationSpec
24 import androidx.compose.foundation.layout.LayoutScopeMarker
25 import androidx.compose.runtime.Composable
26 import androidx.compose.runtime.LaunchedEffect
27 import androidx.compose.runtime.MutableFloatState
28 import androidx.compose.runtime.MutableState
29 import androidx.compose.runtime.SideEffect
30 import androidx.compose.runtime.State
31 import androidx.compose.runtime.getValue
32 import androidx.compose.runtime.mutableFloatStateOf
33 import androidx.compose.runtime.mutableLongStateOf
34 import androidx.compose.runtime.mutableStateOf
35 import androidx.compose.runtime.neverEqualPolicy
36 import androidx.compose.runtime.remember
37 import androidx.compose.runtime.setValue
38 import androidx.compose.runtime.snapshots.Snapshot
39 import androidx.compose.ui.Modifier
40 import androidx.compose.ui.composed
41 import androidx.compose.ui.draw.drawBehind
42 import androidx.compose.ui.draw.scale
43 import androidx.compose.ui.geometry.Rect
44 import androidx.compose.ui.graphics.Color
45 import androidx.compose.ui.layout.Measurable
46 import androidx.compose.ui.layout.MeasurePolicy
47 import androidx.compose.ui.layout.MultiMeasureLayout
48 import androidx.compose.ui.layout.Placeable
49 import androidx.compose.ui.layout.onGloballyPositioned
50 import androidx.compose.ui.layout.onPlaced
51 import androidx.compose.ui.node.Ref
52 import androidx.compose.ui.platform.LocalDensity
53 import androidx.compose.ui.platform.LocalLayoutDirection
54 import androidx.compose.ui.platform.LocalView
55 import androidx.compose.ui.platform.debugInspectorInfo
56 import androidx.compose.ui.semantics.semantics
57 import androidx.compose.ui.unit.Constraints
58 import androidx.compose.ui.unit.Dp
59 import androidx.compose.ui.unit.TextUnit
60 import androidx.compose.ui.unit.dp
61 import androidx.compose.ui.unit.sp
62 import androidx.constraintlayout.core.widgets.Optimizer
63 import kotlin.jvm.internal.Ref.FloatRef
64 import kotlin.math.absoluteValue
65 import kotlinx.coroutines.channels.Channel
66
67 /** Measure flags for MotionLayout */
68 @Deprecated("Unnecessary, MotionLayout remeasures when its content changes.")
69 enum class MotionLayoutFlag(@Suppress("UNUSED_PARAMETER") value: Long) {
70 Default(0),
71 @Suppress("unused") FullMeasure(1)
72 }
73
74 enum class MotionLayoutDebugFlags {
75 NONE,
76 SHOW_ALL,
77 UNKNOWN
78 }
79
80 /**
81 * Layout that can animate between two different layout states described in [ConstraintSet]s.
82 *
83 * The animation is driven by the [progress] value, so it will typically be a result of using an
84 * [Animatable][androidx.compose.animation.core.Animatable] or
85 * [animateFloatAsState][androidx.compose.animation.core.animateFloatAsState]:
86 * ```
87 * var animateToEnd by remember { mutableStateOf(false) }
88 * MotionLayout(
89 * start = ConstraintSet {
90 * constrain(createRefFor("button")) {
91 * top.linkTo(parent.top)
92 * }
93 * },
94 * end = ConstraintSet {
95 * constrain(createRefFor("button")) {
96 * bottom.linkTo(parent.bottom)
97 * }
98 * },
99 * progress = animateFloatAsState(if (animateToEnd) 1f else 0f).value,
100 * modifier = Modifier.fillMaxSize()
101 * ) {
102 * Button(onClick = { animateToEnd = !animateToEnd }, Modifier.layoutId("button")) {
103 * Text("Hello, World!")
104 * }
105 * }
106 * ```
107 *
108 * Note that you must use [Modifier.layoutId][androidx.compose.ui.layout.layoutId] to bind the
109 * references used in the [ConstraintSet]s to the Composable.
110 *
111 * @param start ConstraintSet that defines the layout at 0f progress.
112 * @param end ConstraintSet that defines the layout at 1f progress.
113 * @param progress Sets the interpolated position of the layout between the ConstraintSets.
114 * @param modifier Modifier to apply to this layout node.
115 * @param transition Defines the interpolation parameters between the [ConstraintSet]s to achieve
116 * fine-tuned animations.
117 * @param debugFlags Flags to enable visual debugging. [DebugFlags.None] by default.
118 * @param optimizationLevel Optimization parameter for the underlying ConstraintLayout,
119 * [Optimizer.OPTIMIZATION_STANDARD] by default.
120 * @param invalidationStrategy Provides strategies to optimize invalidations in [MotionLayout].
121 * Excessive invalidations will be the typical cause of bad performance in [MotionLayout]. See
122 * [InvalidationStrategy] to learn how to apply common strategies.
123 * @param content The content to be laid out by MotionLayout, note that each layout Composable
124 * should be bound to an ID defined in the [ConstraintSet]s using
125 * [Modifier.layoutId][androidx.compose.ui.layout.layoutId].
126 */
127 @ExperimentalMotionApi
128 @Composable
MotionLayoutnull129 inline fun MotionLayout(
130 start: ConstraintSet,
131 end: ConstraintSet,
132 progress: Float,
133 modifier: Modifier = Modifier,
134 transition: Transition? = null,
135 debugFlags: DebugFlags = DebugFlags.None,
136 optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
137 invalidationStrategy: InvalidationStrategy = InvalidationStrategy.DefaultInvalidationStrategy,
138 crossinline content: @Composable MotionLayoutScope.() -> Unit
139 ) {
140 /**
141 * MutableState used to track content recompositions. It's reassigned at the content's
142 * composition scope, so that any function reading it is recomposed with the content.
143 * NeverEqualPolicy is used so that we don't have to assign any particular value to trigger a
144 * State change.
145 */
146 val contentTracker = remember { mutableStateOf(Unit, neverEqualPolicy()) }
147 val compositionSource = remember {
148 Ref<CompositionSource>().apply { value = CompositionSource.Unknown }
149 }
150
151 /** Delegate to handle composition tracking before calling the non-inline Composable */
152 val contentDelegate: @Composable MotionLayoutScope.() -> Unit = {
153 // Perform a reassignment to the State tracker, this will force readers to recompose at
154 // the same pass as the content. The only expected reader is our MeasurePolicy.
155 contentTracker.value = Unit
156
157 if (
158 invalidationStrategy.onObservedStateChange == null &&
159 compositionSource.value == CompositionSource.Unknown
160 ) {
161 // Set the content as the original composition source if the MotionLayout was not
162 // recomposed by the caller or by itself
163 compositionSource.value = CompositionSource.Content
164 }
165 content()
166 }
167 MotionLayoutCore(
168 start = start,
169 end = end,
170 transition = transition,
171 progress = progress,
172 informationReceiver = null,
173 optimizationLevel = optimizationLevel,
174 showBounds = debugFlags.showBounds,
175 showPaths = debugFlags.showPaths,
176 showKeyPositions = debugFlags.showKeyPositions,
177 modifier = modifier,
178 contentTracker = contentTracker,
179 compositionSource = compositionSource,
180 invalidationStrategy = invalidationStrategy,
181 content = contentDelegate
182 )
183 }
184
185 /**
186 * Layout that can animate between multiple [ConstraintSet]s as defined by [Transition]s in the
187 * given [MotionScene].
188 *
189 * The animation is driven by the [progress] value, so it will typically be a result of using an
190 * [Animatable][androidx.compose.animation.core.Animatable] or
191 * [animateFloatAsState][androidx.compose.animation.core.animateFloatAsState]:
192 * ```
193 * var animateToEnd by remember { mutableStateOf(false) }
194 * MotionLayout(
195 * motionScene = MotionScene {
196 * val buttonRef = createRefFor("button")
197 * defaultTransition(
198 * from = constraintSet {
199 * constrain(buttonRef) {
200 * top.linkTo(parent.top)
201 * }
202 * },
203 * to = constraintSet {
204 * constrain(buttonRef) {
205 * bottom.linkTo(parent.bottom)
206 * }
207 * }
208 * )
209 * },
210 * progress = animateFloatAsState(if (animateToEnd) 1f else 0f).value,
211 * modifier = Modifier.fillMaxSize()
212 * ) {
213 * Button(onClick = { animateToEnd = !animateToEnd }, Modifier.layoutId("button")) {
214 * Text("Hello, World!")
215 * }
216 * }
217 * ```
218 *
219 * Note that you must use [Modifier.layoutId][androidx.compose.ui.layout.layoutId] to bind the
220 * references used in the [ConstraintSet]s to the Composable.
221 *
222 * @param motionScene Holds all the layout states defined in [ConstraintSet]s and the interpolation
223 * associated between them (known as [Transition]s).
224 * @param progress Sets the interpolated position of the layout between the ConstraintSets.
225 * @param modifier Modifier to apply to this layout node.
226 * @param transitionName The name of the transition to apply on the layout. By default, it will
227 * target the transition defined with [MotionSceneScope.defaultTransition].
228 * @param debugFlags Flags to enable visual debugging. [DebugFlags.None] by default.
229 * @param optimizationLevel Optimization parameter for the underlying ConstraintLayout,
230 * [Optimizer.OPTIMIZATION_STANDARD] by default.
231 * @param invalidationStrategy Provides strategies to optimize invalidations in [MotionLayout].
232 * Excessive invalidations will be the typical cause of bad performance in [MotionLayout]. See
233 * [InvalidationStrategy] to learn how to apply common strategies.
234 * @param content The content to be laid out by MotionLayout, note that each layout Composable
235 * should be bound to an ID defined in the [ConstraintSet]s using
236 * [Modifier.layoutId][androidx.compose.ui.layout.layoutId].
237 */
238 @ExperimentalMotionApi
239 @Composable
MotionLayoutnull240 inline fun MotionLayout(
241 motionScene: MotionScene,
242 progress: Float,
243 modifier: Modifier = Modifier,
244 transitionName: String = "default",
245 debugFlags: DebugFlags = DebugFlags.None,
246 optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
247 invalidationStrategy: InvalidationStrategy = InvalidationStrategy.DefaultInvalidationStrategy,
248 crossinline content: @Composable (MotionLayoutScope.() -> Unit),
249 ) {
250 /**
251 * MutableState used to track content recompositions. It's reassigned at the content's
252 * composition scope, so that any function reading it is recomposed with the content.
253 * NeverEqualPolicy is used so that we don't have to assign any particular value to trigger a
254 * State change.
255 */
256 val contentTracker = remember { mutableStateOf(Unit, neverEqualPolicy()) }
257 val compositionSource = remember {
258 Ref<CompositionSource>().apply { value = CompositionSource.Unknown }
259 }
260
261 /** Delegate to handle composition tracking before calling the non-inline Composable */
262 val contentDelegate: @Composable MotionLayoutScope.() -> Unit = {
263 // Perform a reassignment to the State tracker, this will force readers to recompose at
264 // the same pass as the content. The only expected reader is our MeasurePolicy.
265 contentTracker.value = Unit
266
267 if (
268 invalidationStrategy.onObservedStateChange == null &&
269 compositionSource.value == CompositionSource.Unknown
270 ) {
271 // Set the content as the original composition source if the MotionLayout was not
272 // recomposed by the caller or by itself
273 compositionSource.value = CompositionSource.Content
274 }
275 content()
276 }
277
278 MotionLayoutCore(
279 motionScene = motionScene,
280 progress = progress,
281 transitionName = transitionName,
282 optimizationLevel = optimizationLevel,
283 debugFlags = debugFlags,
284 modifier = modifier,
285 contentTracker = contentTracker,
286 compositionSource = compositionSource,
287 invalidationStrategy = invalidationStrategy,
288 content = contentDelegate
289 )
290 }
291
292 /**
293 * Layout that can animate between multiple [ConstraintSet]s as defined by [Transition]s in the
294 * given [MotionScene].
295 *
296 * The animation is driven based on the given [constraintSetName]. During recomposition,
297 * MotionLayout will interpolate from whichever [ConstraintSet] it currently is, to the one
298 * corresponding to [constraintSetName]. So, a null [constraintSetName] will result in no changes.
299 *
300 * ```
301 * var name by remember { mutableStateOf(0) }
302 * MotionLayout(
303 * motionScene = MotionScene {
304 * val buttonRef = createRefFor("button")
305 * val initialStart = constraintSet("0") {
306 * constrain(buttonRef) {
307 * centerHorizontallyTo(parent, bias = 0f)
308 * centerVerticallyTo(parent, bias = 0f)
309 * }
310 * }
311 * val initialEnd = constraintSet("1") {
312 * constrain(buttonRef) {
313 * centerHorizontallyTo(parent, bias = 0f)
314 * centerVerticallyTo(parent, bias = 1f)
315 * }
316 * }
317 * constraintSet("2") {
318 * constrain(buttonRef) {
319 * centerHorizontallyTo(parent, bias = 1f)
320 * centerVerticallyTo(parent, bias = 0f)
321 * }
322 * }
323 * constraintSet("3") {
324 * constrain(buttonRef) {
325 * centerHorizontallyTo(parent, bias = 1f)
326 * centerVerticallyTo(parent, bias = 1f)
327 * }
328 * }
329 * // We need at least the default transition to define the initial state
330 * defaultTransition(initialStart, initialEnd)
331 * },
332 * constraintSetName = name.toString(),
333 * animationSpec = tween(1200),
334 * modifier = Modifier.fillMaxSize()
335 * ) {
336 * // Switch to a random ConstraintSet on click
337 * Button(onClick = { name = IntRange(0, 3).random() }, Modifier.layoutId("button")) {
338 * Text("Hello, World!")
339 * }
340 * }
341 * ```
342 *
343 * Animations are run one after the other, if multiple are queued, only the last one will be
344 * executed. You may use [finishedAnimationListener] to know whenever an animation is finished.
345 *
346 * @param motionScene Holds all the layout states defined in [ConstraintSet]s and the interpolation
347 * associated between them (known as [Transition]s).
348 * @param constraintSetName The name of the [ConstraintSet] to animate to. Null for no animation.
349 * @param animationSpec Specifies how the internal progress value is animated.
350 * @param modifier Modifier to apply to this layout node.
351 * @param finishedAnimationListener Called when an animation triggered by a change in
352 * [constraintSetName] has ended.
353 * @param debugFlags Flags to enable visual debugging. [DebugFlags.None] by default.
354 * @param optimizationLevel Optimization parameter for the underlying ConstraintLayout,
355 * [Optimizer.OPTIMIZATION_STANDARD] by default.
356 * @param invalidationStrategy Provides strategies to optimize invalidations in [MotionLayout].
357 * Excessive invalidations will be the typical cause of bad performance in [MotionLayout]. See
358 * [InvalidationStrategy] to learn how to apply common strategies.
359 * @param content The content to be laid out by MotionLayout, note that each layout Composable
360 * should be bound to an ID defined in the [ConstraintSet]s using
361 * [Modifier.layoutId][androidx.compose.ui.layout.layoutId].
362 */
363 @ExperimentalMotionApi
364 @Composable
MotionLayoutnull365 inline fun MotionLayout(
366 motionScene: MotionScene,
367 constraintSetName: String?,
368 animationSpec: AnimationSpec<Float>,
369 modifier: Modifier = Modifier,
370 noinline finishedAnimationListener: (() -> Unit)? = null,
371 debugFlags: DebugFlags = DebugFlags.None,
372 optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
373 invalidationStrategy: InvalidationStrategy = InvalidationStrategy.DefaultInvalidationStrategy,
374 @Suppress("HiddenTypeParameter") crossinline content: @Composable (MotionLayoutScope.() -> Unit)
375 ) {
376 /**
377 * MutableState used to track content recompositions. It's reassigned at the content's
378 * composition scope, so that any function reading it is recomposed with the content.
379 * NeverEqualPolicy is used so that we don't have to assign any particular value to trigger a
380 * State change.
381 */
382 val contentTracker = remember { mutableStateOf(Unit, neverEqualPolicy()) }
383 val compositionSource = remember {
384 Ref<CompositionSource>().apply { value = CompositionSource.Unknown }
385 }
386
387 /** Delegate to handle composition tracking before calling the non-inline Composable */
388 val contentDelegate: @Composable MotionLayoutScope.() -> Unit = {
389 // Perform a reassignment to the State tracker, this will force readers to recompose at
390 // the same pass as the content. The only expected reader is our MeasurePolicy.
391 contentTracker.value = Unit
392
393 if (
394 invalidationStrategy.onObservedStateChange == null &&
395 compositionSource.value == CompositionSource.Unknown
396 ) {
397 // Set the content as the original composition source if the MotionLayout was not
398 // recomposed by the caller or by itself
399 compositionSource.value = CompositionSource.Content
400 }
401 content()
402 }
403
404 MotionLayoutCore(
405 motionScene = motionScene,
406 constraintSetName = constraintSetName,
407 animationSpec = animationSpec,
408 modifier = modifier,
409 finishedAnimationListener = finishedAnimationListener,
410 debugFlags = debugFlags,
411 optimizationLevel = optimizationLevel,
412 contentTracker = contentTracker,
413 compositionSource = compositionSource,
414 invalidationStrategy = invalidationStrategy,
415 content = contentDelegate
416 )
417 }
418
419 @ExperimentalMotionApi
420 @PublishedApi
421 @Composable
MotionLayoutCorenull422 internal fun MotionLayoutCore(
423 motionScene: MotionScene,
424 constraintSetName: String?,
425 animationSpec: AnimationSpec<Float>,
426 modifier: Modifier = Modifier,
427 finishedAnimationListener: (() -> Unit)? = null,
428 debugFlags: DebugFlags = DebugFlags.None,
429 optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
430 contentTracker: MutableState<Unit>,
431 compositionSource: Ref<CompositionSource>,
432 invalidationStrategy: InvalidationStrategy,
433 @Suppress("HiddenTypeParameter") content: @Composable (MotionLayoutScope.() -> Unit)
434 ) {
435 val needsUpdate = remember { mutableLongStateOf(0L) }
436
437 val transition =
438 remember(motionScene, needsUpdate.longValue) {
439 motionScene.getTransitionInstance("default")
440 }
441
442 val initialStart =
443 remember(motionScene, needsUpdate.longValue) {
444 val startId = transition?.getStartConstraintSetId() ?: "start"
445 motionScene.getConstraintSetInstance(startId)
446 }
447 val initialEnd =
448 remember(motionScene, needsUpdate.longValue) {
449 val endId = transition?.getEndConstraintSetId() ?: "end"
450 motionScene.getConstraintSetInstance(endId)
451 }
452
453 if (initialStart == null || initialEnd == null) {
454 return
455 }
456
457 var start: ConstraintSet by remember(motionScene) { mutableStateOf(initialStart) }
458 var end: ConstraintSet by remember(motionScene) { mutableStateOf(initialEnd) }
459
460 val targetConstraintSet =
461 remember(motionScene, constraintSetName) {
462 constraintSetName?.let { motionScene.getConstraintSetInstance(constraintSetName) }
463 }
464
465 val progress = remember { Animatable(0f) }
466
467 var animateToEnd by remember(motionScene) { mutableStateOf(true) }
468
469 val channel = remember { Channel<ConstraintSet>(Channel.CONFLATED) }
470
471 if (targetConstraintSet != null) {
472 SideEffect { channel.trySend(targetConstraintSet) }
473
474 LaunchedEffect(motionScene, channel) {
475 for (constraints in channel) {
476 val newConstraintSet = channel.tryReceive().getOrNull() ?: constraints
477 val animTargetValue = if (animateToEnd) 1f else 0f
478 val currentSet = if (animateToEnd) start else end
479 if (newConstraintSet != currentSet) {
480 if (animateToEnd) {
481 end = newConstraintSet
482 } else {
483 start = newConstraintSet
484 }
485 progress.animateTo(animTargetValue, animationSpec)
486 animateToEnd = !animateToEnd
487 finishedAnimationListener?.invoke()
488 }
489 }
490 }
491 }
492 MotionLayoutCore(
493 start = start,
494 end = end,
495 transition = transition,
496 progress = progress.value,
497 informationReceiver = motionScene as? LayoutInformationReceiver,
498 optimizationLevel = optimizationLevel,
499 showBounds = debugFlags.showBounds,
500 showPaths = debugFlags.showPaths,
501 showKeyPositions = debugFlags.showKeyPositions,
502 modifier = modifier,
503 contentTracker = contentTracker,
504 compositionSource = compositionSource,
505 invalidationStrategy = invalidationStrategy,
506 content = content
507 )
508 }
509
510 @ExperimentalMotionApi
511 @PublishedApi
512 @Composable
MotionLayoutCorenull513 internal fun MotionLayoutCore(
514 @Suppress("HiddenTypeParameter") motionScene: MotionScene,
515 progress: Float,
516 transitionName: String,
517 optimizationLevel: Int,
518 debugFlags: DebugFlags,
519 modifier: Modifier,
520 contentTracker: MutableState<Unit>,
521 compositionSource: Ref<CompositionSource>,
522 invalidationStrategy: InvalidationStrategy,
523 @Suppress("HiddenTypeParameter") content: @Composable MotionLayoutScope.() -> Unit,
524 ) {
525 val transition =
526 remember(motionScene, transitionName) { motionScene.getTransitionInstance(transitionName) }
527
528 val start =
529 remember(motionScene, transition) {
530 val startId = transition?.getStartConstraintSetId() ?: "start"
531 motionScene.getConstraintSetInstance(startId)
532 }
533 val end =
534 remember(motionScene, transition) {
535 val endId = transition?.getEndConstraintSetId() ?: "end"
536 motionScene.getConstraintSetInstance(endId)
537 }
538 if (start == null || end == null) {
539 return
540 }
541
542 MotionLayoutCore(
543 start = start,
544 end = end,
545 transition = transition,
546 progress = progress,
547 informationReceiver = motionScene as? LayoutInformationReceiver,
548 optimizationLevel = optimizationLevel,
549 showBounds = debugFlags.showBounds,
550 showPaths = debugFlags.showPaths,
551 showKeyPositions = debugFlags.showKeyPositions,
552 modifier = modifier,
553 contentTracker = contentTracker,
554 compositionSource = compositionSource,
555 invalidationStrategy = invalidationStrategy,
556 content = content
557 )
558 }
559
560 @ExperimentalMotionApi
561 @PublishedApi
562 @Composable
MotionLayoutCorenull563 internal fun MotionLayoutCore(
564 start: ConstraintSet,
565 end: ConstraintSet,
566 transition: Transition?,
567 progress: Float,
568 informationReceiver: LayoutInformationReceiver?,
569 optimizationLevel: Int,
570 showBounds: Boolean,
571 showPaths: Boolean,
572 showKeyPositions: Boolean,
573 modifier: Modifier,
574 contentTracker: MutableState<Unit>,
575 compositionSource: Ref<CompositionSource>,
576 invalidationStrategy: InvalidationStrategy,
577 @Suppress("HiddenTypeParameter") content: @Composable MotionLayoutScope.() -> Unit
578 ) {
579 val motionProgress = createAndUpdateMotionProgress(progress = progress)
580 val transitionImpl = (transition as? TransitionImpl) ?: TransitionImpl.EMPTY
581 // TODO: Merge this snippet with UpdateWithForcedIfNoUserChange
582 val needsUpdate = remember { mutableLongStateOf(0L) }
583 needsUpdate.longValue // Read the value to allow recomposition from informationReceiver
584 informationReceiver?.setUpdateFlag(needsUpdate)
585
586 UpdateWithForcedIfNoUserChange(
587 motionProgress = motionProgress,
588 informationReceiver = informationReceiver
589 )
590
591 val density = LocalDensity.current
592 val layoutDirection = LocalLayoutDirection.current
593 val measurer = remember { MotionMeasurer(density) }
594 val scope = remember { MotionLayoutScope(measurer, motionProgress) }
595
596 remember(start, end, transition) {
597 measurer.initWith(
598 start = start,
599 end = end,
600 layoutDirection = layoutDirection,
601 transition = transitionImpl,
602 progress = motionProgress.floatValue
603 )
604 true // Remember is required to return a non-Unit value
605 }
606
607 if (invalidationStrategy.onObservedStateChange != null) {
608 Snapshot.observe(
609 readObserver = {
610 // Perform a reassignment to the State tracker, this will force readers to recompose
611 // at
612 // the same pass as the content. The only expected reader is our MeasurePolicy.
613 contentTracker.value = Unit
614
615 if (compositionSource.value == CompositionSource.Unknown) {
616 // Set the content as the original composition source if the MotionLayout was
617 // not
618 // recomposed by the caller or by itself
619 compositionSource.value = CompositionSource.Content
620 }
621 },
622 block = invalidationStrategy.onObservedStateChange
623 )
624 }
625
626 val measurePolicy =
627 motionLayoutMeasurePolicy(
628 contentTracker = contentTracker,
629 compositionSource = compositionSource,
630 constraintSetStart = start,
631 constraintSetEnd = end,
632 transition = transitionImpl,
633 motionProgress = motionProgress,
634 measurer = measurer,
635 optimizationLevel = optimizationLevel,
636 invalidationStrategy = invalidationStrategy
637 )
638
639 measurer.addLayoutInformationReceiver(informationReceiver)
640
641 val forcedDebug = informationReceiver?.getForcedDrawDebug()
642 val forcedScaleFactor = measurer.forcedScaleFactor
643
644 var doShowBounds = showBounds
645 var doShowPaths = showPaths
646 var doShowKeyPositions = showKeyPositions
647
648 if (forcedDebug != null && forcedDebug != MotionLayoutDebugFlags.UNKNOWN) {
649 doShowBounds = forcedDebug === MotionLayoutDebugFlags.SHOW_ALL
650 doShowPaths = doShowBounds
651 doShowKeyPositions = doShowBounds
652 }
653 if (
654 Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
655 Api30Impl.isShowingLayoutBounds(LocalView.current)
656 ) {
657 doShowBounds = true
658 }
659
660 @Suppress("DEPRECATION")
661 MultiMeasureLayout(
662 modifier =
663 modifier
664 .motionDebug(
665 measurer = measurer,
666 scaleFactor = forcedScaleFactor,
667 showBounds = doShowBounds,
668 showPaths = doShowPaths,
669 showKeyPositions = doShowKeyPositions
670 )
671 .motionPointerInput(
672 key = transition ?: TransitionImpl.EMPTY,
673 motionProgress = motionProgress,
674 measurer = measurer
675 )
676 .semantics { designInfoProvider = measurer },
677 measurePolicy = measurePolicy,
678 content = { scope.content() }
679 )
680 }
681
682 @ExperimentalMotionApi
683 @LayoutScopeMarker
684 class MotionLayoutScope
685 @Suppress("ShowingMemberInHiddenClass")
686 internal constructor(
687 private val measurer: MotionMeasurer,
688 private val motionProgress: MutableFloatState
689 ) {
690 /**
691 * Invokes [onBoundsChanged] whenever the Start or End bounds may have changed for the
692 * Composable corresponding to the given [layoutId] during positioning. This may happen if the
693 * current Transition for [MotionLayout] changes.
694 *
695 * [onBoundsChanged] will be invoked at least once when the content is placed the first time.
696 *
697 * Use this [Modifier] instead of [onGloballyPositioned] if you wish to keep track of Composable
698 * bounds while ignoring their positioning during animation. Such as when implementing
699 * DragAndDrop logic.
700 */
onStartEndBoundsChangednull701 fun Modifier.onStartEndBoundsChanged(
702 layoutId: Any,
703 onBoundsChanged: (startBounds: Rect, endBounds: Rect) -> Unit
704 ): Modifier {
705 return composed(
706 inspectorInfo =
707 debugInspectorInfo {
708 name = "onStartEndBoundsChanged"
709 properties["layoutId"] = layoutId
710 properties["onBoundsChanged"] = onBoundsChanged
711 }
712 ) {
713 // TODO: Consider returning IntRect directly, note that it would imply adding a
714 // dependency to `androidx.compose.ui.unit`
715 val id = remember(layoutId) { layoutId.toString() }
716
717 // Mutable Array to keep track of bound changes
718 val startPoints = remember { IntArray(4) { 0 } }
719 val startBoundsRef = remember { Ref<Rect>().apply { value = Rect.Zero } }
720
721 // Mutable Array to keep track of bound changes
722 val endPoints = remember { IntArray(4) { 0 } }
723 val endBoundsRef = remember { Ref<Rect>().apply { value = Rect.Zero } }
724
725 // Note that globally positioned is also invoked while animating, so keep the worload as
726 // low as possible
727 this.onPlaced {
728 val startFrame = measurer.transition.getStart(id)
729 var changed = false
730 if (
731 startFrame.left != startPoints[0] ||
732 startFrame.top != startPoints[1] ||
733 startFrame.right != startPoints[2] ||
734 startFrame.bottom != startPoints[3]
735 ) {
736 startPoints[0] = startFrame.left
737 startPoints[1] = startFrame.top
738 startPoints[2] = startFrame.right
739 startPoints[3] = startFrame.bottom
740
741 // Only instantiate a new Rect when we know the old bounds are invalid
742 startBoundsRef.value =
743 Rect(
744 startPoints[0].toFloat(),
745 startPoints[1].toFloat(),
746 startPoints[2].toFloat(),
747 startPoints[3].toFloat(),
748 )
749 changed = true
750 }
751
752 val endFrame = measurer.transition.getEnd(id)
753 if (
754 endFrame.left != endPoints[0] ||
755 endFrame.top != endPoints[1] ||
756 endFrame.right != endPoints[2] ||
757 endFrame.bottom != endPoints[3]
758 ) {
759 endPoints[0] = endFrame.left
760 endPoints[1] = endFrame.top
761 endPoints[2] = endFrame.right
762 endPoints[3] = endFrame.bottom
763
764 // Only instantiate a new Rect when we know the old bounds are invalid
765 endBoundsRef.value =
766 Rect(
767 endPoints[0].toFloat(),
768 endPoints[1].toFloat(),
769 endPoints[2].toFloat(),
770 endPoints[3].toFloat(),
771 )
772 changed = true
773 }
774 if (changed) {
775 onBoundsChanged(
776 startBoundsRef.value ?: Rect.Zero,
777 endBoundsRef.value ?: Rect.Zero
778 )
779 }
780 }
781 }
782 }
783
784 inner class CustomProperties internal constructor(private val id: String) {
785 /**
786 * Return the current [Color] value of the custom property [name], of the [id] layout.
787 *
788 * Returns [Color.Unspecified] if the property does not exist.
789 */
colornull790 fun color(name: String): Color {
791 return measurer.getCustomColor(id, name, motionProgress.floatValue)
792 }
793
794 /**
795 * Return the current [Color] value of the custom property [name], of the [id] layout.
796 *
797 * Returns [Color.Unspecified] if the property does not exist.
798 */
floatnull799 fun float(name: String): Float {
800 return measurer.getCustomFloat(id, name, motionProgress.floatValue)
801 }
802
803 /**
804 * Return the current [Int] value of the custom property [name], of the [id] layout.
805 *
806 * Returns `0` if the property does not exist.
807 */
intnull808 fun int(name: String): Int {
809 return measurer.getCustomFloat(id, name, motionProgress.floatValue).toInt()
810 }
811
812 /**
813 * Return the current [Dp] value of the custom property [name], of the [id] layout.
814 *
815 * Returns [Dp.Unspecified] if the property does not exist.
816 */
distancenull817 fun distance(name: String): Dp {
818 return measurer.getCustomFloat(id, name, motionProgress.floatValue).dp
819 }
820
821 /**
822 * Return the current [TextUnit] value of the custom property [name], of the [id] layout.
823 *
824 * Returns [TextUnit.Unspecified] if the property does not exist.
825 */
fontSizenull826 fun fontSize(name: String): TextUnit {
827 return measurer.getCustomFloat(id, name, motionProgress.floatValue).sp
828 }
829 }
830
831 // TODO: Remove for 1.2.0-alphaXX with all dependent functions. Note that MotionCarousel Api
832 // depends on this.
833 inner class MotionProperties internal constructor(id: String, tag: String?) {
834 private var myId = id
835 private var myTag = tag
836
idnull837 fun id(): String {
838 return myId
839 }
840
tagnull841 fun tag(): String? {
842 return myTag
843 }
844
colornull845 fun color(name: String): Color {
846 return measurer.getCustomColor(myId, name, motionProgress.floatValue)
847 }
848
floatnull849 fun float(name: String): Float {
850 return measurer.getCustomFloat(myId, name, motionProgress.floatValue)
851 }
852
intnull853 fun int(name: String): Int {
854 return measurer.getCustomFloat(myId, name, motionProgress.floatValue).toInt()
855 }
856
distancenull857 fun distance(name: String): Dp {
858 return measurer.getCustomFloat(myId, name, motionProgress.floatValue).dp
859 }
860
fontSizenull861 fun fontSize(name: String): TextUnit {
862 return measurer.getCustomFloat(myId, name, motionProgress.floatValue).sp
863 }
864 }
865
866 @Deprecated(
867 "Unnecessary composable, name is also inconsistent for custom properties",
868 ReplaceWith("customProperties(id)")
869 )
870 @Composable
motionPropertiesnull871 fun motionProperties(id: String): State<MotionProperties> =
872 // TODO: There's no point on returning a [State] object, and probably no point on this being
873 // a Composable
874 remember(id) { mutableStateOf(MotionProperties(id, null)) }
875
876 @Deprecated("Deprecated for naming consistency", ReplaceWith("customProperties(id)"))
motionPropertiesnull877 fun motionProperties(id: String, tag: String): MotionProperties {
878 return MotionProperties(id, tag)
879 }
880
881 @Deprecated("Deprecated for naming consistency", ReplaceWith("customColor(id, name)"))
motionColornull882 fun motionColor(id: String, name: String): Color {
883 return measurer.getCustomColor(id, name, motionProgress.floatValue)
884 }
885
886 @Deprecated("Deprecated for naming consistency", ReplaceWith("customFloat(id, name)"))
motionFloatnull887 fun motionFloat(id: String, name: String): Float {
888 return measurer.getCustomFloat(id, name, motionProgress.floatValue)
889 }
890
891 @Deprecated("Deprecated for naming consistency", ReplaceWith("customInt(id, name)"))
motionIntnull892 fun motionInt(id: String, name: String): Int {
893 return measurer.getCustomFloat(id, name, motionProgress.floatValue).toInt()
894 }
895
896 @Deprecated("Deprecated for naming consistency", ReplaceWith("customDistance(id, name)"))
motionDistancenull897 fun motionDistance(id: String, name: String): Dp {
898 return measurer.getCustomFloat(id, name, motionProgress.floatValue).dp
899 }
900
901 @Deprecated("Deprecated for naming consistency", ReplaceWith("customFontSize(id, name)"))
motionFontSizenull902 fun motionFontSize(id: String, name: String): TextUnit {
903 return measurer.getCustomFloat(id, name, motionProgress.floatValue).sp
904 }
905
906 /**
907 * Returns a [CustomProperties] instance to access the values of custom properties defined for
908 * [id] in different return types: Color, Float, Int, Dp, TextUnit.
909 *
910 * Note that there are no type guarantees when setting or getting custom properties, so be
911 * mindful of the value type used for it in the MotionScene.
912 */
customPropertiesnull913 fun customProperties(id: String): CustomProperties = CustomProperties(id)
914
915 /**
916 * Return the current [Color] value of the custom property [name], of the [id] layout.
917 *
918 * Returns [Color.Unspecified] if the property does not exist.
919 *
920 * This is a short version of: `customProperties(id).color(name)`.
921 */
922 fun customColor(id: String, name: String): Color {
923 return measurer.getCustomColor(id, name, motionProgress.floatValue)
924 }
925
926 /**
927 * Return the current [Color] value of the custom property [name], of the [id] layout.
928 *
929 * Returns [Color.Unspecified] if the property does not exist.
930 *
931 * This is a short version of: `customProperties(id).float(name)`.
932 */
customFloatnull933 fun customFloat(id: String, name: String): Float {
934 return measurer.getCustomFloat(id, name, motionProgress.floatValue)
935 }
936
937 /**
938 * Return the current [Int] value of the custom property [name], of the [id] layout.
939 *
940 * Returns `0` if the property does not exist.
941 *
942 * This is a short version of: `customProperties(id).int(name)`.
943 */
customIntnull944 fun customInt(id: String, name: String): Int {
945 return measurer.getCustomFloat(id, name, motionProgress.floatValue).toInt()
946 }
947
948 /**
949 * Return the current [Dp] value of the custom property [name], of the [id] layout.
950 *
951 * Returns [Dp.Unspecified] if the property does not exist.
952 *
953 * This is a short version of: `customProperties(id).distance(name)`.
954 */
customDistancenull955 fun customDistance(id: String, name: String): Dp {
956 return measurer.getCustomFloat(id, name, motionProgress.floatValue).dp
957 }
958
959 /**
960 * Return the current [TextUnit] value of the custom property [name], of the [id] layout.
961 *
962 * Returns [TextUnit.Unspecified] if the property does not exist.
963 *
964 * This is a short version of: `customProperties(id).fontSize(name)`.
965 */
customFontSizenull966 fun customFontSize(id: String, name: String): TextUnit {
967 return measurer.getCustomFloat(id, name, motionProgress.floatValue).sp
968 }
969 }
970
971 @ExperimentalMotionApi
motionLayoutMeasurePolicynull972 internal fun motionLayoutMeasurePolicy(
973 contentTracker: State<Unit>,
974 compositionSource: Ref<CompositionSource>,
975 constraintSetStart: ConstraintSet,
976 constraintSetEnd: ConstraintSet,
977 @SuppressWarnings("HiddenTypeParameter") transition: TransitionImpl,
978 motionProgress: MutableFloatState,
979 measurer: MotionMeasurer,
980 optimizationLevel: Int,
981 invalidationStrategy: InvalidationStrategy
982 ): MeasurePolicy = MeasurePolicy { measurables, constraints ->
983 // Map to properly capture Placeables across Measure and Layout passes
984 val placeableMap = mutableMapOf<Measurable, Placeable>()
985
986 // Do a state read, to guarantee that we control measure when the content recomposes without
987 // notifying our Composable caller
988 contentTracker.value
989
990 val layoutSize =
991 measurer.performInterpolationMeasure(
992 constraints = constraints,
993 layoutDirection = this.layoutDirection,
994 constraintSetStart = constraintSetStart,
995 constraintSetEnd = constraintSetEnd,
996 transition = transition,
997 measurables = measurables,
998 placeableMap = placeableMap,
999 optimizationLevel = optimizationLevel,
1000 progress = motionProgress.floatValue,
1001 compositionSource = compositionSource.value ?: CompositionSource.Unknown,
1002 invalidateOnConstraintsCallback = invalidationStrategy.shouldInvalidate
1003 )
1004 compositionSource.value = CompositionSource.Unknown // Reset after measuring
1005
1006 layout(layoutSize.width, layoutSize.height) {
1007 with(measurer) { performLayout(measurables = measurables, placeableMap = placeableMap) }
1008 }
1009 }
1010
1011 /**
1012 * Updates [motionProgress] from changes in [LayoutInformationReceiver.getForcedProgress].
1013 *
1014 * User changes, (reflected in [MutableFloatState.floatValue]) take priority.
1015 */
1016 @Composable
UpdateWithForcedIfNoUserChangenull1017 internal fun UpdateWithForcedIfNoUserChange(
1018 motionProgress: MutableFloatState,
1019 informationReceiver: LayoutInformationReceiver?
1020 ) {
1021 if (informationReceiver == null) {
1022 return
1023 }
1024 val currentUserProgress = motionProgress.floatValue
1025 val forcedProgress = informationReceiver.getForcedProgress()
1026
1027 // Save the initial progress
1028 val lastUserProgress = remember { Ref<Float>().apply { value = currentUserProgress } }
1029
1030 if (!forcedProgress.isNaN() && lastUserProgress.value == currentUserProgress) {
1031 // Use the forced progress if the user progress hasn't changed
1032 motionProgress.floatValue = forcedProgress
1033 } else {
1034 informationReceiver.resetForcedProgress()
1035 }
1036 lastUserProgress.value = currentUserProgress
1037 }
1038
1039 /**
1040 * Creates a [MutableFloatState] that may be manipulated internally, but can also be updated by user
1041 * calls with different [progress] values.
1042 *
1043 * @param progress User progress, if changed, updates the underlying [MutableFloatState]
1044 * @return A [MutableFloatState] instance that may change from internal or external calls
1045 */
1046 @Composable
createAndUpdateMotionProgressnull1047 internal fun createAndUpdateMotionProgress(progress: Float): MutableFloatState {
1048 val motionProgress = remember { mutableFloatStateOf(progress) }
1049 val last = remember { FloatRef().apply { element = progress } }
1050 if (last.element != progress) {
1051 // Update on progress change
1052 last.element = progress
1053 motionProgress.floatValue = progress
1054 }
1055 return motionProgress
1056 }
1057
1058 @ExperimentalMotionApi
motionDebugnull1059 internal fun Modifier.motionDebug(
1060 measurer: MotionMeasurer,
1061 scaleFactor: Float,
1062 showBounds: Boolean,
1063 showPaths: Boolean,
1064 showKeyPositions: Boolean
1065 ): Modifier {
1066 var debugModifier: Modifier = this
1067 if (!scaleFactor.isNaN()) {
1068 debugModifier = debugModifier.scale(scaleFactor)
1069 }
1070 if (showBounds || showKeyPositions || showPaths) {
1071 debugModifier =
1072 debugModifier.drawBehind {
1073 with(measurer) {
1074 drawDebug(
1075 drawBounds = showBounds,
1076 drawPaths = showPaths,
1077 drawKeyPositions = showKeyPositions
1078 )
1079 }
1080 }
1081 }
1082 return debugModifier
1083 }
1084
1085 /**
1086 * Indicates where the composition was initiated.
1087 *
1088 * The source will help us identify possible pathways for optimization.
1089 *
1090 * E.g.: If the content was not recomposed, we can assume that previous measurements are still
1091 * valid, so there's no need to recalculate the entire interpolation, only the current frame.
1092 */
1093 @PublishedApi
1094 internal enum class CompositionSource {
1095 // TODO: Consider adding an explicit option for Composition initiated internally, in case we
1096 // need to differentiate them
1097
1098 Unknown,
1099
1100 /**
1101 * Content recomposed, need to remeasure everything: **start**, **end** and **interpolated**
1102 * states.
1103 */
1104 Content
1105 }
1106
1107 /**
1108 * Flags to use with MotionLayout to enable visual debugging.
1109 *
1110 * @property showBounds
1111 * @property showPaths
1112 * @property showKeyPositions
1113 * @see DebugFlags.None
1114 * @see DebugFlags.All
1115 */
1116 @JvmInline
1117 value class DebugFlags internal constructor(private val flags: Int) {
1118 /**
1119 * @param showBounds Whether to show the bounds of widgets at the start and end of the current
1120 * transition.
1121 * @param showPaths Whether to show the paths each widget will take through the current
1122 * transition.
1123 * @param showKeyPositions Whether to show a diamond icon representing KeyPositions defined for
1124 * each widget along the path.
1125 */
1126 constructor(
1127 showBounds: Boolean = false,
1128 showPaths: Boolean = false,
1129 showKeyPositions: Boolean = false
1130 ) : this(
1131 (if (showBounds) BOUNDS_FLAG else 0) or
1132 (if (showPaths) PATHS_FLAG else 0) or
1133 (if (showKeyPositions) KEY_POSITIONS_FLAG else 0)
1134 )
1135
1136 /** When enabled, shows the bounds of widgets at the start and end of the current transition. */
1137 val showBounds: Boolean
1138 get() = flags and BOUNDS_FLAG > 0
1139
1140 /** When enabled, shows the paths each widget will take through the current transition. */
1141 val showPaths: Boolean
1142 get() = flags and PATHS_FLAG > 0
1143
1144 /**
1145 * When enabled, shows a diamond icon representing KeyPositions defined for each widget along
1146 * the path.
1147 */
1148 val showKeyPositions: Boolean
1149 get() = flags and KEY_POSITIONS_FLAG > 0
1150
toStringnull1151 override fun toString(): String =
1152 "DebugFlags(" +
1153 "showBounds = $showBounds, " +
1154 "showPaths = $showPaths, " +
1155 "showKeyPositions = $showKeyPositions" +
1156 ")"
1157
1158 companion object {
1159 private const val BOUNDS_FLAG = 1
1160 private const val PATHS_FLAG = 1 shl 1
1161 private const val KEY_POSITIONS_FLAG = 1 shl 2
1162
1163 /** [DebugFlags] instance with all flags disabled. */
1164 val None = DebugFlags(0)
1165
1166 /**
1167 * [DebugFlags] instance with all flags enabled.
1168 *
1169 * Note that this includes any flags added in the future.
1170 */
1171 val All = DebugFlags(-1)
1172 }
1173 }
1174
1175 /** Wrapper to pass Class Verification from calling methods unavailable on older API. */
1176 @RequiresApi(30)
1177 private object Api30Impl {
1178 @JvmStatic
isShowingLayoutBoundsnull1179 fun isShowingLayoutBounds(view: View): Boolean {
1180 return view.isShowingLayoutBounds
1181 }
1182 }
1183
1184 /**
1185 * Helper scope that provides some strategies to improve performance based on incoming constraints.
1186 *
1187 * As a starting approach, we recommend trying the following:
1188 * ```
1189 * MotionLayout(
1190 * ...,
1191 * invalidationStrategy = remember {
1192 * InvalidationStrategy(
1193 * onIncomingConstraints = { old, new ->
1194 * // We invalidate every third frame, or when the change is higher than 5 pixels
1195 * shouldInvalidateOnFixedWidth(old, new, skipCount = 3, threshold = 5) ||
1196 * shouldInvalidateOnFixedHeight(old, new, skipCount = 3, threshold = 5)
1197 * },
1198 * onObservedStateChange = null // Default behavior
1199 * )
1200 * }
1201 * ) {
1202 * // content
1203 * }
1204 * ```
1205 *
1206 * See either [shouldInvalidateOnFixedWidth] or [shouldInvalidateOnFixedHeight] to learn more about
1207 * the intent behind rate-limiting invalidation.
1208 */
1209 class InvalidationStrategySpecification internal constructor() {
1210 private var widthRateCount = 0
1211
1212 /**
1213 * Limits the rate at which MotionLayout is invalidated while [Constraints.hasFixedWidth] is
1214 * true.
1215 *
1216 * The rate limit is defined by two variables. Use [skipCount] to indicate how many consecutive
1217 * measure passes should skip invalidation, you may then provide a [threshold] (in pixels) to
1218 * indicate when to invalidate regardless of how many passes are left to skip. This is important
1219 * since you only want to skip invalidation passes when there's **not** a significant change in
1220 * dimensions.
1221 *
1222 * Overall, you don't want [skipCount] to be too high otherwise it'll result in a "jumpy" layout
1223 * behavior, but you also don't want the [threshold] to be too low, otherwise you'll lose the
1224 * benefit of rate limiting.
1225 *
1226 * A good starting point is setting [skipCount] to 3 and [threshold] to 5. You can then adjust
1227 * based on your expectations of performance and perceived smoothness.
1228 */
shouldInvalidateOnFixedWidthnull1229 fun shouldInvalidateOnFixedWidth(
1230 oldConstraints: Constraints,
1231 newConstraints: Constraints,
1232 skipCount: Int,
1233 threshold: Int
1234 ): Boolean {
1235 if (oldConstraints.hasFixedWidth && newConstraints.hasFixedWidth) {
1236 val diff = (newConstraints.maxWidth - oldConstraints.maxWidth).absoluteValue
1237 if (diff >= threshold) {
1238 widthRateCount = 0
1239 return true
1240 }
1241 if (diff != 0) {
1242 widthRateCount++
1243 if (widthRateCount > skipCount) {
1244 widthRateCount = 0
1245 return true
1246 }
1247 }
1248 } else {
1249 widthRateCount = 0
1250 }
1251 return false
1252 }
1253
1254 private var heightRateCount = 0
1255
1256 /**
1257 * Limits the rate at which MotionLayout is invalidated while [Constraints.hasFixedHeight] is
1258 * true.
1259 *
1260 * The rate limit is defined by two variables. Use [skipCount] to indicate how many consecutive
1261 * measure passes should skip invalidation, you may then provide a [threshold] (in pixels) to
1262 * indicate when to invalidate regardless of how many passes are left to skip. This is important
1263 * since you only want to skip invalidation passes when there's **not** a significant change in
1264 * dimensions.
1265 *
1266 * Overall, you don't want [skipCount] to be too high otherwise it'll result in a "jumpy" layout
1267 * behavior, but you also don't want the [threshold] to be too low, otherwise you'll lose the
1268 * benefit of rate limiting.
1269 *
1270 * A good starting point is setting [skipCount] to 3 and [threshold] to 5. You can then adjust
1271 * based on your expectations of performance and perceived smoothness.
1272 */
shouldInvalidateOnFixedHeightnull1273 fun shouldInvalidateOnFixedHeight(
1274 oldConstraints: Constraints,
1275 newConstraints: Constraints,
1276 skipCount: Int,
1277 threshold: Int
1278 ): Boolean {
1279 if (oldConstraints.hasFixedHeight && newConstraints.hasFixedHeight) {
1280 val diff = (newConstraints.maxHeight - oldConstraints.maxHeight).absoluteValue
1281 if (diff >= threshold) {
1282 heightRateCount = 0
1283 return true
1284 }
1285 if (diff != 0) {
1286 heightRateCount++
1287 if (heightRateCount > skipCount) {
1288 heightRateCount = 0
1289 return true
1290 }
1291 }
1292 } else {
1293 heightRateCount = 0
1294 }
1295 return false
1296 }
1297 }
1298
1299 /**
1300 * Provide different invalidation strategies for [MotionLayout].
1301 *
1302 * Whenever [MotionLayout] needs invalidating, it has to recalculate all animations based on the
1303 * current state at the measure pass, this is the slowest process in the [MotionLayout] cycle.
1304 *
1305 * An invalidation can be triggered by two reasons:
1306 * - Incoming fixed size constraints have changed. This is necessary since layouts are highly
1307 * dependent on their available space, it'll typically happen if you are externally animating the
1308 * dimensions of [MotionLayout].
1309 * - The content of MotionLayout recomposes. This is necessary since Layouts in Compose don't know
1310 * the reason for a new measure pass, so we need to recalculate animations even if recomposition
1311 * didn't affect the actual Layout. For example, this **definitely** happens if you are using
1312 * [MotionLayoutScope.customProperties], even when you are just animating a background color, the
1313 * custom property will trigger a recomposition in the content and [MotionLayout] will be forced
1314 * to invalidate since it cannot know that the Layout was not affected.
1315 *
1316 * So, you may use [InvalidationStrategy] to help [MotionLayout] decide when to invalidate:
1317 * - [onObservedStateChange]: Mitigates invalidation from content recomposition by explicitly
1318 * reading the State variables you want to cause invalidation. You'll likely want to apply this
1319 * strategy to most of your [MotionLayout] Composables. As, in the most simple cases you can just
1320 * provide an empty lambda. Here's a full example:
1321 * ```
1322 * val progress = remember { Animatable(0f) }
1323 *
1324 * MotionLayout(
1325 * motionScene = remember {
1326 * // A simple MotionScene that animates a background color from Red to Blue
1327 * MotionScene {
1328 * val (textRef) = createRefsFor("text")
1329 *
1330 * val start = constraintSet {
1331 * constrain(textRef) {
1332 * centerTo(parent)
1333 * customColor("background", Color.Red)
1334 * }
1335 * }
1336 * val end = constraintSet(extendConstraintSet = start) {
1337 * constrain(textRef) {
1338 * customColor("background", Color.Blue)
1339 * }
1340 * }
1341 * defaultTransition(from = start, to = end)
1342 * }
1343 * },
1344 * progress = progress.value,
1345 * modifier = Modifier.fillMaxSize(),
1346 * invalidationStrategy = remember {
1347 * InvalidationStrategy(
1348 * onObservedStateChange = { /* Empty, no need to invalidate on content recomposition */ }
1349 * )
1350 * }
1351 * ) {
1352 * // The content doesn't depend on any State variable that may affect the Layout's measure result
1353 * Text(
1354 * text = "Hello, World",
1355 * modifier = Modifier
1356 * .layoutId("text")
1357 * // However, the custom color is causing recomposition on each animated frame
1358 * .background(customColor("text", "background"))
1359 * )
1360 * }
1361 * LaunchedEffect(Unit) {
1362 * delay(1000)
1363 * progress.animateTo(targetValue = 1f, tween(durationMillis = 1200))
1364 * }
1365 * ```
1366 *
1367 * *When should I provide States to read then?*
1368 *
1369 * Whenever a State backed variable that affects the Layout's measure result changes. The most
1370 * common cases are Strings on the Text Composable.
1371 *
1372 * Here's an example where the text changes half-way through the animation:
1373 * ```
1374 * val progress = remember { Animatable(0f) }
1375 *
1376 * var textString by remember { mutableStateOf("Hello, World") }
1377 * MotionLayout(
1378 * motionScene = remember {
1379 * // A MotionScene that animates a Text from one corner to the other with an animated
1380 * // background color
1381 * MotionScene {
1382 * val (textRef) = createRefsFor("text")
1383 *
1384 * defaultTransition(
1385 * from = constraintSet {
1386 * constrain(textRef) {
1387 * top.linkTo(parent.top)
1388 * start.linkTo(parent.start)
1389 *
1390 * customColor("background", Color.LightGray)
1391 * }
1392 * },
1393 * to = constraintSet {
1394 * constrain(textRef) {
1395 * bottom.linkTo(parent.bottom)
1396 * end.linkTo(parent.end)
1397 *
1398 * customColor("background", Color.Gray)
1399 * }
1400 * }
1401 * )
1402 * }
1403 * },
1404 * progress = progress.value,
1405 * modifier = Modifier.fillMaxSize(),
1406 * invalidationStrategy = remember {
1407 * InvalidationStrategy(
1408 * onObservedStateChange = @Suppress("UNUSED_EXPRESSION"){
1409 * // We read our State String variable in this block, to guarantee that
1410 * // MotionLayout will invalidate to accommodate the new Text Layout.
1411 * // Note that we do not read the custom color here since it doesn't affect the Layout
1412 * textString
1413 * }
1414 * )
1415 * }
1416 * ) {
1417 * // The text Layout will change based on the provided State String
1418 * Text(
1419 * text = textString,
1420 * modifier = Modifier
1421 * .layoutId("text")
1422 * // Without an invalidation strategy, the custom color would normally invalidate
1423 * // MotionLayout due to recomposition
1424 * .background(customColor("text", "background"))
1425 * )
1426 * }
1427 * LaunchedEffect(Unit) {
1428 * delay(1000)
1429 * progress.animateTo(targetValue = 1f, tween(durationMillis = 3000)) {
1430 * if (value >= 0.5f) {
1431 * textString = "This is a\n" + "significantly different text."
1432 * }
1433 * }
1434 * }
1435 * ```
1436 *
1437 * *What if my Text changes continuously?*
1438 *
1439 * There's a few strategies you can take depending on how you expect the Text to behave.
1440 *
1441 * For example, if you don't expect the text to need more than one line, you can set the Text with
1442 * `softWrap = false` and `overflow = TextOverflow.Visible`:
1443 * ```
1444 * MotionLayout(
1445 * motionScene = motionScene,
1446 * progress = progress,
1447 * modifier = Modifier.size(200.dp),
1448 * invalidationStrategy = remember { InvalidationStrategy { /* Do not invalidate on content recomposition */ } }
1449 * ) {
1450 * Text(
1451 * text = <your-State-String>,
1452 * modifier = Modifier.layoutId("text"),
1453 * softWrap = false,
1454 * overflow = TextOverflow.Visible
1455 * )
1456 * }
1457 * ```
1458 *
1459 * The Text layout won't change significantly and performance will be much improved.
1460 * - [onIncomingConstraints]: With this lambda you can mitigate invalidation from incoming
1461 * constraints. You'll only have to worry about providing this lambda if you or the Layout you're
1462 * using is animating measuring constraints on [MotionLayout]. If the size is only changing in
1463 * specific, discrete values, then you should allow [MotionLayout] to invalidate normally.
1464 *
1465 * Here's an example where we manually animate [MotionLayout]'s size through a Modifier (along with
1466 * the MotionLayout animation), and shows how to mitigate invalidation by rate-limiting:
1467 * ```
1468 * val textId = "text"
1469 * val progress = remember { Animatable(0f) }
1470 *
1471 * val initial = remember { DpSize(100.dp, 100.dp) }
1472 * val target = remember { DpSize(120.dp, 200.dp) }
1473 * var size by remember { mutableStateOf(initial) }
1474 *
1475 * MotionLayout(
1476 * motionScene = remember {
1477 * MotionScene {
1478 * val (textRef) = createRefsFor( "text")
1479 *
1480 * // Animate text from the bottom of the layout to the top
1481 * defaultTransition(
1482 * from = constraintSet {
1483 * constrain(textRef) {
1484 * centerHorizontallyTo(parent)
1485 * bottom.linkTo(parent.bottom)
1486 * }
1487 * },
1488 * to = constraintSet {
1489 * constrain(textRef) {
1490 * centerHorizontallyTo(parent)
1491 * top.linkTo(parent.top)
1492 * }
1493 * }
1494 * )
1495 * }
1496 * },
1497 * progress = progress.value,
1498 * modifier = Modifier.background(Color.Cyan).size(size),
1499 * invalidationStrategy = remember {
1500 * InvalidationStrategy(
1501 * onIncomingConstraints = { old, new ->
1502 * // We invalidate every third frame, or when the change is higher than 5 pixels
1503 * shouldInvalidateOnFixedWidth(old, new, skipCount = 3, threshold = 5) ||
1504 * shouldInvalidateOnFixedHeight(old, new, skipCount = 3, threshold = 5)
1505 * },
1506 * // No need to worry about content state changes for this example
1507 * onObservedStateChange = {}
1508 * )
1509 * }
1510 * ) {
1511 * Text("Hello, World!", Modifier.layoutId(textId))
1512 * }
1513 *
1514 * // Animate the size along with the MotionLayout. Without an invalidation strategy, this will cause
1515 * // MotionLayout to invalidate at every measure pass since it's getting fixed size Constraints at
1516 * // different values
1517 * LaunchedEffect(Unit) {
1518 * val sizeDifference = target - initial
1519 * delay(1000)
1520 * progress.animateTo(1f, tween(1200)) {
1521 * size = initial + (sizeDifference * value)
1522 * }
1523 * }
1524 * ```
1525 *
1526 * Note that
1527 * [shouldInvalidateOnFixedWidth][InvalidationStrategySpecification.shouldInvalidateOnFixedWidth]
1528 * and
1529 * [shouldInvalidateOnFixedHeight][InvalidationStrategySpecification.shouldInvalidateOnFixedHeight]
1530 * are helper methods available in [InvalidationStrategySpecification].
1531 *
1532 * An alternative to rate-limiting is to "simply" avoid invalidation from changed fixed size
1533 * constraints. This can be done by leaving [MotionLayout] as wrap content and then have it choose
1534 * its own start and ending size. Naturally, this is not always feasible, specially if it's a parent
1535 * Composable the one that's animating the size constraints.
1536 *
1537 * But, here's the MotionScene showing how to achieve this behavior based on the example above:
1538 * ```
1539 * MotionScene {
1540 * // We'll use fakeParentRef to choose our starting and ending size then constrain everything
1541 * // else to it. MotionLayout will animate without invalidating.
1542 * // There's no need to bind "fakeParent" to any actual Composable.
1543 * val (fakeParentRef, textRef) = createRefsFor("fakeParent", "text")
1544 *
1545 * defaultTransition(
1546 * from = constraintSet {
1547 * constrain(fakeParentRef) {
1548 * width = 100.dp.asDimension()
1549 * height = 100.dp.asDimension()
1550 * }
1551 *
1552 * constrain(textRef) {
1553 * bottom.linkTo(fakeParentRef.bottom)
1554 * }
1555 * },
1556 * to = constraintSet {
1557 * constrain(fakeParentRef) {
1558 * width = 120.dp.asDimension()
1559 * height = 200.dp.asDimension()
1560 * }
1561 *
1562 * constrain(textRef) {
1563 * top.linkTo(fakeParentRef.top)
1564 * }
1565 * }
1566 * )
1567 * }
1568 * ```
1569 *
1570 * You can then remove the size modifier and the invalidation strategy for `onIncomingConstraints`,
1571 * as [MotionLayout] will animate through both sizes without invalidating.
1572 *
1573 * @property onIncomingConstraints
1574 * @property onObservedStateChange
1575 * @see InvalidationStrategy.DefaultInvalidationStrategy
1576 * @see InvalidationStrategySpecification
1577 * @see InvalidationStrategySpecification.shouldInvalidateOnFixedWidth
1578 * @see InvalidationStrategySpecification.shouldInvalidateOnFixedHeight
1579 */
1580 class InvalidationStrategy(
1581 /**
1582 * Lambda to implement invalidation based on incoming [Constraints].
1583 *
1584 * Called every measure pass after the first measure (to obtain "old" [Constraints]), return
1585 * `true` to indicate when to invalidate [MotionLayout]. The default behavior, would be to
1586 * always return `false`.
1587 *
1588 * See the documentation on [InvalidationStrategy] or either of
1589 * [shouldInvalidateOnFixedWidth][InvalidationStrategySpecification.shouldInvalidateOnFixedWidth]
1590 * /[shouldInvalidateOnFixedHeight][InvalidationStrategySpecification.shouldInvalidateOnFixedHeight]
1591 * to learn some strategies on how to improve invalidation due to incoming constraints.
1592 */
1593 val onIncomingConstraints:
1594 (InvalidationStrategySpecification.(old: Constraints, new: Constraints) -> Boolean)? =
1595 null,
1596 /**
1597 * Lambda to implement invalidation on observed State changes.
1598 *
1599 * [State][androidx.compose.runtime.State] based variables should be read in the block of this
1600 * lambda to have [MotionLayout] invalidate whenever any of those variables have changed.
1601 *
1602 * You may use an assigned value or delegated variable for this purpose:
1603 * ```
1604 * val stateVar0 = remember { mutableStateOf("Foo") }
1605 * var stateVar1 by remember { mutableStateOf("Bar") }
1606 * val invalidationStrategy = remember {
1607 * InvalidationStrategy(
1608 * onObservedStateChange = @Suppress("UNUSED_EXPRESSION") {
1609 * stateVar0.value
1610 * stateVar1
1611 * }
1612 * )
1613 * }
1614 * ```
1615 *
1616 * See [InvalidationStrategy] to learn more about common strategies regarding invalidation on
1617 * onObservedStateChange.
1618 */
1619 val onObservedStateChange: (() -> Unit)?
1620 ) {
1621 private val scope = InvalidationStrategySpecification()
1622
1623 /**
1624 * Transform: `(InvalidationStrategyScope.(old: Constraints, new: Constraints) -> Boolean)?`
1625 * into `(old: Constraints, new: Constraints) -> Boolean`
1626 *
1627 * Returns null to indicate that there's no user logic to handle this type of invalidation.
1628 */
1629 internal val shouldInvalidate: ShouldInvalidateCallback? =
<lambda>null1630 kotlin.run {
1631 if (onIncomingConstraints == null) {
1632 // Nothing to invalidate with, let MotionMeasurer decide
1633 null
1634 } else {
1635 ShouldInvalidateCallback { old, new ->
1636 onIncomingConstraints.let { lambda -> scope.lambda(old, new) }
1637 }
1638 }
1639 }
1640
1641 companion object {
1642 /**
1643 * Default invalidation strategy for [MotionLayout].
1644 *
1645 * This will cause it to invalidate whenever its content recomposes or when it receives
1646 * different fixed size [Constraints] at the measure pass.
1647 */
1648 val DefaultInvalidationStrategy = InvalidationStrategy(null, null)
1649 }
1650 }
1651