Why fillMaxWidth() sometimes does nothing

Compose sizing confuses people because Constraints is a min/max range rather than a value. Each sizing modifier transforms the range in a specific way, and reading them that way makes layout deterministic.

5 min read
androidcomposekotlinlayout

Day 21 — Why fillMaxWidth() sometimes does nothing

Day 21 of 100. Day 18 established that constraints travel down and sizes travel up. Today: what a constraint actually contains, and why that one detail resolves most sizing bugs.

The symptom

A card that refuses to fill its parent:

Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
    Card(modifier = Modifier.fillMaxWidth()) {      // does nothing
        Text("why am I not wide")
    }
}

No crash, no warning. The card sizes to its content and fillMaxWidth() is silently inert. Adding a second fillMaxWidth() doesn't help, and neither does fillMaxSize().

Why the obvious fix fails

The obvious fix is to force a width:

Card(modifier = Modifier.width(400.dp)) { … }

It works on the device you're testing on. It's wrong on every other screen size, and it's wrong in landscape, and it will be wrong again when someone adds a tablet layout.

Worse, it hides the actual problem, which is that the parent offered infinite width. A hardcoded number papers over a structural decision made two composables up.

Constraints are a min/max range; each sizing modifier is a transformation on that range

The actual mechanism

Constraints is four integers, not one:

Constraints(minWidth = 0, maxWidth = 1080, minHeight = 0, maxHeight = 1920)

A child must choose a size within that range. The parent decides the range; the child decides the value. Four shapes matter, and they have names worth using:

  • BoundedmaxWidth is a real number. Normal case.
  • UnboundedmaxWidth = Infinity. Scrollable axes, and wrapContentSize in some configurations.
  • Exactmin == max. The child has no choice at all.
  • Unspecifiedmin = 0, max = Infinity. "Be whatever you want."

fillMaxWidth() means "set my width to maxWidth". When maxWidth is infinite, there is no number to take, so the implementation falls back to wrapping content. That's the whole bug: fillMaxWidth fills the bound, and a scrolling axis has no bound.

The fix is to bound the axis, not to shout louder:

Row(modifier = Modifier.horizontalScroll(state)) {
    Card(modifier = Modifier.width(screenWidth)) { … }    // an explicit bound
}

or — usually better — to ask whether that axis should scroll at all.

Every sizing modifier as a range transformation

Once constraints are a range, each modifier is a function on it. This table is the whole sizing API:

Modifier Transformation
size(100.dp) min = max = 100 (clamped to the incoming range)
requiredSize(100.dp) min = max = 100, ignoring the incoming range
width(100.dp) same, width axis only
fillMaxWidth(f) min = max = maxWidth * f
wrapContentWidth() min = 0, max unchanged — child may be smaller
widthIn(min, max) clamps the incoming range
defaultMinSize(w, h) raises min only if it is currently 0
weight(f) max = the child's share of the remainder

Two rows there explain most surprises.

size is clamped; requiredSize is not. A Box(Modifier.size(100.dp)) containing Box(Modifier.size(200.dp)) renders at 100dp, because the inner box's request is clamped to the parent's exact constraint. requiredSize(200.dp) overflows instead — the child wins and draws outside its parent. Neither is a bug; they are the two answers to "who decides", and the name says which.

defaultMinSize only applies when nothing else did. It's how Material components get their 48dp touch targets without overriding an explicit caller size. If you set a size, it stays; if you don't, you get the accessible default.

Reading the range yourself

BoxWithConstraints exposes the incoming constraints to a composable, which is the direct way to answer "what was I actually offered":

BoxWithConstraints {
    Text("max width: $maxWidth, min: $minWidth, bounded: ${constraints.hasBoundedWidth}")
    if (maxWidth < 600.dp) CompactLayout() else WideLayout()
}

Two caveats worth knowing before reaching for it. It's a SubcomposeLayout, so its content is composed during the layout phase rather than composition — the extra pass Day 18 mentioned. And it answers "how much room do I have", which is not always the same question as "what kind of device is this". For the second, window size classes are the right tool, and that's Day 29.

Intrinsics, revisited precisely

IntrinsicSize.Min and .Max ask a child "how small could you be while still rendering correctly" and "how large would you like to be" without measuring it for real:

Row(Modifier.height(IntrinsicSize.Min)) {
    Text("short")
    Divider(Modifier.fillMaxHeight().width(1.dp))   // now matches the tallest sibling
    Text("a much\nlonger label")
}

Without the intrinsic, fillMaxHeight() on the divider has an unbounded height constraint to fill and collapses. With it, the row resolves its height first, so the divider has a real number to fill.

The cost: an intrinsic query walks the subtree separately from the measure pass. Inside a LazyColumn item that happens per visible item per frame. Acceptable for a divider in a settings row; not something to put in a hot list.

The two-modifier trick for "fill, but no bigger"

A recurring requirement: an element should fill the available width, but never exceed a readable maximum. Written as two range transformations it's one line:

Modifier.fillMaxWidth().widthIn(max = 640.dp)

fillMaxWidth sets min = max = parentWidth; widthIn(max = 640) then clamps that to 640. On a phone nothing changes; on a tablet the content stops widening — the standard fix for text lines that grow past comfortable reading width.

The centred version adds an alignment on the parent rather than a third sizing modifier:

Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
    Content(Modifier.widthIn(max = 640.dp))
}

Reading both as range transformations is what makes the combination predictable rather than something to try and see.

How to prove it

Print the constraints at the point of confusion. It converts a guess into a fact in about thirty seconds:

Layout(content = { YourComposable() }) { measurables, constraints ->
    Log.d("Constraints", "$constraints  boundedW=${constraints.hasBoundedWidth}")
    val p = measurables.first().measure(constraints)
    layout(p.width, p.height) { p.placeRelative(0, 0) }
}

Drop it around the misbehaving element. If hasBoundedWidth is false, fillMaxWidth was never going to work and no amount of modifier reordering will change that — the fix belongs in the ancestor that removed the bound.

Layout Inspector shows the resulting sizes, which tells you what happened. The log tells you why, and for constraint problems the why is the part you need.

What this generalizes to

The generalisable idea is negotiation instead of assignment. A parent doesn't assign a size; it offers a range and the child picks. That's what makes the same component work in a fixed slot, a scrolling row and a wrap-content box without conditional code.

CSS arrived at the same place from the other direction: width: 100% is meaningless inside a shrink-to-fit container for exactly the reason fillMaxWidth() is inert inside a scrolling row — the percentage has nothing to be a percentage of. Recognising the shape once means you diagnose it in any layout system.

Tomorrow, Day 22: writing your own modifiers with Modifier.Node, and when a custom Layout is the honest answer instead.


Day 21 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Constraints and modifier order.