You don't animate from A to B, you declare B and Compose handles the rest

animate*AsState animates toward a target that can change at any moment. Understanding it as declarative rather than imperative explains interruption, redirection, and why spring is the right default.

6 min read
androidcomposekotlinanimation

Day 62 — Declare the target; Compose handles the path

Day 62 of 100. The most-used animation API, and one property of it that explains why Compose animations feel different from View animations.

The symptom

A View-system reflex, written in Compose:

var alpha by remember { mutableStateOf(0f) }

LaunchedEffect(visible) {
    val start = alpha
    val end = if (visible) 1f else 0f
    animate(start, end) { value, _ -> alpha = value }     // from-to
}

Toggle visible rapidly and it stutters. Each toggle cancels the effect and starts a new animation from wherever the last one stopped, with a fresh duration — so a fast toggle produces a sequence of partial animations rather than one that changes direction.

Why the obvious fix fails

The obvious fix is to track velocity manually, or to debounce the toggle so animations can't overlap. Debouncing makes the UI feel unresponsive; tracking velocity is re-implementing a spring.

The framing is the problem. animate(start, end) describes a journey, and journeys don't compose — a second journey starting mid-first has to decide what to do about the first.

A target is a fact that can change; an animation redirects toward the new one carrying its velocity

The actual mechanism

animate*AsState takes a target, not a range:

val alpha by animateFloatAsState(
    targetValue = if (visible) 1f else 0f,
    label = "alpha",
)

There is no start value in the API, because the start is wherever the value currently is. The animation is a continuous process converging on a target; changing the target redirects it in flight, carrying the current value and velocity forward.

That's why a rapid toggle looks smooth here and stuttered above: with a spring() spec, the reversal preserves momentum, so the value decelerates and comes back the way a physical object would.

The declarative framing is the same one from Day 39 about dialogs and Day 5 about the UI as a whole: a fact that changes, not a command with a moment attached.

Which spec, and why spring is the default

animateFloatAsState(target, animationSpec = spring())       // physics
animateFloatAsState(target, animationSpec = tween(300))     // duration + curve

A tween has a fixed duration. Interrupt it and the new tween starts at the current value with a fresh duration and zero velocity — a visible hitch at the reversal point.

A spring has stiffness and damping instead of a duration. It has velocity as part of its state, so redirection is continuous. That is the whole argument, and it's why Compose defaults to it.

The two parameters worth knowing:

spring(
    dampingRatio = Spring.DampingRatioMediumBouncy,   // overshoot
    stiffness = Spring.StiffnessLow,                  // speed
)

DampingRatioNoBouncy settles without overshoot — right for most UI. The bouncy ratios suit playful motion and read as sloppy on a form. StiffnessMedium to StiffnessLow is the useful range; the default (StiffnessMedium) is fine for nearly everything.

Use tween when timing is specified — a designer's 300ms spec, a motion that must coordinate with a fixed-duration video or a system transition.

The typed variants

animateFloatAsState(…)      animateDpAsState(…)
animateColorAsState(…)      animateIntAsState(…)
animateOffsetAsState(…)     animateSizeAsState(…)
animateRectAsState(…)       animateIntOffsetAsState(…)

animateColorAsState is worth calling out: it interpolates in a perceptually reasonable space rather than naively per-channel, so a blue-to-yellow transition doesn't pass through grey. Animating the channels yourself with three animateFloatAsState calls does pass through grey, which is a real and avoidable ugliness.

For a type without a built-in, animateValueAsState plus a TwoWayConverter handles it:

val converter = TwoWayConverter<MyType, AnimationVector2D>(
    convertToVector = { AnimationVector2D(it.x, it.y) },
    convertFromVector = { MyType(it.v1, it.v2) },
)
val value by animateValueAsState(target, converter, label = "custom")

Everything animatable reduces to a vector of one to four floats — which is why the built-in set is what it is, and why adding your own is a small function rather than a subclass.

The initial-value question

One behaviour that surprises people: animate*AsState does not animate on first composition. It starts at the target and animates only on subsequent changes.

That's the right default — a screen whose elements all animate in from zero on every appearance is noisy, and it would fight LazyColumn's item reuse badly.

When you do want an entrance animation, the target has to change after the first frame:

var appeared by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { appeared = true }
val alpha by animateFloatAsState(if (appeared) 1f else 0f, label = "enter")

That works and is usually the wrong tool — an element appearing is Day 64's AnimatedVisibility, which handles the composition lifecycle as well as the value. The LaunchedEffect trick is worth knowing mainly so you recognise it as a workaround when you meet it in existing code.

finishedListener, and when not to use it

val alpha by animateFloatAsState(
    targetValue = target,
    label = "alpha",
    finishedListener = { finalValue -> onAnimationDone() },
)

Useful for chaining, and a trap for logic. The listener doesn't fire if the animation is interrupted and redirected, so anything essential placed there can be skipped. If the follow-up work must happen, drive it from the state change rather than from the animation completing — or use Animatable, where animateTo is a suspend function that either returns or is cancelled, which is far easier to reason about.

The performance note

This is Day 10 again and worth repeating in context: animate*AsState returns a State, and where you read it decides what invalidates.

val offset by animateDpAsState(target, label = "offset")

Box(Modifier.offset(y = offset))          // read in COMPOSITION — recomposes per frame
Box(Modifier.offset { IntOffset(0, offsetPx) })   // read in LAYOUT — doesn't

For a value animating at 60fps, prefer the lambda form of the modifier. graphicsLayer takes a lambda for the same reason:

Box(Modifier.graphicsLayer { alpha = animatedAlpha })   // draw phase only

That single habit is most of the difference between an animation that costs a recomposition per frame and one that costs nothing above the draw.

How to prove it

The interruption behaviour is the thing to see:

@Preview
@Composable fun InterruptionComparison() {
    var target by remember { mutableStateOf(0.dp) }
    Column {
        Button(onClick = { target = if (target == 0.dp) 200.dp else 0.dp }) { Text("Toggle") }
        Box(Modifier.offset(y = animateDpAsState(target, spring(), label = "s").value))
        Box(Modifier.offset(y = animateDpAsState(target, tween(600), label = "t").value))
    }
}

Toggle twice quickly. The spring reverses smoothly; the tween visibly restarts. Once seen, the default stops looking arbitrary.

For the phase claim, Layout Inspector's recomposition counts on the two Box variants: value-read climbs at 60/second, lambda-read stays flat.

What this generalizes to

The principle is declare the destination, not the path. A target is a fact that can be revised; a journey is a plan that has to be cancelled and replanned. Revision composes; cancellation doesn't.

It's the same shift as the rest of Compose — dialog is ConfirmDelete over showDialog(), a state target over an animation command — and the payoff is identical each time. The hard cases (interruption, reversal, overlap) stop being special cases, because there was never a plan to invalidate.

Physics engines and layout constraint solvers reached the same conclusion decades ago: describe the equilibrium and let the system converge, because a converging system handles a changed goal for free while a scripted one has to be torn down and rebuilt.

Tomorrow, Day 63: updateTransition — coordinating several values, and the state machine underneath.


Day 62 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Value-based animations.