A Modifier is a linked list, and that explains almost everything about it

Modifier looks like a fluent builder but is an ordered immutable linked list. Knowing its actual shape explains chaining, reuse, the modifier parameter convention, and why some combinations do nothing.

6 min read
androidcomposekotlinlayout

Day 19 — A Modifier is a linked list, and that explains almost everyt

Day 19 of 100. Yesterday's containers decide where children go. Modifiers decide everything else about a single element — size, padding, background, click handling — and they are a much simpler object than they look.

The symptom

A reusable card that ignores the caller:

@Composable
fun ProfileCard(user: User) {
    Card(modifier = Modifier.padding(16.dp)) { … }
}

// Caller wants it to fill the width. Nothing happens.
ProfileCard(user)     // no way to pass anything in

The caller can't size it, can't add a click, can't pad it differently. Every attempt ends in editing ProfileCard and adding another boolean.

Why the obvious fix fails

The obvious fix is parameters for the things callers ask for:

@Composable
fun ProfileCard(user: User, fillWidth: Boolean = false, extraPadding: Dp = 0.dp) { … }

Six months later there are nine of them, they interact, and the tenth request needs a tenth. The component is configurable in exactly the ways somebody once needed, and rigid in every other.

Compose's answer is one parameter that covers all of them at once — but it only works if you understand what the parameter is.

A Modifier chain is an ordered list of elements, each wrapping the next, applied outside-in

The actual mechanism

Modifier is an immutable, ordered linked list. Each call in a chain returns a new Modifier whose head is the new element and whose tail is everything before it:

Modifier
    .padding(16.dp)      // element 1
    .background(Blue)    // element 2
    .size(48.dp)         // element 3

Three elements, in that order, forever. Nothing mutates: Modifier.padding(16.dp) does not modify Modifier; it produces a new chain. That immutability is why a modifier can be hoisted into a val, shared between composables, and stored in a constant without any risk of one caller's chain leaking into another's.

private val CardShape = Modifier.clip(RoundedCornerShape(12.dp)).background(Surface)

// Safe to use in twenty places — each `.then` produces a fresh chain
Box(CardShape.size(64.dp))
Box(CardShape.fillMaxWidth())

Chaining is then under the hood, and Modifier itself is the empty element — the identity for the operation. That's why Modifier alone is a valid argument, and why a conditional modifier is written by picking an element or the empty one:

Modifier
    .fillMaxWidth()
    .then(if (selected) Modifier.border(2.dp, Blue) else Modifier)

Not a special API — just the identity element doing what identity elements do.

The convention that makes components composable

Every composable that emits UI should take a modifier parameter, and the convention is precise enough to be worth stating exactly:

@Composable
fun ProfileCard(
    user: User,                              // required params first
    modifier: Modifier = Modifier,           // FIRST optional param, named `modifier`
) {
    Card(modifier = modifier) { … }          // applied to the ROOT, first in the chain
}

Four rules, all load-bearing:

Named modifier. Tooling, lint and every reader expect it.

Defaults to Modifier. The empty chain, so callers who don't care pass nothing.

First optional parameter. So it can be passed positionally after the required ones without naming every other default.

Applied to the root, and applied first. This is the one that gets broken. If you write Card(modifier = Modifier.padding(8.dp).then(modifier)), you've silently forced padding the caller can't remove. The caller's modifier goes first; your own additions chain after it.

Get those right and ProfileCard needs no fillWidth parameter, no extraPadding, and no tenth boolean:

ProfileCard(user, Modifier.fillMaxWidth().clickable { open(user) })

Only one modifier parameter

A component takes exactly one modifier, for its root. When an inner element needs configuring, that's a signal the component should be split, or should take a slot:

// Wrong: two modifier parameters
@Composable
fun Row(modifier: Modifier, textModifier: Modifier)

// Right: the caller composes the inner element themselves
@Composable
fun Row(modifier: Modifier = Modifier, label: @Composable () -> Unit)

That's Day 13's slot argument arriving from the other direction — containers own layout, callers own content.

Modifier.Node, and why the old API is going away

Modifier elements used to be implemented as composed { } — a factory that ran a composable lambda to build state per usage. It worked, and it allocated on every recomposition and prevented the compiler from skipping.

The current API is Modifier.Node: a long-lived object attached to the layout node, created once and updated in place.

// Old: allocates per recomposition
fun Modifier.highlight(color: Color) = composed {
    val alpha by animateFloatAsState(if (color == Red) 1f else 0.5f)
    drawBehind { drawRect(color.copy(alpha = alpha)) }
}

// Current: one node, updated
fun Modifier.highlight(color: Color) = this then HighlightElement(color)

You mostly meet this as a consumer rather than an author — the built-in modifiers were migrated years ago, and the practical effect is that chains are cheaper than they used to be. It matters when reading older answers online: composed { } in a snippet is a reliable sign the snippet predates the current guidance. Writing your own nodes is Day 22.

What a modifier element actually is

Each element in the chain is one of a small number of kinds, and knowing which explains what a modifier can and cannot do:

  • Layout modifiers (padding, size, offset) participate in measurement — they wrap the child's measure call and change its constraints or reported size.
  • Draw modifiers (background, border, drawBehind) add drawing before or after the content.
  • Pointer input modifiers (clickable, draggable) attach gesture handling.
  • Semantics modifiers (contentDescription, testTag) add to the accessibility and test trees.

They live in one chain because a UI element's size, appearance and behaviour are all just decorations on the same node — and because a single ordered list is dramatically cheaper than four separate ones.

Scoped modifiers

Some modifiers only exist inside a particular container, enforced by the type system rather than by documentation:

Row {
    Text("a", Modifier.weight(1f))       // RowScope.weight — compiles
    Text("b", Modifier.align(Alignment.CenterVertically))
}

Box {
    Text("c", Modifier.weight(1f))       // does NOT compile: no weight in BoxScope
}

weight is an extension on RowScope/ColumnScope; align takes a different Alignment type in each scope. This is why copying a modifier chain between a Row and a Box sometimes fails to compile — the chain referenced a member that only that scope provides. The error is doing you a favour: weight in a Box has no meaning, since a Box has no main axis to divide.

How to prove it

The chain is inspectable at runtime, which makes the linked-list claim concrete rather than theoretical:

val m = Modifier.padding(8.dp).background(Color.Red).size(40.dp)
m.foldIn(0) { count, element -> Log.d("Mod", element.toString()); count + 1 }
    .also { Log.d("Mod", "elements: $it") }

foldIn walks the chain outside-in; foldOut walks it the other way. The log prints three elements in declaration order — that ordering is the subject of tomorrow's post, because it changes what you see on screen.

What this generalizes to

The pattern is decoration as data. Rather than a styling language or a property bag, a modifier is an ordered list of transformations, and the component's own behaviour is just what remains after they're applied. Middleware chains in web servers have the same shape, and gain the same property: composition is associative, so callers can build and share fragments without knowing what they'll be attached to.

Tomorrow, Day 20: why Modifier.padding(8.dp).background(Red) and Modifier.background(Red).padding(8.dp) produce visibly different results.


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