What we're building
No single demo this time. Three tips, one for each phase Compose runs every frame, that change how you read state.
The intuition
Every frame goes through three phases: composition (what exists), layout (where/how big), draw (what pixels). Each phase has its own snapshot observer; it only reruns for state reads that happened inside that phase. So the question that matters isn't "where is this value used," it's "which phase am I reading it in."
Tip 1: Recomposition, read late not early
// ❌ read in the composable body → recomposes every scroll pixel
Modifier.offset(x = offsetPx.dp)
// ✅ read inside the modifier's own lambda → only re-places, no recomposition
Modifier.offset { IntOffset(0, offsetPx.roundToInt()) }Reading a State in a composable's body ties it to composition, the
most expensive phase to rerun. Wrapping the read in a lambda defers it
to whichever phase that lambda belongs to.
The one exception
Whether a node exists at all (AnimatedVisibility's visible) has
to be decided in composition; layout and draw can only move or
repaint what composition already emitted.
Tip 2: Layout, constraints down, sizes up, parent decides position
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints.copy(maxHeight = heightPx))
layout(placeable.width, heightPx) {
placeable.placeRelative(0, 0)
}
}A child only gets to pick a size inside the [min, max] box its
parent hands it; it never picks its own position. Modifier.layout
reads state at the layout phase, so animating heightPx re-measures
and re-places without ever touching composition.
Tip 3: DrawScope, pixels, top-left origin, and centered strokes
Canvas(Modifier.size(100.dp)) {
val stroke = 8.dp.toPx()
// inset by half the stroke width, or the outer half clips off
drawCircle(Color.Blue, radius = size.minDimension / 2 - stroke / 2, style = Stroke(stroke))
}Inside DrawScope, everything is already in pixels, origin is
top-left, +y goes down. Strokes are centered on the path, not
inside it, that's the classic "flat edge" bug above. drawBehind
paints before the node's content; drawWithContent { drawContent() }
lets you paint after by moving the content call.
Variations / tradeoffs
Same backing State can be read from all three phases at once (layout
lambda, graphicsLayer, drawBehind), each reruns independently, and
none of them touches composition. That's the whole trick behind smooth
scroll-driven effects like a collapsing header.
