Mobile Computing · UI Frameworks

Constraints Down, Sizes Up: Why Layout Is a Single Pass

The layout protocol that passes size limits to children and returns chosen sizes to parents, why one pass suffices, and where intrinsic measurement forces a second.

Every layout system has to answer the same question: given a parent that has some amount of space and a child that wants some amount of space, who decides. There are two coherent answers. The parent can impose a geometry on the child, telling it exactly where to sit and how large to be, and the child has no say. Or the child can report what it needs and the parent can accommodate it. Both are workable, and both have a fatal case: the first cannot express content-sized widgets, and the second cannot terminate when a child’s desired size depends on the space it is given.

The protocol used by Flutter, by the Android view system, and in a different form by CSS resolves the tension by exchanging limits rather than sizes. A parent passes down a range of acceptable sizes; a child picks one within that range and reports it; the parent positions the child using the reported size. Because the parent constrains before the child decides, no child can demand more than the parent offered, and the recursion bottoms out in leaves that have an intrinsic size of their own.

The shape of the exchange

In Flutter the limit is a BoxConstraints: a minimum and maximum width, a minimum and maximum height, each of which may be infinite. The contract is narrow enough to state in one sentence. A render object must choose a size that satisfies the constraints it was given, and it must do so without consulting anything outside those constraints and its own children.

Two derived notions follow. A constraint is tight when minimum equals maximum in a dimension, which leaves the child no choice; it is loose when the minimum is zero, which lets the child shrink to nothing. A parent that wants to fill the screen hands down tight constraints. A parent that wants to let a child be as small as it likes hands down loose constraints with the parent’s maximum as a cap. Padding subtracts from the limits before passing them on, which is why a padded child cannot overflow its parent’s padding box even if it asks for more.

The child’s reply is a size, not a position. Positioning is the parent’s job, performed after all children have reported, using the sizes and the parent’s own alignment rules. This separation is what makes a single pass possible: sizes are computed bottom-up, positions top-down, and neither direction needs information from the other.

// A minimal layout: the child is measured once against the incoming
// constraints, then placed at the parent's origin.
@override
void performLayout() {
  final child = this.child!;
  child.layout(constraints, parentUsesSize: true);
  size = constraints.constrain(child.size);
  (child.parentData as BoxParentData).offset = Offset.zero;
}

parentUsesSize is the flag that records a dependency: the parent’s own size was derived from the child’s, so if the child relayouts, the parent must too. A render object that computes its size from its constraints alone, such as a fixed-size box, can be a relayout boundary. When its parent’s constraints are unchanged, the framework skips it and everything beneath it, because nothing below can affect anything above. This is the same idea as a memoized subtree, expressed in geometry instead of descriptions, and it is the main reason deep hierarchies are affordable.

Why one pass is enough, and when it is not

The single-pass guarantee is a discipline, not a theorem. It holds because each render object is required to determine its size from its constraints and its children’s reported sizes, in one traversal. A parent that needs a child’s size before it can decide what constraints to give that child has broken the discipline, and the framework has to give it an escape hatch.

That escape hatch is intrinsic measurement. A widget that sizes itself to the width of its widest child cannot know the width to offer its children until it has asked them. Flutter models this with separate calls that compute minimum and maximum intrinsic width and height, and a parent that uses them performs a preliminary traversal whose results are cached. Each intrinsic query is a full subtree walk, and nesting widgets that use them multiplies the cost, because the outer query triggers the inner query again for each candidate width.

Android’s view system has the same structure with different names. measure receives a MeasureSpec that encodes a mode, exactly, at most, or unspecified, together with a size in pixels, and onMeasure must set a measured dimension consistent with it. The parent then calls layout with final bounds. The subtlety is that measure may be called more than once on the same view, with different specs, and a view that caches a measurement keyed on the spec must invalidate it correctly or report a stale size. RelativeLayout is the historical example: a child’s position depends on another child’s size, so its measure pass resolves a dependency graph over its children and can measure a child more than once before it knows the constraints to apply.

