Flutter Layouts & Responsive Design

LayoutBuilder & Adaptive Layouts

50 min Lesson 11 of 16

What Is LayoutBuilder?

While MediaQuery gives you the screen size, LayoutBuilder gives you the available space from the parent widget. This is a crucial distinction—a widget inside a sidebar does not have the full screen width available. LayoutBuilder lets you build responsive widgets that adapt to their actual container size, not the device size.

Key Difference: MediaQuery.sizeOf(context) returns the screen dimensions. LayoutBuilder provides the parent’s constraints—the actual space your widget has to work with. Always prefer LayoutBuilder when your widget might be placed in different-sized containers.

LayoutBuilder Widget

LayoutBuilder provides a builder callback with two parameters: the BuildContext and a BoxConstraints object representing the constraints from the parent. You use these constraints to decide what layout to render.

Basic LayoutBuilder Usage

LayoutBuilder(
  builder: (BuildContext context, BoxConstraints constraints) {
    // constraints.maxWidth  - Maximum available width
    // constraints.maxHeight - Maximum available height
    // constraints.minWidth  - Minimum width (usually 0)
    // constraints.minHeight - Minimum height (usually 0)

    if (constraints.maxWidth > 600) {
      return _buildWideLayout();
    } else {
      return _buildNarrowLayout();
    }
  },
)

BoxConstraints in LayoutBuilder

The BoxConstraints object tells you exactly how much space is available. Understanding its properties is essential:

Understanding BoxConstraints

LayoutBuilder(
  builder: (context, constraints) {
    debugPrint('Min width: \${constraints.minWidth}');
    debugPrint('Max width: \${constraints.maxWidth}');
    debugPrint('Min height: \${constraints.minHeight}');
    debugPrint('Max height: \${constraints.maxHeight}');

    // Useful constraint checks:
    final isTight = constraints.isTight;       // min == max
    final isUnbounded = constraints.maxHeight == double.infinity;
    final hasLooseWidth = constraints.minWidth == 0;

    return Container(
      width: constraints.maxWidth,
      height: constraints.hasBoundedHeight
          ? constraints.maxHeight
          : 400,
      color: Colors.blue[50],
      child: Center(
        child: Text(
          'Available: \${constraints.maxWidth.toStringAsFixed(0)} x '
          '\${constraints.hasBoundedHeight ? constraints.maxHeight.toStringAsFixed(0) : "unbounded"}',
        ),
      ),
    );
  },
)

Breakpoint Patterns

A common pattern is to define breakpoints that determine which layout to show. This approach keeps your code organized and consistent across your application.

Breakpoint Constants

abstract class Breakpoints {
  static const double mobile = 600;
  static const double tablet = 900;
  static const double desktop = 1200;
  static const double wideDesktop = 1800;
}

// Usage in LayoutBuilder
LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth < Breakpoints.mobile) {
      return const MobileLayout();
    } else if (constraints.maxWidth < Breakpoints.tablet) {
      return const TabletLayout();
    } else {
      return const DesktopLayout();
    }
  },
)

Responsive Helper Class

Building a reusable responsive helper simplifies creating adaptive layouts throughout your app:

ResponsiveBuilder Widget

class ResponsiveBuilder extends StatelessWidget {
  final Widget mobile;
  final Widget? tablet;
  final Widget? desktop;

  const ResponsiveBuilder({
    super.key,
    required this.mobile,
    this.tablet,
    this.desktop,
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth >= 1200 && desktop != null) {
          return desktop!;
        }
        if (constraints.maxWidth >= 600 && tablet != null) {
          return tablet!;
        }
        return mobile;
      },
    );
  }

  /// Static helper for getting the current device type
  static ScreenType getScreenType(BoxConstraints constraints) {
    if (constraints.maxWidth >= 1200) return ScreenType.desktop;
    if (constraints.maxWidth >= 600) return ScreenType.tablet;
    return ScreenType.mobile;
  }
}

enum ScreenType { mobile, tablet, desktop }

// Usage:
ResponsiveBuilder(
  mobile: const MobileProductList(),
  tablet: const TabletProductGrid(columns: 3),
  desktop: const DesktopProductGrid(columns: 4),
)
Tip: By using LayoutBuilder instead of MediaQuery in your ResponsiveBuilder, the widget adapts correctly even when placed inside a sidebar, dialog, or split view—places where the available width is much smaller than the screen width.

Adaptive Navigation: Rail vs Bottom

A classic adaptive pattern is switching between a BottomNavigationBar on mobile and a NavigationRail on larger screens. LayoutBuilder makes this straightforward:

Adaptive Navigation

class AdaptiveNavigation extends StatefulWidget {
  const AdaptiveNavigation({super.key});

  @override
  State<AdaptiveNavigation> createState() => _AdaptiveNavigationState();
}

class _AdaptiveNavigationState extends State<AdaptiveNavigation> {
  int _selectedIndex = 0;

  static const List<NavigationDestination> _destinations = [
    NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
    NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
    NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
  ];

  static const List<NavigationRailDestination> _railDestinations = [
    NavigationRailDestination(icon: Icon(Icons.home), label: Text('Home')),
    NavigationRailDestination(icon: Icon(Icons.search), label: Text('Search')),
    NavigationRailDestination(icon: Icon(Icons.person), label: Text('Profile')),
  ];

