InvalidationStrategy

class InvalidationStrategy


Provide different invalidation strategies for MotionLayout.

Whenever MotionLayout needs invalidating, it has to recalculate all animations based on the current state at the measure pass, this is the slowest process in the MotionLayout cycle.

An invalidation can be triggered by two reasons:

  • Incoming fixed size constraints have changed. This is necessary since layouts are highly dependent on their available space, it'll typically happen if you are externally animating the dimensions of MotionLayout.

  • The content of MotionLayout recomposes. This is necessary since Layouts in Compose don't know the reason for a new measure pass, so we need to recalculate animations even if recomposition didn't affect the actual Layout. For example, this definitely happens if you are using MotionLayoutScope.customProperties, even when you are just animating a background color, the custom property will trigger a recomposition in the content and MotionLayout will be forced to invalidate since it cannot know that the Layout was not affected.

So, you may use InvalidationStrategy to help MotionLayout decide when to invalidate:

  • onObservedStateChange: Mitigates invalidation from content recomposition by explicitly reading the State variables you want to cause invalidation. You'll likely want to apply this strategy to most of your MotionLayout Composables. As, in the most simple cases you can just provide an empty lambda. Here's a full example:

val progress = remember { Animatable(0f) }

MotionLayout(
motionScene = remember {
// A simple MotionScene that animates a background color from Red to Blue
MotionScene {
val (textRef) = createRefsFor("text")

val start = constraintSet {
constrain(textRef) {
centerTo(parent)
customColor("background", Color.Red)
}
}
val end = constraintSet(extendConstraintSet = start) {
constrain(textRef) {
customColor("background", Color.Blue)
}
}
defaultTransition(from = start, to = end)
}
},
progress = progress.value,
modifier = Modifier.fillMaxSize(),
invalidationStrategy = remember {
InvalidationStrategy(
onObservedStateChange = { /* Empty, no need to invalidate on content recomposition */ }
)
}
) {
// The content doesn't depend on any State variable that may affect the Layout's measure result
Text(
text = "Hello, World",
modifier = Modifier
.layoutId("text")
// However, the custom color is causing recomposition on each animated frame
.background(customColor("text", "background"))
)
}
LaunchedEffect(Unit) {
delay(1000)
progress.animateTo(targetValue = 1f, tween(durationMillis = 1200))
}

When should I provide States to read then?

Whenever a State backed variable that affects the Layout's measure result changes. The most common cases are Strings on the Text Composable.

Here's an example where the text changes half-way through the animation:

val progress = remember { Animatable(0f) }

var textString by remember { mutableStateOf("Hello, World") }
MotionLayout(
motionScene = remember {
// A MotionScene that animates a Text from one corner to the other with an animated
// background color
MotionScene {
val (textRef) = createRefsFor("text")

defaultTransition(
from = constraintSet {
constrain(textRef) {
top.linkTo(parent.top)
start.linkTo(parent.start)

customColor("background", Color.LightGray)
}
},
to = constraintSet {
constrain(textRef) {
bottom.linkTo(parent.bottom)
end.linkTo(parent.end)

customColor("background", Color.Gray)
}
}
)
}
},
progress = progress.value,
modifier = Modifier.fillMaxSize(),
invalidationStrategy = remember {
InvalidationStrategy(
onObservedStateChange = @Suppress("UNUSED_EXPRESSION"){
// We read our State String variable in this block, to guarantee that
// MotionLayout will invalidate to accommodate the new Text Layout.
// Note that we do not read the custom color here since it doesn't affect the Layout
textString
}
)
}
) {
// The text Layout will change based on the provided State String
Text(
text = textString,
modifier = Modifier
.layoutId("text")
// Without an invalidation strategy, the custom color would normally invalidate
// MotionLayout due to recomposition
.background(customColor("text", "background"))
)
}
LaunchedEffect(Unit) {
delay(1000)
progress.animateTo(targetValue = 1f, tween(durationMillis = 3000)) {
if (value >= 0.5f) {
textString = "This is a\n" + "significantly different text."
}
}
}

What if my Text changes continuously?

There's a few strategies you can take depending on how you expect the Text to behave.

For example, if you don't expect the text to need more than one line, you can set the Text with softWrap = false and overflow = TextOverflow.Visible:

MotionLayout(
motionScene = motionScene,
progress = progress,
modifier = Modifier.size(200.dp),
invalidationStrategy = remember { InvalidationStrategy { /* Do not invalidate on content recomposition */ } }
) {
Text(
text = <your-State-String>,
modifier = Modifier.layoutId("text"),
softWrap = false,
overflow = TextOverflow.Visible
)
}

The Text layout won't change significantly and performance will be much improved.

  • onIncomingConstraints: With this lambda you can mitigate invalidation from incoming constraints. You'll only have to worry about providing this lambda if you or the Layout you're using is animating measuring constraints on MotionLayout. If the size is only changing in specific, discrete values, then you should allow MotionLayout to invalidate normally.

Here's an example where we manually animate MotionLayout's size through a Modifier (along with the MotionLayout animation), and shows how to mitigate invalidation by rate-limiting:

val textId = "text"
val progress = remember { Animatable(0f) }

val initial = remember { DpSize(100.dp, 100.dp) }
val target = remember { DpSize(120.dp, 200.dp) }
var size by remember { mutableStateOf(initial) }

