Lesson 5
Teach Compose with an Applier
Implement the structural bridge through which Compose Runtime maintains the Atlas node tree.
Learning goal
Implement the structural bridge through which Compose Runtime maintains the Atlas node tree.
The bridge
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
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 .- BUAtlas 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 onBeginChanges → onEndChanges batching.
Navigation mental model
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
- Why is an
Appliernot a renderer? - What goes wrong if both insertion callbacks attach nodes?
- Which object is modified by
remove:rootorcurrent?
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.