Lesson 4

Build a retained node tree

Define the objects that survive recomposition and carry UI intent into layout and rendering.

4 min readUpdated Jul 17, 2026

Learning goal

Define the objects that survive recomposition and carry UI intent into layout and rendering.

Tree versus frame

Diagram
flowchart LR
    subgraph Retained[Retained across frames]
      Root --> Group
      Group --> Title["TextNode: Counter"]
      Group --> Value["TextNode: Count 0"]
    end
    Retained -->|layout + paint| Frame1[Frame 1]
    Retained -->|property update| Changed["Value.text = Count 1"]
    Changed -->|layout + paint| Frame2[Frame 2]

Nodes hold identity and current properties. Frames are temporary output snapshots.

Geometry types

package atlas.node

import atlas.surface.AtlasColor

data class IntSize(val width: Int, val height: Int) {
    init {
        require(width >= 0)
        require(height >= 0)
    }
}
data class IntPoint(val x: Int, val y: Int)
data class IntRect(val origin: IntPoint, val size: IntSize)

Node ownership

abstract class AtlasNode {
    var parent: ContainerNode? = null
        internal set

    var bounds: IntRect = IntRect(
        origin = IntPoint(0, 0),
        size = IntSize(0, 0),
    )
        internal set

    var needsLayout: Boolean = true
        internal set

    var needsPaint: Boolean = true
        internal set

    fun invalidateLayout() {
        val wasClean = !needsLayout
        needsLayout = true
        needsPaint = true
        if (wasClean) parent?.invalidateLayout()
    }

    fun invalidatePaint() {
        needsPaint = true
    }
}

abstract class ContainerNode : AtlasNode() {
    internal val mutableChildren: MutableList<AtlasNode> = mutableListOf()
    val children: List<AtlasNode>
        get() = mutableChildren.toList()
}

class RootNode : ContainerNode()
class GroupNode : ContainerNode()

class TextNode(
    text: String,
    color: AtlasColor,
) : AtlasNode() {
    var text: String = text
        set(value) {
            if (field == value) return
            field = value
            invalidateLayout()
        }

    var color: AtlasColor = color
        set(value) {
            if (field == value) return
            field = value
            invalidatePaint()
        }
}

TextNode assumes color does not affect measurement. If a style can change metrics, that property must invalidate layout instead.

Invariants

Diagram
flowchart TD
    Attach[Attach child] --> U{Already has parent?}
    U -->|yes| Reject[Reject duplicate attachment]
    U -->|no| Cycle{Would create cycle?}
    Cycle -->|yes| Reject
    Cycle -->|no| Link[Set parent + insert once]
    Link --> Dirty[Invalidate layout]

A useful tree contract requires:

  1. the root has no parent;
  2. every non-root node has at most one parent;
  3. a parent contains a child exactly once;
  4. the graph has no cycles;
  5. child order is meaningful;
  6. detach clears the parent link;
  7. structural changes invalidate affected layout.

Runtime relies on the Applier to preserve these rules. Runtime does not inspect your node classes to enforce them. Returning a read-only snapshot prevents consumers from casting the public view back to the mutable backing list; the internal mutable list remains available to the runtime bridge.

Dirty propagation

Diagram
flowchart BT
    Leaf[TextNode changed] --> Parent[Group needs layout]
    Parent --> Root[Root needs layout]
    PaintLeaf[Color changed<br/>paint-dirty leaf] -. no layout propagation .-> Stop[reuse geometry]

Invalidation is toolkit policy, not a Compose Runtime feature. Runtime decides when node properties are updated. Atlas decides what those updates imply for layout and paint. Here, needsPaint is a per-node classification rather than a root scheduler signal: composition changes request rendering at Applier.onEndChanges, while resize, focus, and other external mutations must request a frame separately.

Dirty flags must be cleared after a successful phase:

internal fun AtlasNode.markLayoutComplete() {
    needsLayout = false
}

internal fun AtlasNode.markPaintComplete() {
    needsPaint = false
}

A production implementation usually keeps those operations internal to the layout and renderer so callers cannot falsely mark work complete.

Why nodes are mutable

Composition produces incremental changes. Updating a retained node in place preserves:

  • identity used by focus or interaction state;
  • cached measurement;
  • adapter resources associated with a node;
  • the difference between a property update and structural replacement.

The public component API can remain declarative even though the retained implementation is mutable.

Visual checkpoint

Draw a root with two groups and three text leaves. Mark one leaf as paint-dirty and another as layout-dirty. Draw the propagation paths. Paint-only invalidation should not automatically make ancestors require measurement.

Review questions

  1. Who owns node identity: the frame or the retained tree?
  2. Why does a text change generally invalidate layout?
  3. Which layer enforces single-parent ownership?

Recap

Nodes retain identity and current properties.
Containers retain ordered children.
Dirty flags express Atlas policy.
Tree invariants prepare the node model for an Applier.

Finished this lesson?

Your progress stays only in this browser.