Adetunji Dahunsi

3 neat animations you can create with Modifier.animateBounds

See how composition permanence can help you create better animations with Jetpack Compose

TJ Dahunsi

Mar 26 2025 · 10 min read

The upcoming compose 1.8 release adds a new animation Modifier: Modifier.animateBounds. At first it seems like it does the same thing Modifier.animateContentSize does, but a closer look shows that while it's capable of animating content size changes of parent layouts when its children change like its predecessor, it is capable of so much more. This is because it has a sort of "permanence" in layout by virtue of lookahead; not only does it know its initial and target size, but it also knows its initial and target position.

This is made clearer when you look at the animation spec each Modifier takes:

  • Modifier.animateContentSize(): FiniteAnimationSpec<IntSize>
  • Modifier.animateBounds(): FiniteAnimationSpec<Rect>

In other words, since a Rect provides both size and position, Modifier.animateBounds builds upon the properties offered by the ApproachLayoutModifierNode, to offer functionality similar to a dynamic shared element transition. You don't need shared element matches between 2 Composables as it's the same Composable, just well, animating.

Composition Permanence

Composition permanence is similar to object permanence, and it is crucial for proper use of Modifier.animateBounds(). It's the ability of the Compose runtime to identify a Composable in the tree as the same, even though many of its layout properties may have changed.

Every Composition implicitly has a key for each code location in a composable function. This key is crucial; each recomposition finds composables using this key, and subsequently re-invokes it. This is why early return statements are an antipattern in compose; Composable calls after the early return will have different keys, and will be considered distinct on recompositions losing their state.

1DisposableEffect(Unit) { 2 onDispose { println("Removed from Composition") } 3}

With Composition permanence established, here are ways to guarantee it in your UI when animating with Modifier.animateBounds:

  • Make sure your Composable is statically determinate; i.e it runs the same way, every time. If methods are invoked in a for loop or iterated on in any way, it must have the same number of iterations each time.
  • Use the key composable method to override the default implicit key with one you control.
  • Use movableContentOf or movableContentWithReceiverOf to maintain full control over your Composable in different composition contexts.

Depending on your use case, one of the above may be enough, or combinations may be used. Detailed below, are 3 use cases for Modifier.animateBounds() from the open source Heron Bluesky client that illustrate Composition permanence:

Animated presentation

Bluesky allows for embedding arbitrary content in a post, from images, to videos, to just plain text. I'm very fond of birds, and I find that when in my bird feeds, the hashtags added to the post that enable it to be aggregated by my bird feed generator, invariably detract from the content. Therefore, it would be really useful if I could choose the way content in a feed is presented.

hashtags obscuring image in bluesky post
Hashtags taking layout space from content

I want to have 3 presentation types:

  1. Text with embedded content
  2. Expanded media
  3. Condensed media
1sealed class Presentation( 2 val key: String, 3) { 4 sealed class Text { 5 data object WithEmbed : Presentation( 6 key = "presentation-text-and-embed", 7 ) 8 } 9 10 sealed class Media { 11 data object Expanded : Presentation( 12 key = "presentation-expanded-media", 13 ) 14 15 data object Condensed : Presentation( 16 key = "presentation-condensed-media", 17 ) 18 } 19}

Each of these presentations show the same content types, although with different rules. The content types are:

1private sealed class PostContent(val key: String) { 2 data object Attribution : PostContent(key = "Attribution") 3 data object Text : PostContent(key = "Text") 4 sealed class Embed : PostContent(key = "Embed") { 5 data object Link : Embed() 6 data object Media : Embed() 7 } 8 9 10 data object Metadata : PostContent(key = "Metadata") 11 data object Actions : PostContent(key = "Actions") 12} 13

Composition permanence with different presentation types

Regardless of the presentation I want, all content is placed within a Column. However, since content is reordered depending on the presentation, I cannot rely on the implicit key the Compose runtime provides by virtue of being invoked in Composition to identify my composables. Instead, I must provide a key for each one that is stable across recompositions. To do this, I first define canonical orders for each presentation:

