Skip to content

Core Layout Widgets

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.

lib/expanded_vs_flexible.dart
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())]),
);
}
}
test/expanded_vs_flexible_test.dart
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 request
00:00 +1: Flexible lets the child be its own natural size (50) when that fits within its share
00: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:

lib/container_sizing.dart
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),
),
);
}
}
test/container_sizing_test.dart
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 constraints
00:00 +1: Container with no child and no size shrinks to zero under UNBOUNDED parent constraints
00: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"]
Container is composed from simpler widgets

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 a const 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:

lib/wrap_demo.dart
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')),
],
),
);
}
}
test/wrap_demo_test.dart
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 throwing
00: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:

lib/aspect_ratio_demo.dart
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')),
),
),
);
}
}
test/aspect_ratio_demo_test.dart
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 width
00: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:

lib/fitted_box_demo.dart
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')),
),
);
}
}
test/fitted_box_demo_test.dart
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 size
00: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')]),
],
)

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:

Terminal window
# 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.dart
testWidgets('badge renders correctly', (tester) async {
await tester.pumpWidget(const MaterialApp(home: BadgeDemo()));
await expectLater(find.byType(BadgeDemo), matchesGoldenFile('badge_demo.png'));
});

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.

ChoiceBenefitCost
ExpandedPredictable, fills exactly its share — proven aboveChild cannot be smaller than its share even if it wants to be
FlexibleChild keeps its natural size when it fits — proven aboveLayout is less predictable if children’s natural sizes vary a lot
ContainerOne widget for padding+decoration+size+alignmentComposes several widgets under the hood — for one job, the single-purpose widget is clearer/keeps const
WrapNever overflows on the main axis — proven aboveCross-axis size becomes variable (number of runs depends on content) — harder to predict overall height
AspectRatioEnforces a ratio without hardcoding pixel sizesOnly has effect where at least one dimension is genuinely free — proven above, a fully-tight parent overrides it
FittedBoxVisual scale-to-fit without reflowing the child’s own layoutHit-testing/scroll math must account for the visual transform, not just the child’s own reported size
  • “Container always fills its parent.” Proven false above: under unbounded constraints (e.g. inside UnconstrainedBox), a childless, sizeless Container shrinks to Size.zero — exactly matching its own doc comment, not “always fills.”
  • “Expanded and Flexible are basically interchangeable.” Proven above with real getSize results: the identical 50px-wide child ends up 300px under Expanded and stays 50px under Flexible — 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 Row does 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 getSize still reports its full, unscaled natural size (200x100) — FittedBox only changes how it’s painted, not its actual layout size.

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 layout
class 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 Expanded used 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 an Expanded text field.
  • Is a bare, unsized Container relied on to “fill the screen,” when it’s nested somewhere its parent might actually be unbounded (inside a Row, a ListView item, an UnconstrainedBox)? Proven above, that silently shrinks to zero instead.
  • Is Row used for content whose length is data-driven (tags, chips) rather than Wrap? 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;
@override
Widget 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.

What are Row and Column, fundamentally?
Proven with real getSize results in this lesson, what is the actual difference between Expanded and Flexible for the SAME 50px-wide child in a 300px Row?
Proven above, quoting Container's own doc comment, what happens to a childless, sizeless Container under UNBOUNDED parent constraints?
Proven with a real widget test above, how does Wrap behave differently from Row when content is too wide to fit?
Proven above, when does AspectRatio actually get to enforce its ratio on its own size?
Proven above with getSize on both FittedBox and its child, what does FittedBox actually change?