Lesson 48 of 77 intermediate

Adaptive UI: Phone, Tablet, Foldables & Desktop

LayoutBuilder, MediaQuery, adaptive breakpoints, master-detail, WindowSizeClass, foldables

Open interactive version (quiz + challenge)

Real-world analogy

Designing adaptive UI is like being an architect who designs one building plan that automatically adapts: a studio apartment on small plots (phone), a two-bedroom on medium plots (tablet), and a full house on large plots (desktop). The same blueprint but the rooms expand, multiply, or rearrange based on the available space — the architect never designs three separate buildings.

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

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. 1. LayoutBuilder constraints.maxWidth — measures available space for THIS widget, not the full screen; more precise than MediaQuery for component-level decisions
  2. 2. isMedium = constraints.maxWidth >= 600 — compact/medium breakpoint; switches navigation widget
  3. 3. isExpanded = constraints.maxWidth >= 840 — medium/expanded breakpoint; enables side-by-side master-detail
  4. 4. NavigationRail only rendered when isMedium — Row() with conditional if shows it as a sibling to the main content area
  5. 5. _buildMasterDetail() uses a fixed 360dp SizedBox for the list pane — provides consistent list width across different expanded screen sizes
  6. 6. mediaQuery.displayFeatures.where(type == fold) — filters the DisplayFeature list to find only fold hinges
  7. 7. hinge.bounds.left — the pixel position of the hinge; SizedBox(width: hinge.left) places content exactly up to the hinge
  8. 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?
This works but has a performance issue and a potential layout issue. What are they?
Show answer
Performance issue: MediaQuery.of(context) subscribes to ALL MediaQuery changes — including keyboard appearance, system font size changes, and safe area changes. When the keyboard opens (which changes MediaQuery.viewInsets), this entire widget rebuilds unnecessarily. Fix: use MediaQuery.sizeOf(context) (Flutter 3.7+) which only rebuilds on size changes, or wrap in a LayoutBuilder to get constraints local to this widget. Layout issue: Using a hardcoded 600 pixel threshold is fragile — this is 600 logical pixels of the full screen, but if this widget is embedded in a narrow container (e.g., inside a dialog or side panel), the Row layout may not fit. Prefer LayoutBuilder's constraints.maxWidth for widget-level adaptation.

Explain like I'm 5

Imagine you're arranging furniture. On a phone (small room), you put everything in a line because there's only one wall. On a tablet (bigger room), you put the list on one side and the details on the other wall. On a foldable (a room with a fold-down partition in the middle), you put things on either side of the partition and avoid putting the TV right on the fold line.

Fun fact

Samsung's Galaxy Z Fold line has sold over 10 million units since 2019. In 2023 Google shipped the first Pixel Fold. The foldable market is growing at 50%+ annually. Flutter added DisplayFeature (fold detection) API in Flutter 2.5 (2021) — earlier than both React Native and Kotlin/Compose had stable foldable APIs, reflecting Google's investment in Flutter for their own hardware.

Hands-on challenge

Design the adaptive layout for the school management app: (1) describe the navigation component used on each WindowSizeClass breakpoint, (2) sketch the class list screen layout for compact vs expanded, (3) explain how you handle the foldable case where the hinge splits the screen horizontally.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery