Constraints in Jetpack Compose Explained

Quick answer: a Compose parent measures each child by passing minimum and maximum width and height constraints. The child chooses a size inside those bounds and reports it back. Modifiers such as size, fillMaxWidth, and sizeIn transform the constraints before the child is measured, so a composable never chooses its final size in isolation.

If a layout looks too wide, collapses to an unexpected height, or throws an exception inside a scroll container, inspect the constraints reaching that node. Once you can reason about those bounds, most Compose sizing behavior becomes predictable.

What a Compose constraint contains

Constraints describes four bounds:

BoundMeaning
minWidthThe smallest width the child may report
maxWidthThe largest width the child may report
minHeightThe smallest height the child may report
maxHeightThe largest height the child may report

The bounds are represented in pixels during measurement, while layout modifiers in application code usually use Dp. A child must resolve a width between minWidth and maxWidth, and a height between minHeight and maxHeight.

There are three useful cases:

  • Bounded: minimum and maximum values define a finite range.
  • Unbounded: the maximum in one direction is effectively infinite, so the child can choose its content size in that direction.
  • Exact: minimum and maximum are equal, forcing one size.

The official Constraints API reference documents these values. You rarely construct constraints in ordinary UI code; parents and measurement modifiers create and transform them for you.

How measurement works

During the layout phase, Compose follows a parent-to-child measurement flow:

  1. A parent receives constraints from its own parent.
  2. The parent measures each child with constraints it chooses.
  3. Each child measures its children, if it has any, and resolves its own size.
  4. The child reports its measured size to the parent.
  5. The parent chooses its size and places the measured children.

This is part of the layout phase described in Compose UI phases. The constraints and modifier order guide shows the same process in detail.

The important direction is:

constraints down  ->  children are measured  ->  sizes up  ->  children are placed

A parent does not blindly give every child its own full size. For example, a Row may measure one child, reduce the remaining width, and then measure another child with the remainder. A Column may measure children independently and derive its height from their results.

Why fillMaxWidth() means “fill the parent”

fillMaxWidth() uses the maximum width in the incoming constraints. It does not mean the physical screen width:

@Composable
fun AccountHeader() {
    Column(
        modifier = Modifier
            .padding(horizontal = 24.dp)
            .fillMaxWidth(),
    ) {
        Text("Account")
    }
}

If the parent is 360dp wide and the horizontal padding consumes 48dp, the Column can fill the remaining 312dp. If the same composable is placed in a dialog, a list item, or a tablet pane, its maximum width changes with that parent.

This is why adding fillMaxWidth() to a deeply nested child can produce a result that looks smaller than the device. The modifier is responding correctly to the constraints it received. The practical sizing guide covers how fillMaxWidth(), wrapContentSize(), weight(), and size() work together.

size, requiredSize, and sizeIn

These modifiers express different kinds of requests.

size() respects the parent

size(120.dp) asks for a preferred 120dp square. If the parent allows less space, Compose adapts the request to remain within the incoming constraints:

Box(
    modifier = Modifier
        .size(120.dp)
        .background(MaterialTheme.colorScheme.primary),
)

The modifier can make the bounds exact when the requested size is valid, but it cannot make a child larger than a finite maximum supplied by its parent.

requiredSize() deliberately overrides constraints

Use requiredSize() only when the child really must use that exact size:

Box(
    modifier = Modifier
        .requiredSize(120.dp)
        .background(MaterialTheme.colorScheme.tertiary),
)

requiredSize() replaces the incoming size constraints with exact bounds. If the result does not fit, the parent can still receive a coerced size and the child may be positioned in the available space. This is useful for specialized UI, but it is usually the wrong default for responsive screens.

sizeIn() expresses a range

Use sizeIn() when a component needs a minimum or maximum rather than one fixed size:

Text(
    text = "A message that can grow but should remain readable",
    modifier = Modifier.sizeIn(
        minWidth = 120.dp,
        maxWidth = 320.dp,
        minHeight = 48.dp,
    ),
)

sizeIn() adapts the incoming constraints to the requested range. The parent’s bounds still matter: a maximum supplied by the parent can limit the maximum requested by the modifier.

Modifier order changes the constraints

