Skip to content
ExponaBLOG
FLUTTER · DAY 012 / 100

Layout: constraints go down, sizes go up for production

The rule that explains every Flutter layout bug.

Expona Admin9 min read

Flutter layout can feel mystical until you understand the core rule: layout: constraints go down, sizes go up. Once it clicks, every layout bug becomes solvable. I've spent countless hours debugging why a ListView throws "unbounded height" errors or why a Container doesn't size the way I expected—and every single time, it came down to misunderstanding how constraints flow through the widget tree.

The Three Rules of Flutter Layout

Flutter's layout system works in three distinct phases, and understanding them is non-negotiable if you want to ship reliable UIs. It's simpler than CSS, but it's also less forgiving of sloppy thinking. The flow is unidirectional and mechanical: constraints flow downward, sizes flow upward, and positioning happens last.

Here's the mental model: a parent widget doesn't tell its child "be 200 pixels wide." Instead, it says "you can be anywhere from 0 to 200 pixels wide—pick something in that range." The child then picks a size, and the parent decides where to place it. This is radically different from how many developers think layout works.

Constraints Down — Parents Tell Children Their Limits

When a parent lays out a child, it passes a BoxConstraints object that specifies the minimum and maximum width and height the child can adopt. The child receives these constraints and must respect them; it cannot choose a size outside this boundary.

Let me show you a concrete example. Imagine a Column with a child Container:

dart
Column(
  children: [
    Container(
      color: Colors.blue,
      child: Text('Hello'),
    ),
  ],
);

What constraints does the Container receive? The Column says: "You can be as wide as I am (the screen width, let's say 400 pixels), but your minimum width is 0. Your height can be anything from 0 up to whatever space I have left after my other children." Those are the constraints passed down.

Now consider a more complex scenario—a Center widget wrapping a Container:

dart
Center(
  child: Container(
    width: 100,
    height: 100,
    color: Colors.green,
  ),
);

Center passes loose constraints to its child—essentially "you can be whatever size you want, as long as it's not bigger than the screen." The Container receives these loose constraints, sees that you've explicitly set width: 100 and height: 100, and chooses exactly that size.

The key insight: the child always receives constraints from the parent. Those constraints define a valid range. The child must pick a size within that range.

Sizes Up — Children Choose a Size Within Constraints

Once a child receives constraints, it must decide its own size. A Text widget measures its content and returns a size. A Container with explicit width and height returns those values. A Row sizes itself based on its children and spacing. The child's size must always fall within the constraints it received.

Here's where many developers trip up: a child that receives tight constraints (a fixed min and max) has no choice—it must be exactly that size. A child that receives loose constraints has freedom to pick something smaller.

Let me illustrate with code:

dart
// Scenario 1: Tight constraints
SizedBox(
  width: 200,
  height: 100,
  child: Container(
    color: Colors.red,
    // The Container receives tight constraints: 
    // minWidth=200, maxWidth=200, minHeight=100, maxHeight=100.
    // It must be exactly 200x100, regardless of content.
  ),
);
dart
// Scenario 2: Loose constraints
Align(
  alignment: Alignment.topLeft,
  child: Container(
    width: 150,
    height: 75,
    color: Colors.blue,
    // Align passes loose constraints to Container.
    // Container receives "be as big as you want, up to screen size."
    // Container has explicit width/height, so it chooses 150x75.
  ),
);
dart
// Scenario 3: Child sizing itself based on content
Padding(
  padding: EdgeInsets.all(16),
  child: Text('This text sizes itself based on content'),
  // Padding passes loose constraints.
  // Text measures its content and returns that size.
  // Text's size is within the loose constraints, so it works.
);

The pattern: constraints define the boundary, the child picks a size, and that size flows back up the tree.

Parents Position — The Parent Decides Where to Place the Child

After a child chooses its size, the parent decides where to place it. The parent knows the child's size and its own available space, so it can align, center, or position the child however it wants.

A Column positions children vertically, one after another. A Center places the child in the middle. A Stack lets you position children explicitly using Positioned or alignment.

Here's a practical example:

dart
Column(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  children: [
    Container(width: 100, height: 50, color: Colors.red),
    Container(width: 100, height: 50, color: Colors.green),
    Container(width: 100, height: 50, color: Colors.blue),
  ],
);

Each Container has explicit size, so it chooses 100x50. The Column then decides how to position these three children vertically. The mainAxisAlignment: MainAxisAlignment.spaceEvenly means the Column will space them equally down the vertical axis.

Positioning is where layout intent becomes visible. Constraints and sizing happen mechanically; positioning is where aesthetics come in.

Unbounded Errors — The Most Common Layout Trap

Now we get to the error that catches every Flutter developer at some point: "RenderFlex children have non-zero flex but incoming height constraints are unbounded."

