Constraints & Layout
The one rule
Section titled “The one rule”If you remember nothing else about Flutter layout, remember this:
Constraints go down. Sizes go up. The parent sets the position.
Layout is a single pass down the tree and back up. A parent passes constraints to each child — a BoxConstraints with a min and max width and height. Each child must choose a size that satisfies those constraints, and reports it back up. The parent then positions the child inside itself.
flowchart TB parent["Parent widget"] -->|"constraints: min/max w, min/max h"| child["Child widget"] child -->|"chosen size (within constraints)"| parent parent -->|"offset (where to place child)"| placed["Child placed"]
That is the entire algorithm. Everything else is detail about how a particular widget chooses its size and what constraints it passes on.
Proven: the three parts of the rule, read directly off the render tree
Section titled “Proven: the three parts of the rule, read directly off the render tree”WidgetTester.getSize and getTopLeft read the actual, real layout result — not a description of it — so the rule above is directly checkable.
import 'package:flutter/material.dart';
class CenteredBox extends StatelessWidget { const CenteredBox({super.key}); @override Widget build(BuildContext context) { return const SizedBox( width: 200, height: 200, child: Center( child: SizedBox(width: 50, height: 50, key: ValueKey('child')), ), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/constraints_rule.dart';
void main() { testWidgets('constraints go down, sizes go up, the parent sets position', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: Center(child: CenteredBox()))));
final childFinder = find.byKey(const ValueKey('child')); final outerBoxFinder = find.byType(SizedBox).first; // the 200x200 outer box
// SIZES GO UP: the child chose 50x50 — its OWN size — even though the // outer SizedBox is 200x200 and Center gives it loose constraints up to that. expect(tester.getSize(childFinder), const Size(50, 50));
// THE PARENT SETS POSITION: Center placed the 50x50 child in the middle // of its own 200x200 box — (200-50)/2 = 75 on each axis. final childTopLeft = tester.getTopLeft(childFinder); final outerTopLeft = tester.getTopLeft(outerBoxFinder); expect(childTopLeft - outerTopLeft, const Offset(75, 75)); });}00:00 +0: constraints go down, sizes go up, the parent sets position00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__constraints-and-layout/ run on this Mac)
Reading the same example top-down: Center receives tight 200x200 constraints from the outer SizedBox. It passes loose constraints down — “you may be anything from 0 up to 200x200.” The inner SizedBox wants exactly 50x50, which fits, so it sizes to 50x50 and reports that back up — proven directly by getSize. Center is 200x200 (its own size came from ITS parent’s tight constraint) and positions the 50x50 child in the middle — proven directly by the 75,75 offset. Change Center to Align, Padding, or SizedBox and only the constraints passed down (step 2) change — the rule itself never does.
Tight vs loose constraints
Section titled “Tight vs loose constraints”A constraint is tight when min equals max (the widget must be exactly that size) and loose when min is 0 (the widget may be anything up to max).
// SizedBox imposes a tight constraint — the child MUST be 50x50.SizedBox(width: 50, height: 50, child: coloredBox)
// Center imposes a loose constraint — the child may be up to the parent size.Center(child: coloredBox)This is why the same widget behaves differently under different parents: Container(color: Colors.red) with no size fills its parent under tight constraints, but shrinks to nothing under loose ones (this exact rule, proven with a real getSize test, is in the Core Layout Widgets lesson). The widget did not change — the constraints did.
Proven: the real RenderFlex overflowed text
Section titled “Proven: the real RenderFlex overflowed text”Give a Row more content than its available width and it does not shrink the content — it overflows, and Flutter reports exactly how much:
import 'package:flutter/material.dart';
class OverflowRow extends StatelessWidget { const OverflowRow({super.key}); @override Widget build(BuildContext context) { // Two 300-wide boxes in a Row on a 400-wide screen — 200px too wide. return const Row( children: [ SizedBox(width: 300, height: 50), SizedBox(width: 300, height: 50), ], ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/overflow_demo.dart';
void main() { testWidgets('a Row wider than its available space throws the real RenderFlex overflow error', (tester) async { tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset);
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OverflowRow())));
final error = tester.takeException(); expect(error, isNotNull); expect(error.toString(), contains('A RenderFlex overflowed by 200 pixels on the right.')); });}00:00 +0: a Row wider than its available space throws the real RenderFlex overflow error00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__constraints-and-layout/ run on this Mac)
The exact, un-trimmed message, captured from a live run — the “200 pixels” is computed, not hardcoded, and matches the math (2×300 = 600 requested on a 400-wide screen, 600 − 400 = 200):
The following assertion was thrown during layout:A RenderFlex overflowed by 200 pixels on the right.
The relevant error-causing widget was: Row Row:file:///…/overflow_demo.dart:8:12
The overflowing RenderFlex has an orientation of Axis.horizontal.The edge of the RenderFlex that is overflowing has been marked in the rendering with a yellow andblack striped pattern. This is usually caused by the contents being too big for the RenderFlex.Consider applying a flex factor (e.g. using an Expanded widget) to force the children of theRenderFlex to fit within the available space instead of being sized to their natural size.This is considered an error condition because it indicates that there is content that cannot beseen. If the content is legitimately bigger than the available space, consider clipping it with aClipRect widget before putting it in the flex, or using a scrollable container rather than a Flex,like a ListView.Row/Column do not scale children down to fit — that’s exactly what Expanded/Flexible are for (next lesson). An overflow is Flutter telling you, precisely, by how many pixels your layout math didn’t add up.
Proven: the real unbounded-height crash — and the three real fixes
Section titled “Proven: the real unbounded-height crash — and the three real fixes”Some widgets pass unbounded constraints — a max of infinity — meaning “you can be as big as you want.” A scrollable’s Viewport is bounded in the cross axis but needs to know its own extent in the scroll axis from its parent — and a plain, non-flexible Column gives its non-flex children an unbounded main-axis constraint (so each child can report its natural size). Put a ListView directly inside a Column (not wrapped in Expanded) and the viewport cannot determine how tall it should be:
import 'package:flutter/material.dart';
class UnboundedColumn extends StatelessWidget { const UnboundedColumn({super.key}); @override Widget build(BuildContext context) { return Column( children: [ const Text('header'), // BUG: a bare ListView as a non-flex child of a Column — the // Column gives it unbounded height, and a viewport cannot lay // itself out with an infinite extent. ListView( children: const [ SizedBox(height: 50, child: Text('a')), SizedBox(height: 50, child: Text('b')), ], ), ], ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/unbounded_height.dart';
void main() { testWidgets('a bare ListView as a non-flex Column child throws the real unbounded-height error', (tester) async { // This particular assertion fires repeatedly during one failed layout // attempt, so tester.takeException() (single-exception only) is not // enough here — capture the first report directly via FlutterError.onError. FlutterErrorDetails? captured; final original = FlutterError.onError; FlutterError.onError = (details) => captured ??= details; addTearDown(() => FlutterError.onError = original);
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: UnboundedColumn())));
expect(captured, isNotNull); expect(captured!.exception.toString(), contains('Vertical viewport was given unbounded height.')); });}00:00 +0: a bare ListView as a non-flex Column child throws the real unbounded-height error00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__constraints-and-layout/ run on this Mac)
The exact, un-trimmed message, captured from a live run:
The following assertion was thrown during performResize():Vertical viewport was given unbounded height.Viewports expand in the scrolling direction to fill their container. In this case, a verticalviewport was given an unlimited amount of vertical space in which to expand. This situationtypically happens when a scrollable widget is nested inside another scrollable widget.If this widget is always nested in a scrollable widget there is no need to use a viewport becausethere will always be enough vertical space for the children. In this case, consider using a Columnor Wrap instead. Otherwise, consider using a CustomScrollView to concatenate arbitrary slivers intoa single scrollable.All three fixes below genuinely resolve it — verified by pumping each and asserting tester.takeException() is null:
import 'package:flutter/material.dart';
/// Fix 1: Expanded gives the ListView a bounded, finite height — whatever/// space is left in the Column after its other children.class FixExpanded extends StatelessWidget { const FixExpanded({super.key}); @override Widget build(BuildContext context) { return Column(children: [ const Text('header'), Expanded( child: ListView(children: const [SizedBox(height: 50), SizedBox(height: 50)]), ), ]); }}
/// Fix 2: shrinkWrap makes the ListView size itself to its CONTENT instead/// of asking for the viewport's full scroll extent — but it builds every/// item up front, losing laziness (see the Slivers & Scrolling lesson).class FixShrinkWrap extends StatelessWidget { const FixShrinkWrap({super.key}); @override Widget build(BuildContext context) { return Column(children: [ const Text('header'), ListView( shrinkWrap: true, children: const [SizedBox(height: 50), SizedBox(height: 50)], ), ]); }}
/// Fix 3: SizedBox gives the ListView an explicit, finite, tight height.class FixSizedBox extends StatelessWidget { const FixSizedBox({super.key}); @override Widget build(BuildContext context) { return Column(children: [ const Text('header'), SizedBox( height: 200, child: ListView(children: const [SizedBox(height: 50), SizedBox(height: 50)]), ), ]); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/unbounded_height_fixes.dart';
void main() { testWidgets('Expanded fixes the unbounded-height crash', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: FixExpanded()))); expect(tester.takeException(), isNull); });
testWidgets('shrinkWrap: true fixes the unbounded-height crash', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: FixShrinkWrap()))); expect(tester.takeException(), isNull); });
testWidgets('SizedBox with an explicit height fixes the unbounded-height crash', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: FixSizedBox()))); expect(tester.takeException(), isNull); });}00:00 +0: Expanded fixes the unbounded-height crash00:00 +1: shrinkWrap: true fixes the unbounded-height crash00:00 +2: SizedBox with an explicit height fixes the unbounded-height crash00:00 +3: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__constraints-and-layout/ run on this Mac)
Expanded is usually right when the list should fill remaining space; shrinkWrap is right only for genuinely small, bounded lists (it defeats lazy building — see the Slivers & Scrolling lesson); an explicit SizedBox is right when you want a fixed, known height regardless of siblings.
Proven: IntrinsicHeight costs a real extra measurement pass
Section titled “Proven: IntrinsicHeight costs a real extra measurement pass”IntrinsicHeight sizes its child to that child’s own intrinsic (natural) height, even inside a Row/context that wouldn’t normally tell it that. To do this, it must ask the child “how tall do you want to be” (an intrinsic-dimension query) before the real layout pass that actually sizes it — proven directly by instrumenting both calls separately on a custom RenderBox:
import 'package:flutter/material.dart';
int probeLayoutCount = 0;int probeIntrinsicCount = 0;
class LayoutProbe extends LeafRenderObjectWidget { const LayoutProbe({super.key, required this.height}); final double height;
@override RenderObject createRenderObject(BuildContext context) => _RenderLayoutProbe(height);
@override void updateRenderObject(BuildContext context, RenderObject renderObject) { (renderObject as _RenderLayoutProbe).probeHeight = height; }}
class _RenderLayoutProbe extends RenderBox { _RenderLayoutProbe(this._height); double _height; set probeHeight(double v) => _height = v;
@override double computeMinIntrinsicHeight(double width) { probeIntrinsicCount++; return _height; }
@override double computeMaxIntrinsicHeight(double width) { probeIntrinsicCount++; return _height; }
@override void performLayout() { probeLayoutCount++; final w = constraints.maxWidth.isFinite ? constraints.maxWidth : 50.0; size = constraints.constrain(Size(w, _height)); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/intrinsic_height_cost.dart';
void main() { testWidgets('without IntrinsicHeight: only the real layout pass runs', (tester) async { probeLayoutCount = 0; probeIntrinsicCount = 0; await tester.pumpWidget(const MaterialApp(home: Scaffold(body: LayoutProbe(height: 40))));
expect(probeLayoutCount, 1); expect(probeIntrinsicCount, 0, reason: 'nothing ever asked for this widget\'s intrinsic height'); });
testWidgets('with IntrinsicHeight: an extra intrinsic-dimension query happens first', (tester) async { probeLayoutCount = 0; probeIntrinsicCount = 0; await tester.pumpWidget( const MaterialApp(home: Scaffold(body: IntrinsicHeight(child: LayoutProbe(height: 40)))), );
expect(probeLayoutCount, 1, reason: 'the real layout pass still only runs once'); expect(probeIntrinsicCount, greaterThan(0), reason: 'IntrinsicHeight queried the child\'s intrinsic height BEFORE laying it out — a real, extra measurement'); });}00:00 +0: without IntrinsicHeight: only the real layout pass runs00:00 +1: with IntrinsicHeight: an extra intrinsic-dimension query happens first00:00 +2: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__constraints-and-layout/ run on this Mac)
probeIntrinsicCount stays flat at 0 for the plain case and jumps for the IntrinsicHeight case — a real, measurable extra query, not a guess. In a deep tree, that intrinsic query recurses through every descendant, which is why the Flutter docs describe IntrinsicHeight/IntrinsicWidth as “expensive” and worth avoiding in hot paths (e.g. inside a scrolling list) — reach for it only when you genuinely need one child’s size to match a sibling’s natural height (the classic case: two Columns of unequal natural height sharing a Row, both stretched to match the taller one).
ConstrainedBox, UnconstrainedBox, and proven: OverflowBox genuinely breaks the down-flow
Section titled “ConstrainedBox, UnconstrainedBox, and proven: OverflowBox genuinely breaks the down-flow”ConstrainedBoxadds additional constraints on top of what it receives — it can only make constraints tighter, never looser, than what its own parent already imposed.UnconstrainedBoxlets its child size itself as if unconstrained (ignoring incoming constraints for sizing purposes), then aligns it within the space it was actually given — handy for letting a child briefly ignore a tight constraint it doesn’t fit, at the cost of a possible overflow if the child ends up bigger than the available space.OverflowBoxis the most deliberate constraint-breaker of the three: it reports its own size to its parent based on the constraints IT received, but hands its child a different, explicitly-specified set of constraints — which can be looser (or tighter) than whatOverflowBoxitself was given, letting the child be sized larger than its own parent’s box.
import 'package:flutter/material.dart';
class OverflowDemo extends StatelessWidget { const OverflowDemo({super.key}); @override Widget build(BuildContext context) { return SizedBox( width: 100, height: 100, child: OverflowBox( minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 200, child: SizedBox(width: 150, height: 150, key: const ValueKey('overflowChild')), ), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/overflow_box_demo.dart';
void main() { testWidgets('OverflowBox reports its own constrained size but lets its child exceed it', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OverflowDemo())));
// OverflowBox itself still respects the 100x100 its own parent gave it. expect(tester.getSize(find.byType(OverflowBox)), const Size(100, 100));
// But its CHILD is laid out with the explicit, looser constraints // OverflowBox specified (up to 200x200) — genuinely bigger than // OverflowBox's own reported size. expect(tester.getSize(find.byKey(const ValueKey('overflowChild'))), const Size(150, 150)); });}00:00 +0: OverflowBox reports its own constrained size but lets its child exceed it00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__constraints-and-layout/ run on this Mac)
This is the one deliberate, sanctioned exception to “constraints go down, sizes go up” acting as a strict nesting relationship — OverflowBox’s reported size (100x100, up to its own parent) and its child’s actual size (150x150) are allowed to disagree, which is exactly why the visual result overflows its own box (usually intentional, for a badge or decoration that’s meant to bleed outside its logical bounds) — and exactly why OverflowBox should be reached for deliberately, not reached for by accident.
LayoutBuilder for breakpoints
Section titled “LayoutBuilder for breakpoints”LayoutBuilder (proven in depth, including its rebuild-scope difference from MediaQuery.of, in the build and BuildContext lesson) is the standard tool for responding to the actual space this widget was given rather than the whole screen — which is what a real breakpoint needs, since a widget might live inside a fixed-width sidebar or a split view where “screen width” is the wrong number entirely:
LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth < 600) { return const NarrowLayout(); } return const WideLayout(); },)Mental Model
Section titled “Mental Model”A BoxConstraints is a job order, not a suggestion: “build me something between this minimum and this maximum, in both dimensions.” Every widget is a contractor that must hand back a size satisfying the order it was given — no widget gets to unilaterally decide “I’ll just be bigger, thanks,” which is exactly why overflow is an error, not a silent resize. The parent, having received a finished size back from the child, is the only one who then decides where to place it — the child never gets a vote on its own position, only its own size. OverflowBox is a contractor’s foreman quietly handing a subcontractor a different job order than the one written on the building permit — legal, but exactly why it needs a name that says so.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Expanded around a scrollable in a Column | Correct, keeps laziness (proven no crash) | The scrollable takes all remaining space — not appropriate if you wanted it sized to content |
shrinkWrap: true | Sizes to content instead of filling | Builds every item up front — proven cost in the Slivers & Scrolling lesson; wrong choice for long lists |
Explicit SizedBox height | Predictable, simple, no laziness trade-off | Height must be known/decided ahead of time — not responsive to content |
IntrinsicHeight/IntrinsicWidth | Makes siblings match each other’s natural size | A real, proven extra measurement pass, recursive through the subtree — avoid in hot/scrolling paths |
OverflowBox | Lets a child deliberately exceed its logical parent’s bounds | Purely visual — hit-testing and layout of SIBLINGS are unaffected by the overflowed area; easy to misuse for actual layout (rather than decoration) |
Common Misconceptions
Section titled “Common Misconceptions”- “A widget can just decide to be bigger than its constraints allow.” Proven false above:
RenderFlex overflowedis a hard error precisely because Flutter enforces the constraint contract — aRow/Columnwill NOT silently expand. - “Column inside ListView is what causes the unbounded-height crash.” Proven above (and corrected against a commonly-repeated wrong example): that direction does NOT crash. The real crash needs a bare, non-
ExpandedListViewas a child of aColumn. - “IntrinsicHeight is basically free — it’s just one more widget in the tree.” Proven above with a real call counter: it performs a genuinely separate, extra query (
computeMaxIntrinsicHeight/computeMinIntrinsicHeight) before the normal layout pass, not just “one more layout.” - “OverflowBox/UnconstrainedBox violate Flutter’s constraint model — they must be bugs when you see them.” Proven above to be intentional, sanctioned escape hatches:
OverflowBox’s own reported size and its child’s actual size are ALLOWED to disagree, by design, for exactly the “decoration that bleeds outside its box” case.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated Flutter layout code frequently nests a ListView (or any scrollable) directly inside a Column without Expanded/shrinkWrap/a fixed size — the exact crash proven above — because a huge fraction of training-data snippets show a ListView as the sole top-level body of a screen, where no such wrapping is needed, and that pattern gets pasted into a context where it isn’t.
// @expect-error compiles clean — throws the real "Vertical viewport was given unbounded height" error proven above the moment this buildsclass ProfileScreen extends StatelessWidget { const ProfileScreen({super.key}); @override Widget build(BuildContext context) { return Column( children: [ const ProfileHeader(), ListView( children: const [ ListTile(title: Text('Setting 1')), ListTile(title: Text('Setting 2')), ], ), ], ); }}Before trusting generated layout code, check:
- Is a scrollable (
ListView,GridView,SingleChildScrollView) a DIRECT child of aColumn/RowwithoutExpanded/Flexiblewrapping it? That’s the real crash proven above. - Is
IntrinsicHeight/IntrinsicWidthused inside something that rebuilds/re-lays-out often (a scrolling list, an animation)? The extra query proven above compounds badly there. - Does an
OverflowBox/UnconstrainedBoxappear where the intent was actually “make this fit,” rather than “let this deliberately bleed outside its box”? If the former, the real fix is usuallyFittedBoxor rethinking the parent’s constraints, not overriding them.
🐛 Spot the bug
class DashboardBody extends StatelessWidget {const DashboardBody({super.key, required this.items});final List<String> items;
@overrideWidget build(BuildContext context) { return Column( children: [ const Text('Recent activity'), ListView.builder( itemCount: items.length, itemBuilder: (context, i) => Text(items[i]), ), ], );}}Show the answer
ListView.builder is correctly lazy (the earlier lesson’s fix for “don’t build every item up front”), but that does not fix THIS bug: it is still a bare, non-Expanded ListView as a direct child of a Column, which throws the exact “Vertical viewport was given unbounded height” error proven above — laziness and boundedness are two completely separate problems. The fix: wrap it in Expanded (if it should fill remaining space) or give it an explicit height.