Recomposed
Recomposition

Recomposition, Layout, and DrawScope: One Idea, Three Phases

Oct 4, 2026 · 1 min read

Recomposition, Layout, and DrawScope: One Idea, Three Phases

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.

Get new posts in your inbox