Modifiers wrap the nodes that follow them. Consequently, a size or padding modifier can change what the next modifier and the content see:

Text(
    text = "Hello",
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
        .background(MaterialTheme.colorScheme.surfaceVariant),
)

Here, the outer fillMaxWidth() participates in the parent’s available width, while the inner content receives space after padding. Reordering the chain changes which area is filled or painted. This is not just a visual detail; it changes measurement and hit regions too. See Why Modifier Order Matters for before-and-after examples.

When debugging, read the chain from left to right as nested wrappers. Ask what bounds each layout modifier receives and what it passes to the content it wraps.

Unbounded constraints and scroll containers

Scrollable parents often do not provide a finite maximum in the scroll direction. A vertically scrolling container may measure its content with an unbounded height because the content can extend beyond the viewport.

That is useful for a Column of content, but it can expose mistakes such as nesting a vertically scrolling Column inside a vertically scrolling LazyColumn, or asking a child to fill an infinite height. Prefer one owner for scrolling and give nested content a bounded size when nested scrolling is intentional.

The problem is not that “lazy layouts cannot be nested” in every situation. A horizontal LazyRow inside a vertical LazyColumn is a common valid pattern because the scroll directions differ. The issue is an unbounded measurement in the same direction without a clear boundary.

Use BoxWithConstraints for layout decisions

BoxWithConstraints exposes the constraints available to its content as minWidth, maxWidth, minHeight, and maxHeight in Dp:

@Composable
fun AdaptiveSummary() {
    BoxWithConstraints {
        if (maxWidth < 600.dp) {
            CompactSummary()
        } else {
            WideSummary()
        }
    }
}

This is useful when the layout itself must choose between arrangements based on the space offered by its parent. For app-wide adaptive decisions, prefer a window-size or adaptive UI model so the decision is consistent across a screen. Use BoxWithConstraints when the local parent constraints are the information you actually need.

Custom layouts receive constraints directly

When Row, Column, and existing modifiers cannot express a layout, the Layout composable gives you measurables and the incoming constraints:

@Composable
fun TwoColumnLayout(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    Layout(
        modifier = modifier,
        content = content,
    ) { measurables, constraints ->
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints)
        }

        val width = placeables.maxOfOrNull { it.width }
            ?.coerceIn(constraints.minWidth, constraints.maxWidth)
            ?: constraints.minWidth
        val height = placeables.sumOf { it.height }
            .coerceIn(constraints.minHeight, constraints.maxHeight)

        layout(width, height) {
            var y = 0
            placeables.forEach { placeable ->
                placeable.placeRelative(0, y)
                y += placeable.height
            }
        }
    }
}

This sample is illustrative: it stacks children, despite its name, to keep the measurement policy small. A custom layout must measure each child before placing it, and the measured size must be compatible with the constraints. Compose does not permit measuring the same child repeatedly in one ordinary measurement pass; if a layout needs intrinsic information, use the intrinsic measurement APIs deliberately.

The custom layouts documentation explains Layout, MeasureScope, and the single-measure rule. Start with a built-in layout whenever it expresses the design clearly; custom measurement code is powerful but creates another policy to test.

A practical debugging checklist

When a composable has the wrong size, check these in order:

  1. Inspect the parent. Is the node inside padding, a dialog, a list item, or a scroll container?
  2. Check the scroll direction. Is the maximum width or height unbounded?
  3. Read the modifier order. Look for padding, size, requiredSize, fillMax*, and weight.
  4. Remove size requests temporarily. Let the content report its natural size, then add one constraint at a time.
  5. Use Layout Inspector or a small preview. Test the same component at narrow and wide bounds.
  6. Avoid fixing symptoms with requiredSize(). It can hide a parent-child mismatch rather than solve the layout model.

The mental model to keep

Compose constraints are a conversation between parent and child. The parent sets the permitted range, modifiers can transform that range, and the child reports a size that fits. The parent then places the child within its own space.

Once you distinguish “the space my parent offers” from “the size my content wants,” fillMaxWidth(), size(), sizeIn(), BoxWithConstraints, and custom layouts stop feeling like unrelated APIs. They are different ways to participate in the same measurement contract.