animateBounds

Compose Modifier

Common
@ExperimentalSharedTransitionApi // Depends on BoundsTransform
public fun Modifier.animateBounds(
    lookaheadScope: LookaheadScope,
    modifier: Modifier = Modifier,
    boundsTransform: BoundsTransform = DefaultBoundsTransform,
    animateMotionFrameOfReference: Boolean = false,
): Modifier

Modifier to animate layout changes (position and/or size) that occur within a LookaheadScope.

So, the given lookaheadScope defines the coordinate space considered to trigger an animation. For example, if lookaheadScope was defined at the root of the app hierarchy, then any layout changes visible within the screen will trigger an animation, if it, in contrast was defined within a scrolling parent, then, as long the LookaheadScope scrolls with is content, no animation will be triggered, as there will be no changes within its coordinate space.

The animation is driven with a FiniteAnimationSpec produced by the given BoundsTransform function, which you may use to customize the animations based on the initial and target bounds.

Do note that certain Layout Modifiers when chained with animateBounds, may only cause an immediate observable change to either the child or the parent Layout which can result in undesired behavior. For those cases you can instead provide it to the modifier parameter. This allows animateBounds to envelop the size and constraints change and propagate them gradually to both its parent and child Layout.

You may see the difference when supplying a Layout Modifier in modifier on the following example:

By default, changes in position under LayoutCoordinates.introducesMotionFrameOfReference are excluded from the animation and are instead immediately applied, as they are expected to be frequent/continuous (to handle Layouts under Scroll). You may change this behavior by passing animateMotionFrameOfReference as true. Keep in mind, doing that under a scroll may result in the Layout "chasing" the scroll offset, as it will constantly animate to the latest position.

A basic use-case is animating a layout based on content changes, such as the String changing on a Text:

It also provides an easy way to animate layout changes of a complex Composable Layout:

Since BoundsTransform is called when initiating an animation, you may also use it to calculate a keyframe based animation:

It may also be used together with movableContent as long as the given LookaheadScope is in a common place within the Layout hierarchy of the slots presenting the movableContent:

Parameters

lookaheadScopeThe scope from which this animateBounds will calculate its animations from. This implies that as long as you're expecting an animation the reference of the given LookaheadScope shouldn't change, otherwise you may get unexpected behavior.
modifierOptional intermediate Modifier, may be used in cases where otherwise immediate layout changes are perceived as gradual by both the parent and child Layout.
boundsTransformProduce a customized FiniteAnimationSpec based on the initial and target bounds, called when an animation is triggered.
animateMotionFrameOfReferenceWhen true, changes under LayoutCoordinates.introducesMotionFrameOfReference (for continuous positional changes, such as Scroll Offset) are included when calculating an animation. false by default, where the changes are instead applied directly into the layout without triggering an animation.

Code Examples

