DEV Community

Nayden Gochev
Nayden Gochev

Posted on

Flutter Layout — Complete Guide

Flutter Layout — Complete Guide

1. The Core Mental Model

Flutter layout runs on one rule, repeated at every level of the widget tree:

Constraints go down. Sizes go up. Position is set by the parent.

  • A parent passes constraints (min/max width, min/max height) down to its child.
  • The child looks at those constraints, decides its own size (within them), and passes that size back up.
  • The parent then decides where to place the child (its position), based on its own layout logic (Row, Column, Stack, etc.).
  • A widget cannot know or decide its own position — only its size, and only within the constraints given.
  • A parent cannot just tell a child "be this exact size" without constraints — it does so by passing tight constraints.

This single-pass, top-down/bottom-up system is why Flutter layout is fast (no repeated relayout passes like some browser engines) but also why certain patterns (like "make me the same size as my sibling") require special widgets to solve.

Constraints (BoxConstraints)

BoxConstraints(
  minWidth: 0, maxWidth: 300,
  minHeight: 0, maxHeight: 150,
)
Enter fullscreen mode Exit fullscreen mode
  • Tight constraints: min == max (e.g., BoxConstraints.tight(Size(100,100))). The child has no choice — it must be that exact size.
  • Loose constraints: min is 0, max is some value. The child can be anywhere from 0 up to that max.
  • Unbounded constraints: max is double.infinity. This is where the infamous "RenderBox was not laid out" or "BoxConstraints forces an infinite width" errors come from — usually caused by putting an unconstrained widget (like ListView or Column) inside another unconstrained widget (like inside a Row without Expanded, or inside a scrollable without a fixed height).

2. Everything Is a Widget

Widgets are configuration — immutable blueprints. Three widget trees exist under the hood:

  1. Widget tree — what you write (immutable, cheap to rebuild).
  2. Element tree — the "live" instantiation, persists across rebuilds, manages state.
  3. RenderObject tree — does the actual layout, painting, and hit-testing math.

For layout purposes, what matters is the RenderObject tree — every widget that affects layout ultimately creates or configures a RenderBox that participates in the constraints-down/sizes-up protocol.


3. Single-Child Layout Widgets

These take one child and adjust size/position/constraints.

Container

The Swiss-army-knife. Combines padding, margin, alignment, decoration, and constraints.

Container(
  width: 200,
  height: 100,
  padding: EdgeInsets.all(16),
  margin: EdgeInsets.symmetric(horizontal: 8),
  decoration: BoxDecoration(
    color: Colors.blue,
    borderRadius: BorderRadius.circular(12),
  ),
  child: Text('Hello'),
)
Enter fullscreen mode Exit fullscreen mode

Behavior depends on what's set:

  • If width/height given → tightens constraints to that size (if the parent allows).
  • If no size given but has a child → sizes to fit the child (adds padding).
  • If no size and no child → tries to expand to fill available space.

Padding

Adds space around a child, shrinking the constraints passed down by the insets.

Padding(padding: EdgeInsets.all(16), child: Text('Hi'))
Enter fullscreen mode Exit fullscreen mode

Center / Align

Align positions a child within itself according to Alignment (e.g., Alignment.topRight), and by default sizes itself to fill available space. Center is just Align with Alignment.center.

Align(alignment: Alignment.bottomRight, child: Icon(Icons.star))
Enter fullscreen mode Exit fullscreen mode

SizedBox

Forces exact width/height (tight constraints), or use SizedBox.expand() / SizedBox.shrink(). Also commonly used as a spacer:

SizedBox(height: 20) // vertical gap between widgets
Enter fullscreen mode Exit fullscreen mode

ConstrainedBox / UnconstrainedBox

