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.

How this lesson proves things. Every code box lives in a real Flutter test file, 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

Think of a family hanging children's drawings on a fridge. The parent says to a child: "your drawing must be at most this wide and this tall" (a constraint). The child picks a paper size inside those limits and holds it up (its size). Only then does the parent decide where on the fridge it goes (its position). The child never chooses its own spot, and never ignores the limits.

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:

  1. Constraints go down. A parent tells each child the smallest and largest size it may have.
  2. Sizes go up. The child picks a size inside those limits (asking its own children first, if it has any) and reports it back.
  3. 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:

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:

"I gave it width 100, why is it 800?" A child can only ask. If the parent's constraints are tight (min = max = 800), no wish can change the answer. The fix is never "a bigger number on the child"; it is to change what the parent passes down — for example wrap the child in 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.
Layout is a single walk down and back up the render tree: each render object is laid out at most once per frame (unless something asks for its "intrinsic" size, section 5), so the cost grows in a straight line with the number of boxes — O(n). A parent that reads its child's size passes 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.
Constraints go down, sizes go up, the parent sets the position. Tight = one size allowed, loose = 0 up to a max, unbounded = no max. A child's size is always inside its constraints.

2. Common widgets through that rule

Layout widgets are like the people passing a parcel-size rule down a line. Center says "you may be smaller than me, I will put you in the middle". Padding says "take 20 off every side of what I was given". SizedBox says "I want exactly this", but still has to obey what it was told. Container is a helpful assistant that hires the others for you.

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:

"Container sizes itself to its child — unless…" A Container with a child and nothing else is as big as the child plus its padding (cases 4 and 5). It is not when (1) it has 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.
Why does a childless Container fill the space while a childless SizedBox does not? Container with no child and no size wraps a 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.
Root widget = tight screen constraints, so it fills the screen. Center/Align loosen and position; Padding deflates; SizedBox asks for an exact size but is clamped by its parent; Container is a bundle of those, and fills its parent when it has alignment or no child.

3. Row and Column: the Flex algorithm

Sharing a 300 cm shelf. First, people with fixed-size boxes put them down: an 80 cm box and a 40 cm box. 180 cm is left. Then the "flexible" people split what is left by how many shares they hold: one share for Bina, two for Chand, so one share is 60 cm — Bina gets 60, Chand gets 120. If the fixed boxes alone are longer than the shelf, something sticks out over the edge: that is an overflow.

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:

  1. Pass 1 — inflexible children. Every child that is not wrapped in Expanded/Flexible is 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.
  2. Pass 2 — flexible children. Free space = max(0, Row width − used space). One share = free space ÷ total flex. Each flexible child gets flex × share: an Expanded child must be exactly that wide (tight, FlexFit.tight); a Flexible child 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.

