Flutter Layouts II: Stack & Constraints
This is Part 6 of the Flutter Fundamentals series. Part 5 placed widgets side by side. Now two things: how to overlap widgets (Stack/Positioned), and — the big one — the constraints model that governs all Flutter layout. The constraints model is the single concept that turns Flutter layout from "mysterious" ("why is my Container huge?") into "predictable." We'll end the guessing here.
Stack: overlapping widgets
Row/Column lay children out in a line. Stack layers them on top of each other, like sheets of paper in a pile — later children paint over earlier ones.
Stack(
children: [
Container(width: 200, height: 200, color: Colors.blue), // bottom
Container(width: 100, height: 100, color: Colors.red), // on top
const Text('On top of everything'), // topmost
],
)
Use a Stack for badges on icons, text over an image, a floating button over content, overlays/scrims — anything where widgets occupy the same space.
Positioning within a Stack: Positioned
By default, non-positioned children are placed according to the Stack's alignment (top-start by default). To pin a child to specific edges, wrap it in Positioned:
Stack(
children: [
const CircleAvatar(radius: 40, child: Icon(Icons.person)),
Positioned(
right: 0,
top: 0,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle),
child: const Text('3', style: TextStyle(color: Colors.white)),
),
),
],
)
Positioned takes top, right, bottom, left, width, height. Specify any combination:
- Give
top+left→ pin to that corner. - Give
left+right(no width) → stretch horizontally between them. - A child not wrapped in
Positionedis laid out by the Stack'salignment.
Positionedonly works as a direct child of aStack. It's one of a few "parent data" widgets (likeExpandedinside aRow) that only mean something inside their specific parent.
Sizing a Stack
A Stack sizes itself to its largest non-positioned child (subject to its constraints). Positioned children don't influence the Stack's size. To make a Stack fill its parent regardless, use fit: StackFit.expand or wrap in a SizedBox.expand.
The constraints model: the rule that explains everything
Now the centerpiece. Every layout question in Flutter — "why is this too big / too small / ignored?" — is answered by one sentence, straight from the official docs:
Constraints go down. Sizes go up. Parent sets position.
Read it three times. It describes how every widget gets laid out, in three steps:
- Constraints go down. A parent passes each child a
BoxConstraints— a min/max width and min/max height — saying "you must be somewhere in this range." - Sizes go up. Each child picks its own size within those constraints and reports it back up to the parent.
- Parent sets position. The parent, now knowing the child's size, decides where to place it.
It's a single downward-then-upward pass over the tree — which is why Flutter layout is O(n) (fast): each widget is visited essentially once.
The negotiation, dramatized
From the docs, the conversation between a widget and its child:
Widget: "Hey parent, what are my constraints?" Parent: "You must be from
0to300wide, and0to85tall." Widget: "I want5px of padding, so my child can be at most290×75." Widget (to child): "You must be from0to290wide, and0to75tall." Child: "OK, I'll be290×20." (parent positions the child, then reports its own size upward)
That back-and-forth is literally how layout runs. Once you can hear this conversation in your head, layout stops being magic.
Tight vs loose constraints
A BoxConstraints can be tight or loose, and this distinction explains most surprises:
- Tight — min == max. The parent says "you must be exactly this size." No choice. (Example: the screen forces your root widget to be exactly the screen's size.)
- Loose — min is 0. "You can be anywhere from nothing up to this max — your call." (Example:
Centersays "be any size you like, up to my bounds.")
This is the key to the most-asked Flutter layout question of all time...
The famous gotcha: "why does Container(width: 100) fill the screen?"
// You expect a 100×100 red box. You get a full-screen red box. Why?!
Scaffold(
body: Container(width: 100, height: 100, color: Colors.red),
)
Why: the Scaffold body gives the Container tight constraints equal to the whole screen — "you must be screen-sized." A Container with a child of its own size obeys; but the rule is that a tightly-constrained widget can't pick its own size — the parent already forced it. The width: 100 is requested, but the tight constraint overrides it.
The fix — introduce a widget that loosens the constraints, like Center (or Align):
// Now you get a real 100×100 box.
Scaffold(
body: Center(
child: Container(width: 100, height: 100, color: Colors.red),
),
)
Center takes the tight (full-screen) constraints, but passes loose constraints down to its child ("be up to full-screen, but you choose"). Now the Container is free to be 100×100, and Center positions it in the middle. This single pattern — wrap in Center/Align to escape tight constraints — solves a huge fraction of "my size is being ignored" bugs.
More consequences worth internalizing
The official docs distill the limitations that fall out of the one-pass model:
- A widget can only be as big as its parent allows. It can't be any size it wants — it must obey the incoming constraints.
- A widget doesn't choose its own position — its parent does. (So you can't "move yourself"; you ask a parent like
Align/Positioned/Paddingto move you.) - Size & position depend on the whole tree. You can't determine a widget's size in isolation; it's the result of constraints flowing down from every ancestor.
A few widgets and how they behave under constraints:
- Tries to be as big as possible:
Center(when given a child... it centers, but itself fills),ListView, an emptyContainer. - Tries to match its child:
Transform,Opacity,SizedBox.shrink. - Tries to be a specific size:
Image,Text,SizedBox(width:.., height:..).
Why an empty
Containerfills its parent but aContainerwith a small child shrinks to it: an unconstrainedContainerwith no size and no child decides to be as big as possible; give it a child (and loose constraints) and it sizes to the child. This is a design choice by the Container authors — when in doubt, the answer is "read what constraints the parent gives, then read what that specific widget does with them."
Tools for bending constraints
When the default constraints aren't what you want, these widgets adjust them:
| Widget | Effect |
| --- | --- |
| Center / Align | loosen constraints + position the child |
| SizedBox(width, height) | impose a specific size (tight, if unconstrained) |
| ConstrainedBox | add extra min/max limits (can only tighten, never loosen what it's given) |
| UnconstrainedBox | let the child be its natural size (warns on overflow) |
| FittedBox | scale the child to fit the available space |
| LayoutBuilder | read the incoming constraints and build differently |
A subtle but important one: ConstrainedBox can only add constraints, never relax the ones it receives. If its parent already forces a tight size, the ConstrainedBox's own min/max are ignored — which is exactly Example 9 in the official "Understanding constraints" doc. Wrap it in a Center first to loosen, and its constraints take effect.
// Constraints ignored — the screen forces tight constraints through.
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 70, minHeight: 70),
child: Container(color: Colors.red, width: 10, height: 10),
)
// Now respected — Center loosens first, so the min applies (box becomes 70×70).
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 70, minHeight: 70),
child: Container(color: Colors.red, width: 10, height: 10),
),
)
LayoutBuilder: react to the constraints you're given
When you need to adapt to the available space (responsive layouts), LayoutBuilder hands you the incoming constraints so you can branch:
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 600) {
return const OneColumnLayout(); // phone
}
return const TwoColumnLayout(); // tablet/desktop
},
)
This is the constraints model exposed directly to you — "here's what the parent allows; build accordingly."
Debugging layout
When a layout misbehaves, your best friends are:
- Flutter DevTools → Layout Explorer — visualizes the constraints each widget receives and the size it chose. It literally shows "constraints go down, sizes go up" for your tree.
debugPaintSizeEnabled = true— paints box outlines so you can see where things actually are.- The mental script: "What constraints does the parent give this widget? What does this particular widget do with them?" Almost every layout bug answers to those two questions.
Practice Challenges
Challenge 1 — Badge on an avatar. Use a Stack + Positioned to put a small red dot at the top-right of a CircleAvatar.
Show solution
Stack(
children: [
const CircleAvatar(radius: 30, child: Icon(Icons.person)),
Positioned(
right: 0, top: 0,
child: Container(width: 14, height: 14,
decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle)),
),
],
)
Positioned pins the dot to the corner; it must be a direct child of the Stack.
Challenge 2 — Explain the full-screen box. A Container(width: 100, height: 100) as a Scaffold body fills the screen. Explain why and fix it.
Show solution
The body gives the Container tight constraints equal to the screen, and a tightly-constrained widget can't choose its own size — so width: 100 is overridden and it fills the screen. Fix by wrapping in Center (or Align), which passes loose constraints down, letting the Container actually be 100×100:
Center(child: Container(width: 100, height: 100, color: Colors.red))
Challenge 3 — Recite the rule. State the three-part layout rule and what each part means.
Show solution
"Constraints go down. Sizes go up. Parent sets position." (1) A parent passes each child min/max width & height constraints. (2) The child chooses its size within those constraints and reports it upward. (3) The parent, knowing the child's size, positions it. One down-then-up pass → O(n).
Challenge 4 — Why are these constraints ignored? A ConstrainedBox(minWidth: 70) around a tiny Container doesn't enforce 70px when used directly as a body. Why, and how do you make it work?
Show solution
ConstrainedBox can only add constraints, not relax incoming ones. As a body it receives tight (full-screen) constraints, which it passes straight through, so its own minWidth has no room to act. Wrap it in a Center first to loosen the constraints; then the minWidth: 70 applies and the box becomes at least 70px wide.
Challenge 5 — Responsive switch. Show a two-column layout on wide screens and one column on narrow, using the constraints.
Show solution
LayoutBuilder(builder: (context, constraints) {
return constraints.maxWidth < 600
? const OneColumnLayout()
: const TwoColumnLayout();
});
LayoutBuilder exposes the incoming constraints so you can branch on available width.
Questions to test yourself
Q1 (basic). What does a Stack do, and how do you position a child at a specific corner?
Show answer
A Stack layers its children on top of each other (later children paint over earlier ones). To pin a child to specific edges, wrap it in a Positioned (giving top/right/bottom/left); Positioned must be a direct child of the Stack. Non-positioned children are placed by the Stack's alignment.
Q2 (basic). State Flutter's three-part layout rule.
Show answer
"Constraints go down. Sizes go up. Parent sets position." Constraints (min/max width & height) flow from parent to child; each child picks a size within them and reports it back up; the parent then positions the child.
Q3 (intermediate). What's the difference between tight and loose constraints?
Show answer
Tight: min == max — the parent forces an exact size, the child has no choice (e.g. the screen forcing the root widget's size). Loose: min is 0 — the child may be any size up to the max (e.g. Center letting its child be any size up to the screen). Loose constraints let a child choose its own size; tight ones don't.
Q4 (intermediate). Why does a Container(width: 100) fill the screen when used as a Scaffold body, and what's the fix?
Show answer
The body gives the Container tight constraints equal to the screen, and a tightly-constrained widget can't pick its own size — so its width: 100 is overridden and it fills the screen. Wrap it in Center (or Align), which passes loose constraints down, letting the Container actually take 100×100 and be positioned in the middle.
Q5 (intermediate). Can a widget decide its own position? Who can move it?
Show answer
No — a widget cannot choose its own position; its parent sets it. To move a widget, you wrap it in a parent that positions children: Align/Center, Padding, Positioned (inside a Stack), or use the flex alignment of a Row/Column.
Q6 (advanced). Why does ConstrainedBox sometimes appear to ignore its constraints, and why is the constraints model O(n)?
Show answer
ConstrainedBox can only add (tighten) constraints — it can't loosen what its parent gives it. If the parent imposes tight constraints, ConstrainedBox passes them through and its own min/max have no effect (wrap in Center to loosen first). The model is O(n) because layout is a single pass: constraints flow down once and sizes flow up once, so each RenderObject is visited essentially once — no expensive back-tracking or multiple passes.
Wrapping up
You now understand why Flutter layouts behave the way they do:
Stacklayers widgets;Positioned(a direct Stack child) pins them to edges; the Stack sizes to its largest non-positioned child.- The entire layout system is one rule: constraints go down, sizes go up, parent sets position — a single O(n) pass.
- Tight constraints force a size; loose ones let the child choose. Most "my size is ignored" bugs are tight constraints — wrap in
Center/Alignto loosen. - A widget can't pick its own position; a parent does.
ConstrainedBoxcan only add constraints. - Use
LayoutBuilderto adapt to available space and DevTools' Layout Explorer to debug.
You can now build and arrange real screens. But so far we've been editing code and... how do those edits show up so fast? In Part 7 we look under the hood of the feature that makes Flutter development feel magical: hot reload vs hot restart — what actually happens.