ConstrainedBox adds additional constraints (it intersects with what the parent gives, doesn't override).

ConstrainedBox(
  constraints: BoxConstraints(minWidth: 100, maxWidth: 300),
  child: Text('Flexible width text'),
)
Enter fullscreen mode Exit fullscreen mode

FittedBox

Scales/fits a child within itself using BoxFit (cover, contain, fill, etc.) — useful for text or images that must shrink to fit.

AspectRatio

Forces the child into a specific width/height ratio, respecting incoming constraints as much as possible.

IntrinsicWidth / IntrinsicHeight

Sizes a child based on its "intrinsic" (natural/preferred) dimensions rather than the parent's constraints. Expensive (extra layout passes) — use sparingly, e.g., making buttons in a Row all match the height of the tallest one.

Transform

Applies a matrix transform (rotate, scale, skew, translate) at paint time — does not affect layout/constraints, only how it's drawn (so it can visually overflow its bounds without affecting siblings).


4. Multi-Child Layout Widgets

Row & Column (Flex)

Both are Flex under the hood — Row lays out horizontally, Column vertically.

  • Main axis: the direction of layout (horizontal for Row, vertical for Column).
  • Cross axis: perpendicular to that.
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  crossAxisAlignment: CrossAxisAlignment.center,
  mainAxisSize: MainAxisSize.max,
  children: [Widget1(), Widget2(), Widget3()],
)
Enter fullscreen mode Exit fullscreen mode

MainAxisAlignment (spacing along main axis):
start, end, center, spaceBetween, spaceAround, spaceEvenly

CrossAxisAlignment (alignment along cross axis):
start, end, center, stretch, baseline

MainAxisSize:

  • max (default) — Row/Column takes all available space on the main axis.
  • min — shrinks to fit just its children.

Key gotcha: A Row has unbounded width if placed inside something that gives infinite width (like a horizontally scrolling parent without explicit sizes) — this causes overflow errors. Non-flexible children in a Row/Column must fit within the available space, or you get the classic yellow-and-black striped overflow warning.

Expanded & Flexible

Used only inside Row/Column/Flex to control how children share leftover space along the main axis.

Row(
  children: [
    Expanded(flex: 2, child: Container(color: Colors.red)),
    Expanded(flex: 1, child: Container(color: Colors.blue)),
  ],
)
Enter fullscreen mode Exit fullscreen mode
  • Expanded = Flexible with FlexFit.tight — the child is forced to fill its share of space exactly.
  • Flexible with FlexFit.loose (default) — the child can be up to its share of space, but isn't forced to fill it.
  • flex — the relative weight (default 1) determining how leftover space is divided among flexible children.

Without Expanded/Flexible, children in a Row/Column take only their natural (intrinsic) size — if that combined size exceeds available space, you get an overflow.

Stack & Positioned

Layers children on top of each other (z-axis), like absolute positioning in CSS.

Stack(
  alignment: Alignment.center,
  fit: StackFit.loose,
  children: [
    Container(color: Colors.grey), // sized by non-positioned children or Stack itself
    Positioned(top: 10, right: 10, child: Icon(Icons.close)),
    Positioned.fill(child: Center(child: Text('Overlay'))),
  ],
)
Enter fullscreen mode Exit fullscreen mode
  • Children without Positioned are laid out first ("non-positioned"), sized according to fit (loose = as small as possible, expand = fill the Stack) and placed per alignment.
  • Positioned children are placed using top/bottom/left/right/width/height relative to the Stack's own bounds — these are not in the constraints-down flow the same way; the Stack determines its own size from non-positioned children (or fills available space if given tight constraints), then positions the Positioned ones absolutely within that.

Wrap

Like Row/Column, but overflows onto new lines/columns when out of space, instead of clipping/erroring.

Wrap(
  spacing: 8, runSpacing: 4,
  children: List.generate(10, (i) => Chip(label: Text('Item $i'))),
)
Enter fullscreen mode Exit fullscreen mode

ListView, GridView, and other scrollables