MotionLayout(
motionScene = remember {
MotionScene {
val (textRef) = createRefsFor( "text")

// Animate text from the bottom of the layout to the top
defaultTransition(
from = constraintSet {
constrain(textRef) {
centerHorizontallyTo(parent)
bottom.linkTo(parent.bottom)
}
},
to = constraintSet {
constrain(textRef) {
centerHorizontallyTo(parent)
top.linkTo(parent.top)
}
}
)
}
},
progress = progress.value,
modifier = Modifier.background(Color.Cyan).size(size),
invalidationStrategy = remember {
InvalidationStrategy(
onIncomingConstraints = { old, new ->
// We invalidate every third frame, or when the change is higher than 5 pixels
shouldInvalidateOnFixedWidth(old, new, skipCount = 3, threshold = 5) ||
shouldInvalidateOnFixedHeight(old, new, skipCount = 3, threshold = 5)
},
// No need to worry about content state changes for this example
onObservedStateChange = {}
)
}
) {
Text("Hello, World!", Modifier.layoutId(textId))
}

// Animate the size along with the MotionLayout. Without an invalidation strategy, this will cause
// MotionLayout to invalidate at every measure pass since it's getting fixed size Constraints at
// different values
LaunchedEffect(Unit) {
val sizeDifference = target - initial
delay(1000)
progress.animateTo(1f, tween(1200)) {
size = initial + (sizeDifference * value)
}
}

Note that shouldInvalidateOnFixedWidth and shouldInvalidateOnFixedHeight are helper methods available in InvalidationStrategySpecification.

An alternative to rate-limiting is to "simply" avoid invalidation from changed fixed size constraints. This can be done by leaving MotionLayout as wrap content and then have it choose its own start and ending size. Naturally, this is not always feasible, specially if it's a parent Composable the one that's animating the size constraints.

But, here's the MotionScene showing how to achieve this behavior based on the example above:

MotionScene {
// We'll use fakeParentRef to choose our starting and ending size then constrain everything
// else to it. MotionLayout will animate without invalidating.
// There's no need to bind "fakeParent" to any actual Composable.
val (fakeParentRef, textRef) = createRefsFor("fakeParent", "text")

defaultTransition(
from = constraintSet {
constrain(fakeParentRef) {
width = 100.dp.asDimension()
height = 100.dp.asDimension()
}

constrain(textRef) {
bottom.linkTo(fakeParentRef.bottom)
}
},
to = constraintSet {
constrain(fakeParentRef) {
width = 120.dp.asDimension()
height = 200.dp.asDimension()
}

constrain(textRef) {
top.linkTo(fakeParentRef.top)
}
}
)
}

You can then remove the size modifier and the invalidation strategy for onIncomingConstraints, as MotionLayout will animate through both sizes without invalidating.

Summary

Public companion properties

InvalidationStrategy

Default invalidation strategy for MotionLayout.

Public constructors

InvalidationStrategy(
    onIncomingConstraints: (InvalidationStrategySpecification.(old: Constraints, new: Constraints) -> Boolean)?,
    onObservedStateChange: (() -> Unit)?
)

Public properties

(InvalidationStrategySpecification.(old: Constraints, new: Constraints) -> Boolean)?

Lambda to implement invalidation based on incoming Constraints.

(() -> Unit)?

Lambda to implement invalidation on observed State changes.

Public companion properties

DefaultInvalidationStrategy

Added in 1.1.0
val DefaultInvalidationStrategyInvalidationStrategy

Default invalidation strategy for MotionLayout.

This will cause it to invalidate whenever its content recomposes or when it receives different fixed size Constraints at the measure pass.

Public constructors

InvalidationStrategy

Added in 1.1.0
InvalidationStrategy(
    onIncomingConstraints: (InvalidationStrategySpecification.(old: Constraints, new: Constraints) -> Boolean)? = null,
    onObservedStateChange: (() -> Unit)?
)

Public properties

onIncomingConstraints

Added in 1.1.0
val onIncomingConstraints: (InvalidationStrategySpecification.(old: Constraints, new: Constraints) -> Boolean)?

Lambda to implement invalidation based on incoming Constraints.

Called every measure pass after the first measure (to obtain "old" Constraints), return true to indicate when to invalidate MotionLayout. The default behavior, would be to always return false.

See the documentation on InvalidationStrategy or either of shouldInvalidateOnFixedWidth /shouldInvalidateOnFixedHeight to learn some strategies on how to improve invalidation due to incoming constraints.

onObservedStateChange

Added in 1.1.0
val onObservedStateChange: (() -> Unit)?

Lambda to implement invalidation on observed State changes.

State based variables should be read in the block of this lambda to have MotionLayout invalidate whenever any of those variables have changed.

You may use an assigned value or delegated variable for this purpose:

val stateVar0 = remember { mutableStateOf("Foo") }
var stateVar1 by remember { mutableStateOf("Bar") }
val invalidationStrategy = remember {
InvalidationStrategy(
onObservedStateChange = @Suppress("UNUSED_EXPRESSION") {
stateVar0.value
stateVar1
}
)
}

See InvalidationStrategy to learn more about common strategies regarding invalidation on onObservedStateChange.