AnimateBounds_withLayoutModifier

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun AnimateBounds_withLayoutModifier() {
    // Example showing the difference between providing a Layout Modifier as a parameter of
    // `animateBounds` and chaining the Layout Modifier.
    // We use `padding` in this example, as it provides an immediate change in layout to its child,
    // but not the parent, which sees the same resulting layout. The difference can be seen in the
    // Text (content under padding) and an accompanying Cyan Box (a sibling, under the same Row
    // parent).
    LookaheadScope {
        val boundsTransform = remember {
            BoundsTransform { _, _ ->
                spring(stiffness = 50f, visibilityThreshold = Rect.VisibilityThreshold)
            }
        }
        var toggleAnimation by remember { mutableStateOf(true) }
        Column(Modifier.clickable { toggleAnimation = !toggleAnimation }) {
            Text(
                "See the difference in animation when the Layout Modifier is a parameter of animateBounds. Padding, in this example."
            )
            Spacer(Modifier.height(12.dp))
            Text("Layout Modifier as a parameter.")
            Row(Modifier.fillMaxWidth()) {
                Box(
                    Modifier.animateBounds(
                            lookaheadScope = this@LookaheadScope,
                            modifier =
                                // By providing this Modifier as a parameter of `animateBounds`,
                                // both content and parent see a gradual/animated change in Layout.
                                Modifier.padding(
                                    horizontal = if (toggleAnimation) 10.dp else 50.dp
                                ),
                            boundsTransform = boundsTransform,
                        )
                        .background(Color.Red, RoundedCornerShape(12.dp))
                        .height(50.dp)
                ) {
                    Text("Layout Content", Modifier.align(Alignment.Center))
                }
                Box(Modifier.size(50.dp).background(Color.Cyan, RoundedCornerShape(12.dp)))
            }
            Spacer(Modifier.height(12.dp))
            Text("Layout Modifier after AnimateBounds.")
            Row(Modifier.fillMaxWidth()) {
                Box(
                    Modifier.animateBounds(
                            lookaheadScope = this@LookaheadScope,
                            boundsTransform = boundsTransform,
                        )
                        // The content is able to animate the change in padding, but since the
                        // parent Layout sees no difference, the change in position is immediate.
                        .padding(horizontal = if (toggleAnimation) 10.dp else 50.dp)
                        .background(Color.Red, RoundedCornerShape(12.dp))
                        .height(50.dp)
                ) {
                    Text("Layout Content", Modifier.align(Alignment.Center))
                }
                Box(Modifier.size(50.dp).background(Color.Cyan, RoundedCornerShape(12.dp)))
            }
            Spacer(Modifier.height(12.dp))
            Text("Layout Modifier before AnimateBounds.")
            Row(Modifier.fillMaxWidth()) {
                Box(
                    Modifier
                        // The parent is able to see the change in position and the animated size,
                        // so it can smoothly place both its children, but the content of the Box
                        // cannot see the gradual changes so it remains constant.
                        .padding(horizontal = if (toggleAnimation) 10.dp else 50.dp)
                        .animateBounds(
                            lookaheadScope = this@LookaheadScope,
                            boundsTransform = boundsTransform,
                        )
                        .background(Color.Red, RoundedCornerShape(12.dp))
                        .height(50.dp)
                ) {
                    Text("Layout Content", Modifier.align(Alignment.Center))
                }
                Box(Modifier.size(50.dp).background(Color.Cyan, RoundedCornerShape(12.dp)))
            }
        }
    }
}

AnimateBounds_animateOnContentChange

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun AnimateBounds_animateOnContentChange() {
    // Example where the change in content triggers the layout change on the item with animateBounds
    val textShort = remember { "Foo ".repeat(10) }
    val textLong = remember { "Bar ".repeat(50) }
    var toggle by remember { mutableStateOf(true) }
    LookaheadScope {
        Box(
            modifier = Modifier.fillMaxSize().clickable { toggle = !toggle },
            contentAlignment = Alignment.Center,
        ) {
            Text(
                text = if (toggle) textShort else textLong,
                modifier =
                    Modifier.fillMaxWidth(0.7f)
                        .background(Color.LightGray)
                        .animateBounds(this@LookaheadScope)
                        .padding(10.dp),
            )
        }
    }
}

AnimateBounds_inFlowRowSample

@OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class)
@Composable
fun AnimateBounds_inFlowRowSample() {
    var itemRowCount by remember { mutableIntStateOf(1) }
    val colors = remember { listOf(Color.Cyan, Color.Magenta, Color.Yellow, Color.Green) }
    // A case showing `animateBounds` being used to animate layout changes driven by a parent Layout
    LookaheadScope {
        Column(Modifier.clickable { itemRowCount = if (itemRowCount != 2) 2 else 1 }) {
            Text("Click to toggle animation.")
            FlowRow(
                modifier =
                    Modifier.fillMaxWidth()
                        // Note that the wrap content size changes for FlowRow as the content
                        // adjusts
                        // to one or two lines, we can simply use `animateContentSize()` to make
                        // sure
                        // all items are visible during their animation.
                        .animateContentSize(),
                // Try changing the arrangement as well!
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp),
                // We use the maxItems parameter to change the layout of the FlowRow at different
                // states
                maxItemsInEachRow = itemRowCount,
            ) {
                colors.fastForEach {
                    Box(
                        Modifier.animateBounds(this@LookaheadScope)
                            // Note the modifier order, we declare the background after
                            // `animateBounds` to make sure it animates with the rest of the content
                            .background(it, RoundedCornerShape(12.dp))
                            .weight(weight = 1f, fill = true)
                            .height(100.dp)
                    )
                }
            }
        }
    }
}

