updateTransition is a state machine that happens to animate

updateTransition coordinates several animations driven by one state. It handles multi-state enums, per-property specs via transitionSpec, and child transitions — all with coherent interruption a group of animate*AsState calls cannot give you.

5 min read
androidcomposekotlinanimation

Day 63 — updateTransition is a state machine that happens to animate

Day 63 of 100. Day 61 named the multi-value case; this is the API for it, and it does more than group animations together.

The symptom

A component with three states, animated with booleans:

val isLoading = state == State.Loading
val isError = state == State.Error

val bgColor by animateColorAsState(
    when { isError -> errorColor; isLoading -> loadingColor; else -> normalColor },
    label = "bg",
)
val scale by animateFloatAsState(if (isError) 1.05f else 1f, label = "scale")

Two derived booleans, two independent animations, and a when inside each. Adding a fourth state means editing every animation and re-deriving every boolean, and the combinations that shouldn't exist (isLoading && isError) are still representable.

Why the obvious fix fails

The obvious fix is to keep the derived booleans tidier — a sealed class with computed properties, say. That improves the readability of the conditions and leaves the real problem: the animations don't know they belong to the same state change.

Interrupt a Loading → Error transition halfway with a change to Success, and the colour animation and the scale animation each independently decide what to do. They can disagree about where "halfway" was.

One transition object owns every child; per-property specs come from transitionSpec

The actual mechanism

updateTransition takes the state itself, not a boolean:

val transition = updateTransition(targetState = state, label = "card")

val bgColor by transition.animateColor(label = "bg") { s ->
    when (s) {
        State.Normal -> normalColor
        State.Loading -> loadingColor
        State.Error -> errorColor
    }
}

val scale by transition.animateFloat(label = "scale") { s ->
    if (s == State.Error) 1.05f else 1f
}

Each child animation is a function from state to value. The when is exhaustive over the enum, so adding a fourth state is a compile error in every place that needs updating — which is exactly the property the boolean version threw away.

And because one Transition object owns all the children, they share a clock. Interrupt mid-flight and every child redirects from its own current value at the same moment.

transitionSpec: per-edge timing

The parameter that makes this API worth learning. A child can use a different spec depending on which transition is happening:

val elevation by transition.animateDp(
    label = "elevation",
    transitionSpec = {
        when {
            State.Normal isTransitioningTo State.Error -> spring(dampingRatio = 0.4f)
            State.Error isTransitioningTo State.Normal -> tween(400)
            else -> spring()
        }
    },
) { s -> if (s == State.Error) 12.dp else 2.dp }

Entering an error state can snap sharply while leaving it settles slowly. That asymmetry is what makes motion feel designed rather than uniform, and it's not expressible with animate*AsState at all — a single spec has to serve both directions.

Inside transitionSpec, initialState and targetState are in scope, and isTransitioningTo is the readable infix form.

Reading where the transition is

The Transition object exposes its own progress:

transition.currentState      // where it started
transition.targetState       // where it's going
transition.isRunning         // still animating?

Useful for driving things that aren't animated values — disabling a button until a transition settles, or triggering a haptic on arrival:

LaunchedEffect(transition.currentState) {
    if (transition.currentState == State.Error) haptics.performHapticFeedback(…)
}

currentState updates when the animation completes, so it's the settled value — analogous to Day 25's settledPage, and the right thing to key side effects on.

Seeded transitions and the segment

Two smaller pieces that come up once a transition gets real use.

transition.segment inside transitionSpec is the initialState → targetState pair, and it's what isTransitioningTo reads. Matching on it directly is occasionally clearer when several edges share a spec:

transitionSpec = {
    when (segment) {
        Segment(State.Loading, State.Error) -> tween(150)
        else -> spring()
    }
}

And transition.totalDurationNanos reports how long the whole group will take — useful for coordinating something outside the transition, such as delaying a navigation until the exit motion has finished rather than guessing a duration that then drifts when the spec changes.

Child transitions

A Transition can create a nested one via createChildTransition, which is how a sub-component animates in step with a parent without being passed each value:

val contentTransition = transition.createChildTransition(label = "content") { s ->
    s != State.Loading
}

contentTransition.AnimatedVisibility(visible = { it }) { Content() }

The child derives its own state from the parent's, and stays synchronised with it. This is how a screen-level transition can drive several components that each care about a different projection of the same state. It also means the child only re-animates when its projection changes, so a component that looks the same in two of the parent's states stays still while the others move.

Where it fits with the other APIs

Two integrations worth knowing:

Transition.AnimatedVisibility and Transition.AnimatedContent are extension functions that participate in the parent transition rather than running independently. If an element's appearance is part of a larger state change, use these rather than the standalone versions — otherwise its timing is unrelated to everything else.

rememberTransition takes a MutableTransitionState, which lets a transition start animating on first composition:

val visibleState = remember { MutableTransitionState(false).apply { targetState = true } }
val transition = rememberTransition(visibleState, label = "enter")

That's the clean version of the LaunchedEffect entrance trick from Day 62 — the initial state and the target differ, so the transition runs immediately.

How to prove it

The coordination claim needs an interrupted transition, which is hard to see at full speed:

@Preview
@Composable fun CardStates() {
    var state by remember { mutableStateOf(State.Normal) }
    Column {
        Row { State.entries.forEach { s -> Button(onClick = { state = s }) { Text("$s") } } }
        AnimatedCard(state)
    }
}

Android Studio's Animation Preview shows every labelled child on one timeline, with a scrubber and a speed control. Drop to 10% and switch states mid-animation: the transition version keeps its children aligned, and the independent version visibly doesn't.

That inspector only groups the children because they belong to one labelled Transition — which is the practical argument for the label parameters beyond tidiness. Unlabelled animations appear as anonymous rows, which is workable with two children and useless with eight.

What this generalizes to

The idea is making the state explicit rather than deriving booleans from it. A transition over an enum can be exhaustive, can vary per edge, and can coordinate everything that depends on it. A set of booleans extracted from that enum can do none of those, and permits combinations the enum ruled out.

It's Day 12's sealed-hierarchy argument arriving in the animation layer: model the states, then define what each one looks like — rather than modelling the differences and hoping they stay consistent.

Design tools have converged on the same representation — Figma's variants, the state-machine editors in motion tools — because a designer specifying "how does it look in each state, and how does each edge behave" maps directly onto this API and not at all onto a pile of booleans.

Tomorrow, Day 64: AnimatedVisibility and AnimatedContent — animating things that aren't there yet.


Day 63 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Transitions.