Responsive & Adaptive UI
Breakpoints, LayoutBuilder, NavigationRail, foldables, orientation, SafeArea, text scaling
- 01
Opening the keyboard rebuilds widgets that have no text field anywhere near them. What does MediaQuery.of(context) have to do with it?
EasyMediaQuery.of(context)subscribes you to every field ofMediaQueryData, so the keyboard movingviewInsetsrebuilds a widget that only ever readsize.// ❌ every keyboard frame, brightness flip and text-scale change rebuilds this class HeaderBad extends StatelessWidget { const HeaderBad({super.key}); @override Widget build(BuildContext context) { … - 02
The 34 px gap under your bottom action button disappears the moment the keyboard opens. Which MediaQuery padding were you reading?
EasySafeAreareadspadding, andpaddingisviewPaddingminusviewInsets— 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( … - 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?
MediumMediaQuery 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
LayoutBuildergives 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; … - 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?
MediumThe 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 … - 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?
MediumSwap 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'), ]; … - 06
Your tablet layout kicks in when a phone is turned sideways and the two-pane view looks absurd. What are you actually measuring?
MediumOrientation 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(); … - 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?
MediumtextScalerOfreturns aTextScaler— 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, … - 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?
MediumMediaQuery.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; … - 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?
MediumThe
.adaptiveconstructors are a short, fixed list of Material controls that render their Cupertino twin whenTheme.of(context).platformis 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
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?
MediumGive the grid a maximum tile width and let
SliverGridDelegateWithMaxCrossAxisExtentwork 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
The same Flutter screen now ships to desktop and web — what has to change for mouse and keyboard beyond widening the layout?
MediumEverything 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
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?
MediumIntrinsic 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
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?
HardA scroll view hands its child an unbounded constraint along the scroll axis so the content can be as long as it likes, and
Expandedinside 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
The design has to hold at four widths and at 200% text scale — how do you test that without maintaining forty golden files?
HardSplit 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
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?
HardThe state lives inside the branch that the resize destroys: when
LayoutBuilderreturns a widget of a different type, the old element subtree is unmounted and everyState,ScrollControllerandTextEditingControllerit 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
Phone, tablet and desktop out of one Flutter codebase — do you write three widget trees or one tree full of width conditionals?
HardNeither 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, …