Responsive & Adaptive UI
Irbisa · cheatsheetSeptember 13, 2026

Responsive & Adaptive UI

Breakpoints, LayoutBuilder, NavigationRail, foldables, orientation, SafeArea, text scaling

Middle Developer16 itemscompressed for a skim
  1. 01

    Opening the keyboard rebuilds widgets that have no text field anywhere near them. What does MediaQuery.of(context) have to do with it?

    Easy

    MediaQuery.of(context) subscribes you to every field of MediaQueryData, so the keyboard moving viewInsets rebuilds a widget that only ever read size.

    // ❌ every keyboard frame, brightness flip and text-scale change rebuilds this
    class HeaderBad extends StatelessWidget {
      const HeaderBad({super.key});
    
      @override
      Widget build(BuildContext context) {
    …
  2. 02

    The 34 px gap under your bottom action button disappears the moment the keyboard opens. Which MediaQuery padding were you reading?

    Easy

    SafeArea reads padding, and padding is viewPadding minus viewInsets — so once the keyboard covers the home indicator, the bottom safe-area padding correctly drops to zero.

    // iPhone, keyboard closed: viewPadding.bottom 34, viewInsets.bottom 0,   padding.bottom 34
    // iPhone, keyboard open:   viewPadding.bottom 34, viewInsets.bottom 336, padding.bottom 0
    
    // ❌ the 34 px home-indicator gap collapses the moment the keyboard appears
    Scaffold(
      body: Column(
    …
  3. 03

    A product card that sizes itself from MediaQuery.sizeOf renders its wide two-column version inside a 320 px side panel. What should it read instead?

    Medium

    MediaQuery describes the window, and the card is not the window — it has to decide from the constraints its parent actually handed it, which is what LayoutBuilder gives you.

    // A component decides from the box it is given, never from the window
    class ProductCard extends StatelessWidget {
      const ProductCard({super.key, required this.product});
    
      final Product product;
    …
  4. 04

    Your inbox is a list on a phone and list-plus-message on a tablet. After a rotation into two panes, the back gesture pops an invisible route. How do you model this?

    Medium

    The selected message is application state, not a route you pushed — hoist the selected id above the breakpoint and make the Navigator's page list a function of it.

    class _InboxScreenState extends State<InboxScreen> {
      String? _selectedId;  // selection lives ABOVE the breakpoint
    
      void _select(String? id) => setState(() => _selectedId = id);
    
      @override
    …
  5. 05

    Resizing the desktop window from phone width to full screen resets which tab you were on. How do you wire a bottom bar, a rail and a drawer to one router?

    Medium

    Swap only the chrome around a single shared child — one destination list, one router, one body element — and the tab state survives because nothing under the breakpoint is rebuilt from scratch.

    // one source of destinations: the index must mean the same thing in every chrome
    const destinations = [
      (icon: Icons.inbox, label: 'Inbox'),
      (icon: Icons.send, label: 'Sent'),
    ];
    …
  6. 06

    Your tablet layout kicks in when a phone is turned sideways and the two-pane view looks absurd. What are you actually measuring?

    Medium

    Orientation is an aspect ratio, not a size class — a landscape phone is roughly 780 x 360 dp, which is genuinely "landscape" while still being 360 dp tall, so a two-pane layout has nowhere to go.

    // orientationOf is an aspect ratio, nothing more
    final orientation = MediaQuery.orientationOf(context);  // width > height ? landscape : portrait
    
    // ❌ a phone on its side is ~780 x 360 dp — "landscape", and still a phone
    if (orientation == Orientation.landscape) {
      return const TwoPaneLayout();
    …
  7. 07

    A user sets the system font size to 200% and your bottom bar labels turn into three dots. What does MediaQuery.textScalerOf give you, and where is clamping defensible?

    Medium

    textScalerOf returns a TextScaler — a scaling curve, not a multiplier — and the first move is to let the layout grow; clamping is a narrow escape hatch for chrome that physically cannot.

    final scaler = MediaQuery.textScalerOf(context);
    final painted = scaler.scale(14);  // real size of a 14 px font, not 14 * factor
    
    // ❌ fixed height plus one line: at 200% this is an ellipsis and nothing else
    SizedBox(
      height: 48,
    …
  8. 08

    The app is dragged into Android split screen, and on a foldable half the form ends up under the hinge. What does Flutter tell you about that hardware?

    Medium

    MediaQuery.displayFeaturesOf(context) reports the folds, hinges and cutouts in window coordinates, and the resize itself arrives as an ordinary constraint change — the Activity is not recreated.

    class HingeAwarePanes extends StatelessWidget {
      const HingeAwarePanes({super.key, required this.left, required this.right});
    
      final Widget left;
      final Widget right;
    …
  9. 09

    The iOS build has to feel native — how much of that do Flutter's .adaptive constructors actually give you, and what is left to branch by hand?

    Medium

    The .adaptive constructors are a short, fixed list of Material controls that render their Cupertino twin when Theme.of(context).platform is iOS or macOS — everything structural is still yours.

    // Controls: the adaptive constructor is the whole change
    Switch.adaptive(value: on, onChanged: _toggle);   // CupertinoSwitch on iOS/macOS
    const CircularProgressIndicator.adaptive();
    
    showAdaptiveDialog<void>(                         // CupertinoAlertDialog on Apple
      context: context,
    …
  10. 10

    A card grid shows two stretched cards on a tablet and four cramped ones in a desktop window — how do you make it reflow from a card width instead of a breakpoint ladder?

    Medium

    Give the grid a maximum tile width and let SliverGridDelegateWithMaxCrossAxisExtent work out the column count for whatever cross-axis extent the viewport hands it.

    // ❌ a ladder that re-derives what the delegate already knows
    LayoutBuilder(
      builder: (context, c) => GridView.builder(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: c.maxWidth > 1200 ? 4 : c.maxWidth > 600 ? 2 : 1,
          childAspectRatio: 0.75,        // height follows width — the text overflows
    …
  11. 11

    The same Flutter screen now ships to desktop and web — what has to change for mouse and keyboard beyond widening the layout?

    Medium

    Everything a finger cannot do: hover feedback, a secondary-click menu, a visible focus ring, and a scroll view a mouse can actually drag.

    // A row that works with a finger, a mouse and a keyboard
    class ContactRow extends StatelessWidget {
      const ContactRow({super.key, required this.contact});
      final Contact contact;
    
      @override
    …
  12. 12

    A row of cards is wrapped in IntrinsicHeight so they match the tallest one, and the list starts to jank — what is that widget costing you?

    Medium

    Intrinsic sizing asks the subtree how big it would like to be before laying it out, which is an extra walk of that subtree on every layout — and when intrinsics nest, the walks nest with them and the cost goes quadratic.

    // ❌ every row of a long list pays a speculative layout pass
    ListView.builder(
      itemCount: rows.length,
      itemBuilder: (_, i) => IntrinsicHeight(
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.stretch,
    …
  13. 13

    A horizontally scrolling row of chips throws "BoxConstraints forces an infinite width" — what is the render tree asking for, and how do you fix it without hardcoding a width?

    Hard

    A scroll view hands its child an unbounded constraint along the scroll axis so the content can be as long as it likes, and Expanded inside that child asks for all of it — a flex share of infinity is not a number.

    // ❌ BoxConstraints forces an infinite width
    SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      child: Row(
        children: [
          for (final f in filters) Expanded(child: FilterChip(label: Text(f))),
    …
  14. 14

    The design has to hold at four widths and at 200% text scale — how do you test that without maintaining forty golden files?

    Hard

    Split the question in two: "does it lay out at all" is an assertion you can loop over the whole matrix for free, and "does it look right" is a golden — and you only need a handful of those.

    const _sizes = <String, Size>{
      'phone': Size(390, 844),
      'foldable': Size(673, 841),
      'tablet': Size(1024, 768),
      'desktop': Size(1440, 900),
    };
    …
  15. 15

    A desktop user drags the window narrower: the detail pane collapses, the list jumps back to the top and the selection is gone. What did the code get wrong?

    Hard

    The state lives inside the branch that the resize destroys: when LayoutBuilder returns a widget of a different type, the old element subtree is unmounted and every State, ScrollController and TextEditingController it owned is disposed with it.

    class _OrdersRouteState extends State<OrdersRoute> {
      // Owned ABOVE the layout branch — a resize cannot dispose these
      String? _selectedId;
      bool _wide = false;
    
      @override
    …
  16. 16

    Phone, tablet and desktop out of one Flutter codebase — do you write three widget trees or one tree full of width conditionals?

    Hard

    Neither as stated: you split by layer — one set of content widgets that never ask how wide the window is, and one thin scaffold per form factor that arranges them.

    enum WindowClass { compact, medium, expanded, large }
    
    WindowClass classify(double width) => switch (width) {
          < 600 => WindowClass.compact,
          < 840 => WindowClass.medium,
          < 1200 => WindowClass.expanded,
    …