  final List<Widget> _pages = const [
    Center(child: Text('Home Page')),
    Center(child: Text('Search Page')),
    Center(child: Text('Profile Page')),
  ];

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final useRail = constraints.maxWidth >= 600;

        if (useRail) {
          return Scaffold(
            body: Row(
              children: [
                NavigationRail(
                  selectedIndex: _selectedIndex,
                  onDestinationSelected: (index) {
                    setState(() => _selectedIndex = index);
                  },
                  labelType: NavigationRailLabelType.all,
                  destinations: _railDestinations,
                ),
                const VerticalDivider(thickness: 1, width: 1),
                Expanded(child: _pages[_selectedIndex]),
              ],
            ),
          );
        }

        return Scaffold(
          body: _pages[_selectedIndex],
          bottomNavigationBar: NavigationBar(
            selectedIndex: _selectedIndex,
            onDestinationSelected: (index) {
              setState(() => _selectedIndex = index);
            },
            destinations: _destinations,
          ),
        );
      },
    );
  }
}

Practical Example: Master-Detail Layout

On wide screens, show a list and detail side by side. On narrow screens, navigate between them:

Master-Detail Pattern

class MasterDetailScreen extends StatefulWidget {
  const MasterDetailScreen({super.key});

  @override
  State<MasterDetailScreen> createState() => _MasterDetailScreenState();
}

class _MasterDetailScreenState extends State<MasterDetailScreen> {
  int? _selectedItemId;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final isWide = constraints.maxWidth >= 800;

        if (isWide) {
          // Side-by-side layout
          return Row(
            children: [
              SizedBox(
                width: 350,
                child: _buildMasterList(),
              ),
              const VerticalDivider(width: 1),
              Expanded(
                child: _selectedItemId != null
                    ? _buildDetail(_selectedItemId!)
                    : const Center(
                        child: Text('Select an item'),
                      ),
              ),
            ],
          );
        }

        // Stacked layout - use Navigator for detail
        return _buildMasterList();
      },
    );
  }

  Widget _buildMasterList() {
    return ListView.builder(
      itemCount: 20,
      itemBuilder: (context, index) {
        return ListTile(
          title: Text('Item \$index'),
          subtitle: Text('Description for item \$index'),
          selected: _selectedItemId == index,
          onTap: () {
            setState(() => _selectedItemId = index);

            // On narrow screens, navigate to detail page
            final isWide = MediaQuery.sizeOf(context).width >= 800;
            if (!isWide) {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (_) => Scaffold(
                    appBar: AppBar(title: Text('Item \$index')),
                    body: _buildDetail(index),
                  ),
                ),
              );
            }
          },
        );
      },
    );
  }

  Widget _buildDetail(int id) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.article, size: 80, color: Colors.blue[300]),
            const SizedBox(height: 16),
            Text('Detail for Item \$id',
                style: const TextStyle(fontSize: 24)),
            const SizedBox(height: 8),
            Text('Full content for item \$id goes here.'),
          ],
        ),
      ),
    );
  }
}

Practical Example: Responsive Grid

Responsive Grid with LayoutBuilder

class ResponsiveGrid extends StatelessWidget {
  final List<Widget> children;
  final double spacing;

  const ResponsiveGrid({
    super.key,
    required this.children,
    this.spacing = 16,
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final columns = _getColumnCount(constraints.maxWidth);
        final itemWidth =
            (constraints.maxWidth - spacing * (columns - 1)) / columns;

        return Wrap(
          spacing: spacing,
          runSpacing: spacing,
          children: children.map((child) {
            return SizedBox(
              width: itemWidth,
              child: child,
            );
          }).toList(),
        );
      },
    );
  }

  int _getColumnCount(double width) {
    if (width >= 1200) return 4;
    if (width >= 800) return 3;
    if (width >= 500) return 2;
    return 1;
  }
}

Practical Example: Adaptive Sidebar

Adaptive Sidebar Layout

class AdaptiveSidebar extends StatelessWidget {
  final Widget sidebar;
  final Widget body;

  const AdaptiveSidebar({
    super.key,
    required this.sidebar,
    required this.body,
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth >= 1200) {
          // Wide: expanded sidebar
          return Row(
            children: [
              SizedBox(width: 280, child: sidebar),
              const VerticalDivider(width: 1),
              Expanded(child: body),
            ],
          );
        } else if (constraints.maxWidth >= 800) {
          // Medium: collapsed sidebar (icons only)
          return Row(
            children: [
              SizedBox(width: 72, child: sidebar),
              const VerticalDivider(width: 1),
              Expanded(child: body),
            ],
          );
        } else {
          // Narrow: use Drawer
          return Scaffold(
            drawer: Drawer(child: sidebar),
            body: body,
          );
        }
      },
    );
  }
}
Warning: LayoutBuilder cannot provide infinite constraints to its builder. If you place a LayoutBuilder inside a scrollable widget without bounded constraints, maxHeight will be double.infinity. Always check constraints.hasBoundedHeight before using maxHeight in calculations.
Summary: Use LayoutBuilder whenever you need widgets to adapt to their available space rather than the screen size. Define breakpoints as constants for consistency. Build reusable responsive helpers like ResponsiveBuilder to keep your code DRY. Combine LayoutBuilder with adaptive navigation patterns to create truly responsive applications.