Skip to main content

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

Flutter lays out in a single pass, and the whole model is three sentences:

  1. A parent passes constraints down to its child.
  2. The child chooses its size within those constraints and passes it up.
  3. 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.

KindMeaning
Tightmin equals max — the child must be exactly this size
Loosemin is zero, max is bounded — the child may be anything up to max
Unboundedmax is infinity — usually inside a scroll view or an unconstrained flex
Expandedthe 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

WidgetPurpose
PaddingInsets the child
Align / CenterPositions a loosely constrained child
SizedBoxFixed size, or a gap when it has no child
ConstrainedBoxApplies additional min/max constraints
FittedBoxScales the child to fit
AspectRatioSizes to a width/height ratio
FractionallySizedBoxSizes as a fraction of the incoming constraints
TransformApplies a Matrix4 at paint time — does not affect layout
DecoratedBoxPaints 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:

  1. Lay out every non-flex child with unbounded main-axis constraints.
  2. Divide the remaining space among Expanded and Flexible children by their flex values.
  3. Size itself: MainAxisSize.max fills the incoming constraint, MainAxisSize.min hugs 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

ToolGives you
LayoutBuilderThe constraints from the parent, at build time
MediaQuery.sizeOf(context)Window size, safe areas, text scale, platform brightness
OrientationBuilderPortrait or landscape
SafeAreaPadding 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

MessageCauseFix
A RenderFlex overflowed by N pixelsChildren need more main-axis space than the row/column hasExpanded, Flexible, ellipsis, or make it scrollable
Vertical viewport was given unbounded heightA scroll view inside a Column or another scroll viewBound it with Expanded or a SizedBox
RenderBox was not laid outA layout error was thrown earlier and swallowedFix the first exception in the console; the rest are fallout
Incorrect use of ParentDataWidgetExpanded/Positioned under the wrong parentExpanded needs a Flex ancestor; Positioned needs a Stack
Infinite size in an IntrinsicHeightIntrinsics need a bounded childAvoid 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 Row or Column, and lets you change them live.
  • debugPaintSizeEnabled = true — draws layout bounds and padding.
  • Text with a coloured Container behind 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.