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