AnimateBounds_usingKeyframes

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun AnimateBounds_usingKeyframes() {
    var toggle by remember { mutableStateOf(true) }
    // Example using BoundsTransform to calculate an animation using keyframes with splines.
    LookaheadScope {
        Box(Modifier.fillMaxSize().clickable { toggle = !toggle }) {
            Text(
                text = "Hello, World!",
                textAlign = TextAlign.Center,
                modifier =
                    Modifier.align(if (toggle) Alignment.TopStart else Alignment.TopEnd)
                        .animateBounds(
                            lookaheadScope = this@LookaheadScope,
                            boundsTransform = { initialBounds, targetBounds ->
                                // We'll use a keyframe to emphasize the animation in position and
                                // size.
                                keyframesWithSpline {
                                    durationMillis = 1200
                                    // Emphasize with an increase in size
                                    val size = targetBounds.size.times(2f)
                                    // Emphasize the path with a slight curve at the halfway point
                                    val position =
                                        targetBounds.topLeft
                                            .plus(initialBounds.topLeft)
                                            .times(0.5f)
                                            .plus(
                                                Offset(
                                                    // Consider the increase in size (from the
                                                    // center,
                                                    // to keep the Layout aligned at the keyframe)
                                                    x = -(size.width - targetBounds.width) * 0.5f,
                                                    // Emphasize the path with a vertical offset
                                                    y = size.height * 0.5f,
                                                )
                                            )
                                    // Only need to define the intermediate keyframe, initial and
                                    // target are implicit.
                                    Rect(position, size).atFraction(0.5f).using(LinearEasing)
                                }
                            },
                        )
                        .background(Color.LightGray, RoundedCornerShape(50))
                        .padding(10.dp)
                        // Text is laid out with the animated fixed Constraints, relax constraints
                        // back to wrap content to be able to center Align vertically.
                        .wrapContentSize(Alignment.Center),
            )
        }
    }
}

AnimateBounds_withMovableContent

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun AnimateBounds_withMovableContent() {
    // Example showing how to animate a Layout that can be presented on different Layout Composables
    // as the state changes using `movableContent`.
    var position by remember { mutableIntStateOf(-1) }
    val movableContent = remember {
        // To animate a Layout that can be presented in different Composables, we can use
        // `animateBounds` with `movableContent`.
        movableContentWithReceiverOf<LookaheadScope> {
            Box(
                Modifier.animateBounds(
                        lookaheadScope = this@movableContentWithReceiverOf,
                        boundsTransform = { _, _ ->
                            spring(
                                dampingRatio = Spring.DampingRatioLowBouncy,
                                stiffness = Spring.StiffnessVeryLow,
                                visibilityThreshold = Rect.VisibilityThreshold,
                            )
                        },
                    )
                    // Our movableContent can always fill its container in this example.
                    .fillMaxSize()
                    .background(Color.Cyan, RoundedCornerShape(8.dp))
            )
        }
    }
    LookaheadScope {
        Box(Modifier.fillMaxSize()) {
            // Initial container of our Layout, at the center of the screen.
            Box(
                Modifier.size(200.dp)
                    .border(3.dp, Color.Red, RoundedCornerShape(8.dp))
                    .align(Alignment.Center)
                    .clickable { position = -1 }
            ) {
                if (position < 0) {
                    movableContent()
                }
            }
            repeat(4) { index ->
                // Four additional Boxes where our content may be move to.
                Box(
                    Modifier.size(100.dp)
                        .border(2.dp, Color.Blue, RoundedCornerShape(8.dp))
                        .align { size, space, _ ->
                            val horizontal = if (index % 2 == 0) 0.15f else 0.85f
                            val vertical = if (index < 2) 0.15f else 0.85f
                            Offset(
                                    x = (space.width - size.width) * horizontal,
                                    y = (space.height - size.height) * vertical,
                                )
                                .round()
                        }
                        .clickable { position = index }
                ) {
                    if (position == index) {
                        // The call to movable content will trigger `Modifier.animateBounds()` to
                        // animate the content's position and size from its previous state.
                        movableContent()
                    }
                }
            }
        }
    }
}