Pocket Studio Academy
HomePart 33.13

Animation basics

Full course11 min read·4 questions

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.

Think of it like this

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:

kotlin
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:

  1. rolling changes. Your composable recomposes, as normal.
  2. animateFloatAsState sees a new targetValue and starts an animation.
  3. On every frame — sixty times a second on most phones — it writes a new value into its box.
  4. Every write triggers a of whatever read that box.
  5. 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.

Careful

Because the value is a State<Float>, you almost always want by rather than =:

kotlin
1val angle by animateFloatAsState(...)   // a Float
2val angle = animateFloatAsState(...)    // a State

Miss 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.

kotlin
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:

EasingFeels like
LinearEasingA conveyor belt. Right for spinners, wrong for almost everything else
FastOutSlowInEasingLeaves quickly, arrives gently. Material's default, and a safe pick
FastOutLinearInEasingFor something leaving the screen and not coming back
LinearOutSlowInEasingFor something entering the screen
kotlin
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.

Tip

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.

kotlin
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.

GameScreen.ktkotlin
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.

9:41▲ ▮
DICE DUEL
First to 30 wins the duel
PLAYER 1
12
PLAYER 2
9
PLAYER 1 IS ROLLING

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.

kotlin
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:

kotlin
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.

Animation and accessibility

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.

Try it in Pocket Studio
  1. Open ComposeLab and open MainActivity.kt.
  2. Inside setContent, add var big by remember { mutableStateOf(false) } and a Box that is Modifier.size(100.dp), has a .background(Color(0xFF5B3FD6)), and a .clickable { big = !big }.
  3. Add val scale by animateFloatAsState(targetValue = if (big) 1.6f else 1f, label = "scale") above the Box, and .graphicsLayer { scaleX = scale; scaleY = scale } as the first modifier in the chain. Accept the imports for androidx.compose.animation.core.animateFloatAsState and androidx.compose.ui.graphics.graphicsLayer.
  4. Tap Run, then tap the square. It grows smoothly. Tap again — it shrinks.
  5. Delete the word by and change it to =. Try to build. Read the error: Type mismatch: inferred type is State<Float> but Float was expected. Put by back.
  6. Add animationSpec = spring(dampingRatio = Spring.DampingRatioHighBouncy) and Run. Same square, completely different personality — it overshoots and wobbles into place.
  7. Change it to tween(durationMillis = 2000, easing = LinearEasing) and Run. Slow and mechanical. Now you can feel what easing does.
  8. Add rotationZ = scale * 90f inside the graphicsLayer block and Run. Two properties, one animated value.
  9. Finally, replace graphicsLayer with Modifier.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. Put graphicsLayer back.
Error Doctor5 common errors
e: Unresolved reference: animateFloatAsState
MeansMissing import. The animation functions live in their own package, separate from both material3 and ui.
FixAdd 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.
e: Type mismatch: inferred type is State<Float> but Float was expected
MeansYou wrote val spin = animateFloatAsState(...) with an =. That gives you the state box itself, not the number inside it.
FixUse 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.
e: Type 'State<Float>' has no method 'getValue(Nothing?, KProperty<*>)' and thus it cannot serve as a delegate
MeansYou used by correctly, but the import that makes by work on Compose state is missing. Kotlin does not know how to read through the delegate.
FixAdd 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.
e: @Composable invocations can only happen from the context of a @Composable function
MeansanimateFloatAsState 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.
FixMove the call to the body of the composable, above the layout. Then have the tap change a plain state value that the targetValue is calculated from — exactly what rollId and rolling do in Dice Duel.
The animation runs once and never again, or snaps back to the start
MeansThe target went back to where it was. Either the value it is calculated from was reset, or the animation is keyed on something that flips back and forth, like rollId % 360.
FixMake the target monotonic where the movement should never reverse: 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.
Recap
  • 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 import androidx.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 remember calls and becomes something you can test, reuse and trust.