These are unbounded on the scroll axis internally — they build children lazily (ListView.builder) and give each child unbounded space along the scroll direction but bounded on the cross axis. This is why putting a ListView inside a Column without wrapping it in Expanded or giving it a fixed height throws a layout error (a Column gives unbounded height to its children by default when it's inside something scrollable, and ListView also wants unbounded height — two unbounded things fighting).

Column(
  children: [
    Text('Header'),
    Expanded(child: ListView(children: [...])), // now ListView gets bounded height
  ],
)
Enter fullscreen mode Exit fullscreen mode

GridView works similarly but lays children in a 2D grid — controlled via gridDelegate:

GridView.count(crossAxisCount: 3, children: [...])
// or
GridView.builder(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
  itemBuilder: (context, i) => Card(),
  itemCount: 20,
)
Enter fullscreen mode Exit fullscreen mode

CustomMultiChildLayout / CustomSingleChildLayout

Escape hatches for fully custom layout logic — you implement a LayoutDelegate/SingleChildLayoutDelegate and manually compute constraints/positions for each labeled child. Rarely needed unless building complex custom widgets.


5. Slivers (for scroll-heavy layouts)

CustomScrollView + slivers give fine control over scroll behavior — collapsing app bars, mixed lists/grids, sticky headers.

CustomScrollView(
  slivers: [
    SliverAppBar(expandedHeight: 200, pinned: true, flexibleSpace: FlexibleSpaceBar(title: Text('Title'))),
    SliverList(delegate: SliverChildBuilderDelegate((c, i) => ListTile(title: Text('$i')))),
    SliverGrid(gridDelegate: ..., delegate: ...),
  ],
)
Enter fullscreen mode Exit fullscreen mode

Slivers use a different protocol than boxes (SliverConstraints/SliverGeometry instead of BoxConstraints/Size), because they need to describe how much of themselves is currently visible within a scrolling viewport, not just a fixed size.


6. Responsive & Adaptive Layout

MediaQuery

Gives you the size/properties of the whole screen (or nearest ancestor MediaQuery):

final size = MediaQuery.of(context).size;
final isTablet = size.width > 600;
Enter fullscreen mode Exit fullscreen mode

Rebuilds the widget whenever the queried value changes (e.g., rotation), so avoid over-querying deep in performance-critical trees.

LayoutBuilder

Gives you the incoming constraints at that point in the tree (more precise than MediaQuery, which is screen-wide) — essential for building widgets that adapt to their parent's size, not the whole screen:

LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth > 600) {
      return WideLayout();
    }
    return NarrowLayout();
  },
)
Enter fullscreen mode Exit fullscreen mode

OrientationBuilder

Rebuilds based on portrait/landscape orientation specifically.

FractionallySizedBox

Sizes a child as a fraction of the parent's available space:

FractionallySizedBox(widthFactor: 0.8, child: Text('80% width'))
Enter fullscreen mode Exit fullscreen mode

7. Text Layout Specifics

Text has its own layout quirks:

  • Text wraps automatically within its constrained width; overflow: TextOverflow.ellipsis combined with maxLines prevents overflow errors.
  • softWrap, textAlign, textScaleFactor (deprecated in favor of TextScaler) all affect how much space text actually needs.
  • Wrapping Text in Expanded or Flexible inside a Row is often required, otherwise a long string can force the Row to overflow horizontally.

8. Common Errors & How to Read Them

Error Typical Cause Fix
"RenderFlex overflowed by X pixels" Children's natural size exceeds Row/Column space Wrap overflowing child in Expanded/Flexible, or use Wrap, or scroll
"BoxConstraints forces an infinite width/height" Unbounded widget (ListView, Column) inside another unbounded context Give explicit size, or wrap in Expanded/SizedBox/ConstrainedBox
"Vertical viewport was given unbounded height" ListView/Column with scrollable inside another scrollable/Column without constraints Wrap the scrollable in Expanded or set shrinkWrap: true (careful: perf cost)
"A RenderFlex overflowed" only in debug (yellow/black stripes) Same as above — it's a visual warning, not a crash Same fixes

Debugging tools:

  • debugPaintSizeEnabled = true — visualizes all box boundaries.
  • Flutter DevTools Layout Explorer — inspect constraints/flex factors visually per widget.
  • Widget Inspector — click any widget on screen to see its render tree info.

9. Performance Notes

  • Layout is generally a single pass per frame (parent asks child to lay out once) — but widgets like IntrinsicWidth/IntrinsicHeight force extra passes and are more expensive.
  • Rebuilds (setState) don't necessarily mean relayout — Flutter only recomputes layout/paint for subtrees whose constraints or geometry actually changed (RenderObject.markNeedsLayout is only called when necessary).
  • Keys (ValueKey, GlobalKey) matter for preserving state and identity across rebuilds when list order or widget type changes, but they don't directly affect the layout algorithm itself.

10. Quick Reference Cheat Sheet

  • Center something: Center or Align
  • Fixed size: SizedBox
  • Extra space around: Padding
  • Share space along a Row/Column: Expanded / Flexible
  • Overlap widgets: Stack + Positioned
  • Wrap onto new lines: Wrap
  • Scrollable list: ListView.builder
  • Adapt to parent size: LayoutBuilder
  • Adapt to screen size: MediaQuery
  • Percentage sizing: FractionallySizedBox
  • Force aspect ratio: AspectRatio
  • Fit content to box (image/text): FittedBox

Top comments (0)