← All posts

4 min read

Responsive Flutter Layouts: One Codebase for Phones and Tablets

A practical approach to responsive Flutter UIs — breakpoints, LayoutBuilder, adaptive navigation and list-detail layouts that work from small phones to large tablets.

  • Flutter
  • Responsive UI
  • Tablets
  • UI
Cover illustration for Responsive Flutter Layouts: One Codebase for Phones and Tablets

When I worked on Schedule Pro, the app had to work equally well on a small Android phone in a worker’s pocket and on a tablet mounted at a front desk. Stretching a phone layout across a 12-inch screen looks lazy; building a separate tablet app doubles the work.

Flutter makes a middle path easy: one codebase, layouts that adapt to the space they’re given. Here’s the approach I use.

Think in window size, not device type

The first instinct is to ask “is this a tablet?”. That’s the wrong question. A tablet in split-screen mode might give your app a phone-sized window. A foldable changes size while the app is running. A phone in landscape is wider than some tablets in portrait.

Ask instead: how much space do I have right now? Then define a few size classes. I use breakpoints close to Material 3’s guidance:

enum WindowSize { compact, medium, expanded }

WindowSize windowSizeOf(BuildContext context) {
  final width = MediaQuery.sizeOf(context).width;
  if (width < 600) return WindowSize.compact;   // phones
  if (width < 840) return WindowSize.medium;    // small tablets, foldables, landscape phones
  return WindowSize.expanded;                   // tablets, desktop
}

MediaQuery.sizeOf(context) is preferable to MediaQuery.of(context).size because the widget only rebuilds when the size changes, not when any other media query value (like keyboard insets) changes.

MediaQuery vs LayoutBuilder

These two are often confused:

  • MediaQuery.sizeOf gives you the size of the whole window. Use it for app-level decisions, like which navigation to show.
  • LayoutBuilder gives you the constraints of the space the widget has been given. Use it for component-level decisions.

For example, a card component inside a side panel should adapt to the panel’s width, not the screen’s:

class ShiftCard extends StatelessWidget {
  const ShiftCard({super.key, required this.shift});
  final Shift shift;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final wide = constraints.maxWidth > 420;
        return Card(
          child: wide
              ? Row(children: [ShiftTime(shift), Expanded(child: ShiftDetails(shift)), ShiftActions(shift)])
              : Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ShiftTime(shift), ShiftDetails(shift), ShiftActions(shift)]),
        );
      },
    );
  }
}

Components that respond to their own constraints can be dropped anywhere and just work.

Adaptive navigation

Bottom navigation bars are great on phones and awkward on tablets — they stretch across the screen and put controls far from the user’s hands. Material 3’s pattern is:

  • Compact: NavigationBar at the bottom.
  • Medium: NavigationRail on the side.
  • Expanded: NavigationRail (extended, with labels) or a permanent NavigationDrawer.
class AppShell extends StatelessWidget {
  const AppShell({super.key, required this.index, required this.onSelect, required this.body});
  final int index;
  final ValueChanged<int> onSelect;
  final Widget body;

  @override
  Widget build(BuildContext context) {
    final size = windowSizeOf(context);

    if (size == WindowSize.compact) {
      return Scaffold(
        body: body,
        bottomNavigationBar: NavigationBar(
          selectedIndex: index,
          onDestinationSelected: onSelect,
          destinations: const [
            NavigationDestination(icon: Icon(Icons.calendar_today), label: 'Schedule'),
            NavigationDestination(icon: Icon(Icons.swap_horiz), label: 'Swaps'),
            NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
          ],
        ),
      );
    }

    return Scaffold(
      body: Row(children: [
        NavigationRail(
          selectedIndex: index,
          onDestinationSelected: onSelect,
          extended: size == WindowSize.expanded,
          destinations: const [
            NavigationRailDestination(icon: Icon(Icons.calendar_today), label: Text('Schedule')),
            NavigationRailDestination(icon: Icon(Icons.swap_horiz), label: Text('Swaps')),
            NavigationRailDestination(icon: Icon(Icons.person), label: Text('Profile')),
          ],
        ),
        const VerticalDivider(width: 1),
        Expanded(child: body),
      ]),
    );
  }
}

The destinations and the selected index are shared; only the presentation changes. With go_router, this shell fits naturally into a StatefulShellRoute.

List-detail layouts

The biggest win on large screens is showing a list and its detail side by side. On a phone, tapping a shift opens a new screen. On a tablet, the detail appears next to the list.

class ShiftsPage extends StatefulWidget {
  const ShiftsPage({super.key});
  @override
  State<ShiftsPage> createState() => _ShiftsPageState();
}

class _ShiftsPageState extends State<ShiftsPage> {
  Shift? _selected;

  @override
  Widget build(BuildContext context) {
    final twoPane = windowSizeOf(context) != WindowSize.compact;

    final list = ShiftList(
      selected: twoPane ? _selected : null,
      onTap: (shift) {
        if (twoPane) {
          setState(() => _selected = shift);
        } else {
          Navigator.of(context).push(MaterialPageRoute(builder: (_) => ShiftDetailPage(shift)));
        }
      },
    );

    if (!twoPane) return list;

    return Row(children: [
      SizedBox(width: 360, child: list),
      const VerticalDivider(width: 1),
      Expanded(
        child: _selected == null
            ? const Center(child: Text('Select a shift to see details'))
            : ShiftDetail(_selected!),
      ),
    ]);
  }
}

Note that ShiftDetail (the content) is separate from ShiftDetailPage (a screen with an app bar that wraps the content). The same widget works in both layouts.

Watch for one edge case: if the window shrinks from two-pane to single-pane while something is selected (rotating a tablet, or resizing in split-screen), decide what should happen. Usually, pushing the detail page so the user doesn’t lose their place is the right answer.

Constrain content width

On a wide screen, a form or a paragraph of text stretched to 1,200 pixels is hard to read. Wrap reading-heavy content in a max width:

Center(
  child: ConstrainedBox(
    constraints: const BoxConstraints(maxWidth: 640),
    child: settingsForm,
  ),
)

For grids, let the column count follow the space available with SliverGridDelegateWithMaxCrossAxisExtent instead of hard-coding a count.

Test on more than one size

  • Run the app on at least one small phone, one large phone and one tablet emulator before each release.
  • Test split-screen on Android and Stage Manager / Split View on iPad.
  • Rotate the device on every main screen.
  • Turn up the system font size. A layout that fits at 100% text scale can overflow at 130%.
  • Widget tests can set a surface size with tester.view.physicalSize to check key screens at multiple widths automatically.

Takeaways

  • Decide layouts by available window size, not by “phone or tablet”.
  • Use MediaQuery.sizeOf for app-level layout, LayoutBuilder for components.
  • Switch between bottom navigation, navigation rail and drawer as space grows.
  • Use list-detail layouts on large screens, and separate content widgets from page wrappers.
  • Constrain reading width, and test at several sizes, orientations and text scales.

Responsive design used to mean building two apps. In Flutter, it mostly means a few breakpoints and components that respect their constraints.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).