1@Stable 2private val Timeline.Presentation.contentOrder 3 get() = when (this) { 4 Timeline.Presentation.Text.WithEmbed -> TextAndEmbedOrder 5 Timeline.Presentation.Media.Expanded -> ExpandedMediaOrder 6 Timeline.Presentation.Media.Condensed -> CondensedMediaOrder 7 } 8 9 10private val TextAndEmbedOrder = listOf( 11 PostContent.Attribution, 12 PostContent.Text, 13 PostContent.Embed.Media, 14 PostContent.Metadata, 15 PostContent.Actions, 16) 17 18 19private val ExpandedMediaOrder = listOf( 20 PostContent.Attribution, 21 PostContent.Embed.Media, 22 PostContent.Actions, 23 PostContent.Text, 24 PostContent.Metadata, 25) 26 27 28private val CondensedMediaOrder = listOf( 29 PostContent.Attribution, 30 PostContent.Text, 31 PostContent.Embed.Media, 32 PostContent.Metadata, 33 PostContent.Actions, 34)

With the above defined, I now need to make sure the Compose runtime uses the same key for each content type:

1@Composable 2fun Post( 3 panedSharedElementScope: PanedSharedElementScope, 4 presentationLookaheadScope: LookaheadScope, 5 modifier: Modifier = Modifier, 6 post: Post, 7 ... 8 timeline: @Composable (BoxScope.() -> Unit) = {}, 9) { 10 Box(modifier = modifier) { 11 if (presentation == Timeline.Presentation.Text.WithEmbed) Box( 12 modifier = Modifier 13 .matchParentSize() 14 .padding(horizontal = 8.dp), 15 content = timeline, 16 ) 17 val postData = rememberUpdatedPostData(...) 18 val verticalPadding = when (presentation) { 19 Timeline.Presentation.Text.WithEmbed -> 4.dp 20 Timeline.Presentation.Media.Expanded -> 8.dp 21 Timeline.Presentation.Media.Condensed -> 0.dp 22 } 23 Column( 24 modifier = Modifier 25 .padding(vertical = verticalPadding) 26 .fillMaxWidth(), 27 verticalArrangement = Arrangement.spacedBy(verticalPadding), 28 ) { 29 presentation.contentOrder.forEach { order -> 30 key(order.key) { 31 when (order) { 32 PostContent.Actions -> ActionsContent(postData) 33 PostContent.Attribution -> AttributionContent(postData) 34 PostContent.Embed.Link -> EmbedContent(postData) 35 PostContent.Embed.Media -> EmbedContent(postData) 36 PostContent.Metadata -> if (isAnchoredInTimeline) MetadataContent(postData) 37 PostContent.Text -> TextContent(postData) 38 } 39 } 40 } 41 } 42 } 43}

Each of the content Composables use Modifier.animateBounds() on their children to then animate their bounds smoothly depending on the desired presentation. For example, ActionsContent() looks like:

1@Composable 2private fun ActionsContent( 3 data: PostData, 4) { 5 when (data.presentation) { 6 Timeline.Presentation.Text.WithEmbed, 7 Timeline.Presentation.Media.Expanded, 8 -> PostInteractions( 9 modifier = Modifier 10 .contentPresentationPadding( 11 content = PostContent.Actions, 12 presentation = data.presentation, 13 ) 14 .animateBounds( 15 lookaheadScope = data.presentationLookaheadScope, 16 boundsTransform = data.boundsTransform, 17 ), 18 replyCount = format(data.post.replyCount), 19, 20 onPostInteraction = data.postActions::onPostInteraction, 21 ) 22 23 24 Timeline.Presentation.Media.Condensed -> Unit 25 } 26}

The end result is this animation for presentation changes:

animated content changes with presentation
Letting content shine with different presentation types

Aggregated Notifications

Social media notifications tend to come in bursts. Typically, multiple people perform an action on the most recent post you have. In the case of Heron, this can be likes, reposts or new followers. When Heron encounters consecutive notifications of the same type, it aggregates them into a single notification:

aggregated notifications screenshot
Aggregated notifications for a post

Should you want more detail on the individuals that performed the action, the notification expands in place to show more details about their profiles.

