Intermediate · Lesson 6 · 12 min read
Responsive and Adaptive UI
Make one codebase look right on phones, tablets, desktop and web — with layout breakpoints, adaptive components and correct theming.
Updated July 30, 2026
What you will learn
- Distinguish responsive layout from adaptive behaviour
- Use LayoutBuilder and breakpoints instead of device checks
- Build a Material 3 theme with light and dark variants
- Handle safe areas, text scaling and pointer input correctly
Responsive means the layout reflows to the space available. Adaptive means the behaviour and components change to match the platform's conventions. A good app does both, and neither should be done by checking Platform.isIOS in your widgets.
Measure the space, not the device
LayoutBuilder gives you the constraints of the box you are actually in — which is what matters, since your widget might sit in a side panel rather than the full screen.
enum FormFactor { compact, medium, expanded }
FormFactor formFactorFor(double width) {
if (width < 600) return FormFactor.compact; // phone
if (width < 900) return FormFactor.medium; // small tablet / split view
return FormFactor.expanded; // tablet landscape, desktop
}
class AdaptiveScaffold extends StatelessWidget {
const AdaptiveScaffold({super.key, required this.body, required this.destinations});
final Widget body;
final List<NavigationDestination> destinations;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final factor = formFactorFor(constraints.maxWidth);
return switch (factor) {
FormFactor.compact => Scaffold(
body: body,
bottomNavigationBar: NavigationBar(destinations: destinations),
),
FormFactor.medium => Scaffold(
body: Row(
children: [
const NavigationRail(destinations: [], selectedIndex: 0),
const VerticalDivider(width: 1),
Expanded(child: body),
],
),
),
FormFactor.expanded => Scaffold(
body: Row(
children: [
const SizedBox(width: 260, child: NavigationDrawer(children: [])),
const VerticalDivider(width: 1),
Expanded(child: body),
],
),
),
};
},
);
}
}Grids that reflow
// Fixed column count is wrong on tablets — fix the tile width instead
GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 240, // as many columns as fit at <= 240 each
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 3 / 4,
),
itemCount: products.length,
itemBuilder: (context, i) => ProductCard(product: products[i]),
)Also cap line length on wide screens. Text running the full width of a desktop window is genuinely hard to read — around 70 characters is the comfortable maximum.
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: articleBody,
),
)Theming with Material 3
ThemeData buildTheme(Brightness brightness) {
final colorScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF0175C2),
brightness: brightness,
);
return ThemeData(
colorScheme: colorScheme,
useMaterial3: true,
textTheme: const TextTheme(
titleLarge: TextStyle(fontWeight: FontWeight.w600, letterSpacing: -0.2),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
);
}
// Wire both variants and let the OS decide
MaterialApp(
theme: buildTheme(Brightness.light),
darkTheme: buildTheme(Brightness.dark),
themeMode: settings.themeMode,
)Set component styles in the theme, not on individual widgets. A FilledButton with a custom shape repeated in forty files is forty places to change; one filledButtonTheme is one.
Adaptive components
// These already adapt per platform — use them
const CircularProgressIndicator.adaptive();
Switch.adaptive(value: on, onChanged: setOn);
// Platform-appropriate dialog
showAdaptiveDialog<void>(
context: context,
builder: (context) => AlertDialog.adaptive(
title: const Text('Sign out?'),
actions: [/* ... */],
),
);
// Where behaviour genuinely differs, branch on the theme's platform
// (not dart:io, so it stays testable and works on web)
final platform = Theme.of(context).platform;
final useCupertinoStyle = platform == TargetPlatform.iOS ||
platform == TargetPlatform.macOS;Safe areas, insets and input
- Wrap screen content in
SafeAreaso notches, status bars and home indicators do not clip your UI. - Use
MediaQuery.viewInsetsOf(context).bottomto lift content above the keyboard, orScaffold'sresizeToAvoidBottomInset(on by default). - Touch targets must be at least 48×48 logical pixels.
IconButtongives you this automatically; a bareGestureDetectoraround a 16px icon does not. - On desktop and web, support hover states, keyboard focus traversal and scroll wheels —
FocusTraversalGroupandMouseRegionhandle most of it.
Text scaling
Users can enlarge system text substantially. If your layout uses fixed-height boxes around text, it will overflow for those users — and this is one of the most common accessibility bugs in shipped Flutter apps.
// Test it: run the app and force a large scale factor
MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: const TextScaler.linear(1.8),
),
child: const MyScreen(),
)
// Prefer intrinsic sizing over fixed heights around text
// Bad: SizedBox(height: 48, child: Text(label))
// Good: Padding(padding: EdgeInsets.symmetric(vertical: 12), child: Text(label))Key takeaways
- Branch on available width with
LayoutBuilder, not on device type. - Use Material 3's 600/900 window size classes as your breakpoints.
- Define component styles once in
ThemeDataand usecolorSchemeroles instead of literal colours. - Safe areas, 48px touch targets and large text scaling are correctness issues, not polish.
Practice
One app, three form factors
Take a list-detail screen and make it adaptive: bottom navigation with push-to-detail on phones, a navigation rail on medium widths, and a permanent side-by-side list-detail on expanded widths. Verify it at 200% text scale in both light and dark mode.
Show hints
- On expanded layouts, keep the selected item in state rather than pushing a route.
Flexiblewith different flex factors gives you a natural 1:2 list-to-detail split.