Animation basics
You never write "move from here to there over 300 milliseconds". You say what the target is, and Compose takes time to get there. Build Dice Duel's real roll animation and learn why it spins forwards forever without ever rewinding.
The die that teleports
Dice Duel, as you have built it so far, works perfectly. Tap the die, a number appears. Tap again, a different number appears.
It also feels like a spreadsheet.
Nothing about the game changes when you add the roll animation. The random number is the same random number. What changes is that a player believes the die was thrown — and that belief is worth more to a game than any feature you could add in the same forty lines.
This is the last lesson of Part 3, and it is the one that turns a screen into an app.
Put a mug down at the far end of a table. Look away for one second. Look back: the mug is now on the shelf behind you.
You have to find it. Your eyes sweep the room, you spot it, you work out that somebody must have moved it. It takes about half a second and a small amount of thinking.
Now do it again, but watch. The hand picks the mug up, carries it across, sets it down. You know where it is before it lands, and you did no thinking at all — your eyes simply followed.
That difference is the entire point of animation on a screen. It is not decoration. It is the app declining to make you re-find things.
Animation is declarative too
Every animation system you may have seen elsewhere works like an instruction: move this thing from 0 to 360 over 640 milliseconds. You start it, you stop it, you keep track of whether it is running.
Compose does not work that way, for the same reason it does not work that way for anything else. You describe where the value should be, and it takes care of the journey:
1val angle by animateFloatAsState(
2 targetValue = if (rolling) 360f else 0f,
3 label = "angle"
4) hands you a Float that is always slightly out of date. Change the target and the number does not jump — it starts sliding, arriving a few hundred milliseconds later. Read it in your layout and the screen follows it all the way.
Here is what is actually happening, and it is worth being precise about, because it explains several bugs at once:
rollingchanges. Your composable recomposes, as normal.animateFloatAsStatesees a newtargetValueand starts an animation.- On every frame — sixty times a second on most phones — it writes a new value into its box.
- Every write triggers a of whatever read that box.
- When the value reaches the target, the writes stop and everything goes quiet.
So an animation is just state changing very fast, using the machinery from Lesson 3.5. There is no separate animation system bolted on the side.
Because the value is a State<Float>, you almost always want by rather than =:
1val angle by animateFloatAsState(...) // a Float
2val angle = animateFloatAsState(...) // a StateMiss the by and the first thing you do with angle gives you Type mismatch: inferred type is State<Float> but Float was expected. You also need androidx.compose.runtime.getValue imported, exactly as in Lesson 3.5 — and the error for missing it is the same unfriendly delegate message.
Choosing how it moves: the animation spec
The second parameter decides the character of the movement. There are two you will use constantly.
1animationSpec = tween(
2 durationMillis = 640,
3 easing = FastOutSlowInEasing
4)is a duration: get there in exactly this many milliseconds. Predictable, and right for anything with a known length — a card sliding in, a bar filling up.
is the shape of the speed. Nothing in the physical world starts and stops instantly, so a straight-line animation reads as mechanical:
| Easing | Feels like |
|---|---|
LinearEasing | A conveyor belt. Right for spinners, wrong for almost everything else |
FastOutSlowInEasing | Leaves quickly, arrives gently. Material's default, and a safe pick |
FastOutLinearInEasing | For something leaving the screen and not coming back |
LinearOutSlowInEasing | For something entering the screen |
1animationSpec = spring(
2 dampingRatio = Spring.DampingRatioMediumBouncy,
3 stiffness = Spring.StiffnessLow
4) has no duration at all. It is physics: a weight on a spring, pulled towards the target. is how hard it pulls; is how much it wobbles before settling — DampingRatioNoBouncy stops dead, DampingRatioHighBouncy overshoots several times.
Springs feel alive, and they have one genuine practical advantage: interrupt one halfway and it carries its current speed into the new animation, so a fast-tapping user never sees a jerk. A tween interrupted mid-flight restarts from wherever it happened to be.
Use spring for things the user is directly manipulating. Use tween when the timing matters.
There is a third, , for animations with named waypoints — "70% of the way by 200ms, then ease out". You will rarely need it.
Every animate*AsState takes a label. It costs nothing at runtime and names the animation in the Animation Preview tools, so a screen with four animations does not show four rows called "FloatAnimation". The real Dice Duel labels are "spin", "lift", "track" and "banner".
Where to apply it: graphicsLayer
You could animate a number and feed it into Modifier.padding or Modifier.size. It works, and it is the expensive way to do it: changing a size forces Compose to measure and lay out the screen again, every frame, including everything around it.
changes only how the pixels are drawn — position, rotation, scale, transparency — without disturbing the layout at all. Its neighbours never find out. That is the difference between a smooth animation and a stuttering one on a cheap phone.
1Modifier.graphicsLayer {
2 rotationZ = spin
3 scaleX = lift
4 scaleY = lift
5}The properties you will use: rotationZ (degrees, clockwise), scaleX / scaleY, translationX / translationY (pixels), and alpha (0 invisible, 1 solid).
Rule of thumb: if you can express the animation as moving, turning, scaling or fading, use graphicsLayer. Only animate a size or a padding when the layout genuinely has to change, and then reach for Modifier.animateContentSize(), which does it properly.
The real roll
This is Dice Duel's animation, exactly as it ships.
1var rollId by remember { mutableStateOf(0) }
2var tilt by remember { mutableStateOf(0f) }
3var rolling by remember { mutableStateOf(false) }
4
5val spin by animateFloatAsState(
6 targetValue = rollId * 360f + tilt,
7 animationSpec = tween(
8 durationMillis = 640,
9 easing = FastOutSlowInEasing
10 ),
11 label = "spin"
12)
13
14val lift by animateFloatAsState(
15 targetValue = if (rolling) 1.16f else 1f,
16 animationSpec = tween(durationMillis = 280),
17 label = "lift"
18)
19
20LaunchedEffect(rollId) {
21 if (rollId == 0) return@LaunchedEffect
22 repeat(9) {
23 die = Random.nextInt(1, 7)
24 delay(60)
25 }
26 die = Random.nextInt(1, 7)
27 rolling = false
28}
29
30fun roll() {
31 if (rolling) return
32 rolling = true
33 tilt = Random.nextInt(-8, 9).toFloat()
34 rollId++
35}
36
37DieFace(
38 value = die,
39 size = 172.dp,
40 modifier = Modifier.graphicsLayer {
41 rotationZ = spin
42 scaleX = lift
43 scaleY = lift
44 },
45 onRoll = { roll() }
46)The last part of the trick is not an animation at all. That LaunchedEffect(rollId) runs a every time rollId changes: nine random faces, 60 milliseconds apart, then the real one. It is Lesson 1.19's delay doing the work.
Be clear about the division of labour, because it is easy to blur:
- The spin and the lift are animations — a continuous value moving smoothly to a target.
- The flickering faces are not. A die face is 1 to 6; there is nothing in between to animate. That is a coroutine changing state nine times.
Use an animation when a value has a meaningful in-between. Use a coroutine when it does not.
Mid-roll: the die at about 200 degrees, scaled to 1.16, with the hint changed.
The rest of the toolbox
You now know the pattern, so the rest is naming.
Other values, same function. animateColorAsState for a fading from one to another, animateDpAsState for a in dp. Identical parameters, identical behaviour.
1val panel by animateColorAsState(
2 targetValue = if (active) {
3 colors.primary
4 } else {
5 colors.surfaceVariant
6 },
7 label = "panel"
8)Appearing and disappearing. animates a composable in and out instead of it popping:
1AnimatedVisibility(visible = winner != 0) {
2 WinBanner()
3}By default it fades and expands on the way in, and shrinks and fades on the way out. Pass enter = slideInVertically() and exit = fadeOut() to choose.
Changing size smoothly. on a container makes it grow and shrink smoothly when its contents change — the standard way to build an expanding card.
Swapping one thing for another. fades between two composables based on a value: Crossfade(targetState = screen) { s -> when (s) { ... } }.
Full control. is the low-level version, driven from a coroutine rather than from a target value. You need it when you want to await an animation finishing, or to interrupt one deliberately. animate*AsState is built on top of it, and covers the vast majority of real work.
Something that never stops. rememberInfiniteTransition() is for loading spinners and pulsing dots — an that runs until the composable leaves the screen.
Android has a Remove animations setting, used by people who get motion sickness from moving interfaces, and by anyone who simply wants the phone to feel fast.
Compose's animation APIs read that setting for you: with animations turned off, animateFloatAsState jumps straight to the target rather than sliding, so the app still works and still shows the right thing. An animation you hand-rolled out of delay loops does not — which is one more reason to use the real functions.
- Open ComposeLab and open
MainActivity.kt. - Inside
setContent, addvar big by remember { mutableStateOf(false) }and aBoxthat isModifier.size(100.dp), has a.background(Color(0xFF5B3FD6)), and a.clickable { big = !big }. - Add
val scale by animateFloatAsState(targetValue = if (big) 1.6f else 1f, label = "scale")above theBox, and.graphicsLayer { scaleX = scale; scaleY = scale }as the first modifier in the chain. Accept the imports forandroidx.compose.animation.core.animateFloatAsStateandandroidx.compose.ui.graphics.graphicsLayer. - Tap Run, then tap the square. It grows smoothly. Tap again — it shrinks.
- Delete the word
byand change it to=. Try to build. Read the error: Type mismatch: inferred type is State<Float> but Float was expected. Putbyback. - Add
animationSpec = spring(dampingRatio = Spring.DampingRatioHighBouncy)and Run. Same square, completely different personality — it overshoots and wobbles into place. - Change it to
tween(durationMillis = 2000, easing = LinearEasing)and Run. Slow and mechanical. Now you can feel what easing does. - Add
rotationZ = scale * 90finside thegraphicsLayerblock and Run. Two properties, one animated value. - Finally, replace
graphicsLayerwithModifier.size((100 * scale).dp)and tap repeatedly. Look closely: anything you put next to the square now jumps around, because the layout is being recalculated every frame. PutgraphicsLayerback.
material3 and ui.import androidx.compose.animation.core.animateFloatAsState. tween, spring, Spring, keyframes and the easings are all in androidx.compose.animation.core too. animateColorAsState is the odd one out — it is in androidx.compose.animation.val spin = animateFloatAsState(...) with an =. That gives you the state box itself, not the number inside it.by instead of =, and import androidx.compose.runtime.getValue. If you would rather keep =, every use becomes spin.value — pick one style and stay with it.by correctly, but the import that makes by work on Compose state is missing. Kotlin does not know how to read through the delegate.import androidx.compose.runtime.getValue. Unlike mutableStateOf you do not need setValue here, because an animated value is read-only — you change the target, never the value.animateFloatAsState was called inside an onClick lambda or another event handler. It is a composable function: it must be called while the screen is being described, not when a tap happens.targetValue is calculated from — exactly what rollId and rolling do in Dice Duel.rollId % 360.rollId * 360f only ever grows, so the die always turns forwards. If the value genuinely lives in the composable, check it is inside a remember — a plain var is rebuilt on every recomposition and takes the animation with it.- You never animate directly. You change a target, and gives you a value that slides towards it — writing state once per frame, which recomposes whatever reads it.
- Use
by, and importandroidx.compose.runtime.getValue. - is a duration plus an curve. is physics, survives interruption gracefully, and has no duration.
- Prefer —
rotationZ,scaleX,translationY,alpha— over animating sizes and padding, which re-lays-out the screen every frame. - Give every animation a
label. - Make a target monotonic when the movement must never reverse. That one line is what makes the die always turn forwards.
- Animate values with meaningful in-betweens; use a for values that jump, like a die face.
animateColorAsState,animateDpAsState, , , and cover everything else.- Next: Part 4, and the idea every screen you have built has been quietly waiting for — . Move state out of the composable that draws it, and a screen stops being a pile of
remembercalls and becomes something you can test, reuse and trust.