LayoutBuilder & Adaptive Layouts
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.
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),
)
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,
);
}
},
);
}
}
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.
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.