Crossfade & AnimatedContent
AnimatedVisibility answers "is this one composable here or not?" A different, equally
common problem is swapping between two or more entirely different composables based on some state
— a spinner replaced by loaded content, one wizard step replaced by the next. Compose gives you
two tools for that: Crossfade, a minimal fade-between-states helper, and
AnimatedContent, which gives you full control over exactly how each transition enters and
exits, including different motion depending on which direction the state changed.
Crossfade: The Simple Case
When all you want is "fade the old content out while fading the new content in," Crossfade
is the least code you can write to get there. It takes a targetState and a content lambda
keyed on that state:
Crossfade between a spinner and content
@Composable
fun RecipeDetailContent(isLoading: Boolean, recipe: Recipe?) {
Crossfade(targetState = isLoading, label = "recipeLoadingCrossfade") { loading ->
if (loading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
recipe?.let { RecipeDetailBody(it) }
}
}
}
Crossfade is deliberately limited to a fade — there's no way to make the incoming
content slide in from the side, for example. When a fade is all the design calls for, that limitation
is exactly what keeps the call site so small.
AnimatedContent: Full Control Per Direction
AnimatedContent covers the same "swap between states" problem but lets you specify a
transitionSpec lambda that decides the enter/exit transitions for each specific state
change, using targetState and initialState to tell them apart:
Different transitions depending on direction
@Composable
fun AddRecipeSteps(currentStep: Int) {
AnimatedContent(
targetState = currentStep,
transitionSpec = {
if (targetState > initialState) {
// moving forward: new step slides in from the right
slideInHorizontally { width -> width } togetherWith
slideOutHorizontally { width -> -width }
} else {
// moving back: new step slides in from the left
slideInHorizontally { width -> -width } togetherWith
slideOutHorizontally { width -> width }
}
},
label = "addRecipeStep"
) { step ->
when (step) {
0 -> RecipeBasicInfoStep()
1 -> RecipeIngredientsStep()
else -> RecipeStepsStep()
}
}
}
togetherWith pairs one EnterTransition with one ExitTransition
into a single ContentTransform that AnimatedContent plays for that particular
state change — this is what lets forward navigation slide one way and backward navigation slide
the other, something Crossfade simply has no parameters for.
Worked Example: RecipeBox's Loading-to-Content Transition
RecipeBox's recipe detail screen wants a bit more polish than a plain crossfade: the spinner should
fade out quickly, but the loaded content should fade in and expand into place, since it's
usually taller than the spinner it replaces. That asymmetry is exactly what AnimatedContent
is for:
RecipeBox: AnimatedContent keyed on the loading flag
@Composable
fun RecipeDetailScreen(isLoading: Boolean, recipe: Recipe?) {
AnimatedContent(
targetState = isLoading,
transitionSpec = {
if (targetState) {
fadeIn(tween(150)) togetherWith fadeOut(tween(150))
} else {
(fadeIn(tween(300)) + expandVertically(tween(300))) togetherWith
fadeOut(tween(150))
}
},
label = "recipeLoadingContent"
) { loading ->
if (loading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
recipe?.let { RecipeDetailBody(it) }
}
}
}
Because targetState is a plain Boolean here rather than an ordered step
index, the branch checks the boolean directly instead of comparing against initialState
— the same pattern from the wizard example above, just simplified for a two-state toggle.
Which One Should You Reach For?
| Use | When |
|---|---|
Crossfade | A plain fade between states is all the design needs, and you want the smallest possible call site |
AnimatedContent | You need different motion per direction, non-fade transitions (slide, expand/shrink), or want the content to affect surrounding layout size as it transitions |