Let's Make Modifier Work: The Order Is the API

Modifier chains read like a list of properties and behave like a pipeline. Swap two calls and you get a different widget — and the version that looks right is sometimes right by accident, which is worse than being wrong.

Let's Make Modifier Work: The Order Is the API

A Modifier chain looks like a list of properties. It isn't. It's an ordered pipeline, and each call wraps everything after it.

That's the whole article, but it doesn't land as a sentence. It lands when you see two chains with identical calls produce different widgets.

This came out of a live stream where I built a small icon-button component and shipped a bug that was invisible because another modifier was covering for it. The code below is the real thing.

The example everybody starts with#

Kotlin
Box(
    Modifier
        .padding(16.dp)
        .background(Color.Red)
        .size(100.dp)
)
Kotlin
Box(
    Modifier
        .background(Color.Red)
        .padding(16.dp)
        .size(100.dp)
)

The first one pads first, then paints what's left. You get a red square with 16dp of nothing around it.

The second paints first, then insets the content. The red covers the padding too, so the box is visually 16dp bigger on every side.

Two calls, same names, different output. No warning, no lint, nothing.

The mental model that makes this predictable: a modifier applies to everything after it in the chain. padding shrinks the space available to the modifiers that follow. background fills the space it was handed. Reading top to bottom is reading outside to inside.

Now the one from my own code#

Here's the component I was building on stream — an icon button with a configurable shape:

Kotlin
IconButton(
    modifier = modifier
        .clip(iconShape)
        .border(
            border = BorderStroke(
                width = borderWidth,
                color = borderColor
            )
        )
        .background(color = backgroundColor)
        .size(size = iconSize),
    onClick = onClickItem,
    shape = iconShape,
    enabled = isEnabled
) {
    Icon(imageVector = imageVector, contentDescription = contentDescription)
}

iconShape defaults to CircleShape. The preview rendered a circle. Ship it.

Except look at the signature of the thing I called:

Kotlin
fun Modifier.border(border: BorderStroke, shape: Shape = RectangleShape): Modifier

shape defaults to RectangleShape. I never passed one. So I asked for a rectangular border on a component whose entire purpose is being round.

Why it looked fine anyway#

Modifier.clip(shape) is graphicsLayer(shape = shape, clip = true). It clips everything drawn after it in the chain.

My chain puts .clip(iconShape) first. So the rectangular border was drawn inside a circular clip, and the parts of it that stuck out past the circle were cut away. What survived looked close enough to a ring that I didn't notice — especially at Dp.Hairline, which is the thinnest line the device can draw.

The output was right. The code was wrong. Those are different things, and the gap between them is where the expensive bugs live.

Why "right by accident" is worse than "wrong"#

A wrong result gets fixed. An accidentally-right result gets built on.

Three ways this one breaks later, none of which look related to borders:

Someone reorders the chain. .border() before .clip() is a completely reasonable-looking edit. Now the rectangle isn't clipped and the border is visibly square.

Someone raises borderWidth. At 2.dp the surviving fragments of a clipped rectangle stop reading as a ring. The reported bug is "the border looks broken on the new design", and nobody thinks to look at a clip call three lines up.

Someone passes a different iconShape. A RoundedCornerShape(8.dp) chip clips a lot less of the rectangle away, so the border suddenly looks heavier than the circular variant. Same code, different component, inconsistent result.

In every case the person debugging is looking at border and the cause is clip.

The fix is one argument#

Kotlin
.border(
    border = BorderStroke(width = borderWidth, color = borderColor),
    shape = iconShape,
)

Now the border is the shape I actually want, and it stays correct if someone moves .clip(), deletes it, or changes the shape.

The clip still earns its place — it keeps the ripple and the background inside the shape — but it's no longer silently compensating for a default I didn't choose.

This is a good habit beyond border. Several Compose modifiers take a shape that defaults to RectangleShapebackground and shadow among them. If your component has a shape parameter, pass it to every modifier that accepts one, rather than relying on a clip upstream to sort it out.

How to check your own chains#

Set the width to something absurd. A Dp.Hairline border hides everything; a 6.dp border hides nothing:

Kotlin
@Preview
@Composable
private fun BorderShapeCheck() {
    Row {
        // Relies on clip
        Box(
            Modifier
                .clip(CircleShape)
                .border(BorderStroke(6.dp, Color.Red))
                .size(64.dp)
        )
        // Says what it means
        Box(
            Modifier
                .border(BorderStroke(6.dp, Color.Green), CircleShape)
                .size(64.dp)
        )
    }
}

Run it and you'll see which one is a ring and which one is four fragments. That's a thirty-second check and it's the only way to be sure — this is exactly the class of thing that's easier to verify than to reason about.

What I'd change about the rest of that chain#

.size() last is doing less than it looks like. Layout modifiers affect the constraints passed inward, and draw modifiers fill the node they're attached to. Putting size at the end still produces a 40dp node, but reading the chain gives the impression that clip/border/background happen to something before it's been sized. Put .size() first and the chain reads in the order things conceptually happen.

IconButton already takes shape and enabled. I pass both, and clip, and border, and background. Some of that is duplicating what the Material component does for me. Worth deleting what's redundant rather than layering on top of it.

contentDescription defaults to null. Fine for decoration, wrong for a button someone taps. A default that produces an inaccessible control is a default that should not exist.

Check yourself#

Check yourself

1What does Modifier.padding(16.dp).background(Color.Red) render?

2What is the default shape of Modifier.border(border)?

3Why did a rectangular border still look circular in the component above?

4Why is "right by accident" worse than a visible bug?

5Fastest way to check whether a border is actually the shape you want?

Answers are client-side only — nothing is recorded.

Wrapping up#

Three things, and only the first is about Modifier:

Order is part of the API. A chain isn't a property bag. Each call wraps what follows, so reading top to bottom is reading outside to inside.

Defaults you didn't choose are still choices you made. RectangleShape got into my component because I didn't type anything, which is exactly how these arrive.

Code that works for the wrong reason is a bug with a delay on it. It doesn't get fixed, because nothing looks broken — it gets built on, and it surfaces later wearing a disguise.

Until then, let's make this work.

Keep reading

More breakdowns

Kotlin & Core Concepts

Let's Make a Callback Suspend: suspendCancellableCoroutine, Line by Line

Wrapping a callback API in a suspend function is four lines. The fifth line — invokeOnCancellation — is the one that decides whether cancelling actually stops anything, and it's the one people leave out.

· 4 min read
Kotlin & Core Concepts

Let's Make SupervisorJob Work: Independent Failure Without Breaking the Tree

One child throws and the whole scope dies. SupervisorJob fixes that — but you probably already have one and didn't know, and the way most people reach for it does nothing except break structured concurrency.

· 4 min read
Android Architecture

Let's Make WorkManager Work: Constraints, Unique Work, and a Retry That Stops

Forty lines that run a job surviving the app being killed. Three decisions inside are worth stealing, and one line is a bug you can ship without noticing — because CancellationException is an Exception.

· 4 min read