CSS flexbox shows the same problem from the other side. The algorithm in the specification has multiple stages: determine the flex base size of each item, distribute free space according to the flex factors, then resolve the main size and finally the cross size, with a second layout of the items once their cross size is known. The specification calls for this because flex items can be sized proportionally to space that only exists after the container’s own size is known. That is more general than constraints-down, sizes-up, and it is correspondingly more expensive: a browser’s layout of a flex subtree is not a single bottom-up pass.

The degenerate cases

Layout protocols are defined by what they do at the boundaries, and the boundaries are the infinite constraint and the zero constraint.

An infinite maximum is what a scrollable viewport passes down in its scroll direction, meaning the child may be as tall as it likes. Any widget that tries to fill an infinite constraint cannot: there is no finite size that fills infinity, and frameworks treat this as an error. Flutter raises an exception naming the offending widget when a child with an unbounded constraint is asked to expand. The practical consequence is that a ListView nested inside a Column fails unless the list is given a bounded height, because the column offers unbounded height along its main axis. The fix is always to bound the height, either with an explicit size or with an expanded region that divides the parent’s finite height.

Zero minimum is the mirror case. A child given a loose constraint of zero minimum may legitimately report zero, and a parent that divides space among children by weight must handle the case where the total weight exceeds the available space. Flexbox handles it by scaling the flex factors down when the sum of the hypothetical sizes overflows, so a child that would have received 60 percent of the space gets a proportional share of a smaller total rather than a negative size. Android’s LinearLayout with weights and a zero-width child does something equivalent: it distributes the remaining space, and when remaining space is negative, it distributes the deficit.

Rounding is the quieter failure. Sizes are computed in floating point and rasterized at integer device pixels, and a device pixel ratio of 2.75 means a logical size of 100 becomes 275 physical pixels while a neighbouring 50 becomes 137.5. Layout systems differ on whether they round at each step or once at the end, and the difference shows up as a one-pixel gap between two boxes that should be flush, or a border that renders at two different widths on the two sides of a rectangle. This is why several frameworks keep the layout geometry in floating point and round only when producing the raster, and why subpixel borders are drawn with a coverage-based shader rather than a rectangle.

Repainting is not relayout

The render tree has three phases that are often conflated: layout, painting, and compositing. Layout assigns sizes and positions. Painting records the drawing commands for a layer. Compositing combines layers, and on modern mobile platforms the compositor runs on a separate thread or a separate process, so a change that only affects compositing does not require the layout phase at all.

A transform, an opacity change, or a scroll offset can be handled as a compositing property of an existing layer, which is why animations built from transforms and opacities are cheap and animations that change a width are not. Changing a width invalidates layout for the subtree, then paint for the affected region, then requires a new layer to be uploaded. The same distinction explains why a virtualized list that materializes new rows during a fling costs far more than one that merely moves already-painted rows, as covered in View Recycling and Virtualization in Long Scrolling Lists. A framework enforces the distinction with per-node invalidation bookkeeping: it marks nodes as needing layout, needing paint, or needing only a composited transform, then schedules the minimum corresponding work. The bookkeeping and the scheduling are the subject of From Invalidation to Present: The Frame Pipeline.

What the protocol costs

The constraint protocol buys a linear layout pass, predictable cost per frame, and a clean separation between sizing and positioning that makes custom widgets a matter of implementing one method. It pays for those with an inability to express constraints that flow sideways or upward: a child cannot ask a sibling to be smaller, and a parent cannot ask a grandchild for its size before deciding the child’s constraints. Every framework that supports such relations, from Android’s relative positioning to CSS grid, reintroduces a multi-pass solver, with the accompanying cache invalidation and the potential for layout to depend on measurement order.

The single-pass model also fixes the direction of information flow, which has a second-order benefit: the layout of a subtree is a pure function of the constraints passed to it, so the result can be cached on the constraint value, and an unchanged constraint means an unchanged layout. Frameworks exploit this with relayout boundaries. A design that permitted arbitrary upward queries could not make that claim, because a grandchild’s size could depend on a cousin’s, and the cache key would have to be the entire tree.

References

  1. Flutter Documentation: Understanding Constraints — Flutter project
  2. CSS Flexible Box Layout Module Level 1 — W3C
  3. Android Developers: Custom View Components — Google