Adaptive UI: Phone, Tablet, Foldables & Desktop
LayoutBuilder, MediaQuery, adaptive breakpoints, master-detail, WindowSizeClass, foldables
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Adaptive UI ensures your app looks and functions correctly across the full device spectrum — from a 360dp phone to a 1280dp desktop. As foldables gain market share and Flutter expands to desktop, senior engineers must design layouts that are not just 'responsive' (scaling) but 'adaptive' (behaviorally different for different form factors).
Real-world relevance
The school management app runs on phones (teachers marking attendance in class), tablets (admin reviewing dashboards), and a foldable used by the principal (unfolded for two-pane class + student view). flutter_adaptive_scaffold handles navigation adaptation. A LayoutBuilder switches the attendance screen between a scrollable list (compact) and a grid with detail panel (expanded). The hinge crease detection prevents the action buttons from landing on the fold.
Key points
- MediaQuery — MediaQuery.of(context).size gives screen dimensions. MediaQuery.of(context).devicePixelRatio gives density. Use for global layout decisions (screen width breakpoints). Caveat: MediaQuery rebuilds the entire widget when size changes — prefer LayoutBuilder for local adaptations.
- LayoutBuilder — LayoutBuilder gives the constraints available to a specific widget — not the screen size. Use for widget-level adaptations (e.g., a sidebar that only appears when its parent allocates 600+ dp). More precise than MediaQuery for responsive components.
- Breakpoint Strategy — Material 3 WindowSizeClass: compact (<600dp), medium (600–840dp), expanded (840dp+). Compact: single-column stack navigation. Medium: optional two-pane (NavigationRail). Expanded: full master-detail side-by-side. Match the platform's UX conventions.
- Master-Detail Pattern — On phones: navigate list → detail as separate screens. On tablets: show list and detail side-by-side. Implement by wrapping in LayoutBuilder and conditionally rendering Row([ListPane, DetailPane]) vs Navigator-based navigation.
- NavigationRail vs BottomNavigationBar — Use BottomNavigationBar on compact screens (thumbs reach bottom). Use NavigationRail on medium/expanded (side rail with labels). Adaptive navigation is a Material 3 recommendation — switch based on WindowSizeClass.
- Foldable Device Support — Foldable phones have two states: folded (compact) and unfolded (medium/table-top). The display_features API and flutter_adaptive_scaffold package handle fold hinge position and fold angle. Avoid placing interactive content over the hinge crease.
- flutter_adaptive_scaffold — Google's official package for adaptive layouts. AdaptiveScaffold automatically provides NavigationRail, NavigationDrawer, or BottomNavigationBar based on screen width, and supports two-pane layouts with the slot system (body, secondaryBody).
- Platform-Specific Widgets — Use Platform.isIOS or Theme.of(context).platform to conditionally render CupertinoNavigationBar on iOS vs AppBar on Android. package:flutter/cupertino.dart provides full Cupertino widget set. Consider adaptive_theme for per-platform theming.
- Desktop Considerations — Desktop (Windows/macOS/Linux) has mouse/keyboard input. PointerEvent includes hover states. Use Focus and keyboard shortcuts (Shortcuts/Actions widgets). Minimum touch target rules are relaxed but clickable areas must still be clear.
- Pixel Density Awareness — 1dp = 1 logical pixel on mdpi. Flutter's layout system works in dp — you never need to manually multiply by devicePixelRatio for sizing. However, image assets should provide @1x, @2x, @3x variants for resolution-appropriate rendering.
Code example
// === ADAPTIVE NAVIGATION ===
class AdaptiveHome extends StatefulWidget {
@override
State<AdaptiveHome> createState() => _AdaptiveHomeState();
}
class _AdaptiveHomeState extends State<AdaptiveHome> {
int _selectedIndex = 0;
Widget? _selectedDetail;
@override
Widget build(BuildContext context) {
// LayoutBuilder for widget-level constraints
return LayoutBuilder(builder: (context, constraints) {
final isExpanded = constraints.maxWidth >= 840;
final isMedium = constraints.maxWidth >= 600;
return Scaffold(
// Adaptive navigation
bottomNavigationBar: isMedium ? null : NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => setState(() => _selectedIndex = i),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.class_), label: 'Classes'),
NavigationDestination(icon: Icon(Icons.person), label: 'Students'),
],
),
body: Row(children: [
if (isMedium) NavigationRail(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => setState(() => _selectedIndex = i),
labelType: isExpanded
? NavigationRailLabelType.selected
: NavigationRailLabelType.none,
destinations: const [
NavigationRailDestination(icon: Icon(Icons.home), label: Text('Home')),
NavigationRailDestination(icon: Icon(Icons.class_), label: Text('Classes')),
NavigationRailDestination(icon: Icon(Icons.person), label: Text('Students')),
],
),
Expanded(
child: isExpanded
? _buildMasterDetail()
: _buildCurrentPage(),
),
]),
);
});
}
Widget _buildMasterDetail() {
return Row(children: [
SizedBox(
width: 360,
child: ClassListPane(
onClassSelected: (classItem) =>
setState(() => _selectedDetail = ClassDetailPane(classItem: classItem)),
),
),
const VerticalDivider(width: 1),
Expanded(
child: _selectedDetail ??
const Center(child: Text('Select a class to view details')),
),
]);
}
Widget _buildCurrentPage() {
return switch (_selectedIndex) {
0 => const HomePane(),
1 => ClassListPane(
onClassSelected: (c) => Navigator.push(context,
MaterialPageRoute(builder: (_) => ClassDetailPane(classItem: c))),
),
2 => const StudentsPane(),
_ => const SizedBox.shrink(),
};
}
}
// === FOLDABLE HINGE AVOIDANCE ===
class FoldAwareLayout extends StatelessWidget {
final Widget child;
const FoldAwareLayout({required this.child, super.key});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
// displayFeatures contains fold hinge information
final folds = mediaQuery.displayFeatures
.where((f) => f.type == DisplayFeatureType.fold ||
f.type == DisplayFeatureType.hinge)
.toList();
if (folds.isEmpty) return child;
final hinge = folds.first.bounds;
// Split layout around the hinge
return Row(children: [
SizedBox(width: hinge.left, child: child),
SizedBox(width: hinge.width), // Hinge gap
Expanded(child: SecondaryPane()),
]);
}
}
// === flutter_adaptive_scaffold (official package) ===
AdaptiveScaffold(
selectedIndex: _selectedIndex,
onSelectedIndexChange: (i) => setState(() => _selectedIndex = i),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.class_), label: 'Classes'),
],
body: (_) => const ClassListPane(),
secondaryBody: (_) => _selectedDetail ?? const EmptyDetailPane(),
)Line-by-line walkthrough
- 1. LayoutBuilder constraints.maxWidth — measures available space for THIS widget, not the full screen; more precise than MediaQuery for component-level decisions
- 2. isMedium = constraints.maxWidth >= 600 — compact/medium breakpoint; switches navigation widget
- 3. isExpanded = constraints.maxWidth >= 840 — medium/expanded breakpoint; enables side-by-side master-detail
- 4. NavigationRail only rendered when isMedium — Row() with conditional if shows it as a sibling to the main content area
- 5. _buildMasterDetail() uses a fixed 360dp SizedBox for the list pane — provides consistent list width across different expanded screen sizes
- 6. mediaQuery.displayFeatures.where(type == fold) — filters the DisplayFeature list to find only fold hinges
- 7. hinge.bounds.left — the pixel position of the hinge; SizedBox(width: hinge.left) places content exactly up to the hinge
- 8. AdaptiveScaffold secondaryBody — shown alongside body on expanded screens; null shows nothing in the secondary pane
Spot the bug
class ResponsiveLayout extends StatelessWidget {
final Widget child;
const ResponsiveLayout({required this.child, super.key});
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
if (width > 600) {
return Row(children: [
SizedBox(width: 300, child: const Sidebar()),
Expanded(child: child),
]);
}
return child;
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Flutter adaptive layout docs (Flutter Docs)
- flutter_adaptive_scaffold package (pub.dev)
- Material 3 — Adaptive design (Material Design)
- Flutter — Foldable support (Flutter Docs)
- NavigationRail API (Flutter Docs)