Which Compose animation API should you use?
| You want to | Use | Level |
|---|---|---|
| Animate one value toward a target | animate*AsState | High |
| Show or hide a composable | AnimatedVisibility | High |
| Swap content when state changes | AnimatedContent | High |
| Animate several values between states together | updateTransition | High |
| Loop forever | rememberInfiniteTransition | High |
| Drive a value from a gesture or coroutine | Animatable | Low |
Animatable only when a finger is involved or you need to sequence by hand.Micro-Interactions with animate*AsState
label, which is what tooling shows. And apply the animated value in a lambda modifier such as graphicsLayer { }, the form the documentation's own sample uses. The performance section explains why that second habit is the difference between a cheap animation and an expensive one.
One correction to the earlier sample: it showed an animateIntAsState counter and described it as rolling digits up and down. It does not. It interpolates the number, so the text flickers through intermediate values. Content that should slide between states is a job for AnimatedContent, which "animates its content as it changes based on a target state".@Composable
fun LikeButton(isLiked: Boolean, onToggle: () -> Unit) {
val scale by animateFloatAsState(
targetValue = if (isLiked) 1.0f else 0.85f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "likeScale"
)
val tint by animateColorAsState(
targetValue = if (isLiked) Color(0xFFE53935) else Color(0xFF9E9E9E),
animationSpec = tween(durationMillis = 300),
label = "likeTint"
)
IconButton(onClick = onToggle) {
Icon(
imageVector = if (isLiked) Icons.Filled.Favorite
else Icons.Outlined.FavoriteBorder,
contentDescription = if (isLiked) "Unlike" else "Like",
tint = tint,
// Read in the draw phase, not in composition
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
}
)
}
}
// A counter whose digits slide: AnimatedContent, not animateIntAsState
@Composable
fun AnimatedCounter(count: Int) {
AnimatedContent(
targetState = count,
label = "counter"
) { targetCount ->
// Use targetCount here, not count
Text(
text = "$targetCount",
style = MaterialTheme.typography.headlineLarge
)
}
}AnimatedVisibility: Enter and Exit Choreography
if removes content in one frame. AnimatedVisibility keeps it in the composition long enough to animate out. The defaults are sensible: "By default, the content appears by fading in and expanding, and it disappears by fading out and shrinking." You replace them by combining EnterTransition and ExitTransition objects with +.
For lists, do not build staggered entrances by hand. The earlier sample here kept a visible flag per row and flipped it after a delay in a LaunchedEffect. In a lazy list that state is lost when a row scrolls away, so the entrance replays on every scroll back. Lazy layouts have an API for item motion: "you just need to set the animateItem modifier to the item content", and it requires keys, because "It is important to provide a key to each item to ensure animateItem() works as expected" (lists guide). The same keys are what make paged lists cheap to update.@Composable
fun NotificationBanner(
message: String,
isVisible: Boolean,
onDismiss: () -> Unit
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(
initialOffsetY = { -it }, // from above
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy
)
) + fadeIn(animationSpec = tween(300)),
exit = slideOutVertically(
targetOffsetY = { -it },
animationSpec = tween(200)
) + fadeOut(animationSpec = tween(150))
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(message, modifier = Modifier.weight(1f))
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Dismiss")
}
}
}
}
}
// List item motion: keys plus animateItem
@Composable
fun ProductList(products: List<Product>) {
LazyColumn {
items(products, key = { it.id }) { product ->
ProductRow(
product = product,
modifier = Modifier.animateItem()
)
}
}
}Shared Element Transitions
SharedTransitionLayout: "The outermost layout required to implement shared element transitions."
- Modifier.sharedElement(): marks "the composable that should be matched with another composable."
- Modifier.sharedBounds(): for a container whose content differs on each side. "In contrast to sharedElement(), sharedBounds() is designed for visually different content."
Both sides must use the same key through rememberSharedContentState(), and both need the scope of whatever is animating between them. With Navigation Compose the wiring is documented: "you must first wrap your app's NavHost in a SharedTransitionLayout", and inside a destination "you can use this@composable to reference that scope" (with navigation). The same page covers Navigation 3, which matters now that Navigation Compose is in maintenance mode.
Order in the modifier chain is not cosmetic: "Put anything you don't want to be shared before the sharedElement() in the modifier chain." The earlier sample here passed the state as state =, a parameter name the current samples do not use. The version below passes it positionally, as the guide does.@Composable
fun AppNavHost(navController: NavHostController) {
SharedTransitionLayout {
NavHost(navController, startDestination = ProductList) {
composable<ProductList> {
ProductListScreen(
onProductClick = { id ->
navController.navigate(ProductDetail(id))
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable
)
}
composable<ProductDetail> { backStackEntry ->
val route: ProductDetail = backStackEntry.toRoute()
ProductDetailScreen(
productId = route.productId,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable
)
}
}
}
}
@Composable
fun ProductThumbnail(
product: Product,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
modifier: Modifier = Modifier
) {
with(sharedTransitionScope) {
AsyncImage(
model = product.imageUrl,
contentDescription = product.name,
modifier = modifier
// Same key on the list screen and the detail screen
.sharedElement(
rememberSharedContentState(key = "image-${product.id}"),
animatedVisibilityScope = animatedVisibilityScope
)
.size(100.dp)
.clip(CircleShape)
)
}
}Gesture-Driven Animation with Animatable
Animatable is the layer underneath them: "a value holder that can animate the value as it is changed using animateTo. This is the API backing up the implementation of animate*AsState." It "ensures consistent continuation and mutual exclusiveness", which is what lets a drag interrupt a running animation without a jump.
The pattern from the advanced guide is three moves: stop() the animation on touch down, snapTo() during the drag, then animateTo() or animateDecay() on release.
The earlier sample in this section was wrong in a way worth naming. It launched every animation with CoroutineScope(Dispatchers.Main).launch, a new unowned scope per drag event. Nothing cancels those when the composable leaves, which is exactly the leak structured concurrency exists to prevent. pointerInput already gives you a suspending block. Open a coroutineScope inside it and launch there, as the documentation does, and everything is cancelled with the modifier.
A spring is the right spec for the snap back. "spring can handle interruptions more smoothly than duration-based AnimationSpec types because it guarantees the continuity of velocity" (customize).// Adapted from the swipeToDismiss sample in the Android docs
fun Modifier.swipeToDismiss(onDismissed: () -> Unit): Modifier = composed {
val offsetX = remember { Animatable(0f) }
pointerInput(Unit) {
val decay = splineBasedDecay<Float>(this)
// One scope, owned by pointerInput. No CoroutineScope(...).
coroutineScope {
while (true) {
val velocityTracker = VelocityTracker()
offsetX.stop() // interrupt any running animation
awaitPointerEventScope {
val pointerId = awaitFirstDown().id
horizontalDrag(pointerId) { change ->
launch {
offsetX.snapTo(
offsetX.value + change.positionChange().x
)
}
velocityTracker.addPosition(
change.uptimeMillis, change.position
)
}
}
val velocity = velocityTracker.calculateVelocity().x
val target = decay.calculateTargetValue(offsetX.value, velocity)
offsetX.updateBounds(
lowerBound = -size.width.toFloat(),
upperBound = size.width.toFloat()
)
launch {
if (target.absoluteValue <= size.width) {
// Not enough velocity: slide back
offsetX.animateTo(0f, initialVelocity = velocity)
} else {
// Swiped away
offsetX.animateDecay(velocity, decay)
onDismissed()
}
}
}
}
}.offset { IntOffset(offsetX.value.roundToInt(), 0) }
}Performance: Keeping Animation Off the Recomposition Path
Modifier.graphicsLayer { alpha = animatedAlpha } and Modifier.offset { ... } read state during draw or layout. The value form, Modifier.alpha(animatedAlpha), reads it during composition and recomposes the function every frame. The general rule is in the best practices guide: "Deferring state reads will ensure that Compose re-runs the minimum possible code on recomposition."
2. Prefer transforms to layout changes. Scale, translation, rotation and alpha in a graphics layer do not re-measure anything. Animating a width or a padding does, on every frame. When the size really must change, use Modifier.animateContentSize(), which exists for that case.
3. Remember that alpha zero is still there. The quick guide's caveat: after fading out, "the composable remains in the composition and continues to occupy the space it's laid out in." Screen readers can still reach it. Use AnimatedVisibility when the content should actually go.
4. Label animations. In Animation Preview, each transition and value "is labeled with a default name", and you set a useful one with the label parameter (tooling).
5. Measure on a release build. The rest of the method is in the Compose performance guide.Key Takeaways
- 1Pick the highest-level API that fits: animate*AsState, AnimatedVisibility, AnimatedContent, then Animatable for gestures.
- 2Apply animated values in lambda modifiers such as graphicsLayer { } so they are read in draw, not composition.
- 3Animate list items with keys and Modifier.animateItem(), not with per-row visibility flags.
- 4Shared elements need SharedTransitionLayout, the same key on both sides, and the animating scope passed to each.
- 5Launch gesture animations inside pointerInput with coroutineScope. Never create a CoroutineScope per drag event.
- 6Use spring for anything a user can interrupt: it keeps velocity continuous where a tween cannot.
Frequently Asked
How do I avoid animation performance problems in Compose?
Read the animated value as late as possible. Apply it inside a lambda modifier such as graphicsLayer or offset, so the read happens during draw or layout and composition is skipped. Prefer scale, translation and alpha to animating size or padding, which forces a layout pass on every frame of the animation.
Should I use spring or tween?
Use spring for anything a user can interrupt, such as a press, a drag release or a toggle. The documentation notes that spring handles interruption more smoothly because it keeps velocity continuous. Use tween when you need an exact duration, for example to line an animation up with another timed event.
How do shared element transitions work with navigation?
Wrap the NavHost in a SharedTransitionLayout and pass its scope to each screen, together with the destination scope, which you reference as this@composable. Apply Modifier.sharedElement with the same key on the matching composable in both screens. Compose then animates position and size between the two automatically during the navigation.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.