Good animation is not decoration. It tells the user what just happened: a card expanded, a row was removed, a screen changed. In the old View system, animation meant XML files, ObjectAnimator and a lot of bookkeeping. In Compose, most animation is just state that changes smoothly, and a few lines of code are enough.
This lesson covers the 5 quick animations to make your app stand out and Animating elements in Jetpack Compose items of Pathway 2 of the Jetpack Compose for Android developers programme (https://developer.android.com/courses/pathways/jetpack-compose-for-android-developers-2). It follows the ideas of the official Animation in Jetpack Compose codelab, using our own example: a weather-style dashboard with a refresh button, an expandable card and a loading shimmer.
By the end you will be able to:
- Pick the right animation API for a job from the "animation decision tree".
- Animate values with
animate*AsState. - Show and hide content with
AnimatedVisibilityand swap content withAnimatedContentandCrossfade. - Animate several values together with
updateTransition. - Loop animations with
rememberInfiniteTransition. - Control animations with
Animatableand run them from coroutines. - Choose an
animationSpec:tween,spring,keyframes,snap. - Animate content size and list items.
- Combine animation with gestures.
- Respect the user's "remove animations" setting.
Big idea: in Compose you do not animate views. You change state, and Compose animates the values that depend on that state.
- State and recomposition (lessons 17 and 37). An animation is a value that changes over many frames, each frame causing a small recomposition or redraw.
rememberandmutableStateOf(lesson 17).- Coroutines (lesson 28).
Animatableusessuspendfunctions. - Modifiers and layout (lessons 16 and 38).
Compose has many animation APIs. The beginner's problem is not how to use them but which one to pick. Use this decision list.
Is it a single value that should move smoothly to a new target?
yes -> animate*AsState (animateFloatAsState, animateDpAsState, animateColorAsState ...)
Is it showing/hiding a composable?
yes -> AnimatedVisibility
Is it swapping one composable for another based on a value?
yes -> AnimatedContent (or Crossfade for a simple fade)
Do several values change together as one "state change"?
yes -> updateTransition
Does it run forever (spinner, pulse, shimmer)?
yes -> rememberInfiniteTransition
Do you need to start/stop/interrupt it from code, or drive it with gestures?
yes -> Animatable
Does the layout size change?
yes -> Modifier.animateContentSize()
A list item enters, leaves or moves?
yes -> Modifier.animateItem() in a lazy list (lesson 39)
All of these are built on the same low-level pieces:
| Piece | Meaning |
|---|---|
| Target | The value you want to reach |
animationSpec |
How to get there: duration, easing, spring physics |
| Animation state | The value at the current frame |
Two behaviours matter for correctness. First, animations are interruptible: if the target changes mid-animation, Compose redirects from the current value instead of jumping. Second, animations run on the frame clock, which is why they stay in step with the screen refresh rate.
Spring versus tween
A tween is like a train timetable: arrive in 300 ms, with an easing curve. A spring is like a ball on a rubber band: no fixed time; it moves according to stiffness and damping ratio. Springs feel natural, and handle interruptions best because velocity carries over. Compose's default for most animate*AsState calls is a spring.
Create a project WeatherBoard (Empty Activity, Compose). We start with the simplest animation: a card that grows when tapped.
package com.example.weatherboard
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface { TapToGrow() }
}
}
}
}
@Composable
fun TapToGrow() {
var big by remember { mutableStateOf(false) }
val height by animateDpAsState(
targetValue = if (big) 220.dp else 100.dp,
label = "cardHeight"
)
val colour by animateColorAsState(
targetValue = if (big) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.secondaryContainer,
label = "cardColour"
)
val alpha by animateFloatAsState(
targetValue = if (big) 1f else 0.7f,
label = "cardAlpha"
)
Box(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(height)
.alpha(alpha)
.background(colour)
.clickable { big = !big },
contentAlignment = Alignment.Center
) {
Text(if (big) "Lagos: 31°C" else "Tap me")
}
}
(Add import androidx.compose.foundation.background to the imports.) Run it and tap the card.
What happens: big flips between true and false. The targetValue of each animation changes, and Compose moves the value from its current number to the new target over several frames. The by delegates (val height by ...) read the current animated value on each frame, which makes the layout recompose with a slightly different height each time.
Three details to remember:
labelis a name shown in Android Studio's Animation Preview and tooling. It is optional but good practice.- The animated value is a
State<Dp>. Reading it in the composition (.height(height)) re-composes the function every frame. For high-frequency animations (a scrolling parallax, a fade on a big tree) read it later, in the draw or layout phase, to avoid recomposition: useModifier.graphicsLayer { alpha = animatedAlpha }orModifier.drawBehind { ... }. We use a lambdagraphicsLayerfor this in lesson 57. - Tap fast and watch: the card reverses smoothly mid-way. That is interruption working.
The family of animate*AsState functions
| Function | Animates |
|---|---|
animateFloatAsState |
Float (alpha, scale, rotation) |
animateDpAsState |
Dp (size, padding, elevation) |
animateColorAsState |
Color |
animateIntAsState |
Int |
animateSizeAsState, animateOffsetAsState, animateIntOffsetAsState |
geometry |
animateValueAsState |
any type, with a TwoWayConverter |
Animation Preview in Android Studio
Use @Preview on a composable with a transition-based animation, and the preview shows a small Start Animation Preview icon on the composable. Clicking it opens a panel where you can scrub through time, which is the quickest way to tune a duration. It works with updateTransition and AnimatedVisibility. (The exact controls change between Studio versions.)
An animationSpec says how a value moves. Every animate*AsState function accepts one.
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun specExamples(expanded: Boolean): List<State<Dp>> {
val target = if (expanded) 200.dp else 80.dp
// 1. tween: fixed duration plus easing
val a = animateDpAsState(
target,
animationSpec = tween(durationMillis = 400, delayMillis = 50, easing = FastOutSlowInEasing),
label = "tween"
)
// 2. spring: physics, no duration
val b = animateDpAsState(
target,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "spring"
)
// 3. keyframes: specify values at times
val c = animateDpAsState(
target,
animationSpec = keyframes {
durationMillis = 600
120.dp at 150 // reach 120 dp at 150 ms
180.dp at 300 using LinearEasing
},
label = "keyframes"
)
// 4. snap: jump with no animation
val d = animateDpAsState(target, animationSpec = snap(), label = "snap")
return listOf(a, b, c, d)
}
| Spec | Use it when | Feel |
|---|---|---|
spring |
Default choice; interactions, gestures | Natural, interruptible |
tween |
Fixed timing matters (a splash fade) | Predictable |
keyframes |
Custom path, e.g. overshoot | Choreographed |
snap |
Reduced motion or instant change | None |
repeatable / infiniteRepeatable |
Loops | Repeating |
Spring parameters
- dampingRatio: below 1 it bounces.
DampingRatioNoBouncy(1.0) settles without overshoot;DampingRatioHighBouncybounces a lot. - stiffness: how fast it pulls to the target.
StiffnessHighis snappy;StiffnessLowis lazy.
Easing
FastOutSlowInEasing starts quickly and ends gently, the standard for elements moving on the screen. LinearEasing is constant speed and is right for spinners and progress. LinearOutSlowInEasing suits entering elements; FastOutLinearInEasing suits leaving ones.
AnimatedVisibility adds the element to the composition with an enter transition and removes it with an exit transition. Unlike changing alpha to 0, an exited element no longer takes part in layout and is not composed.
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun ForecastPanel() {
var showDetails by remember { mutableStateOf(false) }
Column(Modifier.padding(16.dp)) {
Button(onClick = { showDetails = !showDetails }) {
Text(if (showDetails) "Hide forecast" else "Show forecast")
}
AnimatedVisibility(
visible = showDetails,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Card(Modifier.padding(top = 12.dp)) {
Text("Mon 31° Tue 30° Wed 29°", Modifier.padding(16.dp))
}
}
}
}
Enter and exit transitions are combined with +. The built-in ones are:
| Enter | Exit |
|---|---|
fadeIn() |
fadeOut() |
slideInVertically(), slideInHorizontally() |
slideOutVertically(), slideOutHorizontally() |
scaleIn() |
scaleOut() |
expandIn(), expandVertically(), expandHorizontally() |
shrinkOut(), shrinkVertically(), shrinkHorizontally() |
Different animation for children
Inside the AnimatedVisibility content, use Modifier.animateEnterExit(enter = ..., exit = ...) to give children their own transitions, for example the background fades while the content slides.
Visibility from a MutableTransitionState
To show something immediately when first composed, pass a MutableTransitionState:
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@Composable
fun WelcomeBanner() {
val state = remember { MutableTransitionState(false).apply { targetState = true } }
AnimatedVisibility(visibleState = state) {
Text("Welcome back!")
}
}
The element starts hidden and animates in as soon as the screen appears. state.isIdle and state.currentState tell you when the animation has finished.
AnimatedContent runs a transition when a value changes, animating the old content out and the new content in. It is ideal for counters, tabs and loading → data → error.
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun TemperatureCounter(temp: Int) {
AnimatedContent(
targetState = temp,
transitionSpec = {
if (targetState > initialState) {
// value goes up: new number slides up from below
(slideInVertically { height -> height } + fadeIn())
.togetherWith(slideOutVertically { height -> -height } + fadeOut())
} else {
(slideInVertically { height -> -height } + fadeIn())
.togetherWith(slideOutVertically { height -> height } + fadeOut())
}.using(SizeTransform(clip = false))
},
label = "temp"
) { value ->
Text("$value°C", style = MaterialTheme.typography.displayMedium)
}
}
Key points:
targetStateis the value that decides the content. The lambda receives the state it is animating for, so do not read your outer variable inside; use the lambda parameter (value), or the old content would flip too early.transitionSpecis a receiver lambda withinitialStateandtargetStateavailable, so you can choose a direction.togetherWith(formerlywith) pairs the enter transition and the exit transition.SizeTransformcontrols how the container grows from the old content's size to the new.
For a plain fade between two different screens, Crossfade is simpler:
import androidx.compose.animation.Crossfade
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
sealed interface LoadState {
data object Loading : LoadState
data class Ready(val summary: String) : LoadState
data class Failed(val message: String) : LoadState
}
@Composable
fun WeatherContent(state: LoadState) {
Crossfade(targetState = state, label = "load") { s ->
when (s) {
LoadState.Loading -> CircularProgressIndicator()
is LoadState.Ready -> Text(s.summary)
is LoadState.Failed -> Text("Error: ${s.message}")
}
}
}
When one state change should animate several properties together, and you want them to start and finish as a group, use updateTransition. Imagine a "favourite" star: it grows, changes colour and rotates.
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
enum class StarState { Off, On }
@Composable
fun FavouriteStar() {
var state by remember { mutableStateOf(StarState.Off) }
val transition = updateTransition(targetState = state, label = "star")
val size by transition.animateDp(
transitionSpec = { spring(dampingRatio = 0.4f) },
label = "size"
) { s -> if (s == StarState.On) 48.dp else 32.dp }
val tint by transition.animateColor(label = "tint") { s ->
if (s == StarState.On) Color(0xFFFFB300) else Color.Gray
}
val angle by transition.animateFloat(
transitionSpec = { tween(400) },
label = "angle"
) { s -> if (s == StarState.On) 360f else 0f }
Icon(
imageVector = Icons.Filled.Star,
contentDescription = "Favourite",
tint = tint,
modifier = Modifier
.size(size)
.rotate(angle)
.clickable {
state = if (state == StarState.On) StarState.Off else StarState.On
}
)
}
(For animateColor, import androidx.compose.animation.animateColor.) All three values are driven by one state, each with its own spec. The transition also gives you transition.currentState, transition.targetState and transition.isRunning, which is useful for knowing when to start something else. The Animation Preview in Studio can scrub through a Transition.
AnimatedVisibility and AnimatedContent are themselves built on Transition. You can access it inside their content with transition to add child animations.
For loading indicators and pulses, use rememberInfiniteTransition.
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun SkeletonLine(modifier: Modifier = Modifier) {
val infinite = rememberInfiniteTransition(label = "skeleton")
val alpha by infinite.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "pulse"
)
Box(
modifier
.fillMaxWidth()
.height(20.dp)
.clip(RoundedCornerShape(6.dp))
.alpha(alpha)
.background(Color.LightGray)
)
}
RepeatMode.Reverse goes back and forth; RepeatMode.Restart jumps to the start. An infinite animation runs as long as it is in the composition, which is a battery cost. Never leave one running in a hidden or off-screen part of the UI, and consider stopping it with AnimatedVisibility, or by removing it from the composition once data has loaded.
animate*AsState is declarative: "here is the target". Sometimes you need imperative control: start from a tap, stop on touch, run steps in sequence, or follow a finger. Animatable holds a value and exposes suspend functions.
A button that shakes on error
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun ShakeButton(onTap: () -> Boolean) {
val offsetX = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Button(
onClick = {
val ok = onTap()
if (!ok) {
scope.launch {
repeat(3) {
offsetX.animateTo(16f, tween(50))
offsetX.animateTo(-16f, tween(50))
}
offsetX.animateTo(0f, tween(50))
}
}
},
modifier = Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) }
) {
Text("Submit")
}
}
Notice Modifier.offset { ... } with a lambda: it reads offsetX.value during the layout phase, so the shaking does not recompose the button. This is the recommended pattern for fast-changing values.
Key Animatable functions:
| Function | What it does |
|---|---|
animateTo(target, spec) |
Animates to a target; suspends until done; cancels any running animation |
snapTo(value) |
Jumps instantly |
animateDecay(velocity, spec) |
Continues with velocity and slows down (fling) |
stop() |
Stops the current animation |
updateBounds(lower, upper) |
Limits the value range |
Dragging a card and flinging it back
This combines pointerInput, detectDragGestures and Animatable: when the user lets go, the card springs back to the centre.
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun DraggableCard() {
val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
val scope = rememberCoroutineScope()
Box(Modifier.size(300.dp), contentAlignment = Alignment.Center) {
Card(
Modifier
.offset { IntOffset(offset.value.x.roundToInt(), offset.value.y.roundToInt()) }
.size(160.dp, 100.dp)
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, dragAmount ->
change.consume()
scope.launch { offset.snapTo(offset.value + dragAmount) }
},
onDragEnd = {
scope.launch {
offset.animateTo(
Offset.Zero,
spring(dampingRatio = Spring.DampingRatioMediumBouncy)
)
}
}
)
}
) {
Text("Drag me")
}
}
}
While dragging, we snapTo the finger position; on release, animateTo(Offset.Zero) with a bouncy spring pulls it home. Because a new animateTo cancels the previous one, touching the card mid-bounce takes over smoothly. (Offset.VectorConverter comes from androidx.compose.ui.geometry; if your Compose version reports it unresolved, add import androidx.compose.animation.core.VectorConverter.) For swipe-to-dismiss, Material 3 already provides SwipeToDismissBox, so build custom gestures only when you need something special.
animateContentSize
When content grows (a long text expanded with "Read more"), apply Modifier.animateContentSize() to the container and size changes animate for free:
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun NewsCard(body: String) {
var expanded by remember { mutableStateOf(false) }
Card(
Modifier
.animateContentSize()
.clickable { expanded = !expanded }
) {
Text(
body,
Modifier.padding(16.dp),
maxLines = if (expanded) Int.MAX_VALUE else 2,
overflow = TextOverflow.Ellipsis
)
}
}
Order matters. animateContentSize() must come before the size-constraining modifiers that should animate. Put it too late and the animation does nothing.
List item animations
In a LazyColumn, Modifier.animateItem() (lesson 39) animates insertions, removals and reordering, as long as items have stable keys. Without keys, Compose cannot know which item moved.
The official course has a short piece named 5 quick animations to make your app stand out. Here is our own set of five, all small and all built from the tools above.
- Press feedback. Scale a card slightly when pressed, using
MutableInteractionSource,collectIsPressedAsStateandanimateFloatAsState. - Fade-slide entrance for list rows:
AnimatedVisibilitywithfadeIn() + slideInVertically(), staggered by index. - Animated counter:
AnimatedContentwith a vertical slide for numbers. - Skeleton loading with
rememberInfiniteTransition(Build 6). - Expand/collapse with
animateContentSizeand a rotating arrow usinganimateFloatAsState.
Code for the first one, since it uses a pattern you have not seen:
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
@Composable
fun PressCard(title: String, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.96f else 1f, label = "press")
Card(
Modifier
.padding(8.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
) {
Text(title, Modifier.padding(24.dp))
}
}
The graphicsLayer { } lambda reads scale in the draw phase, so pressing the card never recomposes it.
Rules of good taste: keep durations short (150–350 ms), animate only what helps understanding, never block the user while an animation plays, and do not animate everything.
Some users get dizzy from motion, and Android has a developer/accessibility setting that scales or disables animations (Remove animations in accessibility settings, and Animation scale in Developer options). Compose's standard animations follow the system animator duration scale; a custom Animatable follows it too, because it uses the frame clock. Nevertheless, long decorative motion should be optional and infinite animations should stop when not needed.
Other practical points:
- Previews can show animation progress with the Animation Preview; use it to tune durations.
- Testing: the Compose test rule controls time (
composeTestRule.mainClock), so you can advance an animation by 500 ms in a test instead of waiting (lesson 45). Infinite animations makewaitForIdlehang;mainClock.autoAdvance = falsethen step manually. - Performance: animate with
graphicsLayer(alpha, scale, rotation, translation) wherever possible; it avoids recomposition and relayout, and is the cheapest kind of animation (lesson 57). - Shared element transitions (an image that flies from a list into a detail screen) exist in Compose via
SharedTransitionLayout. The API has been moving, so check its current stability in the official documentation before relying on it in production.
Exercise 1: Expandable forecast card
Build a ForecastCard that shows today's temperature. When tapped, it expands to reveal three more lines (humidity, wind, rain chance). Use animateContentSize, rotate a small arrow icon 180° with animateFloatAsState, and fade the details in with AnimatedVisibility.
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
@Composable
fun ForecastCard() {
var expanded by remember { mutableStateOf(false) }
val arrow by animateFloatAsState(if (expanded) 180f else 0f, label = "arrow")
Card(
Modifier
.padding(16.dp)
.animateContentSize()
.clickable { expanded = !expanded }
) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Ibadan: 29°C", Modifier.weight(1f))
Icon(Icons.Filled.KeyboardArrowDown, "Expand", Modifier.rotate(arrow))
}
AnimatedVisibility(expanded, enter = fadeIn(), exit = fadeOut()) {
Column {
Text("Humidity 78%")
Text("Wind 12 km/h")
Text("Rain chance 40%")
}
}
}
}
}
weight needs the RowScope, which the Row lambda provides.
Exercise 2: A like button with a bounce
Build a heart button. On tap, the heart scales up to 1.4× and springs back to 1×, while its colour changes to red. Use Animatable and a coroutine so the bounce plays on every tap, even if the state does not change from "liked" to "not liked".
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import kotlinx.coroutines.launch
@Composable
fun LikeButton() {
var liked by remember { mutableStateOf(false) }
val scale = remember { Animatable(1f) }
val scope = rememberCoroutineScope()
val tint by animateColorAsState(if (liked) Color.Red else Color.Gray, label = "tint")
Icon(
Icons.Filled.Favorite,
contentDescription = if (liked) "Unlike" else "Like",
tint = tint,
modifier = Modifier
.graphicsLayer { scaleX = scale.value; scaleY = scale.value }
.clickable {
liked = !liked
scope.launch {
scale.animateTo(1.4f, tween(100))
scale.animateTo(1f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
}
}
)
}
Each tap starts a new coroutine that restarts the bounce; because animateTo cancels the previous run, rapid taps never pile up.
Exercise 3: Choose the API
For each case choose the best API and say why: (a) a profile photo border that turns green when a user is online; (b) a banner that slides in after a network error; (c) a three-dot "typing…" indicator; (d) a card that follows the finger and snaps to the nearest edge; (e) a tab content area that changes with the selected tab.
(a) animateColorAsState: one value moving to a new target.
(b) AnimatedVisibility with slideInVertically() + fadeIn().
(c) rememberInfiniteTransition with three staggered animateFloats using StartOffset.
(d) Animatable with snapTo while dragging and animateTo on release (or animateDecay plus a snap target).
(e) AnimatedContent (or Crossfade for a plain fade) keyed on the selected tab.
Mistake #1: Using Thread.sleep or manual timers to animate. What happens: the UI freezes. Fix: use the animation APIs, which run on the frame clock.
Mistake #2: Forgetting label. What happens: nothing breaks, but tools show generic names. Fix: add labels.
Mistake #3: Reading the outer variable inside AnimatedContent. What happens: the old content already shows the new value during the exit animation. Fix: use the lambda's parameter.
Mistake #4: Animating alpha to 0 and calling it hidden. What happens: the invisible element still takes space and receives clicks. Fix: AnimatedVisibility.
Mistake #5: Wrong modifier order for animateContentSize. What happens: no animation. Fix: place it before the size modifiers.
Mistake #6: Reading animated state in composition for big trees. What happens: heavy recomposition each frame. Fix: read in graphicsLayer { } or offset { } lambdas.
Mistake #7: Infinite animations that never stop. What happens: battery drain, test hangs. Fix: remove them when not needed.
Mistake #8: Launching animateTo in composition. What happens: a side effect runs on every recomposition. Fix: start it in LaunchedEffect, or in an event handler through a coroutine scope.
Mistake #9: Over-animating. What happens: the app feels slow. Fix: short durations, only meaningful motion.
Mistake #10: Unstable list keys. What happens: item animations look wrong. Fix: stable ids (lesson 39).
Question 1. What is the difference between tween and spring?
tween runs for a fixed duration along an easing curve. spring uses physics (stiffness and damping), has no fixed duration and keeps velocity when interrupted, so it handles changes of target more naturally.
Question 2. Which API would you use to show and hide a composable with enter and exit effects?
AnimatedVisibility, using enter and exit transitions such as fadeIn() + expandVertically().
Question 3. When is Animatable a better choice than animate*AsState?
When you need imperative control: starting from an event, sequencing animations, stopping them, snapping to a value, or driving the value from a gesture or a fling.
Question 4. Why use Modifier.graphicsLayer { } or Modifier.offset { } with a lambda for fast animations?
The lambda reads the animated value in the layout or draw phase, so the animation does not trigger recomposition each frame, which is cheaper.
Question 5. What does updateTransition give you that several separate animate*AsState calls do not?
One shared state drives several animated values that begin and end as a group, with a single Transition object you can observe and preview in Android Studio.
Go deeper: official resources
- Jetpack Compose for Android developers, Pathway 2: Layouts, theming and animation
- Jetpack Compose animation codelab
- Animation in Compose (documentation)
- Jetpack Compose course page
Project goal: Build a small dashboard that uses at least five different animation APIs in a way that helps the user.
Requirements:
- A refresh button that rotates an icon while loading (infinite or
Animatable) and swaps the content between Loading skeleton, Ready data and Error usingAnimatedContentorCrossfade. - A big temperature number that changes with an
AnimatedContentvertical slide each time you press Next city. - A list of three cities as expandable cards, using
animateContentSizeand an arrow rotation. - A favourite star using
updateTransition(size, colour, rotation). - A press-scale effect on the cards via
graphicsLayer. - All loading and fake data is local (use
delay(1000)in a coroutine).
Stretch goal: add a drag-to-refresh pull gesture using Animatable, or a reduced-motion switch that replaces every tween and spring with snap().
package com.example.weatherboard
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
sealed interface Load {
data object Loading : Load
data class Ready(val temp: Int) : Load
data class Error(val msg: String) : Load
}
@Composable
fun Dashboard() {
var load by remember { mutableStateOf<Load>(Load.Ready(29)) }
var attempt by remember { mutableIntStateOf(0) }
val scope = rememberCoroutineScope()
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Ibadan", style = MaterialTheme.typography.headlineMedium, modifier = Modifier.weight(1f))
SpinningRefresh(spinning = load is Load.Loading) {
scope.launch {
load = Load.Loading
delay(1000)
attempt++
load = if (attempt % 3 == 0) Load.Error("No network") else Load.Ready(26 + attempt % 7)
}
}
}
Crossfade(targetState = load is Load.Loading, label = "loading") { loading ->
if (loading) {
Text("Loading…")
} else {
when (val s = load) {
is Load.Ready -> AnimatedContent(
targetState = s.temp,
transitionSpec = {
(slideInVertically { it } + fadeIn())
.togetherWith(slideOutVertically { -it } + fadeOut())
},
label = "temp"
) { t -> Text("$t°C", style = MaterialTheme.typography.displayMedium) }
is Load.Error -> Text("Error: ${s.msg}", color = MaterialTheme.colorScheme.error)
Load.Loading -> {}
}
}
}
listOf("Lagos", "Abuja", "Kano").forEach { city -> CityCard(city) }
}
}
@Composable
fun SpinningRefresh(spinning: Boolean, onClick: () -> Unit) {
val infinite = rememberInfiniteTransition(label = "spin")
val angle by infinite.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Restart),
label = "angle"
)
Button(onClick = onClick, enabled = !spinning) {
Icon(Icons.Filled.Refresh, "Refresh", Modifier.rotate(if (spinning) angle else 0f))
}
}
@Composable
fun CityCard(city: String) {
var expanded by remember { mutableStateOf(false) }
val arrow by animateFloatAsState(if (expanded) 180f else 0f, label = "arrow")
val scale by animateFloatAsState(if (expanded) 1.02f else 1f, label = "scale")
Card(
Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.animateContentSize()
.clickable { expanded = !expanded }
) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(city, Modifier.weight(1f))
Icon(Icons.Filled.KeyboardArrowDown, "Expand", Modifier.rotate(arrow))
}
if (expanded) {
Text("Humidity 72%")
Text("Wind 10 km/h")
}
}
}
}
The favourite star from requirement 4 is the FavouriteStar composable from Build 5; place one in each CityCard row. This solution uses Crossfade, AnimatedContent, rememberInfiniteTransition, animateFloatAsState, graphicsLayer and animateContentSize together. Note that the Crossfade hides the old value while loading, and that the spinning icon only rotates when spinning is true, so the infinite transition runs but visually rests otherwise (for a battery-conscious version, place the spinner in its own composable that exists only while loading).
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 41 project”.
On github.com click New repository, name it android-lesson-41-animation-in-compose, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 41 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Choosing an Animation API
- Step-by-Step Build 1: animate*AsState
- Step-by-Step Build 2: Animation Specs
- Step-by-Step Build 3: AnimatedVisibility
- Step-by-Step Build 4: AnimatedContent and Crossfade
- Step-by-Step Build 5: updateTransition for Several Values
- Step-by-Step Build 6: Infinite Animations
- Step-by-Step Build 7: Animatable and Gesture-Driven Animation
- Step-by-Step Build 8: Size, Lists and Visibility Helpers
- Step-by-Step Build 9: Five Quick Animations That Make an App Stand Out
- Step-by-Step Build 10: Respecting Reduced Motion and Other Practical Concerns
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check