Lesson 5

Teach Compose with an Applier

Implement the structural bridge through which Compose Runtime maintains the Atlas node tree.

3 min readUpdated Jul 17, 2026

Learning goal

Implement the structural bridge through which Compose Runtime maintains the Atlas node tree.

The bridge

Diagram
sequenceDiagram
    participant Apply as Runtime apply phase
    participant Applier as AtlasApplier
    participant Current as current ContainerNode

    Apply->>Applier: insertTopDown(0, group) [no-op]
    Apply->>Applier: down(group)
    Applier->>Applier: current = group
    Apply->>Applier: insertTopDown(0, text) [no-op]
    Apply->>Applier: down(text)
    Apply->>Applier: apply TextNode property updates
    Apply->>Applier: up()
    Apply->>Applier: insertBottomUp(0, text)
    Applier->>Current: attach text to current group
    Apply->>Applier: up()
    Apply->>Applier: insertBottomUp(0, group)

An Applier<N> navigates and structurally edits a tree of N. Property updates are scheduled by ComposeNode updaters, which Lesson 06 adds.

Bottom-up implementation

package atlas.runtime

import androidx.compose.runtime.AbstractApplier
import atlas.node.AtlasNode
import atlas.node.ContainerNode
import atlas.node.RootNode

class AtlasApplier(
    private val rootNode: RootNode,
    private val onTreeChanged: () -> Unit,
    private val onSubtreeRemoving: (AtlasNode) -> Unit = {},
) : AbstractApplier<AtlasNode>(rootNode) {

    private fun currentContainer(): ContainerNode =
        current as? ContainerNode
            ?: error("Cannot insert children into ${current::class.simpleName}")

    override fun insertTopDown(index: Int, instance: AtlasNode) = Unit

    override fun insertBottomUp(index: Int, instance: AtlasNode) {
        val parent = currentContainer()
        require(instance !is RootNode) { "The root cannot become a child" }
        require(instance.parent == null) { "Node is already attached" }

        var ancestor: AtlasNode? = parent
        while (ancestor != null) {
            require(ancestor !== instance) { "Insertion would create a cycle" }
            ancestor = ancestor.parent
        }

        parent.mutableChildren.add(index, instance)
        instance.parent = parent
        parent.invalidateLayout()
    }

    override fun remove(index: Int, count: Int) {
        val parent = currentContainer()
        repeat(count) {
            val removed = parent.mutableChildren[index]
            onSubtreeRemoving(removed)
            parent.mutableChildren.removeAt(index).parent = null
        }
        parent.invalidateLayout()
    }

    override fun move(from: Int, to: Int, count: Int) {
        val parent = currentContainer()
        parent.mutableChildren.move(from, to, count)
        parent.invalidateLayout()
    }

    override fun onClear() {
        rootNode.mutableChildren.forEach {
            onSubtreeRemoving(it)
            it.parent = null
        }
        rootNode.mutableChildren.clear()
        rootNode.invalidateLayout()
    }

    override fun onEndChanges() {
        onTreeChanged()
    }
}

AbstractApplier supplies root, current, down, up, clear, and protected list helpers such as MutableList<T>.move.

Choose exactly one insertion direction

Diagram
flowchart LR
    TD[Top-down<br/>parent attaches before descendants]
    BU[Bottom-up<br/>descendants attach before parent]
    Choice{Choose for this tree}
    Choice --> TD
    Choice --> BU
    TD -. never both .- BU

Atlas uses bottom-up insertion, so insertTopDown is intentionally empty. Attaching in both callbacks would duplicate nodes.

The move rule that deserves a test

Compose defines to relative to the list before removal. For this initial list:

[A, B, C, D, E]
 move(from = 1, to = 4, count = 2)

The range [B, C] is moved to the pre-move boundary at index 4:

[A, D, B, C, E]

Do not replace the protected move helper with an untested remove/add loop.

Test structural behavior directly

@Test
fun insertsAndDetachesChildren() {
    val root = RootNode()
    val group = GroupNode()
    val applier = AtlasApplier(root, onTreeChanged = {})

    applier.insertBottomUp(0, group)
    assertEquals(listOf(group), root.children)
    assertSame(root, group.parent)

    applier.remove(0, 1)
    assertTrue(root.children.isEmpty())
    assertNull(group.parent)
}

The full test suite should independently cover insertion order, removing ranges, forward and backward moves, clearing, duplicate attachment, ancestor cycles, attempts to attach the root, and attempts to insert into a leaf. Also cover down/up, verify that clear() resets current to root, and use a real Composition test for onBeginChangesonEndChanges batching.

Diagram
stateDiagram-v2
    [*] --> Root
    Root --> Group: down(group)
    Group --> Nested: down(nested)
    Nested --> Group: up()
    Group --> Root: up()

current is the parent affected by insert/remove/move. It is not necessarily the root.

Visual checkpoint

Use five sticky notes labeled A–E. Perform forward and backward range moves while treating to as a pre-removal boundary. Then compare your results with the AbstractApplier.move implementation.

Review questions

  1. Why is an Applier not a renderer?
  2. What goes wrong if both insertion callbacks attach nodes?
  3. Which object is modified by remove: root or current?

Recap

Applier navigates and edits structure.
Updater operations edit properties.
Tree policy remains Atlas's responsibility.
onEndChanges is a useful boundary for requesting rendering.

Source

Finished this lesson?

Your progress stays only in this browser.