Animating offset or alpha properties using standard compose states can easily tank your UI frame rates. If you read animation states directly in composition, your composable runs all three phases—composition, layout, and drawing—at 120 FPS.
You can bypass this overhead by deferring state reads to the layout or draw phases.
The Three Phases of Compose
Jetpack Compose renders frames in three distinct steps:
- Composition: What to show (runs the composable functions and builds the UI tree).
- Layout: Where to show (measures children and places them in coordinates).
- Draw: How to render (draws pixels on the canvas).
When a state changes, Compose starts from the phase where that state was read. If you read state during composition, you force the entire pipeline to rerun. If you defer the state read to the layout or draw phase, you skip composition entirely.
- Animating Positions (Layout Phase) Here is a common animation implementation that degrades layout performance:
// BAD: Recomposes the Box and its parents on every single frame
@Composable
fun SlidingCard(targetOffset: Dp) {
val translationX by animateDpAsState(targetValue = targetOffset)
Box(
modifier = Modifier
.size(100.dp)
.offset(x = translationX, y = 0.dp) // State read occurs in composition
.background(Color.Blue)
)
}
Since the state translationX is read in the composition phase (as an argument to Modifier.offset), the composable recomposes at 120 FPS as the animation updates.
We can fix this by wrapping the offset calculation in a lambda:
// GOOD: Zero recompositions. Updates coordinates directly in the Layout phase
@Composable
fun SlidingCard(targetOffset: Dp) {
val translationX by animateDpAsState(targetValue = targetOffset)
Box(
modifier = Modifier
.size(100.dp)
.offset { IntOffset(x = translationX.roundToPx(), y = 0) } // Lambda defers state read
.background(Color.Blue)
)
}
By using the lambda version of offset, the value of translationX is not read until the layout phase. The composition step is skipped entirely.
2.Animating Alpha and Rotations (Draw Phase)
Similarly, animating visual transformations like opacity or rotations can trigger layout recalculations if done incorrectly:
// BAD: Triggers composition and layout passes on every frame change
@Composable
fun FadingCard(targetAlpha: Float) {
val alphaState by animateFloatAsState(targetValue = targetAlpha)
Box(
modifier = Modifier
.size(100.dp)
.alpha(alphaState) // State read occurs in composition
.background(Color.Blue)
)
}
Since changing alpha does not affect the size or positions of elements, there is no reason to rerun layout passes.
We can bypass both composition and layout by reading the state directly inside a graphics layer block:
// GOOD: Zero recompositions. Modifies drawing properties directly on the GPU
@Composable
fun FadingCard(targetAlpha: Float) {
val alphaState by animateFloatAsState(targetValue = targetAlpha)
Box(
modifier = Modifier
.size(100.dp)
.graphicsLayer { alpha = alphaState } // Direct draw-phase read
.background(Color.Blue)
)
}
Inside the graphicsLayer lambda, alphaState is read during the drawing phase. Compose skips composition and layout, rendering the visual changes directly.
How to Verify in Layout Inspector
Open the Layout Inspector in Android Studio:
- Run the animation.
- Watch the Recomposition Counts column.
- Using the standard modifiers, the recomposition count climbs rapidly.
- Switch to lambda modifiers (offset {} or graphicsLayer {}) and the recomposition count stays at exactly 0.
Open-Source Reference
This optimization is part of the open-source Compose Performance Cheat Sheet. You can clone the full repository of performance starter kits, stability configurations, and lazy list recycling templates here:
👉 GitHub: Compose Performance & Recomposition Cheat Sheet (A print-ready, high-resolution A4 PDF version is also pinned in the repository description).













