Lesson 6

Emit nodes from composables

Define a Compose target and connect declarative Atlas components to retained Atlas nodes.

3 min readUpdated Jul 17, 2026

Learning goal

Define a Compose target and connect declarative Atlas components to retained Atlas nodes.

From call to node

Diagram
flowchart LR
    Call["Text(Hello)"] --> Compiler[Compose compiler transformation]
    Compiler --> CN[ComposeNode]
    CN -->|new call-site identity| Factory[create TextNode]
    CN -->|changed parameter| Update[record property update]
    Factory --> Applier
    Update --> Node[existing TextNode]

Mark the custom target

A target marker lets the Compose compiler distinguish Atlas composables from composables intended for another Applier family.

package atlas.component

import androidx.compose.runtime.ComposableTargetMarker

@Retention(AnnotationRetention.BINARY)
@ComposableTargetMarker(description = "Atlas UI")
@Target(
    AnnotationTarget.FILE,
    AnnotationTarget.FUNCTION,
    AnnotationTarget.PROPERTY_GETTER,
    AnnotationTarget.TYPE,
    AnnotationTarget.TYPE_PARAMETER,
)
annotation class AtlasComposable

Target inference covers callers of already-targeted composables, such as Greeting. Explicit @AtlasComposable is required on functions that call ComposeNode directly, abstract composables, subcomposition lambdas, and stored composable lambda types; it is also useful for making public API boundaries clear.

A leaf component

import androidx.compose.runtime.Composable
import androidx.compose.runtime.ComposeNode

private val DefaultTextColor = AtlasColor(0xFFFFFFFFu)

@Composable
@AtlasComposable
fun Text(
    value: String,
    color: AtlasColor = DefaultTextColor,
) {
    ComposeNode<TextNode, AtlasApplier>(
        factory = { TextNode(value, color) },
        update = {
            update(value) { text = it }
            update(color) { this.color = it }
        },
    )
}

Because the factory initializes both properties, update avoids redundantly assigning them during insertion. Updater.set is the alternative when a property must also be assigned during insertion.

The property setters on TextNode decide whether a change invalidates layout or paint. Composables describe values; nodes own retained mutation policy.

A container component

@Composable
@AtlasComposable
fun Group(
    content: @Composable @AtlasComposable () -> Unit,
) {
    ComposeNode<GroupNode, AtlasApplier>(
        factory = ::GroupNode,
        update = {},
        content = content,
    )
}

A container opens a node group so emitted descendants become its children through the Applier.

Diagram
flowchart TD
    GroupCall[Group composable] --> GroupNode
    GroupNode --> TextCall1[Text call site 1]
    GroupNode --> TextCall2[Text call site 2]
    TextCall1 --> TextNode1
    TextCall2 --> TextNode2

Public content type

Any host or component that stores Atlas content should preserve both annotations:

typealias AtlasContent = @Composable @AtlasComposable () -> Unit

This lets the compiler diagnose accidental calls between incompatible composable targets when it can determine the mismatch.

Never perform surface output here

Diagram
flowchart LR
    Composable -->|emit/update intent| Node
    Node -->|later| Layout
    Layout --> Paint
    Paint --> Surface
    Composable -. forbidden direct output .-> Surface

Composable functions can rerun, be skipped, or have a recomposition discarded. Direct calls to surface.present inside Text would make output depend on composition execution details rather than committed tree state.

First content tree

@Composable
@AtlasComposable
fun Greeting() {
    Group {
        Text("Hello")
        Text("new surface", color = AtlasColor(0xFF7DD3FCu))
    }
}

After composition and apply, the retained result is:

RootNode
└── GroupNode
    ├── TextNode("Hello")
    └── TextNode("new surface")

Visual checkpoint

Draw two paths from Text(value): one for first insertion and one for a later changed value. The first must pass through the factory and Applier; the second must end at the retained node’s property update.

Review questions

  1. Why does Text emit a node rather than a SurfaceCommand?
  2. When should an updater use update rather than set?
  3. What does the target marker protect?

Recap

@AtlasComposable identifies the Applier family.
ComposeNode connects a call site to a retained node.
Factories create; updaters change properties; the Applier changes structure.
Surface output still happens later.

Sources

Finished this lesson?

Your progress stays only in this browser.