Compose has a dozen animation APIs and one decision tree

The Compose animation surface looks large because it is organised by what you're animating rather than by how. Three questions pick the right API, and picking wrong is why animation code gets complicated.

6 min read
androidcomposekotlinanimation

Day 61 — Compose has a dozen animation APIs and one decision tree

Day 61 of 100, opening the animation pillar. The API surface is large, and it's organised around a question most people don't ask before reaching into it.

The symptom

An expand/collapse animation built from the first API you find:

var expanded by remember { mutableStateOf(false) }
val height by animateDpAsState(if (expanded) 200.dp else 60.dp, label = "height")
val alpha by animateFloatAsState(if (expanded) 1f else 0f, label = "alpha")
val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "rotation")
val elevation by animateDpAsState(if (expanded) 8.dp else 1.dp, label = "elevation")

Four animations driven by one boolean, each with its own spec, each independently interruptible. They start together and — because nothing coordinates them — can end at slightly different times, so the card settles in stages.

Adding a fifth property means a fifth val, and changing the timing means editing four places.

Why the obvious fix fails

The obvious fix is to share a spec:

val spec = tween<Float>(300)
val alpha by animateFloatAsState(…, animationSpec = spec, label = "alpha")

That synchronises the duration and not the animations. They're still four independent state objects; interrupting mid-flight leaves each one where it happened to be, and a Dp spec and a Float spec are different types so the sharing is partial anyway.

The problem isn't the spec. It's that four values driven by one boolean is a transition, and there's an API for that.

Three questions pick the API: one value, several values, or an element entering and leaving

The actual mechanism

The decision tree has three branches, and they map to what you're animating.

One value, driven by state → animate*AsState.

val alpha by animateFloatAsState(if (enabled) 1f else 0.4f, label = "alpha")

The workhorse. animateFloatAsState, animateDpAsState, animateColorAsState, animateOffsetAsState and friends. Declarative: you describe the target, Compose animates toward it, and a new target mid-flight redirects smoothly.

Several values, driven by the same state → updateTransition.

val transition = updateTransition(expanded, label = "card")
val height by transition.animateDp(label = "height") { if (it) 200.dp else 60.dp }
val alpha by transition.animateFloat(label = "alpha") { if (it) 1f else 0f }
val rotation by transition.animateFloat(label = "rotation") { if (it) 180f else 0f }

One transition object owns all the children. They start and finish together, interrupt coherently, and the Animation Preview in Android Studio can inspect them as a group — which is why the label parameters exist.

An element appearing or disappearing → AnimatedVisibility / AnimatedContent.

AnimatedVisibility(visible = expanded) { Details(item) }

The important part: these handle the composition lifecycle. A composable that leaves the composition can't animate out, because it's gone — Day 8. AnimatedVisibility keeps it alive until the exit animation finishes, which is a problem the value-based APIs cannot solve.

The two lower-level escape hatches

Below the declarative APIs sit two imperative ones, for when the target isn't known declaratively:

Animatable — a single value with suspend-function control. Right when you need to snapTo a position, animate from a gesture's release velocity, or await completion:

val offset = remember { Animatable(0f) }
LaunchedEffect(Unit) {
    offset.animateTo(100f, spring())
    offset.animateTo(0f, tween(200))       // sequential, awaited
}

rememberInfiniteTransition — for animation with no end state: a pulse, a shimmer, a loading spinner.

val transition = rememberInfiniteTransition(label = "pulse")
val scale by transition.animateFloat(
    initialValue = 1f, targetValue = 1.1f,
    animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
    label = "scale",
)

Animatable is the one to know exists. Most "I need to animate this from a gesture" questions are Animatable plus animateDecay, and Day 74 comes back to it.

Specs are orthogonal

Every API above takes an animationSpec, and the choice is independent of which API:

  • spring() — physics-based, no fixed duration. Interrupts gracefully, because a spring has velocity and can redirect without a discontinuity. The right default for anything gesture-driven or interruptible.
  • tween() — duration plus easing curve. Right when the timing is specified by design or must coordinate with something external.
  • keyframes() — explicit values at explicit times, for a multi-stage motion.
  • snap() — no animation; useful as a conditional value rather than a special case.

The default across Compose is a spring, and it's a good default. Reaching for tween(300) by habit is a View-system reflex; springs feel better under interruption, which is most of what makes an interface feel responsive rather than scripted.

The label parameter

Every animation API takes label, and it's not decoration. Android Studio's Animation Preview inspects a composable's animations by label, letting you scrub a transition frame-by-frame. Without labels the panel shows anonymous entries and is much less useful.

It costs one string and pays for itself the first time an animation looks wrong and you need to see it at 10% speed.

How to prove it

The coordination difference is the claim to check, and it's visible:

@Preview
@Composable fun CoordinationComparison() = Column {
    IndependentVersion()      // four animate*AsState
    TransitionVersion()       // one updateTransition
}

Toggle both, then toggle again mid-animation. The independent version's properties end up at inconsistent points and settle raggedly; the transition version redirects as one unit.

For inspection, Android Studio's Animation Preview on the transition version shows all four children on one timeline with a scrubber. That view is the fastest way to answer "why does this look slightly wrong", and it only works if you used the grouped API.

What this generalizes to

The organising idea: the API you want depends on what you're animating, not on how it should look. Compose's surface looks sprawling because it's split by subject — a value, a group of values, an element's presence, an unbounded loop — and each split exists because those cases genuinely need different machinery.

Most animation code that becomes hard to maintain got there by using the one-value API for a multi-value problem, or by using a value API where the element itself was appearing. Asking the three questions first is what keeps it small.

The question before all three

Worth asking first, and skipped surprisingly often: should this animate at all?

Motion has a cost beyond frames. It delays the user, it draws attention that may be needed elsewhere, and for some people it causes genuine discomfort. Android exposes that preference, and honouring it is a few lines:

val reduceMotion = LocalAccessibilityManager.current
    ?.let { /* query the system's reduced-motion setting */ } ?: false

val spec = if (reduceMotion) snap() else spring()

snap() is why the spec list included something that doesn't animate — it lets "no animation" be a value rather than a branch, so the reduced-motion path goes through exactly the same code.

The animations worth keeping under reduced motion are the ones that convey information — where an item moved to, which pane replaced which. The ones to drop are decorative: bounces, parallax, celebratory motion. That distinction is Day 67's subject, and it's easier to honour if the spec is a value from the start.

Tomorrow, Day 62: animate*AsState in detail — the declarative model, and what happens when the target changes mid-flight.


Day 61 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Choose an animation API.