Layout & Rendering in Flutter
By the end of this lesson you will be able to predict the exact size and position of any widget on the screen, explain why a Row overflows or a ListView inside a Column crashes and fix it properly, split space between children like Flutter's own two-pass Flex algorithm, and walk an interviewer through one frame — from the display's tick to pixels — including what RepaintBoundary really does. Every number on this page was printed by a real Flutter test.
verify_flutter/test/f03_test.dart. A widget test starts a pretend screen with no window — always 800 × 600 logical pixels (a logical pixel is Flutter's size unit; one logical pixel may be several real dots on a sharp phone screen) — puts widgets on it with tester.pumpWidget(...) and draws frames with tester.pump(). The test font draws every letter as a square as wide as the font size (14 by default), so text sizes are exact too. The // => lines under each box are what the test really printed. This lesson builds on Step 12.1 (the widget, element and render object trees) and Step 12.2 (setState and what rebuilds).1. The layout rule: constraints down, sizes up
Layout is the step where Flutter decides the size and the position of every box on the screen, before anything is drawn. It is done by the render objects — the third tree from Step 12.1, the objects that actually measure and paint. Every render object follows one rule, in three parts:
- Constraints go down. A parent tells each child the smallest and largest size it may have.
- Sizes go up. The child picks a size inside those limits (asking its own children first, if it has any) and reports it back.
- The parent sets the position. Knowing the child's size, the parent decides where the child's top-left corner goes.
The limits travel in a BoxConstraints object: four numbers, minWidth, maxWidth, minHeight and maxHeight. Three words describe them:
- Tight: min equals max, so exactly one size is allowed. The screen gives the root widget tight constraints: exactly 800 × 600 in tests.
- Loose: min is 0, so anything from nothing up to the max is allowed.
- Unbounded: the max is infinity — there is no limit at all. This happens along the scrolling direction of a list, and along the main direction of a
RoworColumnin some cases (sections 3 and 4).
The test below puts a 100 × 50 box inside a Padding inside a Center, then asks each render box what constraints it received, what size it picked and where it ended up:
You can build and test constraints directly. constrain(wish) returns the size closest to a wish that the constraints allow — that is what a box without children does to pick its size:
Center or Align, which loosen the constraints (section 2). Also remember that a child does not know its position while it is being laid out; code that needs a widget's position must ask after layout.parentUsesSize: true when it calls child.layout(constraints, ...); Flutter uses that flag to decide how far a later change must spread (relayout boundaries, section 6). BoxConstraints objects are immutable values: a parent creates a new one for each child instead of changing one.2. Common widgets through that rule
Every layout widget is just a rule for turning the constraints it receives into constraints for its child, and the child's size into its own size and the child's position:
SizedBox(width, height)asks for an exact size on the axes you give — but the result is squeezed into the incoming constraints. A 1000-wide SizedBox on an 800-wide screen is 800 wide.ConstrainedBox(constraints)adds its own limits on top of the incoming ones. A minimum width of 150 forces even a child that asks for 100 to be 150 wide.CenterandAlignloosen the constraints (min becomes 0), let the child pick, and place it. With bounded constraints they make themselves as big as allowed.Align(alignment: Alignment(x, y))uses x and y from −1 (left/top) to 1 (right/bottom): the child's left edge is(free width) × (x + 1) / 2.Paddingdeflates the constraints (takes the padding off the max) and adds the padding back to the child's size.Containeris not a layout rule of its own: it builds the small widgets above for you (Paddingforpadding,ColoredBoxforcolor,Alignforalignment, aConstrainedBoxforwidth/height/constraints, anotherPaddingformargin).
width, height or constraints (it asks for that size), (2) it has an alignment (it becomes as big as its parent allows, so it can position the child inside — case 6), or (3) it has no child (it tries to be as big as allowed — case 3). Beginners add alignment "to centre the text" and are surprised that the coloured box suddenly fills the screen.LimitedBox around a ConstrainedBox(constraints: BoxConstraints.expand()): "be as big as allowed — but if a direction is unbounded, use a limit instead of infinity". A SizedBox with no size simply takes the smallest size allowed. The rule-of-thumb names you will hear: Center/Align loosen, Padding deflates, SizedBox/ConstrainedBox tighten within the incoming constraints.3. Row and Column: the Flex algorithm
Row and Column are the same render object, RenderFlex, in two directions. The main axis is the direction children are laid out in (horizontal for Row, vertical for Column); the cross axis is the other one. Along the main axis, Flex lays out its children in two passes:
- Pass 1 — inflexible children. Every child that is not wrapped in
Expanded/Flexibleis laid out with an unbounded main axis: it may be as wide as it likes. Their sizes are added up ("used space"), and the flex factors of the other children are added up. - Pass 2 — flexible children. Free space = max(0, Row width − used space). One share = free space ÷ total flex. Each flexible child gets
flex × share: anExpandedchild must be exactly that wide (tight,FlexFit.tight); aFlexiblechild may be anything up to it (loose,FlexFit.loose).
Then the children are placed one after another. If the children add up to more than the Row's width, the extra is the overflow. A Spacer is simply an Expanded with an empty box inside: it takes free space. Since Flutter 3.27 Row and Column also take a spacing value: a fixed gap between children, subtracted like used space before the shares are computed.
Try it: lay out your own Row
Give the Row's width and its children, and watch both passes with the exact numbers. The player runs a JavaScript copy of the algorithm; the test file runs the same algorithm in Dart (interview question q18) and compares it with real Flutter Rows on every example here and on 400 random rows.
Two more settings change the picture. mainAxisSize: MainAxisSize.max (the default) makes the Row as wide as allowed; MainAxisSize.min makes it only as wide as its children — but an Expanded child still takes all the free space, so the Row ends up full width anyway. mainAxisAlignment (start, center, spaceBetween, …) only moves children around when there is free space left over, which never happens when a tight flexible child took it all. On the cross axis, crossAxisAlignment centres children by default; CrossAxisAlignment.stretch gives every child tight cross-axis constraints equal to the Row's height.
Center, say) and Flutter reports Incorrect use of ParentDataWidget. — Expanded does not lay anything out itself; it only writes a note ("flex 2, tight") on its child for the Flex parent to read. Second trap: an Expanded inside a Row that sits inside another Row. The inner Row is an inflexible child of the outer one, so it gets an unbounded width in pass 1, and "share the free space" is meaningless: RenderFlex children have non-zero flex but incoming width constraints are unbounded. Wrap the inner Row in Expanded too.FlexParentData: flex, fit, and later the offset the Row chooses); the same mechanism lets Positioned talk to a Stack.4. Overflow and unbounded errors
Overflow. In debug builds, when a Row's or Column's children need more room than it has, Flutter paints a yellow-and-black striped strip on the overflowing edge and reports an error whose first line names the amount: A RenderFlex overflowed by 40 pixels on the right. The amount is the sum of the children's sizes minus the Row's size, rounded the way Flutter prints it: whole numbers above 10 (40), one decimal between 1 and 10 (5.0, 10.0), three significant digits below 1 (0.500).
The fixes all come from section 3: make the child that can shrink flexible (Expanded, or Flexible with TextOverflow.ellipsis for text, question q14), make the fixed children smaller, wrap a long row in a horizontal scroll view, or use Wrap to flow children onto a new line.
Unbounded height. A Column lays out its inflexible children in pass 1 with an unbounded height (section 3). A ListView is a viewport — a window onto content that may be endless — so it must be given a height; by default it wants to be as tall as allowed. "As tall as infinity" has no answer, so Flutter throws Vertical viewport was given unbounded height. That first error then causes a chain of follow-up errors ("RenderBox was not laid out…"); always read the first one.
Which fix? Expanded when the list should fill the rest of the screen — the usual answer. SizedBox(height: …) for a fixed-height strip. shrinkWrap: true makes the list exactly as tall as its content — but to know that height it must lay out every item, which throws away the main benefit of a list (building only what is visible):
For long content made of several lists and other pieces, the scalable answer is one scroll view for the whole page, a CustomScrollView whose parts are slivers (pieces of a scrolling area, such as SliverList), so everything stays lazy.
BoxConstraints forces an infinite height. (a Row with CrossAxisAlignment.stretch inside a Column, question q17), and RenderFlex children have non-zero flex but incoming width constraints are unbounded. (an Expanded in a Row inside a horizontal scroll view or inside another Row). Each time, ask: "which ancestor passed infinity, and who tried to fill it?"clipBehavior: Clip.none) simply draws its children past its own edge, so text is cut off by the screen or overlaps its neighbour.5. Intrinsics and LayoutBuilder
Normally a parent never asks a child "how big would you like to be?" before laying it out. Sometimes you need exactly that — for example, cards in a Row that should all be as tall as the tallest one. IntrinsicHeight asks its subtree for its intrinsic height (the height it would like, given a width) and then lays it out with that exact height. Each child is asked questions and laid out:
Without IntrinsicHeight, the stretched Row is as tall as the screen allows (600). With it, the Row is 80 — the taller child's wish — but every child was first measured (asked "how wide?" and "how tall at that width?") and only then laid out. Flutter's documentation warns that in the worst case intrinsic widgets can make layout cost grow with the square of the tree's depth (O(n²)), because each one measures its whole subtree before laying it out. Flutter caches answers within a frame, so in simple cases the extra work grows in a straight line (question q24 measures it) — but it is extra work on every relayout, so keep intrinsic widgets out of long lists and frequently animating parts.
LayoutBuilder gives you the constraints your widget received, while Flutter is laying out — so you can choose a different widget tree for a narrow or a wide space. It reports the space given to this widget, not the whole screen, which is what responsive components need:
Read these five lines like a map of the rules: Center passes loose limits; a 300-wide SizedBox passes a tight width; a Row passes an unbounded width (pass 1); a Column passes an unbounded height; a vertical ListView passes a tight width (the list's width) and an unbounded height.
constraints.maxWidth is finite: in a Row or a horizontal list it is infinity — check constraints.hasBoundedWidth first (question q13 handles it).6. The frame pipeline and RepaintBoundary
A frame is one picture on the screen. Phones show 60 or 120 frames per second, so Flutter has about 16.7 ms (or 8.3 ms) for each one. When the display is ready for a new picture it sends a vsync signal, and — if something asked for a frame — Flutter runs these steps, in this order:
- Animate (transient callbacks): every running
Tickergets the current time; animation controllers compute their new value and notify listeners, which often callsetState. - Build: every element marked dirty by
setState(markNeedsBuild) runsbuild(), parents before children (Step 12.2). Updated render objects learn their new settings. - Layout: every render object marked
markNeedsLayoutis laid out again — the constraints-down, sizes-up walk of section 1, but only where something changed. - Paint: every render object marked
markNeedsPaintrecords its drawing commands into a layer (a separate drawing surface that can be moved or reused). - Composite: the tree of layers is turned into a scene and handed to the engine.
- Raster: on a separate raster thread, the engine turns the scene into actual pixels using the GPU, while the UI thread can already work on the next frame.
Steps 2 to 5 run inside one scheduler phase called persistent callbacks; afterwards post-frame callbacks run (code you registered with addPostFrameCallback, handy for reading a widget's size after layout). The test logs each step with the scheduler phase it ran in:
What marks what. The three "mark" calls decide how much work the next frame does. setState calls markNeedsBuild. A render object property that can change the size (a width) calls markNeedsLayout, and every layout is followed by a paint of that object. A property that only changes pixels (a colour) calls markNeedsPaint — no layout at all. And a layout request climbs up only to the nearest relayout boundary: a render object whose size cannot affect its parent — for example because its constraints are tight, so its size is decided already:
In the loose case, the parent had to lay out again because the swatch's new width could change the parent's own size. In the tight case (inside a 100 × 50 SizedBox) the swatch is its own relayout boundary: it was laid out alone and the parent was left untouched.
RepaintBoundary. Painting has a similar boundary. When a render object calls markNeedsPaint, Flutter walks up to the nearest repaint boundary — a render object with its own layer — and repaints that whole layer. Without any boundary, that is the root: a small blinking dot repaints its static neighbours on every frame. RepaintBoundary gives its child its own layer, so repaints inside stay inside:
ListView.drawFrame; there are separate "dirty lists" for each (the build owner keeps dirty elements; the pipeline owner keeps nodes needing layout and nodes needing paint). Layout is processed starting at relayout boundaries, paint starting at repaint boundaries, deepest first so parents see updated children. The raster thread cannot be seen from a widget test, but it is why a frame can be late for two reasons: too much UI-thread work (build/layout/paint) or too much raster work (complex drawing, many layers, saveLayer effects such as some clips and shadows). DevTools shows both bars per frame.7. How to answer the three classic interview questions
| Question | A strong answer, in order |
|---|---|
| "Explain how Flutter renders a frame." | (1) On vsync, transient callbacks run animations. (2) Build: dirty elements rebuild, parents first. (3) Layout: constraints go down, sizes go up, parents position children; only from dirty relayout boundaries. (4) Paint: dirty repaint boundaries record into layers. (5) Composite: the layer tree becomes a scene for the engine. (6) Raster thread draws pixels with the GPU in parallel with the next frame. Landmark: "a colour change skips layout; a size change does not". |
| "Why is my Row overflowing?" | (1) Row lays out inflexible children with unbounded width, so each takes what it wants. (2) Their total is more than the Row's width; the difference is the overflow amount in the message. (3) Fix: Expanded/Flexible on the child that can shrink (with ellipsis for text), smaller fixed widths, Wrap, or a horizontal scroll view. Landmark: "Text has no width limit inside a Row unless it is flexible". |
| "What does RepaintBoundary do?" | (1) It gives its subtree its own layer. (2) markNeedsPaint climbs only to the nearest repaint boundary, so repaints inside do not repaint outside and vice versa. (3) Use it for something that repaints often next to expensive still content; avoid it everywhere because each layer costs memory and compositing time. Landmark: "10 frames of a blinker: the static neighbour painted 10 times without a boundary, 0 times with one". |
const and smaller widgets, from Step 12.2), and "add a SingleChildScrollView to fix overflow" for everything (it hides the problem and makes the whole column non-lazy).Quiz
Interview questions
Cheat sheet
| Concept | Fact |
|---|---|
| The rule | Constraints go down, sizes go up, the parent sets the position. A size is always inside its constraints. |
BoxConstraints | min/max width and height. Tight: min = max. Loose: min = 0. Unbounded: max = infinity. constrain(size) clamps a wish. |
| Root widget | Gets the screen's tight constraints, so it fills the screen whatever size it asks for. |
| Center / Align | Loosen, let the child pick, position it; as big as allowed. Align x, y in −1…1: left = free × (x + 1) / 2. |
| Padding / SizedBox / ConstrainedBox | Deflate / ask for an exact size (clamped) / add limits on top of the parent's. |
| Container | Bundle of Padding, ColoredBox, Align, ConstrainedBox. Sizes to its child unless it has a size, an alignment, or no child. |
| Flex pass 1 | Inflexible children, unbounded main axis; add used space and total flex. |
| Flex pass 2 | share = max(0, width − used) ÷ total flex. Expanded = flex × share exactly; Flexible = up to it; Spacer = empty Expanded. |
| Overflow | Children total > Flex size. Debug: stripes + "A RenderFlex overflowed by N pixels on the right." Reported once per RenderFlex. |
| Unbounded errors | "Vertical viewport was given unbounded height", "BoxConstraints forces an infinite height", "non-zero flex but … unbounded". Find who passes infinity. |
| ListView in Column | Expanded (usual), SizedBox, shrinkWrap (lays out every item), or one CustomScrollView with slivers. |
| Intrinsics | Measure, then lay out. Extra work every relayout; worst case O(n²) in depth. |
| LayoutBuilder | Builds during layout from its own constraints; maxWidth may be infinity. |
| Frame | vsync → animate → build → layout → paint → composite → raster (own thread). Budget 16.7 ms at 60 fps. |
| Marks | setState → markNeedsBuild; size → markNeedsLayout (+ paint); pixels → markNeedsPaint only. |
| Boundaries | Tight constraints → relayout boundary. RepaintBoundary → own layer; isolates frequent repaints; each layer costs memory. |