Widgets and layout
The core widget vocabulary and the layout algorithm underneath it. Once the constraint model is clear, most layout bugs stop being mysteries.
Contents
- The layout algorithm
- Constraints
- Single-child layout widgets
- Row, Column, and Flex
- Stack
- Scrolling and slivers
- Content widgets
- Responding to size
- Theming
- Common layout errors
- Debugging layout
The layout algorithm
Flutter lays out in a single pass, and the whole model is three sentences:
- A parent passes constraints down to its child.
- The child chooses its size within those constraints and passes it up.
- The parent sets the child's position.
A child never knows or chooses its position, and a parent never dictates an
exact size unless its constraints are tight. This is why layout is O(n) and
why a widget cannot ask "how big is my parent?" — it can only be told what range
it is allowed to occupy.
Constraints
BoxConstraints carries four numbers: min/max width and min/max height.
| Kind | Meaning |
|---|---|
| Tight | min equals max — the child must be exactly this size |
| Loose | min is zero, max is bounded — the child may be anything up to max |
| Unbounded | max is infinity — usually inside a scroll view or an unconstrained flex |
| Expanded | the child is told to fill available space |
// Tight: child has no choice
SizedBox(width: 100, height: 40, child: child)
// Loose: child may be smaller
Align(alignment: Alignment.center, child: child)
// Unbounded height: child must size itself
SingleChildScrollView(child: child)
Most "why is my widget the wrong size?" questions resolve to: the constraints
arriving at this widget are not what you assumed. LayoutBuilder or the Layout
Explorer will show you what they actually are.
Single-child layout widgets
| Widget | Purpose |
|---|---|
Padding | Insets the child |
Align / Center | Positions a loosely constrained child |
SizedBox | Fixed size, or a gap when it has no child |
ConstrainedBox | Applies additional min/max constraints |
FittedBox | Scales the child to fit |
AspectRatio | Sizes to a width/height ratio |
FractionallySizedBox | Sizes as a fraction of the incoming constraints |
Transform | Applies a Matrix4 at paint time — does not affect layout |
DecoratedBox | Paints a decoration behind or in front of the child |
Container is a convenience that composes several of these. It is fine in app
code, but when you only need one behaviour, the specific widget is clearer and
cheaper — Padding rather than Container(padding: ...).
Row, Column, and Flex
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, // along the row
crossAxisAlignment: CrossAxisAlignment.center, // across it
mainAxisSize: MainAxisSize.max, // fill or hug
children: [
const Icon(Icons.person),
const SizedBox(width: 8),
Expanded(child: Text(name, overflow: TextOverflow.ellipsis)),
IconButton(icon: const Icon(Icons.more_vert), onPressed: _openMenu),
],
)
How a Row allocates space:
- Lay out every non-flex child with unbounded main-axis constraints.
- Divide the remaining space among
ExpandedandFlexiblechildren by theirflexvalues. - Size itself:
MainAxisSize.maxfills the incoming constraint,MainAxisSize.minhugs its children.
Expanded forces the child to fill its share; Flexible lets the child be
smaller. Spacer is an Expanded with an empty child. Wrapping text in
Expanded is the standard fix for horizontal overflow.
Stack
Stack(
fit: StackFit.loose,
children: [
Image.asset('assets/cover.jpg', fit: BoxFit.cover),
Positioned(
left: 16,
bottom: 16,
child: Text(title, style: Theme.of(context).textTheme.headlineSmall),
),
],
)
Non-positioned children are sized by the stack's constraints and aligned by
alignment; the first non-positioned child determines the stack's size when
fit is loose. Positioned children are laid out against the stack's edges.
Later children paint on top.
Scrolling and slivers
For a simple list, ListView.builder builds only what is visible:
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ProductTile(product: items[index]),
)
For anything that combines scroll effects — a collapsing header over a grid, several list sections — use slivers, which are the protocol underneath every scroll view:
CustomScrollView(
slivers: [
const SliverAppBar(pinned: true, expandedHeight: 200, flexibleSpace: ...),
SliverList.builder(
itemCount: items.length,
itemBuilder: (context, i) => ProductTile(product: items[i]),
),
const SliverToBoxAdapter(child: Footer()),
],
)
Never put a ListView directly inside a Column without bounding its height —
the column offers unbounded height and the list wants infinite size. Use
Expanded, a fixed SizedBox, or shrinkWrap: true (which costs a full layout
of every child, so prefer the first two).
Content widgets
Text(
'Order summary',
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
Image.asset('assets/logo.png', width: 120, cacheWidth: 240)
Image.network(url, loadingBuilder: ..., errorBuilder: ...)
Always set cacheWidth/cacheHeight (or use ResizeImage) for images that are
displayed far smaller than their source. Decoding a 4000-pixel photo into a
100-pixel avatar is one of the most common memory problems in Flutter apps —
see DevTools and performance.
Material 3 buttons, in rough order of emphasis: FilledButton,
ElevatedButton, OutlinedButton, TextButton, plus IconButton and
FloatingActionButton. A null onPressed renders the disabled state — that
is the intended way to disable a button.
Responding to size
| Tool | Gives you |
|---|---|
LayoutBuilder | The constraints from the parent, at build time |
MediaQuery.sizeOf(context) | Window size, safe areas, text scale, platform brightness |
OrientationBuilder | Portrait or landscape |
SafeArea | Padding for notches and system bars |
Prefer MediaQuery.sizeOf(context) over MediaQuery.of(context).size: the
scoped accessors only rebuild your widget when that specific property changes,
instead of on every metric change including keyboard insets.
Break layouts at content-driven widths rather than device names:
LayoutBuilder(
builder: (context, constraints) => constraints.maxWidth >= 900
? const _TwoPaneLayout()
: const _SinglePaneLayout(),
)
Theming
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1F4FD8)),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1F4FD8),
brightness: Brightness.dark,
),
useMaterial3: true,
),
themeMode: ThemeMode.system,
)
Read colours and text styles from the theme rather than hard-coding them —
Theme.of(context).colorScheme.primary,
Theme.of(context).textTheme.bodyMedium. That is what makes dark mode and
rebranding a configuration change instead of a search-and-replace.
Common layout errors
| Message | Cause | Fix |
|---|---|---|
A RenderFlex overflowed by N pixels | Children need more main-axis space than the row/column has | Expanded, Flexible, ellipsis, or make it scrollable |
Vertical viewport was given unbounded height | A scroll view inside a Column or another scroll view | Bound it with Expanded or a SizedBox |
RenderBox was not laid out | A layout error was thrown earlier and swallowed | Fix the first exception in the console; the rest are fallout |
Incorrect use of ParentDataWidget | Expanded/Positioned under the wrong parent | Expanded needs a Flex ancestor; Positioned needs a Stack |
Infinite size in an IntrinsicHeight | Intrinsics need a bounded child | Avoid intrinsics — they cost an extra layout pass |
Debugging layout
- Flutter Inspector — select a widget on the device and see its place in the tree, its constraints, and its size.
- Layout Explorer — visualises flex factors and alignment for a
RoworColumn, and lets you change them live. debugPaintSizeEnabled = true— draws layout bounds and padding.Textwith a colouredContainerbehind it is still the fastest way to see where the space is actually going.
Details of the pipeline that runs all of this are in Rendering pipeline. Next: Animation.