This error happens when you try to place a scrollable widget (like ListView) inside a Column:

dart
Column(
  children: [
    Text('Header'),
    ListView(
      children: [
        // Items here
      ],
    ),
  ],
);

Why does this fail? The Column passes loose constraints to ListView: "you can be as tall as you want." A ListView is infinite by default—it wants to be as tall as all its children combined. But the Column says "pick a height within my available space." ListView says "I don't know my height until I build all children." Deadlock. Error.

The fix: use Expanded to convert the loose constraints into tight constraints:

dart
Column(
  children: [
    Text('Header'),
    Expanded(
      child: ListView(
        children: [
          // Items here
        ],
      ),
    ),
  ],
);

Expanded tells the Column: "give me all remaining vertical space and make it tight." Now ListView receives a tight height constraint and can render. It knows exactly how much space it has and can scroll within it.

Another variant: use SizedBox.expand() to explicitly constrain size:

dart
Column(
  children: [
    Text('Header'),
    SizedBox.expand(
      child: ListView(
        children: [
          // Items here
        ],
      ),
    ),
  ],
);

Or set a fixed height:

dart
Column(
  children: [
    Text('Header'),
    SizedBox(
      height: 300,
      child: ListView(
        children: [
          // Items here
        ],
      ),
    ),
  ],
);

The pattern: scrollable widgets need bounded constraints. If you see "unbounded" errors, look for a scrollable widget receiving infinite constraints from its parent.

Common Mistakes

  • Forgetting that constraints are bidirectional logic. Developers often think "I'll just set width: 500 and it'll work." But if the parent's max width is 300, that explicit 500 will be clamped. Always think about both what you're asking for and what the parent allows.
  • Wrapping ListView in a Column without Expanded. This is the single most common layout error I see in production code. ListView needs bounded height; a Column provides unbounded height. Always use Expanded or SizedBox with explicit height.
  • Using Row or Column as a direct child of a scrollable. If you nest a Row inside a ListView, the Row receives tight width constraints and loose height constraints. If the Row has a tall child, things break. Wrap in Container with explicit height or use mainAxisSize: MainAxisSize.min on the Row.
  • Not understanding flex and Expanded. Expanded doesn't just take remaining space—it sets flex: 1, which tells the parent "claim an equal share of available space." If you have two Expanded children, they split the space 50/50. If one has flex: 2, it gets twice the space. This is powerful but confusing if you're not thinking in terms of constraints.
  • Assuming explicit size always wins. If you set width: 500 on a Container but the parent's maxWidth is 300, the parent wins. Constraints always win over explicit sizing.

Real-World Scenario: Building a Chat Screen

Let me walk through a realistic layout to tie this together. A chat screen has a header, a scrollable message list, and an input field at the bottom.

dart
class ChatScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Chat')),
      body: Column(
        children: [
          // Message list takes all available space
          Expanded(
            child: ListView.builder(
              itemCount: 100,
              itemBuilder: (context, index) {
                return Padding(
                  padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
                  child: Text('Message $index'),
                );
              },
            ),
          ),
          // Input field takes fixed space
          Padding(
            padding: EdgeInsets.all(16),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    decoration: InputDecoration(
                      hintText: 'Type a message',
                      border: OutlineInputBorder(),
                    ),
                  ),
                ),
                SizedBox(width: 8),
                IconButton(
                  icon: Icon(Icons.send),
                  onPressed: () {},
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Breaking down the constraints:

  1. Column receives tight constraints from Scaffold's body area. It passes those down to its children.
  2. Expanded tells the Column: "I want flex: 1, give me all remaining vertical space." Column allocates space proportionally. Expanded receives tight height constraints equal to the remaining space after the input field.
  3. ListView now has bounded height and can render and scroll.
  4. The Padding around the input field takes a natural size based on its children. The TextField inside is wrapped in Expanded, so it takes all horizontal space minus the button.

This layout is robust. The message list grows or shrinks based on available space, and the input field always stays at the bottom.

Key Takeaways

  • Constraints flow down from parent to child. The parent says "here are your size limits." The child must respect them.
  • Sizes flow up. The child picks a size within its constraints and returns it. The parent uses this information to position the child.
  • Parents position children last, after children have chosen their sizes. This is when alignment and spacing logic applies.
  • Unbounded errors almost always mean a scrollable widget is receiving infinite constraints. Use Expanded, SizedBox, or explicit height to bound it.
  • Explicit sizing doesn't override parent constraints. If you set width: 500 but the parent's maxWidth is 300, you get 300. Constraints always win.
  • Master this rule, and layout debugging shifts from guesswork to logical deduction. Once you think in constraints, every layout problem becomes solvable.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles