Lesson 13 of 77 intermediate

Layout System, Constraints & Flex

Constraints Go Down, Sizes Go Up — Master This or Fight Flutter Forever

Open interactive version (quiz + challenge)

Real-world analogy

Flutter's layout is like a parent giving a child a box to play in: 'You can be anywhere from 0 to 300 pixels wide.' The child decides its size: 'I'll be 200px wide.' Then the parent places it where it wants. The child doesn't decide where it goes — only how big it is within the allowed range. Flex is like sharing a pizza: Expanded eats as much as it can, Flexible takes what it needs.

What is it?

Flutter's layout system sends BoxConstraints down the tree and receives Sizes back up. Parents then position children with Offsets. Row and Column use a flex algorithm to distribute space. Understanding tight vs loose vs unbounded constraints explains every layout bug you'll encounter. LayoutBuilder enables constraint-aware responsive layouts.

Real-world relevance

In an offline-first field app, a job details screen uses a Column with the job header (fixed height), an Expanded ListView of task items (fills remaining space), and a fixed bottom action bar. LayoutBuilder on the action bar adapts between a single-column button layout (phone) and a row of buttons (tablet). This avoids RenderFlex overflow on small screens.

Key points

Code example

// Flutter Layout System, Constraints & Flex

import 'package:flutter/material.dart';

// --- 1. CONSTRAINTS DOWN, SIZES UP demo ---

class ConstraintDemo extends StatelessWidget {
  const ConstraintDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder( // Exposes parent's constraints
      builder: (context, constraints) {
        debugPrint('Available: ${constraints.maxWidth}x${constraints.maxHeight}');
        // Branch on available width — responsive layout
        if (constraints.maxWidth > 600) {
          return const _DesktopLayout();
        }
        return const _MobileLayout();
      },
    );
  }
}

// --- 2. Expanded vs Flexible ---

class FlexDemo extends StatelessWidget {
  const FlexDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // ROW: non-flex child first, then flex children share remaining space
        Row(
          children: [
            // Non-flex: takes its natural size (64px icon)
            const Icon(Icons.account_circle, size: 64),
            // Expanded: takes ALL remaining space after the icon
            Expanded(
              flex: 2,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: const [
                  Text('John Doe', style: TextStyle(fontWeight: FontWeight.bold)),
                  Text('Senior Engineer'),
                ],
              ),
            ),
            // Expanded(flex:1): gets half as much as flex:2
            Expanded(
              flex: 1,
              child: ElevatedButton(
                onPressed: () {},
                child: const Text('Edit'),
              ),
            ),
          ],
        ),

        const SizedBox(height: 16),

        // Flexible vs Expanded difference
        Row(
          children: [
            // Expanded: MUST fill its share (200px if total is 400px)
            Expanded(
              child: Container(color: Colors.blue, height: 50),
            ),
            // Flexible: CAN be smaller than its share
            Flexible(
              child: Container(
                color: Colors.green,
                height: 50,
                width: 80, // Takes only 80px even if more is available
              ),
            ),
          ],
        ),
      ],
    );
  }
}

// --- 3. FIX: ListView inside Column (common crash) ---

class JobDetailScreen extends StatelessWidget {
  const JobDetailScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          // Fixed header
          const _JobHeader(),

          // BAD: ListView in Column → "Vertical viewport unbounded height"
          // ListView(children: [...]),

          // GOOD: Wrap in Expanded — Column gives ListView its remaining space
          Expanded(
            child: ListView.builder(
              itemCount: 20,
              itemBuilder: (context, index) => ListTile(
                title: Text('Task #$index'),
              ),
            ),
          ),

          // Fixed bottom bar
          const _ActionBar(),
        ],
      ),
    );
  }
}

// --- 4. ConstrainedBox, SizedBox, FractionallySizedBox ---

class SizingDemo extends StatelessWidget {
  const SizingDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Tight: exactly 200x50
        SizedBox(
          width: 200,
          height: 50,
          child: ElevatedButton(onPressed: () {}, child: const Text('Login')),
        ),

        // At most 300px wide
        ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 300),
          child: const TextField(),
        ),

        // 80% of parent width
        FractionallySizedBox(
          widthFactor: 0.8,
          child: ElevatedButton(onPressed: () {}, child: const Text('Full Action')),
        ),
      ],
    );
  }
}

// --- 5. CustomMultiChildLayout ---

class BadgeOverlay extends StatelessWidget {
  final Widget child;
  final Widget badge;

  const BadgeOverlay({super.key, required this.child, required this.badge});

  @override
  Widget build(BuildContext context) {
    return CustomMultiChildLayout(
      delegate: _BadgeDelegate(),
      children: [
        LayoutId(id: 'child', child: child),
        LayoutId(id: 'badge', child: badge),
      ],
    );
  }
}

class _BadgeDelegate extends MultiChildLayoutDelegate {
  @override
  void performLayout(Size size) {
    final childSize = layoutChild('child', BoxConstraints.loose(size));
    positionChild('child', Offset.zero);

    final badgeSize = layoutChild(
        'badge', BoxConstraints.loose(Size(size.width / 3, size.height / 3)));
    positionChild(
        'badge',
        Offset(childSize.width - badgeSize.width,
            childSize.height - badgeSize.height));
  }

  @override
  bool shouldRelayout(_BadgeDelegate oldDelegate) => false;
}

class _JobHeader extends StatelessWidget {
  const _JobHeader();
  @override
  Widget build(BuildContext context) =>
      Container(height: 120, color: Colors.blueGrey, child: const Center(child: Text('Job Header')));
}

class _ActionBar extends StatelessWidget {
  const _ActionBar();
  @override
  Widget build(BuildContext context) =>
      Container(height: 64, color: Colors.blue, child: const Center(child: Text('Actions')));
}

class _DesktopLayout extends StatelessWidget {
  const _DesktopLayout();
  @override
  Widget build(BuildContext context) => const Row(children: [Text('Desktop')]);
}

class _MobileLayout extends StatelessWidget {
  const _MobileLayout();
  @override
  Widget build(BuildContext context) => const Column(children: [Text('Mobile')]);
}

Line-by-line walkthrough

  1. 1. LayoutBuilder exposes parent BoxConstraints — used for responsive branching
  2. 2. constraints.maxWidth > 600 → desktop layout, else mobile
  3. 3. Row with Icon (natural size) + Expanded(flex:2) + Expanded(flex:1) — 0%, 67%, 33% of remaining space
  4. 4. flex:2 child gets twice the space of flex:1 child
  5. 5. Flexible with explicit width — takes only 80px even if more is available
  6. 6. Column + Expanded wrapping ListView — Column's remaining space given to ListView
  7. 7. SizedBox creates tight constraints — child MUST be exactly that size
  8. 8. ConstrainedBox adds maxWidth — child can be smaller but not larger
  9. 9. FractionallySizedBox uses parent's width as reference — 80% of parent
  10. 10. CustomMultiChildLayout: layoutChild gives constraints, positionChild places result

Spot the bug

Widget build(BuildContext context) {
  return Row(
    children: [
      Column(
        children: [
          Text('Name'),
          Text('email@example.com'),
        ],
      ),
      ElevatedButton(onPressed: () {}, child: Text('Edit')),
    ],
  );
}
Need a hint?
What happens when the email text is very long? What constraint does Column receive from Row?
Show answer
Row gives Column a loose (0 to maxWidth) constraint. Column is as wide as its widest child. If 'email@example.com' is very long, Column tries to be wider than available Row space → RenderFlex overflow. Fix: wrap Column in Expanded so it has a bounded width, and Text will wrap within that bound. Also add overflow: TextOverflow.ellipsis if wrapping is undesirable.

Explain like I'm 5

Imagine you're in a sandbox. Your parent says: 'You can use any area between 0 and 5 feet wide.' You decide: 'I'll make my sandcastle 3 feet wide.' Then your parent decides where to put you in the yard. You can't make yourself bigger than the sandbox, and you don't get to pick YOUR spot — only your size. Flex (Row/Column) is like dividing pizza: Expanded kids eat everything, Flexible kids stop when they're full.

Fun fact

Flutter's constraint-based layout was designed specifically to avoid the 'layout thrashing' problem in web browsers. In CSS, a child can expand and force the parent to resize, which can force siblings to resize, which can cascade into hundreds of layout passes per frame. Flutter's rule — parent constrains child, child never changes parent's size — guarantees a single linear layout pass. This is a major reason Flutter achieves consistent 60fps.

Hands-on challenge

Build a ResponsiveGrid widget using LayoutBuilder that shows 1 column below 400px, 2 columns below 700px, and 3 columns at 700px+. Each cell should use Expanded to fill its row. Test with different screen sizes using Flutter DevTools.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery