Techbase Academy
Android · Lesson 41

Animation in Compose

16 phasesWeather Dashboard with MotionAndroid Studio
Phase 1 of 16
Lesson Introduction

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 AnimatedVisibility and swap content with AnimatedContent and Crossfade.
  • Animate several values together with updateTransition.
  • Loop animations with rememberInfiniteTransition.
  • Control animations with Animatable and 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.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 16
Prerequisite Concepts
  • 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.
  • remember and mutableStateOf (lesson 17).
  • Coroutines (lesson 28). Animatable uses suspend functions.
  • Modifiers and layout (lessons 16 and 38).
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 16
Conceptual Understanding: Choosing an Animation API

Compose has many animation APIs. The beginner's problem is not how to use them but which one to pick. Use this decision list.

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

✏️ Your Task
Practise what you just learned about Conceptual Understanding: Choosing an Animation API. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 16
Step-by-Step Build 1: animate*AsState

Create a project WeatherBoard (Empty Activity, Compose). We start with the simplest animation: a card that grows when tapped.

MainActivity.kt
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:

  • label is 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: use Modifier.graphicsLayer { alpha = animatedAlpha } or Modifier.drawBehind { ... }. We use a lambda graphicsLayer for 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.)

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: animate*AsState. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 16
Step-by-Step Build 2: Animation Specs

An animationSpec says how a value moves. Every animate*AsState function accepts one.

kotlin
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; DampingRatioHighBouncy bounces a lot.
  • stiffness: how fast it pulls to the target. StiffnessHigh is snappy; StiffnessLow is 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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Animation Specs. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 16
Step-by-Step Build 3: AnimatedVisibility

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.

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

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

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: AnimatedVisibility. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 16
Step-by-Step Build 4: AnimatedContent and Crossfade

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.

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

  • targetState is 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.
  • transitionSpec is a receiver lambda with initialState and targetState available, so you can choose a direction.
  • togetherWith (formerly with) pairs the enter transition and the exit transition.
  • SizeTransform controls how the container grows from the old content's size to the new.

For a plain fade between two different screens, Crossfade is simpler:

kotlin
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}")
 }
 }
}
✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: AnimatedContent and Crossfade. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 16
Step-by-Step Build 5: updateTransition for Several Values

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.

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

✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: updateTransition for Several Values. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 16
Step-by-Step Build 6: Infinite Animations

For loading indicators and pulses, use rememberInfiniteTransition.

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

✏️ Your Task
Practise what you just learned about Step-by-Step Build 6: Infinite Animations. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 16
Step-by-Step Build 7: Animatable and Gesture-Driven Animation

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

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

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

✏️ Your Task
Practise what you just learned about Step-by-Step Build 7: Animatable and Gesture-Driven Animation. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 16
Step-by-Step Build 8: Size, Lists and Visibility Helpers

animateContentSize

When content grows (a long text expanded with "Read more"), apply Modifier.animateContentSize() to the container and size changes animate for free:

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

✏️ Your Task
Practise what you just learned about Step-by-Step Build 8: Size, Lists and Visibility Helpers. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 16
Step-by-Step Build 9: Five Quick Animations That Make an App Stand Out

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.

  1. Press feedback. Scale a card slightly when pressed, using MutableInteractionSource, collectIsPressedAsState and animateFloatAsState.
  2. Fade-slide entrance for list rows: AnimatedVisibility with fadeIn() + slideInVertically(), staggered by index.
  3. Animated counter: AnimatedContent with a vertical slide for numbers.
  4. Skeleton loading with rememberInfiniteTransition (Build 6).
  5. Expand/collapse with animateContentSize and a rotating arrow using animateFloatAsState.

Code for the first one, since it uses a pattern you have not seen:

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

✏️ Your Task
Practise what you just learned about Step-by-Step Build 9: Five Quick Animations That Make an App Stand Out. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 13 of 16
Step-by-Step Build 10: Respecting Reduced Motion and Other Practical Concerns

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 make waitForIdle hang; mainClock.autoAdvance = false then 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.
✏️ Your Task
Practise what you just learned about Step-by-Step Build 10: Respecting Reduced Motion and Other Practical Concerns. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 14 of 16
Guided Practice Exercises
🎯 Your Challenge

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.

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

🎯 Your Challenge

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

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

🎯 Your Challenge

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.

✏️ Your Task
Practise what you just learned about Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 15 of 16
Common Beginner Mistakes (and How to Fix Them)

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

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 16 of 16
Knowledge Check

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

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Weather Dashboard with Motion

Project goal: Build a small dashboard that uses at least five different animation APIs in a way that helps the user.

Requirements:

  1. A refresh button that rotates an icon while loading (infinite or Animatable) and swaps the content between Loading skeleton, Ready data and Error using AnimatedContent or Crossfade.
  2. A big temperature number that changes with an AnimatedContent vertical slide each time you press Next city.
  3. A list of three cities as expandable cards, using animateContentSize and an arrow rotation.
  4. A favourite star using updateTransition (size, colour, rotation).
  5. A press-scale effect on the cards via graphicsLayer.
  6. 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().

Dashboard.kt
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).

1

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.

2

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

3

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.

4

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.

5

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
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close