Flutter Lesson 59 of 83 5 min read
Responsive Layouts in Flutter: Breakpoints for Phone, Tablet and Desktop
Build Flutter layouts that adapt to phone, tablet and desktop with breakpoints, adaptive navigation, responsive grids and max widths.
On this page
A responsive app changes its layout at certain widths, called breakpoints. A phone shows one column and a bottom bar. A tablet shows two columns and a side rail. A desktop shows a full sidebar.
Breakpoints #
Material Design suggests these window size classes.
| Class | Width | Typical device |
|---|---|---|
| Compact | Under 600 | Phone in portrait |
| Medium | 600 to 839 | Tablet in portrait, foldable, large phone in landscape |
| Expanded | 840 and up | Tablet in landscape, desktop |
enum ScreenSize {
compact,
medium,
expanded;
static ScreenSize of(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
if (width < 600) return ScreenSize.compact;
if (width < 840) return ScreenSize.medium;
return ScreenSize.expanded;
}
}
Base decisions on width, not on whether the device is a “phone” or a “tablet”. A tablet in split-screen mode is as narrow as a phone, and a desktop window can be any size.
Adaptive navigation #
The shell of the app is the most visible responsive decision.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: AppShell()));
class AppShell extends StatefulWidget {
const AppShell({super.key});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
int _index = 0;
static const _destinations = [
(icon: Icons.home, label: 'Home'),
(icon: Icons.search, label: 'Search'),
(icon: Icons.favorite, label: 'Saved'),
(icon: Icons.person, label: 'Profile'),
];
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final page = Center(
child: Text(_destinations[_index].label, style: const TextStyle(fontSize: 32)),
);
// Phone: bottom bar.
if (width < 600) {
return Scaffold(
body: page,
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i),
destinations: [
for (final d in _destinations)
NavigationDestination(icon: Icon(d.icon), label: d.label),
],
),
);
}
// Tablet and desktop: rail at the side, with labels when there is room.
return Scaffold(
body: Row(
children: [
NavigationRail(
extended: width >= 1100,
selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i),
labelType: width >= 1100
? NavigationRailLabelType.none
: NavigationRailLabelType.all,
destinations: [
for (final d in _destinations)
NavigationRailDestination(icon: Icon(d.icon), label: Text(d.label)),
],
),
const VerticalDivider(width: 1),
Expanded(child: page),
],
),
);
}
}
Drag the window narrower and wider to watch it switch between the three forms.
List and detail side by side #
On a phone, tapping an item opens a new screen. On a wide screen, the list stays visible and the detail appears beside it.
class MailPage extends StatefulWidget {
const MailPage({super.key});
@override
State<MailPage> createState() => _MailPageState();
}
class _MailPageState extends State<MailPage> {
int? _selected;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= 840;
final list = ListView.builder(
itemCount: 20,
itemBuilder: (context, i) => ListTile(
title: Text('Message $i'),
selected: wide && i == _selected,
onTap: () {
if (wide) {
setState(() => _selected = i);
} else {
Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: Text('Message $i')),
body: MessageDetail(index: i),
),
),
);
}
},
),
);
if (!wide) return list;
return Row(
children: [
SizedBox(width: 360, child: list),
const VerticalDivider(width: 1),
Expanded(
child: _selected == null
? const Center(child: Text('Select a message'))
: MessageDetail(index: _selected!),
),
],
);
},
);
}
}
class MessageDetail extends StatelessWidget {
const MessageDetail({super.key, required this.index});
final int index;
@override
Widget build(BuildContext context) =>
Center(child: Text('Contents of message $index'));
}
MessageDetail is written once and used in both layouts.
Responsive grids #
Let the grid decide its own column count from a maximum item width.
GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 220,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 0.8,
),
itemCount: products.length,
itemBuilder: (context, i) => ProductCard(product: products[i]),
)
Two columns on a phone, four on a tablet, six or more on a desktop, with no breakpoint code.
Limit the width of content #
Text lines that run the full width of a monitor are hard to read, and a form stretched across a desktop looks odd. Centre the content and cap its width.
class MaxWidth extends StatelessWidget {
const MaxWidth({super.key, required this.child, this.maxWidth = 640});
final Widget child;
final double maxWidth;
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: child,
),
);
}
}
// Usage
body: MaxWidth(child: LoginForm())
This single widget fixes most “my phone app looks wrong on a tablet” problems.
Adjust spacing and columns, not just structure #
final size = ScreenSize.of(context);
final padding = switch (size) {
ScreenSize.compact => 16.0,
ScreenSize.medium => 24.0,
ScreenSize.expanded => 32.0,
};
Row on wide, Column on narrow #
Flex(
direction: constraints.maxWidth > 600 ? Axis.horizontal : Axis.vertical,
children: const [SummaryCard(), SizedBox(width: 16, height: 16), ChartCard()],
)
For chips, tags and buttons that should flow, Wrap is simpler still.
Testing #
- On desktop or web, drag the window edge through every width. This is the fastest test there is.
- In Android Studio, create emulators for a small phone, a large phone, a foldable and a tablet.
- Rotate the device.
- The
device_previewpackage shows your app inside many device frames at once.
Checklist #
- No fixed widths wider than about 320 pixels.
- Long text wraps or truncates.
- Lists and grids scroll.
- Navigation changes form at 600 pixels.
- Content has a maximum width on large screens.
- Nothing is hidden behind notches or the gesture bar.
Try it yourself #
Build a dashboard with four summary cards and a list of recent activity. On compact widths, stack everything in one column. On medium, show the cards in a two-by-two grid. On expanded, show four cards in a row with the list in a side column.