Engineering

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.

MobileByteSensei 7 min read
Day 21 — Why fillMaxWidth() sometimes does nothing
androidcomposekotlinlayout

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 case this post is about

One card that refuses to fill its parent:

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

fillMaxWidth is right there and the card is as wide as its text. No warning, no error, and the same modifier works perfectly one screen over.

The reason is that fillMaxWidth means take my parent's maximum width, and a horizontally scrolling parent has no maximum — the axis is unbounded, so there is no number to take. This post follows that one card, because the instinct is to shout louder with an explicit width, and the useful move is to ask why that axis scrolls.

The symptom: a card that refuses to fill its parent

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 forcing a width doesn't fix it

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 mechanism: Constraints is four integers, not one

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:

  • Bounded — maxWidth is a real number. Normal case.
  • Unbounded — maxWidth = Infinity. Scrollable axes, and wrapContentSize in some configurations.
  • Exact — min == max. The child has no choice at all.
  • Unspecified — min = 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 is a function from an incoming range to an outgoing one, and writing them that way makes the infinity case stop being a surprise:

data class Range(val min: Int, val max: Int) {       // max == Int.MAX_VALUE means unbounded
    val bounded get() = max != Int.MAX_VALUE
    override fun toString() = "[$min..${if (bounded) "$max" else "inf"}]"
}

fun fillMax(incoming: Range, intrinsic: Int): Pair<Range, String> =
    if (incoming.bounded) {
        Range(incoming.max, incoming.max) to "took the bound"
    } else {
        // There is no number to fill TO. The implementation wraps content instead —
        // silently, because "no bound" is not an error condition.
        Range(intrinsic, intrinsic) to "no bound to fill; wrapped content"
    }

fun width(incoming: Range, dp: Int): Pair<Range, String> {
    val clamped = dp.coerceIn(incoming.min, incoming.max)
    val note = if (clamped != dp) "requested $dp, parent allows $incoming" else "exact"
    return Range(clamped, clamped) to note
}

The card in both parents, with the intrinsic width of its content at 180:

Column (bounded 1080):
  fillMaxWidth  [0..1080] -> [1080..1080]   took the bound

Row(horizontalScroll) (unbounded):
  fillMaxWidth  [0..inf]  -> [180..180]     no bound to fill; wrapped content
  width(1080)   [0..inf]  -> [1080..1080]   exact

Column (bounded 320), card asking for 1080:
  width(1080)   [0..320]  -> [320..320]     requested 1080, parent allows [0..320]

Three lines, three different outcomes from the same modifier, and the middle one is the bug this post is about. Note the last case too: width(1080) inside a 320dp parent does not produce a 1080dp card and does not complain — coerceIn is the whole of it. The parent decides the range; the child decides the value; a child asking for a value outside the range gets the nearest one in it.

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

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: negotiation instead of assignment

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.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS