An animation is state, and that is why it restarts
animate*AsState holds its value in the composition. A key change starts it over.
TL;DR
animateFloatAsState(target) is not a fire-and-forget effect. It is state held in the
composition, running toward a target, and its lifetime is the call site's presence in the
composition tree.
So an animation restarts when the composable leaves and re-enters — a scroll out of a lazy list's
cache, a navigation, a key() change. And it does not restart when the target changes, which is
the opposite of what people expect: it redirects smoothly from wherever it currently is.
Both behaviours are correct and they are frequently the reverse of what the code wanted. Target changes redirect; key changes restart.
The case this post is about
A list row with an expand animation and a progress bar:
@Composable
fun TaskRow(task: Task) {
var expanded by remember { mutableStateOf(false) }
val rotation by animateFloatAsState(
targetValue = if (expanded) 180f else 0f,
animationSpec = tween(300),
)
val progress by animateFloatAsState(
targetValue = task.progress, // 0f..1f, updated from the server
animationSpec = tween(600),
)
Column(Modifier.clickable { expanded = !expanded }) {
Row {
Text(task.title)
Icon(Icons.Default.ExpandMore, null, Modifier.rotate(rotation))
}
LinearProgressIndicator(progress = { progress })
if (expanded) TaskDetail(task)
}
}
Both animations are standard. Three separate bugs came out of them.
The symptom
The chevron snapped back when scrolled away and back:
expand row 3 (chevron rotates to 180°)
scroll down 40 rows, scroll back
→ row 3 is collapsed, chevron at 0°
Which is really the remember lifetime (the row left the lazy list's cache), and the animation
restarting made it look like an animation bug.
The progress bar animated from zero on every poll:
poll every 5s; task.progress goes 0.42 → 0.44
expected: a small 600ms slide from 42% to 44%
actual: a 600ms sweep from 0% to 44%, every 5 seconds
Because the row was being recreated — a different bug with the same visible symptom as a restart.
And a third, after the first fix attempt, which is the one that taught me the mechanism. We
added a key() to force a fresh animation when a task changed:
key(task.id) { … } // "so a different task animates in cleanly"
The progress bar then jumped rather than animating at all, on legitimate updates, because every state update was producing a new key.
Why the obvious fix failed
"Hoist expanded into the ViewModel." Fixes the chevron snap-back, and I initially read the
chevron as an animation problem and spent an hour on the animation. The animation was correct; the
state holding expanded had the wrong lifetime.
"Use Animatable and call animateTo manually." More control, same lifetime. An Animatable
held by remember has exactly the same scope and restarts identically.
"Add key(task.id)." The third bug. key() changes the identity of the whole subtree, so
everything inside it — including both animations and the remember — is discarded and recreated.
Forcing a restart is sometimes what you want, and it is a much bigger hammer than it looks.
"Use rememberSaveable for the animation value." Saves a float across process death and does
nothing about leaving the composition, which is the actual lifetime at issue.
The actual mechanism
animateFloatAsState is, roughly:
@Composable
fun animateFloatAsState(targetValue: Float, animationSpec: AnimationSpec<Float>): State<Float> {
val animatable = remember { Animatable(targetValue) } // ← state in the composition
LaunchedEffect(targetValue, animationSpec) { // ← redirects on target change
animatable.animateTo(targetValue, animationSpec)
}
return animatable.asState()
}
Two things fall out of those two lines:
The current value lives in a remember. Its lifetime is the call site's presence in the
composition. Leave the composition and the Animatable is discarded; re-enter and a new one is
created, initialised at the current target — so it does not animate in, it appears at its
destination. (Which is why the chevron appeared at 0° rather than animating to 0°.)
The target is a LaunchedEffect key, so a target change redirects. animateTo on a running
animation does not restart from the initial value — it continues from the current one toward the new
target. That is why 0.42 → 0.44 should have been a small slide.
So the two cases are:
target changes → redirect, smoothly, from the current value (correct, and surprising)
call site re-enters → new Animatable at the new target, no animation (correct, and surprising)
key() changes → subtree discarded: everything restarts (a blunt instrument)
Our progress bar sweeping from zero meant neither of the first two was happening — the row was
being recreated, so a new Animatable was starting at 0f because that was the previous frame's
initial value on a freshly composed row.

animate*AsStateholds state, and state has a lifetime. A target change redirects; leaving the composition resets. Most animation surprises are really lifetime surprises.
The sub-case that sends people back to key(): sometimes you want the animation to play on
re-entry — a badge that should pulse in each time its row scrolls back into view. animate*AsState
cannot do that, and the reason is the same remember: a fresh Animatable is constructed with
initialValue = targetValue, so there is no distance left to travel. The instinct is to reach for
key() to force a rebuild, which is the blunt instrument that caused the original bug.
Drop to the Animatable and state the start explicitly instead:
val scale = remember { Animatable(0.8f) } // start, not target
LaunchedEffect(Unit) {
scale.snapTo(0.8f) // explicit on every re-entry
scale.animateTo(1f, spring(dampingRatio = 0.4f))
}
animate*AsState is the right default precisely because it refuses to replay — and when replay is
the requirement, the answer is to own the Animatable, never to churn the composition around it.
The fix
Give the state the right lifetime, then let the animation do what it already does correctly.
1. The expand state belongs to the item, keyed by its identity:
// ViewModel — survives the row leaving the lazy list's cache
private val expandedIds = MutableStateFlow(persistentSetOf<String>())
fun toggle(id: String) = expandedIds.update { if (id in it) it.remove(id) else it.add(id) }
@Composable
fun TaskRow(task: Task, expanded: Boolean, onToggle: () -> Unit) {
val rotation by animateFloatAsState(
targetValue = if (expanded) 180f else 0f,
animationSpec = tween(300),
label = "chevron", // name it: shows up in the animation inspector
)
…
}
With expanded passed in, scrolling away and back re-creates the Animatable at 180° — which
is correct: the row was already expanded, so it should appear expanded rather than animate again.
2. The list needs stable keys, which is what stopped the row being recreated:
LazyColumn {
items(tasks, key = { it.id }) { task -> // ← identity, so the row is REUSED
TaskRow(task, expanded = task.id in expandedIds, onToggle = { vm.toggle(task.id) })
}
}
That is the progress-bar fix. With keys, a poll updating task.progress recomposes the same row, so
the existing Animatable redirects 0.42 → 0.44 in 600ms, as intended.
3. And key() used only where a restart is genuinely wanted:
// a "new message" highlight SHOULD replay when the message id changes
key(message.id) {
val alpha by animateFloatAsState(if (seen) 0f else 1f, tween(800), label = "highlight")
…
}
Two more things worth knowing. For an animation that must survive the composition entirely — a shared-element transition, a value that outlives a screen — the state has to live above it:
// hoisted Animatable, owned by something with a longer lifetime
class TransitionState { val offset = Animatable(0f) }
val transition = rememberSaveable(saver = …) { TransitionState() } // or in the ViewModel
And label on every animation is not decoration — it is what makes the Animation Inspector
readable, and it is how we diagnosed the third bug:
Animation Inspector, during a poll
before: "FloatAnimation" created+destroyed every 5s ← the row was being recreated
after: "progress" one instance, target updated
How we proved it
That the row was being recreated, not the animation misbehaving — the diagnosis that redirected the whole investigation:
Layout Inspector + Animation Inspector, 60s of polling
before (no keys) after (keys)
TaskRow compositions 72 12
Animatable instances created 72 6
progress animation start value 0f each time previous value
Seventy-two Animatable instances for six visible rows over twelve polls. That number is the bug.
That the progress bar now slides rather than sweeps:
task.progress 0.42 → 0.44, frame-by-frame capture
before: 0.00 … 0.44 over 600ms (sweep)
after: 0.42 … 0.44 over 600ms (slide)
That the chevron survives scrolling:
@Test
fun expandedStateAndRotationSurviveScrollingAway() {
composeTestRule.setContent { TaskList(tasks) }
composeTestRule.onNodeWithText("Task 3").performClick() // expand
composeTestRule.onNodeWithTag("list").performScrollToIndex(60)
composeTestRule.onNodeWithTag("list").performScrollToIndex(0)
composeTestRule.onNodeWithText("Detail of Task 3").assertExists()
}
remember { } in the row: FAILED — collapsed
hoisted, keyed by task id: PASSED
And the key() misuse, confirmed as a real regression rather than a theory:
with key(task.id) wrapping the row
progress updates: jump instantly, no animation (new Animatable each time, at the new target)
without: animates
One limit: the frame-by-frame captures are from a screen recording stepped manually, not an automated animation assertion — Compose's animation testing APIs can advance the clock and assert values, and we did not build that. So the "slide not sweep" claim is verified visually and by the Animatable instance count, which I would call strong evidence rather than a test.
What this generalizes to
An animation is state, so it has a lifetime. Most "why did my animation restart" questions are "why did this composable leave the composition" questions. Find the lifetime before debugging the animation.
Redirect and restart are different operations, and the API distinguishes them. A target change redirects from the current value; discarding the state restarts. Knowing which one a given code change triggers is the whole skill here.
key() discards a subtree. It is the right tool for deliberately replaying something and a very
blunt one — everything inside loses its state, including animations and remembers you were not
thinking about.
Name your animations. label = "progress" costs nothing and turns the Animation Inspector from
a list of FloatAnimation entries into something diagnostic. Ours showed 72 instances where there
should have been 6, which is what found the real bug.
Related
- A LazyColumn without keys loses your scroll position and your animations — the keys this fix depends on
- remember is not a cache, and the key is the whole contract — the lifetime underneath
- LaunchedEffect(Unit) runs once, and that is usually the wrong once — the effect inside animate*AsState
Keep reading
This is part of 100 Days of Compose — the mechanisms underneath the API, measured rather than guessed. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".