Google's Play Store now ranks and reviews apps on how they behave on tablets, foldables and Chromebooks, Android 16 ships true desktop windowing, and every iPad build has to survive Stage Manager resizing. Meanwhile Material 3 Expressive landed in Flutter's Material library and moved adaptive layout out of the "nice to have" column.
On client engagements we see the same failure mode: an app that looks correct on a Pixel, then stretches into a 1200-logical-pixel column of full-width buttons on a tablet, loses its state when a Fold is opened, and gets flagged in a large-screen quality review. This tutorial is the fix — a layout architecture that adapts by available size and input, not by device name, plus the tests that keep it from regressing.
Stack: Flutter 3.4x, Material 3, go_router, and no platform checks anywhere in the widget tree.
Rule 1: never ask "is this a tablet?"
Platform.isAndroid, screen diagonal maths and MediaQuery.of(context).size sniffing all break the moment your app is in a split-screen window, a floating window, or a resized desktop pane. The device is not the constraint; the window is.
Two inputs matter:
- Window size class — derived from the width (and sometimes height) your app actually has.
- Input mode — touch, pointer, or both. This decides hit target sizes, hover affordances and density.
Flutter's material.dart exposes size class breakpoints via Breakpoints in flutter_adaptive_scaffold, but you do not need the package to get the semantics right. Define them once:
// lib/ui/layout/window_size.dart
enum WindowSizeClass { compact, medium, expanded, large }
WindowSizeClass sizeClassFor(double width) {
if (width < 600) return WindowSizeClass.compact; // phone portrait
if (width < 840) return WindowSizeClass.medium; // small tablet, phone landscape
if (width < 1200) return WindowSizeClass.expanded; // tablet landscape, small window
return WindowSizeClass.large; // desktop, unfolded large
}
extension WindowSizeClassX on BuildContext {
WindowSizeClass get sizeClass =>
sizeClassFor(MediaQuery.sizeOf(this).width);
bool get isPointerFirst =>
MediaQuery.maybeOf(this)?.navigationMode == NavigationMode.directional ||
{TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.linux}
.contains(Theme.of(this).platform);
}
Note MediaQuery.sizeOf(context) rather than MediaQuery.of(context).size. The sizeOf accessor subscribes only to size changes, so a keyboard insert or a text-scale change does not rebuild your whole layout tree. On a resize-heavy surface like a foldable this is measurably cheaper.
Better still, resolve size class from the local constraints of the region you are laying out, not from the window:
LayoutBuilder(
builder: (context, constraints) {
final cls = sizeClassFor(constraints.maxWidth);
return switch (cls) {
WindowSizeClass.compact => const InboxList(),
_ => const InboxListDetail(),
};
},
)
That distinction matters once your app has a nav rail: the content pane may be medium while the window is expanded.
Rule 2: one navigation widget, three presentations
Bottom navigation on compact, navigation rail on medium/expanded, extended rail or drawer on large. Same destinations, same router, same state.
// lib/ui/layout/adaptive_shell.dart
class AdaptiveShell extends StatelessWidget {
const AdaptiveShell({super.key, required this.child, required this.index, required this.onSelect});
final Widget child;
final int index;
final ValueChanged<int> onSelect;
static const _destinations = [
(icon: Icons.inbox_outlined, selected: Icons.inbox, label: 'Inbox'),
(icon: Icons.event_outlined, selected: Icons.event, label: 'Schedule'),
(icon: Icons.person_outline, selected: Icons.person, label: 'Profile'),
];
@override
Widget build(BuildContext context) {
final cls = context.sizeClass;
if (cls == WindowSizeClass.compact) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: onSelect,
destinations: [
for (final d in _destinations)
NavigationDestination(
icon: Icon(d.icon), selectedIcon: Icon(d.selected), label: d.label),
],
),
);
}
return Scaffold(
body: Row(
children: [
NavigationRail(
extended: cls == WindowSizeClass.large,
selectedIndex: index,
onDestinationSelected: onSelect,
labelType: cls == WindowSizeClass.large
? NavigationRailLabelType.none
: NavigationRailLabelType.all,
destinations: [
for (final d in _destinations)
NavigationRailDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selected),
label: Text(d.label)),
],
),
const VerticalDivider(width: 1),
Expanded(child: child),
],
),
);
}
}
Two details people get wrong. First, extended: true on a rail narrower than ~200 px throws layout errors — gate it on large, not on a guess. Second, wrap the whole shell in SafeArea inside the Row, not around it, or the rail will float away from the window edge on devices with display cutouts.
Rule 3: list-detail with a router, not a nested Navigator
The canonical large-screen pattern is list-detail (Inbox → message). On compact it is two routes; on expanded it is one screen with two panes. Implement it once by making the route the source of truth and letting the layout decide how to render it.
final router = GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) => AdaptiveShell(
index: _indexFor(state.uri.path),
onSelect: (i) => context.go(_pathFor(i)),
child: child,
),
routes: [
GoRoute(
path: '/inbox',
builder: (context, state) =>
InboxScreen(selectedId: state.uri.queryParameters['id']),
// detail is a query param, not a child route: the same URL
// renders as one pane or two depending on width.
),
],
),
],
);
class InboxScreen extends StatelessWidget {
const InboxScreen({super.key, this.selectedId});
final String? selectedId;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, c) {
final twoPane = c.maxWidth >= 840;
if (!twoPane) {
return selectedId == null
? InboxList(onTap: (id) => context.go('/inbox?id=$id'))
: MessageDetail(id: selectedId!);
}
return Row(
children: [
SizedBox(
width: 360,
child: InboxList(
selectedId: selectedId,
onTap: (id) => context.go('/inbox?id=$id'),
),
),
const VerticalDivider(width: 1),
Expanded(
child: selectedId == null
? const EmptyDetailPlaceholder()
: MessageDetail(id: selectedId!),
),
],
);
});
}
}
Because selection lives in the URL, unfolding a device mid-task keeps the user exactly where they were, deep links open the right pane on every form factor, and Android's "restore on configuration change" story is free. Encoding the detail as a child route instead is the mistake that produces the classic "fold the device, lose the message" bug.
Add a back-button policy: on compact, back clears the detail; on two-pane, back leaves the screen.
PopScope(
canPop: twoPane || selectedId == null,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) context.go('/inbox');
},
child: ...,
)
Rule 4: constrain your content, don't stretch it
A 1400 px-wide form is unreadable. Cap measure and centre it; let grids consume the extra width instead of text.
class ReadableWidth extends StatelessWidget {
const ReadableWidth({super.key, required this.child, this.max = 720});
final Widget child;
final double max;
@override
Widget build(BuildContext context) => Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: max),
child: child,
),
);
}
For card collections use a max-extent grid so column count derives from width automatically:
GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 380,
mainAxisExtent: 168,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemBuilder: (context, i) => JobCard(job: jobs[i]),
itemCount: jobs.length,
)
Dialogs need the same treatment. On compact, a Dialog covering 90% of the screen should usually be a full-screen route instead:
Future<T?> showAdaptiveSheet<T>(BuildContext context, WidgetBuilder builder) {
final compact = context.sizeClass == WindowSizeClass.compact;
return compact
? showModalBottomSheet<T>(
context: context, isScrollControlled: true, useSafeArea: true, builder: builder)
: showDialog<T>(
context: context,
builder: (c) => Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), child: builder(c)),
),
);
}
Rule 5: foldables and display cutouts
Flutter surfaces hinge and cutout geometry through MediaQuery.of(context).displayFeatures. A book-posture fold has a vertical hinge that your two-pane layout must avoid painting under.
Widget hingeAwareTwoPane(BuildContext context, Widget start, Widget end) {
final mq = MediaQuery.of(context);
final hinge = mq.displayFeatures
.where((f) => f.type == DisplayFeatureType.hinge ||
f.type == DisplayFeatureType.fold)
.cast<DisplayFeature?>()
.firstWhere((f) => f!.bounds.height >= f.bounds.width, orElse: () => null);
if (hinge == null) {
return Row(children: [Expanded(child: start), Expanded(child: end)]);
}
return Row(children: [
SizedBox(width: hinge.bounds.left, child: start),
SizedBox(width: hinge.bounds.width), // the physical seam
Expanded(child: end),
]);
}
DisplayFeatureSubScreen does the same job for dialogs and popups automatically, and Scaffold's built-in dialogs already use it — a good reason to prefer framework dialogs over hand-rolled overlays.
For desktop and Chromebook windows, also declare that the app resizes gracefully: no WidgetsBinding.instance.window.physicalSize reads at startup, and set a sensible minimumSize in the desktop runners so no one can drag your app into a broken 200 px state.
Rule 6: input mode changes the design, not just the size
A Chromebook window at 1100 px and a tablet at 1100 px are the same size class but not the same product. With a pointer you can afford hover states, denser rows, right-click menus and keyboard shortcuts.
final density = context.isPointerFirst
? VisualDensity.compact
: VisualDensity.standard;
// Keyboard shortcuts, harmless on touch:
CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyN, control: true): _newItem,
const SingleActivator(LogicalKeyboardKey.slash): _focusSearch,
},
child: Focus(autofocus: true, child: body),
)
Keep touch targets at 48×48 logical pixels minimum regardless of density — Material 3 Expressive's tighter components can drop below that if you shrink padding manually, and that is an accessibility audit finding waiting to happen.
Rule 7: Material 3 Expressive theming without a repaint of your app
Expressive adds motion-forward components and a wider shape/typography scale. You do not have to adopt it wholesale; adopt the tokens.
final scheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF2A5CFF),
brightness: Brightness.light,
);
final theme = ThemeData(
colorScheme: scheme,
useMaterial3: true,
visualDensity: VisualDensity.adaptivePlatformDensity,
// Shape tokens: one place to tune "how expressive" the product feels.
cardTheme: CardThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(minimumSize: const Size(64, 48)),
),
);
Two adoption notes from recent client upgrades: CardTheme became CardThemeData (and TabBarTheme → TabBarThemeData) in recent stable releases, so an Expressive refresh often surfaces those deprecations first; and if you support dynamic colour on Android, wrap with DynamicColorBuilder and treat your seed scheme as the fallback rather than the default.
Testing it so it stays fixed
Adaptive layout regressions are invisible on a developer's phone-shaped simulator. Put sizes in CI.
// test/adaptive_test.dart
Future<void> pumpAt(WidgetTester tester, Size size, Widget child) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: child));
await tester.pumpAndSettle();
}
void main() {
testWidgets('compact shows bottom nav, expanded shows rail', (tester) async {
await pumpAt(tester, const Size(390, 844), const AppRoot());
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing);
await pumpAt(tester, const Size(1280, 800), const AppRoot());
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
});
testWidgets('selection survives a fold', (tester) async {
await pumpAt(tester, const Size(390, 844), const AppRoot());
await tester.tap(find.text('Invoice #1042'));
await tester.pumpAndSettle();
tester.view.physicalSize = const Size(1600, 1000); // unfolded
await tester.pumpAndSettle();
expect(find.byType(InboxList), findsOneWidget); // both panes now
expect(find.text('Invoice #1042'), findsWidgets); // still selected
});
}
Add golden tests at three widths (390, 840, 1280) for your two or three highest-traffic screens and run them on the same CI runner image you already use for widget tests. Three goldens per screen catches the overwhelming majority of large-screen review findings before submission.
A shipping checklist
- No
Platform.is*or device-model checks in the widget tree; size class and input mode only. - Every screen renders at 390, 600, 840, 1280 and 1600 logical px wide without overflow.
- Text columns capped around 720 px; grids use
maxCrossAxisExtent. - Detail selection lives in the URL/route state, so folding and rotating preserve context.
- Hinge-aware panes; dialogs use framework widgets so
DisplayFeatureSubScreenapplies. - Keyboard shortcuts and hover states when a pointer is present; 48 px targets always.
- Widget tests at three widths plus goldens in CI; a fold/resize test on the primary flow.
- Desktop runners declare a sensible minimum window size.
Where teams usually need help
The layout code above is the easy half. The hard half is deciding which screens deserve a two-pane treatment, how the information architecture changes when a user can see two things at once, and how to sequence the refactor so the phone app never regresses while the tablet app is being built.
That is the work our Flutter consultants do most often on modernization engagements: an adaptive audit of the existing screens, a size-class architecture the team can extend, and a golden-test harness so the improvement holds after we leave. If you have an app facing a large-screen quality review — or a tablet release you keep postponing — get in touch and we will walk your screens with you.