Lesson 9
Add layout and rendering
Turn retained intent into geometry and then into a complete `SurfaceFrame` that is not mutated after construction.
Learning goal
Turn retained intent into geometry and then into a complete SurfaceFrame that is not mutated after construction.
Separate the phases
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
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
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.
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
- Why are units defined by Atlas rather than Compose Runtime?
- Why can’t a fresh frame builder blindly skip paint-clean nodes?
- 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.