Expanded must be a direct child of a Row, Column or Flex. Put it anywhere else (inside a 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.
Why two passes and not one? A flexible child's width depends on the free space, and the free space depends on every inflexible child's width — so all inflexible children must be measured first. Each child is still laid out only once, so the whole algorithm is O(n). The note Expanded leaves is a parent data object (FlexParentData: flex, fit, and later the offset the Row chooses); the same mechanism lets Positioned talk to a Stack.
Pass 1: inflexible children, unbounded main axis, add up used space and flex. Pass 2: share = max(0, width − used) ÷ total flex; Expanded = exactly its share, Flexible = up to its share. Too much fixed width → overflow.

4. Overflow and unbounded errors

Two different shelf accidents. Overflow: the boxes are longer than the shelf — something sticks out, and Flutter paints warning tape (the yellow and black stripes) where it sticks out. Unbounded: someone asks the carpenter for "a shelf as long as possible" in a room with no end wall — there is no answer, so the carpenter refuses to build anything.

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.

Other errors with the same root cause, an infinite limit meeting a "fill it" request: 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?"
Three details the tests revealed. (1) The overflow error is reported once per RenderFlex: the same Row overflowing again in a later frame stays silent (question q30). (2) A Row whose height is zero paints nothing at all, so its overflow is never reported — an invisible bug. (3) The stripes and the message exist only in debug builds; in release builds the Row (which does not clip by default, clipBehavior: Clip.none) simply draws its children past its own edge, so text is cut off by the screen or overlaps its neighbour.
Overflow = children's sizes add up to more than the Flex's size → stripes + "overflowed by N pixels" (debug only); fix with Expanded/Flexible, smaller children, scrolling or Wrap. ListView in Column = unbounded height → Expanded (usually), SizedBox, or shrinkWrap (builds every item).

5. Intrinsics and LayoutBuilder

Before ordering a table, a host phones every guest: "how many people are you bringing?" Only after every answer does she order the table and seat people. That is an intrinsic pass: extra phone calls before the real work. LayoutBuilder is the opposite: a guest who waits to see the table she was given and then decides how to sit.

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.

Using intrinsic widgets to "fix" layout errors. Wrapping something in IntrinsicHeight to silence an unbounded-height error works only by accident and costs a measuring pass every time. Find the real cause instead (who passes infinity?). And inside a LayoutBuilder, do not assume constraints.maxWidth is finite: in a Row or a horizontal list it is infinity — check constraints.hasBoundedWidth first (question q13 handles it).
LayoutBuilder's builder runs during the layout phase, not the build phase, and runs again only when its constraints change (or it is rebuilt). Intrinsic answers are cached per render object and per question, and the cache is thrown away whenever that render object is marked as needing layout — which is why an animating subtree below an IntrinsicHeight pays for measuring again on every frame.
IntrinsicHeight/IntrinsicWidth = measure first, then lay out: correct but expensive; use them in small, stable subtrees. LayoutBuilder = read the constraints you were given and build accordingly; maxWidth can be infinity.

6. The frame pipeline and RepaintBoundary

A newspaper printed every 16 milliseconds. The bell rings (vsync). Reporters update running stories (animate). Editors rewrite only the articles marked "changed" (build). Layout artists measure and place the boxes that moved (layout). Illustrators redraw only the pictures marked "smudged" (paint). The pages are stacked into a bundle (composite) and sent to the printing press, which works in another room on its own (raster) while the newsroom starts the next edition.

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:

  1. Animate (transient callbacks): every running Ticker gets the current time; animation controllers compute their new value and notify listeners, which often call setState.
  2. Build: every element marked dirty by setState (markNeedsBuild) runs build(), parents before children (Step 12.2). Updated render objects learn their new settings.
  3. Layout: every render object marked markNeedsLayout is laid out again — the constraints-down, sizes-up walk of section 1, but only where something changed.
  4. Paint: every render object marked markNeedsPaint records its drawing commands into a layer (a separate drawing surface that can be moved or reused).
  5. Composite: the tree of layers is turned into a scene and handed to the engine.
  6. 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:

RepaintBoundary is not free. Each one creates a layer: the test in question q25 counts 2 layers for 100 plain tiles and 201 layers when each tile has its own boundary. Every layer keeps its own recorded picture in memory and must be put together with the others in the composite step. It helps when one part repaints often and its neighbours are expensive and still (a spinner, a ticking clock or a video next to a heavy chart or a long list); it is pure cost when everything repaints together anyway. Measure with Flutter DevTools' "highlight repaints" option before adding one. Flutter already adds boundaries where they usually pay off — for example, around each item of a ListView.
The test log shows something subtle: build, layout and paint run in the same scheduler phase, one after another, inside 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.
vsync → animate → build → layout → paint → composite → raster (raster on its own thread). setState → build; size change → layout (+ paint); colour change → paint only. Tight constraints make a relayout boundary; RepaintBoundary makes a repaint boundary with its own layer — use it to isolate frequent repaints, not everywhere.

7. How to answer the three classic interview questions

An interview answer is like giving directions: say the destination first (one sentence), then the turns in order, then one landmark that proves you have really been there (a real number, a real error message, a tool you used).
QuestionA 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".
Common weak answers: "Flutter redraws the whole screen every frame" (it rebuilds, relays out and repaints only dirty parts), "RepaintBoundary stops rebuilds" (it has nothing to do with build — that is 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).
Interviewers often follow up with a custom render object ("write a widget that makes its child at least 48 × 48", question q23) or with performance ("how would you find a janky frame?", question q28). Both are answered with the same model you learned here: constraints, sizes, positions, marks and boundaries.
Lead with the one-line rule, walk the steps in order, finish with a concrete number or error message.

Quiz

Interview questions

Cheat sheet

ConceptFact
The ruleConstraints go down, sizes go up, the parent sets the position. A size is always inside its constraints.
BoxConstraintsmin/max width and height. Tight: min = max. Loose: min = 0. Unbounded: max = infinity. constrain(size) clamps a wish.
Root widgetGets the screen's tight constraints, so it fills the screen whatever size it asks for.
Center / AlignLoosen, let the child pick, position it; as big as allowed. Align x, y in −1…1: left = free × (x + 1) / 2.
Padding / SizedBox / ConstrainedBoxDeflate / ask for an exact size (clamped) / add limits on top of the parent's.
ContainerBundle of Padding, ColoredBox, Align, ConstrainedBox. Sizes to its child unless it has a size, an alignment, or no child.
Flex pass 1Inflexible children, unbounded main axis; add used space and total flex.
Flex pass 2share = max(0, width − used) ÷ total flex. Expanded = flex × share exactly; Flexible = up to it; Spacer = empty Expanded.
OverflowChildren 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 ColumnExpanded (usual), SizedBox, shrinkWrap (lays out every item), or one CustomScrollView with slivers.
IntrinsicsMeasure, then lay out. Extra work every relayout; worst case O(n²) in depth.
LayoutBuilderBuilds during layout from its own constraints; maxWidth may be infinity.
Framevsync → animate → build → layout → paint → composite → raster (own thread). Budget 16.7 ms at 60 fps.
MarkssetState → markNeedsBuild; size → markNeedsLayout (+ paint); pixels → markNeedsPaint only.
BoundariesTight constraints → relayout boundary. RepaintBoundary → own layer; isolates frequent repaints; each layer costs memory.