Build apps faster with our new App builder! Check it out →

animateBounds

Common

Modifier in Compose Animation

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

Last updated:

Installation

dependencies {
   implementation("androidx.compose.animation:animation:1.8.0-alpha04")
}

Overloads

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

Parameters

namedescription
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
private 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
private 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
private 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
private 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
private 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()
                    }
                }
            }
        }
    }
}
by @alexstyl