Lesson 9

Add layout and rendering

Turn retained intent into geometry and then into a complete `SurfaceFrame` that is not mutated after construction.

5 min readUpdated Jul 17, 2026

Learning goal

Turn retained intent into geometry and then into a complete SurfaceFrame that is not mutated after construction.

Separate the phases

Diagram
flowchart LR
    Tree[Retained node tree] --> Measure
    Measure --> Place
    Place --> Paint
    Paint --> Frame[SurfaceFrame]
    Frame --> Surface
  • Measure: a parent offers constraints; a child chooses a size.
  • Place: a parent assigns child positions.
  • Paint: nodes append commands using final geometry.
  • Present: the adapter receives one complete frame.

Constraints

data class Constraints(
    val minWidth: Int = 0,
    val maxWidth: Int,
    val minHeight: Int = 0,
    val maxHeight: Int,
) {
    init {
        require(minWidth in 0..maxWidth)
        require(minHeight in 0..maxHeight)
    }

    fun constrain(size: IntSize): IntSize = IntSize(
        width = size.width.coerceIn(minWidth, maxWidth),
        height = size.height.coerceIn(minHeight, maxHeight),
    )
}

The units belong to the Atlas surface contract. They are not assumed to be density-independent pixels, physical pixels, or terminal cells.

Measurement protocol

Diagram
sequenceDiagram
    participant Parent
    participant Child
    Parent->>Child: measure(constraints)
    Child-->>Parent: measured size
    Parent->>Child: place(x, y)

Atlas nodes now gain polymorphic phase methods. Text measurement is injected because Runtime does not provide surface text metrics:

fun interface TextMeasurer {
    fun measure(text: String, maxWidth: Int): IntSize
}

data class LayoutContext(val textMeasurer: TextMeasurer)

// Add these abstract members to AtlasNode.
abstract fun measure(constraints: Constraints, context: LayoutContext): IntSize
abstract fun paint(builder: FrameBuilder, parentOffset: IntPoint)

// Add this implementation inside TextNode.
override fun measure(
    constraints: Constraints,
    context: LayoutContext,
): IntSize = constraints.constrain(
    context.textMeasurer.measure(text, constraints.maxWidth)
).also { measured ->
    bounds = bounds.copy(size = measured)
    needsLayout = false
}

Every measure implementation must return a non-negative size satisfying its offered constraints. A simple vertical policy shared by RootNode and GroupNode through ContainerNode can be expressed as:

fun ContainerNode.measureVertical(
    constraints: Constraints,
    context: LayoutContext,
): IntSize {
    var y = 0
    var widest = 0

    for (child in children) {
        val remainingHeight = (constraints.maxHeight - y).coerceAtLeast(0)
        val childSize = child.measure(
            constraints = Constraints(
                minWidth = 0,
                maxWidth = constraints.maxWidth,
                minHeight = 0,
                maxHeight = remainingHeight,
            ),
            context = context,
        )
        val placedBounds = IntRect(
            origin = IntPoint(0, y),
            size = childSize,
        )
        if (child.bounds != placedBounds) {
            child.bounds = placedBounds
            child.needsPaint = true
        }
        y += childSize.height
        widest = maxOf(widest, childSize.width)
    }

    val measured = constraints.constrain(IntSize(widest, y))
    bounds = bounds.copy(size = measured)
    needsLayout = false
    return measured
}

RootNode and GroupNode delegate their measure overrides to measureVertical. The snippet combines measuring children and placing them for brevity. A richer toolkit may represent measured results separately so a parent can choose placement after all children are measured.

Paint into commands

class FrameBuilder(private val size: SurfaceSize) {
    private val commands = mutableListOf<SurfaceCommand>()

    fun add(command: SurfaceCommand) {
        commands += command
    }

    fun build(): SurfaceFrame =
        SurfaceFrame(size, commands.toList())
}

// Add this member inside TextNode.
override fun paint(builder: FrameBuilder, parentOffset: IntPoint) {
    val absolute = IntPoint(
        x = parentOffset.x + bounds.origin.x,
        y = parentOffset.y + bounds.origin.y,
    )
    builder.add(
        SurfaceCommand.DrawText(
            origin = SurfacePoint(absolute.x, absolute.y),
            text = text,
            color = color,
        )
    )
    needsPaint = false
}

Container painting recursively accumulates offsets. This introductory contract provides frame-level clipping through frame.size; nested clipping is deferred until Atlas adds an explicit clip command or clip parameter:

fun ContainerNode.paintChildren(
    builder: FrameBuilder,
    parentOffset: IntPoint,
) {
    val ownOffset = IntPoint(
        x = parentOffset.x + bounds.origin.x,
        y = parentOffset.y + bounds.origin.y,
    )
    children.forEach { child -> child.paint(builder, ownOffset) }
    needsPaint = false
}

RootNode and GroupNode delegate their paint overrides to paintChildren.

One render pass

fun render(
    root: RootNode,
    size: SurfaceSize,
    surface: Surface,
    layoutContext: LayoutContext,
) {
    if (root.needsLayout) {
        root.measure(
            constraints = Constraints(
                maxWidth = size.width,
                maxHeight = size.height,
            ),
            context = layoutContext,
        )
    }

    val frame = FrameBuilder(size)
    root.paint(frame, parentOffset = IntPoint(0, 0))
    surface.present(frame.build())
}

Because FrameBuilder starts empty, painting must still reproduce the complete logical frame. A paint-dirty flag can control cache reuse, but it cannot simply skip a clean subtree unless that subtree’s previous commands or rendered region are retained and copied. Cache keys must include geometry and clipping; moving a node or ancestor can change absolute commands even when leaf properties are unchanged.

Invalidation matrix

Diagram
flowchart TD
    Property{What changed?}
    Property -->|text or size policy| LayoutDirty[Measure + place + paint]
    Property -->|color only| PaintDirty[Reuse geometry + paint]
    Property -->|surface size| RootDirty[Relayout from root]
    Property -->|nothing visible| NoFrame[May avoid presentation]

Dirty flags classify work; they do not schedule it. onEndChanges covers composition-driven updates, while surface resize, focus visualization, and other non-composition changes must enqueue a render request on the host owner context. Resize atomically updates the current SurfaceSize, calls root.invalidateLayout(), and requests rendering; a clean-root resize test should prove geometry is recomputed.

The adapter may optimize later

The core produces a correct complete frame. An adapter can compare it with a previous frame, batch commands, cache resources, or translate it into a retained external API. Those optimizations must preserve the same observable result.

Diagram
flowchart LR
    CorrectFrame --> Adapter
    Adapter --> Full[full submission]
    Adapter --> Diff[incremental patch]
    Adapter --> Retained[external retained objects]

Visual checkpoint

Take a three-level tree and annotate every node with measured size, local position, and absolute position. Then convert each leaf into one surface command. Verify that no composition concept appears in the calculation.

Review questions

  1. Why are units defined by Atlas rather than Compose Runtime?
  2. Why can’t a fresh frame builder blindly skip paint-clean nodes?
  3. Which change can require rendering without recomposition?

Recap

Composition maintains intent.
Layout assigns geometry.
Painting creates surface vocabulary.
Presentation crosses the adapter boundary.
Correctness comes before incremental optimization.

Finished this lesson?

Your progress stays only in this browser.