animateBounds

Functions summary

Modifier
Modifier.animateBounds(
    lookaheadScope: LookaheadScope,
    modifier: Modifier,
    boundsTransform: BoundsTransform,
    animateMotionFrameOfReference: Boolean
)

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

Cmn

Functions

Modifier.animateBounds

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:

import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.animateBounds
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.LookaheadScope
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round

// 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)))
        }
    }
}

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:

import androidx.compose.animation.animateBounds
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.LookaheadScope
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round

// 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),
        )
    }
}

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

import androidx.compose.animation.animateBounds
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.LookaheadScope
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.util.fastForEach

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)
                )
            }
        }
    }
}

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

import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.animateBounds
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.keyframesWithSpline
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.LookaheadScope
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round

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),
        )
    }
}

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:

import androidx.compose.animation.animateBounds
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.movableContentWithReceiverOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.LookaheadScope
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round

// 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()
                }
            }
        }
    }
}
Parameters
lookaheadScope: LookaheadScope

The 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.

modifier: Modifier = Modifier

Optional intermediate Modifier, may be used in cases where otherwise immediate layout changes are perceived as gradual by both the parent and child Layout.

boundsTransform: BoundsTransform = DefaultBoundsTransform

Produce a customized FiniteAnimationSpec based on the initial and target bounds, called when an animation is triggered.

animateMotionFrameOfReference: Boolean = false

When 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.