Write a custom modifier only after you've ruled out a function

Most custom modifiers should be plain extension functions returning a chain. Modifier.Node is for elements that need their own state or draw/measure participation, and it replaced composed { } for good reasons.

6 min read
androidcomposekotlinlayout

Day 22 — Write a custom modifier only after you've ruled out a functi

Day 22 of 100. Days 19 and 20 covered using modifiers. Today: writing them, and the more useful skill of recognising when you shouldn't.

The symptom

The same six-line chain in fourteen files:

Modifier
    .clip(RoundedCornerShape(12.dp))
    .background(MaterialTheme.colorScheme.surface)
    .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp))
    .padding(16.dp)

Change the corner radius and you change it fourteen times, and miss two.

Why the obvious fix fails

The obvious fix is to reach for composed { }, because that's what most search results from a few years ago show:

fun Modifier.card() = composed {
    val scheme = MaterialTheme.colorScheme
    clip(RoundedCornerShape(12.dp))
        .background(scheme.surface)
        .border(1.dp, scheme.outline, RoundedCornerShape(12.dp))
        .padding(16.dp)
}

This works and it is the wrong tool twice over.

composed { } is deprecated. It defeats modifier reuse: a composed modifier can't be hoisted into a constant and shared, because its factory has to run inside composition for each usage. It allocates on every recomposition, and it blocks the compiler optimisation that would otherwise let the chain be compared by equality.

But the deeper problem is that this modifier doesn't need to be a modifier at all.

Four ways to factor a modifier — and the two questions that pick between them

The actual mechanism: three options, in order of preference

1. A plain extension function. If your modifier is a fixed chain of existing modifiers, it is a function. No composed, no node, no ceremony:

fun Modifier.card(shape: Shape = RoundedCornerShape(12.dp)) = this
    .clip(shape)
    .background(CardSurface)
    .border(1.dp, CardOutline, shape)
    .padding(16.dp)

this at the start is the important part — it chains onto the caller's modifier rather than discarding it. Note this version takes its colours as constants; if it needs MaterialTheme, see option 2.

2. A composable-reading value, passed in. When the chain needs MaterialTheme or another CompositionLocal, the temptation is composed { }. Take a parameter instead:

fun Modifier.card(colors: CardColors, shape: Shape = RoundedCornerShape(12.dp)) = this
    .clip(shape)
    .background(colors.surface)
    …

// at the call site, where composition context already exists
Box(Modifier.card(CardDefaults.colors()))

The modifier stays a pure function, stays hoistable, and the theme read happens where it always should — in the composable.

3. Modifier.Node. For elements that genuinely need to participate in draw, layout or pointer input, or hold their own state across recompositions.

// The public API — a pure, comparable data holder
private data class ShakeElement(val offset: Float) : ModifierNodeElement<ShakeNode>() {
    override fun create() = ShakeNode(offset)
    override fun update(node: ShakeNode) { node.offset = offset }
}

private class ShakeNode(var offset: Float) : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() {
        translate(left = offset) { this@draw.drawContent() }
    }
}

fun Modifier.shake(offset: Float) = this then ShakeElement(offset)

Three pieces, each with one job. The Element is a data class so Compose can compare old and new by equality — unchanged means no work. create() runs once when the node attaches; update() runs when the element changes, mutating the existing node in place rather than allocating a new one. The node itself is a long-lived object with the same lifecycle as the layout node it's attached to.

That in-place update is the entire performance argument over composed { }: one allocation for the element, zero for the node, and a cheap equality check to skip both.

Which node interface

You mix in the capability you need, and only that:

Interface For
DrawModifierNode drawing before/after/around content
LayoutModifierNode changing measurement or placement
PointerInputModifierNode gestures
SemanticsModifierNode accessibility and test properties
GlobalPositionAwareModifierNode reacting to position in the window
DelegatingNode composing several of the above

A node implementing LayoutModifierNode overrides measure, receives the incoming constraints, and returns a placement — the same Constraints-in, size-out contract from Day 18, at the modifier level rather than the container level:

private class MinWidthNode(var min: Dp) : LayoutModifierNode, Modifier.Node() {
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val minPx = min.roundToPx()
        val p = measurable.measure(constraints.copy(minWidth = maxOf(constraints.minWidth, minPx)))
        return layout(p.width, p.height) { p.placeRelative(0, 0) }
    }
}

When it should be a composable instead

The honest test: does it emit UI, or decorate something that does?

A modifier decorates. If you find yourself wanting to add a child — a badge, a label, an icon — you want a composable, and probably a slot:

// Not a modifier: this emits an element
@Composable
fun Badged(badge: @Composable () -> Unit, content: @Composable () -> Unit) {
    Box { content(); Box(Modifier.align(Alignment.TopEnd)) { badge() } }
}

Trying to express that as a modifier leads to drawWithContent re-implementations of text layout, which is a long road to a worse result.

How to prove it

The reuse property is the easy check, and it's the one composed { } fails:

private val CardModifier = Modifier.card()      // compiles only if `card()` is pure

@Test fun stable() {
    assertEquals(Modifier.shake(1f), Modifier.shake(1f))   // data class equality
}

If your modifier can be hoisted into a top-level val, it's a pure function and Compose can skip work when it hasn't changed. If it can't, you've either used composed { } or read composition state inside it — both worth fixing.

For nodes, the allocation claim is checkable with the Layout Inspector's recomposition counts plus a log in create() versus update(). create should fire once per attach; update should fire only when the parameters change. If create fires repeatedly, the element isn't comparing equal — usually because it holds a lambda that's freshly allocated each composition.

What this generalizes to

The pattern is separating the description from the instance. ShakeElement is a cheap, comparable value describing what you want; ShakeNode is the expensive long-lived thing that does it. The framework diffs the descriptions and mutates the instances.

That is the same split as @Composable functions versus the composition, and as React elements versus fibers. Once you see it, create/update stops looking like boilerplate and starts looking like the two halves of a diff.

A checklist before you write one

Four questions, in order. The first "yes" is your answer.

  1. Is it a fixed chain of existing modifiers? → extension function.
  2. Does it need theme or other composition values? → extension function taking those values as parameters.
  3. Does it emit or wrap a child element? → composable with a slot, not a modifier.
  4. Does it need to draw, measure, handle pointers, or hold state across recompositions?Modifier.Node.

In a typical app codebase the counts come out roughly ten of the first, two of the second, a few of the third, and zero or one of the fourth. Modifier.Node is a real tool with a real purpose, and it is not the common case — worth saying because the amount written about it implies otherwise.

The reason the ordering matters is that each step down costs something permanent. An extension function is readable by anyone; a node requires a reader to know three types and their lifecycle. Paying that once for a genuine drawing or measurement need is fine. Paying it for a chain of four built-in modifiers is a cost with no matching benefit, and it is the most common way custom-modifier code goes wrong.

Tomorrow, Day 23: LazyColumn — what "lazy" actually buys you, and the key parameter whose absence causes bugs that look like anything but a missing key.


Day 22 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Custom modifiers.