Composition permanence with aggregated notifications

The parent layout of the notifications is changing from a Row to a Column, therefore, the most convenient way to maintain composition permanence, is to actually hold a reference to the Composition using movableContentOf or movableContentWithReceiverOf.

1@Composable 2fun NotificationAggregateScaffold( 3 panedSharedElementScope: PanedSharedElementScope, 4 modifier: Modifier = Modifier, 5 isRead: Boolean, 6 notification: Notification, 7 profiles: List<Profile>, 8 onProfileClicked: (Notification, Profile) -> Unit, 9 icon: @Composable () -> Unit, 10 content: @Composable () -> Unit, 11) { 12 Row( 13 modifier = modifier, 14 horizontalArrangement = Arrangement.spacedBy(16.dp), 15 ) { 16 icon() 17 18 Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 19 var isExpanded by rememberSaveable { mutableStateOf(false) } 20 val renderedProfiles = remember(profiles) { 21 when (profiles.size) { 22 in 0..6 -> profiles 23 else -> profiles.take(6) 24 } 25 } 26 val expandButton = remember { 27 movableContentWithReceiverOf<PanedSharedElementScope, Boolean> { expanded -> 28 ExpandButton( 29 isExpanded = expanded, 30 onExpansionToggled = { isExpanded = it } 31 ) 32 } 33 } 34 val items = remember { 35 movableContentWithReceiverOf< 36 PanedSharedElementScope, 37 Boolean, 38 Notification, 39 List<Profile>, 40 > 41 { isExpanded, notification, renderedProfiles -> 42 ExpandableProfiles( 43 isExpanded = isExpanded, 44 notification = notification, 45 renderedProfiles = renderedProfiles, 46 onProfileClicked = onProfileClicked, 47 ) 48 } 49 } 50 with(panedSharedElementScope) { 51 if (isExpanded) Column( 52 modifier = Modifier 53 .fillMaxWidth() 54 .clickable { isExpanded = !isExpanded }, 55 verticalArrangement = Arrangement.spacedBy(8.dp), 56 ) { 57 if (renderedProfiles.size > 1) expandButton(isExpanded) 58 items(isExpanded, notification, renderedProfiles) 59 } 60 else Row( 61 modifier = Modifier 62 .fillMaxWidth() 63 .clickable { isExpanded = !isExpanded }, 64 horizontalArrangement = Arrangement.spacedBy(8.dp) 65 ) { 66 items(isExpanded, notification, renderedProfiles) 67 if (renderedProfiles.size > 1) expandButton(isExpanded) 68 } 69 } 70 Box( 71 modifier = Modifier.animateBounds( 72 lookaheadScope = panedSharedElementScope 73 ) 74 ) { 75 content() 76 } 77 } 78 } 79}

With Composition permanence guaranteed, next is actually animating each element in ExpandableProfiles:

