Dart Tutorial

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.

ClassWidthTypical device
CompactUnder 600Phone in portrait
Medium600 to 839Tablet in portrait, foldable, large phone in landscape
Expanded840 and upTablet 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_preview package 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.

Practise in the playground Updated by Santosh Adhikari