Core Layout Widgets
Row, Column, and Flex
Section titled “Row, Column, and Flex”Row and Column lay children out along one axis — horizontal and vertical. Both are just Flex with the direction fixed. You control alignment with mainAxisAlignment (along the axis) and crossAxisAlignment (across it).
Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, // along the vertical axis crossAxisAlignment: CrossAxisAlignment.start, // across (horizontal) children: const [ Text('top'), Text('middle'), Text('bottom') ],)mainAxisSize controls how much of the main axis the Flex itself claims: MainAxisSize.max (default) fills whatever the incoming constraint allows; MainAxisSize.min shrinks to just fit the children. crossAxisAlignment: CrossAxisAlignment.stretch forces every child to fill the full cross-axis extent (e.g. every child in a Column becomes exactly as wide as the Column), overriding whatever width each child would otherwise have chosen for itself.
A Column is unbounded along its main axis unless constrained (recall the ListView-in-Column error from the Constraints & Layout lesson).
Proven: Expanded forces the child’s size; Flexible only bounds it
Section titled “Proven: Expanded forces the child’s size; Flexible only bounds it”Inside a Row/Column, Expanded and Flexible split the remaining space by a flex factor — the mechanism is identical, but the constraint each hands its child is not, and the difference is directly measurable.
import 'package:flutter/material.dart';
/// A child that WANTS to be 50 wide — its natural/requested size.class NarrowChild extends StatelessWidget { const NarrowChild({super.key}); @override Widget build(BuildContext context) => const SizedBox(width: 50, height: 50);}
class ExpandedDemo extends StatelessWidget { const ExpandedDemo({super.key}); @override Widget build(BuildContext context) { // A 300-wide Row with ONE Expanded child — it claims all 300px, // regardless of what the child itself asked for. return const SizedBox( width: 300, height: 50, child: Row(children: [Expanded(child: NarrowChild())]), ); }}
class FlexibleDemo extends StatelessWidget { const FlexibleDemo({super.key}); @override Widget build(BuildContext context) { return const SizedBox( width: 300, height: 50, child: Row(children: [Flexible(child: NarrowChild())]), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/expanded_vs_flexible.dart';
void main() { testWidgets('Expanded forces the child to fill its full share (300), ignoring the child\'s own 50-wide request', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: ExpandedDemo()))); expect(tester.getSize(find.byType(NarrowChild)), const Size(300, 50)); });
testWidgets('Flexible lets the child be its own natural size (50) when that fits within its share', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: FlexibleDemo()))); expect(tester.getSize(find.byType(NarrowChild)), const Size(50, 50)); });}00:00 +0: Expanded forces the child to fill its full share (300), ignoring the child's own 50-wide request00:00 +1: Flexible lets the child be its own natural size (50) when that fits within its share00:00 +2: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__core-layout-widgets/ run on this Mac)
Expanded is literally Flexible with fit: FlexFit.tight — it hands the child a tight main-axis constraint (min = max = its share), the same forcing mechanism proven throughout this course for any tight constraint. Flexible’s default (FlexFit.loose) hands a loose constraint (0 to its share) — proven above, the child genuinely keeps its own 50px size rather than being stretched to 300. Reach for Expanded when you want the child to stretch; Flexible when you want it to take at most a share but shrink to content otherwise.
Stack and Positioned: overlapping children
Section titled “Stack and Positioned: overlapping children”Stack layers children on top of each other; Positioned pins a child to edges of the stack. This is how you build badges, overlays, and custom placements.
Stack( children: [ const Avatar(), Positioned( right: 0, top: 0, child: const Badge(), // pinned to the top-right corner ), ],)Non-positioned children are sized to the stack and aligned by its alignment; positioned ones are placed by their edge offsets.
Proven: Container with no child fills its parent — unless the parent is unbounded
Section titled “Proven: Container with no child fills its parent — unless the parent is unbounded”Container’s own doc comment states the rule precisely: “If null [no child], and if the constraints are unbounded or also null, the container will expand to fill all available space in its parent, unless the parent provides unbounded constraints, in which case the container will attempt to be as small as possible.” Both halves are provable:
import 'package:flutter/material.dart';
class BoundedNoChild extends StatelessWidget { const BoundedNoChild({super.key}); @override Widget build(BuildContext context) { return SizedBox( width: 100, height: 100, child: Container(key: const ValueKey('c'), color: Colors.blue), ); }}
class UnboundedNoChild extends StatelessWidget { const UnboundedNoChild({super.key}); @override Widget build(BuildContext context) { return SizedBox( width: 300, height: 300, child: UnconstrainedBox( child: Container(key: const ValueKey('c'), color: Colors.blue), ), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/container_sizing.dart';
void main() { testWidgets('Container with no child and no size fills BOUNDED parent constraints', (tester) async { await tester.pumpWidget(MaterialApp(home: Scaffold(body: BoundedNoChild()))); expect(tester.getSize(find.byKey(const ValueKey('c'))), const Size(100, 100)); });
testWidgets('Container with no child and no size shrinks to zero under UNBOUNDED parent constraints', (tester) async { await tester.pumpWidget(MaterialApp(home: Scaffold(body: UnboundedNoChild()))); expect(tester.getSize(find.byKey(const ValueKey('c'))), Size.zero); });}00:00 +0: Container with no child and no size fills BOUNDED parent constraints00:00 +1: Container with no child and no size shrinks to zero under UNBOUNDED parent constraints00:00 +2: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__core-layout-widgets/ run on this Mac)
Under a bounded SizedBox(100, 100), the bare Container genuinely fills it (100x100, proven by getSize). UnconstrainedBox deliberately hands its child unbounded constraints (proven distinctly in the Constraints & Layout lesson’s OverflowBox test) — and under THAT, the same bare Container shrinks to Size.zero, exactly as its own doc comment describes, not “as big as possible” in both cases.
Container is a convenience that composes several simpler widgets — padding, margin, decoration, constraints, alignment — into one:
flowchart LR container["Container"] --> align["Align"] container --> pad["Padding"] container --> deco["DecoratedBox"] container --> box["ConstrainedBox"]
For a single job, prefer the single-purpose widget — it is clearer and often lets you keep a const constructor:
Padding— insets only.SizedBox— fixed size (or aconst SizedBox(height: 8)as a cheap spacer).Center/Align— positioning.DecoratedBox— background/border only.
Proven: Wrap does not overflow where Row does
Section titled “Proven: Wrap does not overflow where Row does”Row never breaks a line — content too wide simply overflows (the real, exact RenderFlex overflowed error proven in the Constraints & Layout lesson). Wrap lays children along a main axis just like Row/Column, but moves to a new “run” (a new line) instead of overflowing when it runs out of space:
import 'package:flutter/material.dart';
class WrapDemo extends StatelessWidget { const WrapDemo({super.key}); @override Widget build(BuildContext context) { // Two 300-wide boxes in only 400px of width — would overflow a Row // (proven in the Constraints & Layout lesson) but not a Wrap. return SizedBox( width: 400, child: Wrap( children: [ SizedBox(width: 300, height: 50, key: const ValueKey('a')), SizedBox(width: 300, height: 50, key: const ValueKey('b')), ], ), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/wrap_demo.dart';
void main() { testWidgets('Wrap moves overflowing content to a new line instead of throwing', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: WrapDemo())));
expect(tester.takeException(), isNull, reason: 'unlike a Row with the same content, Wrap never overflows');
// "b" doesn't fit next to "a" (300+300 > 400), so it wraps to a new // run — a strictly lower y offset than "a", same x (both runs start at 0). final aTopLeft = tester.getTopLeft(find.byKey(const ValueKey('a'))); final bTopLeft = tester.getTopLeft(find.byKey(const ValueKey('b'))); expect(aTopLeft, const Offset(0, 0)); expect(bTopLeft, const Offset(0, 50), reason: 'wrapped to a new run, directly below "a"'); });}00:00 +0: Wrap moves overflowing content to a new line instead of throwing00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__core-layout-widgets/ run on this Mac)
Use Wrap for chip lists, tag clouds, and anything where “just start a new line” is the correct overflow behavior — Row’s hard-error-on-overflow is a feature for catching real layout bugs, not something to route around by switching to Wrap everywhere.
Proven: AspectRatio sizes itself within whichever dimension is unconstrained
Section titled “Proven: AspectRatio sizes itself within whichever dimension is unconstrained”AspectRatio tries to size itself so width / height equals the given ratio, but it can only do so within the freedom its incoming constraints actually allow. Give it a tight width and a loose height, and it picks the height that satisfies the ratio:
import 'package:flutter/material.dart';
class AspectDemo extends StatelessWidget { const AspectDemo({super.key}); @override Widget build(BuildContext context) { return Center( // width: 300 is tight; height is left loose (up to the screen). child: SizedBox( width: 300, child: AspectRatio( aspectRatio: 2, // width:height = 2:1 child: ColoredBox(color: Colors.blue, key: const ValueKey('c')), ), ), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/aspect_ratio_demo.dart';
void main() { testWidgets('AspectRatio picks the free dimension to satisfy the ratio, given a tight width', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: AspectDemo())));
// Width is tight at 300 (from the SizedBox); height is free — AspectRatio // picks 150 so that 300 / 150 == 2, the requested ratio. expect(tester.getSize(find.byType(AspectRatio)), const Size(300, 150)); });}00:00 +0: AspectRatio picks the free dimension to satisfy the ratio, given a tight width00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__core-layout-widgets/ run on this Mac)
If BOTH dimensions are already tight (e.g. a plain SizedBox(width: 300, height: 300) wrapping it directly, with no Center in between), AspectRatio has no freedom left at all and simply keeps the tight size it was given — the ratio only wins when at least one dimension is actually free to choose.
Proven: FittedBox scales visually — it does not change its child’s layout size
Section titled “Proven: FittedBox scales visually — it does not change its child’s layout size”FittedBox scales and positions its child to fit the space FittedBox itself was given (BoxFit.contain by default), but the scaling is a paint-time transform, not a change to the child’s actual layout size:
import 'package:flutter/material.dart';
class FittedDemo extends StatelessWidget { const FittedDemo({super.key}); @override Widget build(BuildContext context) { return SizedBox( width: 100, height: 100, child: FittedBox( // A child that wants to be 200x100 — twice as wide as FittedBox itself. child: SizedBox(width: 200, height: 100, key: const ValueKey('c')), ), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/fitted_box_demo.dart';
void main() { testWidgets('FittedBox itself is constrained normally; its child keeps its own natural layout size', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: FittedDemo())));
// FittedBox respects the 100x100 its own parent gave it. expect(tester.getSize(find.byType(FittedBox)), const Size(100, 100));
// But the CHILD's actual layout size is still its own natural 200x100 — // FittedBox scales it visually at paint time, it does not shrink the // child's layout box to make it "fit". expect(tester.getSize(find.byKey(const ValueKey('c'))), const Size(200, 100)); });}00:00 +0: FittedBox itself is constrained normally; its child keeps its own natural layout size00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__core-layout-widgets/ run on this Mac)
This is a subtle but real distinction: getSize on the child reports its layout size (200x100, unchanged), while what you’d actually see on screen is scaled down to fit the 100x100 box — because FittedBox lays its child out once at the child’s natural size, then applies a Transform.scale-like paint transform around it. Hit-testing follows the painted (scaled) position, not the unscaled layout box.
Table lays children out in rows and columns where cell widths are coordinated across rows — the widest cell in a column sets that column’s width for every row, something Columns of Rows cannot do on their own (each Row would size its own cells independently). Reach for it for genuinely tabular data (a pricing comparison, a spreadsheet-like grid); for anything that doesn’t need cross-row column alignment, a Column/ListView of Rows (or, better, a data-grid package) is usually simpler.
Table( columnWidths: const {0: IntrinsicColumnWidth(), 1: FlexColumnWidth()}, children: const [ TableRow(children: [Text('Name'), Text('Description')]), TableRow(children: [Text('Flutter'), Text('Google\'s UI toolkit')]), ],)The golden-test option
Section titled “The golden-test option”matchesGoldenFile compares a rendered widget against a stored reference PNG — the strongest possible proof that a layout renders correctly, pixel for pixel. It genuinely runs headless in this course’s probe (verified directly: flutter test --update-goldens writes a real PNG, and a subsequent plain flutter test run passes by comparing against it) — but it is not shippable as a graded fence in this course: the harness’s fence convention only collects dart/yaml fences (spec §5) and has no mechanism to commit a reference PNG asset alongside a lesson, so a golden fence would fail on its very first run (no stored image to compare against) with no way to seed one through the harness. Run it locally instead:
# lib/badge_demo.dart contains the widget; run once to CREATE the reference:flutter test --update-goldens test/badge_demo_golden_test.dart# every subsequent run compares against the committed PNG:flutter test test/badge_demo_golden_test.darttestWidgets('badge renders correctly', (tester) async { await tester.pumpWidget(const MaterialApp(home: BadgeDemo())); await expectLater(find.byType(BadgeDemo), matchesGoldenFile('badge_demo.png'));});Mental Model
Section titled “Mental Model”Expanded vs Flexible is a landlord handing out a lease: Expanded says “this unit is exactly this size, move in and fill it, no negotiation” (tight); Flexible says “you may use up to this much space, but if your furniture is smaller, that’s fine too” (loose) — proven above, the SAME 50px-wide tenant ends up 300px under one lease and 50px under the other. Wrap is that same landlord refusing to let tenants overlap: when a “row” of tenants doesn’t fit, it starts a brand-new row rather than letting anyone spill over the wall (the hard Row overflow). FittedBox is a picture frame with a shrink-fit mat: the photo inside is still its own full size (proven above by getSize), the frame just visually presents it smaller — the photo itself was never reprinted.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Expanded | Predictable, fills exactly its share — proven above | Child cannot be smaller than its share even if it wants to be |
Flexible | Child keeps its natural size when it fits — proven above | Layout is less predictable if children’s natural sizes vary a lot |
Container | One widget for padding+decoration+size+alignment | Composes several widgets under the hood — for one job, the single-purpose widget is clearer/keeps const |
Wrap | Never overflows on the main axis — proven above | Cross-axis size becomes variable (number of runs depends on content) — harder to predict overall height |
AspectRatio | Enforces a ratio without hardcoding pixel sizes | Only has effect where at least one dimension is genuinely free — proven above, a fully-tight parent overrides it |
FittedBox | Visual scale-to-fit without reflowing the child’s own layout | Hit-testing/scroll math must account for the visual transform, not just the child’s own reported size |
Common Misconceptions
Section titled “Common Misconceptions”- “Container always fills its parent.” Proven false above: under unbounded constraints (e.g. inside
UnconstrainedBox), a childless, sizelessContainershrinks toSize.zero— exactly matching its own doc comment, not “always fills.” - “Expanded and Flexible are basically interchangeable.” Proven above with real
getSizeresults: the identical 50px-wide child ends up 300px underExpandedand stays 50px underFlexible— a real, measurable behavioral difference, not a style preference. - “Wrap is just Row with word-wrap turned on.” Proven above: it genuinely never throws the overflow error
Rowdoes for the same content — a structural difference in the layout algorithm, not a cosmetic one. - “FittedBox shrinks the child, so the child ‘becomes’ smaller.” Proven false above: the child’s own
getSizestill reports its full, unscaled natural size (200x100) —FittedBoxonly changes how it’s painted, not its actual layout size.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated Flutter layout code frequently defaults to Expanded wherever “make this fit the row” is the actual intent — when the real goal is “don’t let this get bigger than its share, but let it be smaller if its content is smaller,” Flexible is what’s meant, and the substitution is invisible until you actually measure the result, exactly as proven above.
// @expect-error compiles clean — Expanded here forces a checkbox column to stretch to fill leftover space, distorting the intended compact layoutclass SettingsRow extends StatelessWidget { const SettingsRow({super.key, required this.label}); final String label;
@override Widget build(BuildContext context) { return Row( children: [ Expanded(child: Text(label)), Expanded(child: Checkbox(value: true, onChanged: (_) {})), // should stay compact ], ); }}Before trusting generated Row/Column layout code, check:
- Is
Expandedused where the intent is actually “at most this much space, but stay compact if possible”?Flexible(proven above to preserve the child’s own natural size) is usually the correct choice for compact controls like checkboxes, icons, or short labels sharing a row with anExpandedtext field. - Is a bare, unsized
Containerrelied on to “fill the screen,” when it’s nested somewhere its parent might actually be unbounded (inside aRow, aListViewitem, anUnconstrainedBox)? Proven above, that silently shrinks to zero instead. - Is
Rowused for content whose length is data-driven (tags, chips) rather thanWrap? That’s a real overflow risk proven in the Constraints & Layout lesson, not just a style choice.
🐛 Spot the bug
class TagList extends StatelessWidget {const TagList({super.key, required this.tags});final List<String> tags;
@overrideWidget build(BuildContext context) { return Row( children: [ for (final tag in tags) Chip(label: Text(tag)), ], );}}Show the answer
A Row of Chips built from data-driven, unbounded-length tags is exactly the scenario proven earlier to throw the real RenderFlex overflowed error the moment the tags don’t fit on one line — Row never wraps. The fix is Wrap (proven in this lesson to move overflowing content to a new line instead of throwing): swap Row(children: [...]) for Wrap(children: [...]) with no other change needed, since both take a plain children list.