1@Composable 2private fun PanedSharedElementScope.ExpandableProfiles( 3 isExpanded: Boolean, 4 notification: Notification, 5 renderedProfiles: List<Profile>, 6 onProfileClicked: (Notification, Profile) -> Unit, 7) { 8 renderedProfiles.forEach { profile -> 9 Row( 10 modifier = Modifier.animateBounds( 11 lookaheadScope = this@ExpandableProfiles, 12 ), 13 verticalAlignment = Alignment.CenterVertically, 14 ) { 15 AsyncImage( 16 modifier = Modifier 17 .size(32.dp), 18 args =19 ) 20 AnimatedVisibility( 21 visible = isExpanded, 22 exit = fadeOut(), 23 ) { 24 Text( 25 modifier = Modifier 26 // Fill max width is needed so the text measuring doesn't cause 27 // animation glitches. This is also why the link is used for clicking 28 // as opposed to the full text. 29 .fillMaxWidth() 30 .padding(horizontal = 8.dp), 31 text = remember { 32 buildAnnotatedString { 3334 } 35 }, 36 overflow = TextOverflow.Visible, 37 maxLines = 1, 38 ) 39 } 40 } 41 } 42}
animating notification expansion
Moving content with Modifier.animateBounds() to ensure composition permanence with movableContentOf()

Large screen layouts and app panes

Building a large screen app with Jetpack Compose does not have to mean compromising on developer or user experience, and this is very well illustrated with this last example.

The basic unit for an app, is what is displayed on the device at any point in time; that is a navigation destination. This used to be an Activity on Android but in Compose large screen parlance, this is a "Pane". To build a large screen app at scale, it is necessary for an app to easily render multiple panes at once. Once an app can do this, the next step is achieving a pleasant UI/UX with those panes with each user interaction and app state change. If the app initially showed pane "A" and a state change caused both pane "A" and pane "B" to show, there should be an animation that meaningfully conveys this state change.

Composition permanence with large screen layouts and app panes

In the previous examples, permanence was achieved with a key, or with moving Composables with movableContentOf(). This example has the delightful distinction of needing both:

  • A navigation destination may be in the primary, secondary, or tertiary pane (if in a standard 3 pane layout), and it must use the same key regardless of which pane it is in.
  • The navigation destination has state. It may have a ViewModel, the user may have typed some text… whatever it is, that state has to follow the UI across each pane it is in.

For the former, I use a SplitLayout Composable, that allows me specify the key for the content rendered within the pane. For the latter, things get a little interesting as the navigation library of choice has to:

  • Allow concurrently for rendering multiple navigation destinations in different panes.
  • Support the concept of movable navigation destinations, their life cycles and their state.

These are currently novelties as far as navigation libraries go. To my knowledge, there are only two navigation libraries that do this (Other navigation libraries show one navigation destination at a time), and they're both experimental:

  1. TreeNav: An experiment I started a few years ago to experiment with movable navigation destinations in Compose.
  2. Jetpack Navigation 3: An experiment that defines the primitives needed for movable navigation destinations at scale, and Compose first APIs for managing navigation state.

TreeNav provides a MultiPaneDisplay following the Navigation 3 convention for naming a Composable that displays panes. Each navigation destination composable that shows in a pane is backed by movableContentOf. Combined with Navigation 3 and the SplitLayout Composable, Composition permanence for navigation destinations in arbitrary panes is achieved by:

  • Navigation 3 providing permanence with regards to business and UI logic architectural state with its NavLocalProvider primitives:
    • SaveableStateNavLocalProvider for rememberSaveable to work across navigation panes.
    • SavedStateNavLocalProvider for SavedStateRegistryOwner semantics.
    • ViewModelStoreNavLocalProvider for preserving the same ViewModel instance across panes.
  • MultiPaneDisplay provides permanence with UI logic architectural state with the use of movableContentOf so usages of remember { } and other values stored in composition are persisted across navigation panes.
  • SplitLayout uses a key to make sure if the pane render order is changed, everything still works as expected. This is also for UI logic architectural state.

With all that heavy lifting done by library code, the app code only needs to call:

1Scaffold( 2 modifier = Modifier 3 .animateBounds( 4 lookaheadScope = panedSharedElementScope, 5 boundsTransform = remember { 6 scaffoldBoundsTransform( 7 appState = appState, 8 paneScaffoldState = paneScaffoldState, 9 ) 10 } 11 ), 12 containerColor = containerColor, 13 topBar = { 1415 }, 16 floatingActionButton = { 1718 }, 19 bottomBar = { 2021 }, 22 content = { paddingValues -> 23 paneScaffoldState.content(paddingValues) 24 }, 25)

The result is as navigation destinations move from pane to pane in whatever order, state is preserved.

animated navigation pane changes
Compositional permanence across navigation destinations in different app panes

Wrap up

In Jetpack Compose apps, preserving state with each composition is crucial to achieving visual continuity in apps. This visual continuity, also called "motional intelligence" is critical for building immersive, world class apps for all platforms.

Outside of use with Modifier.animatedBounds(), composition permanence when scoped to navigation state also allows for:

  • Moving arbitrary content across navigation destinations
  • "Live" previews of the navigation stack with the predictive back APIs.
animated video shared elements
Compositional permanence across navigation destinations used for back previews

You can see the source code for all the animations shown above here:

Happy composing!

37