Responsive UI rearranges the same content to fit the space it has; adaptive UI changes how the app behaves for the device, input and platform. A grid that changes its column count is responsive; swapping a bottom bar for a navigation rail is adaptive.
Responsive: LayoutBuilder, Flexible, Wrap and a grid with maxCrossAxisExtent resize and reflow the same widgets
Adaptive: different navigation per window size, hover and right-click on desktop, keyboard shortcuts, Switch.adaptive on iOS
Decide from space, not device: an iPad in Split View or an app on a foldable can be any size, so ask how much room you have, not which device it is
FlutterMiddle
Responsive & Adaptive UI
Breakpoints, LayoutBuilder, NavigationRail, foldables, orientation, SafeArea, text scaling
Questions closed
0 / 20
- 0Review
- 5Asked often
Questions
MediaQuery describes the whole window (size, insets, text scale, user settings), while LayoutBuilder gives you the constraints the parent actually passed to this widget. A reusable widget picks its layout from LayoutBuilder; window facts like the keyboard come from MediaQuery.
Same window, different space: a card in a 320 px side panel and a card filling a 1280 px window read the same MediaQuery.sizeOf, but LayoutBuilder reports maxWidth 320 and 1280
Screen vs component: "show a navigation rail from 600 dp" is a screen decision, and the window size is fine there; "the image fits beside the text from 480 px" belongs to the card
When it runs: the builder is called during layout and again whenever the constraints change, so no setState inside it
Unbounded axes: inside a vertical scroll view maxHeight is double.infinity; check hasBoundedHeight before computing with it
Use Material 3's window size classes: compact under 600 dp, medium 600–839, expanded 840–1199, large 1200–1599, extra-large from 1600. Map the width to an enum in one function and branch on it once, at the top of the screen.
Units: Flutter sizes are already logical pixels (dp); never multiply by devicePixelRatio
What each class usually gets: compact: one column and a NavigationBar; medium: a NavigationRail, sometimes two panes; expanded and up: list and detail side by side
One switch per screen: the widgets below take parameters and never compare widths themselves
Common mistake: if (width > 600) copied into ten files: the numbers drift apart, and each file decides the 600 edge differently
Rotation is just a new window size: nothing is recreated, and widgets that depend on the size rebuild. Read it with MediaQuery.orientationOf or OrientationBuilder, lock it with SystemChrome.setPreferredOrientations, but pick layouts from the available size, not the orientation.
**MediaQuery.orientationOf(context): only compares the window's width and height; no sensor is involved
OrientationBuilder: compares its parent's constraints, so a 300 × 600 box reports portrait even on a landscape tablet
The trap**: a phone on its side is about 780 × 360 dp: landscape, yet far too short for two panes. Use shortestSide >= 600 for "is this a tablet" and constraints on both axes for layout
Locking: call setPreferredOrientations in main after WidgetsFlutterBinding.ensureInitialized(), and declare the same orientations in Info.plist and the manifest so the launch screen matches
Locks are hints on big screens: Android 16 ignores them on large screens for apps targeting API 36, and iPad multitasking windows can be any shape
Flutter sizes are already logical pixels, so 16 dp is about the same physical size on every phone; scaling by width just makes a bigger screen show the same layout, larger. Teams keep fixed dp, flexible widgets and breakpoints, so a bigger screen shows more.
What it does: with designSize: Size(375, 812), .w multiplies by width / 375, .h by height / 812, and .sp by the width ratio too (by the smaller ratio with minTextAdapt)
A tablet gets a zoomed phone: on an 834 dp wide iPad a 343.w button is 763 dp wide and 16.sp text is 36 dp, where the user expected a second column
Rotation and resizing: in landscape (812 × 375) 48.h shrinks to 22 dp, under the 48 dp tap target, while 16.sp grows to 35; split screen or a desktop window rescales everything on each drag
It fights the user's text size: the OS text scaler still applies on top of .sp, so 200% on that iPad makes body text about 71 px, while boxes sized with .h stay put and their text overflows
Instead: spacing and type in plain dp from the theme, Expanded, Flexible and AspectRatio where a proportion really is the design, a maxWidth on content, and breakpoints that change the layout
Where it is defensible: a portrait-locked, phone-only app drawn to one fixed frame, such as a kiosk or a game menu
MediaQuery.of(context) subscribes a widget to every field of MediaQueryData, and the keyboard changes viewInsets on each frame of its slide-in. So every widget that called of, even just to read size, rebuilds; MediaQuery.sizeOf(context) depends on the size alone.
Why the aspect getters work: MediaQuery is an InheritedModel; each …Of getter registers a dependency on one field, and only a change to that field rebuilds you
The getters: sizeOf, paddingOf, viewPaddingOf, viewInsetsOf, textScalerOf, orientationOf, platformBrightnessOf, disableAnimationsOf
Where it hurts: a MediaQuery.of in a screen-level build rebuilds the whole screen on every keyboard frame, and again on rotation, a brightness switch or a text-scale change
**Read it in build**: do not cache the size in initState or a late final field; rotation, split screen and window resizing change it at any moment
SafeArea reads MediaQuery.padding, which is viewPadding minus viewInsets, so once the keyboard covers the home indicator the bottom padding is zero. That is not a bug: the bar now sits on top of the keyboard, where no home-indicator gap is needed.
**viewInsets**: what the keyboard covers; viewInsets.bottom is its height
**viewPadding: what fixed system UI covers: notch, status bar, home indicator; the keyboard does not change it
padding**: viewPadding minus viewInsets, never below zero; this is what SafeArea applies
**Scaffold already did the work**: with the default resizeToAvoidBottomInset: true it shrinks the body by the keyboard height and removes that inset from the body's MediaQuery; padding by viewInsetsOf(context).bottom read from a context above the Scaffold counts the keyboard height twice and leaves an empty band
**maintainBottomViewPadding: true**: for layouts that handle the keyboard themselves: SafeArea keeps viewPadding.bottom, so flexible children do not jump when the keyboard opens
Android 15 and later: apps targeting API 35 draw edge to edge, so the navigation bar shows up in viewPadding.bottom the same way
The header is 40% of the window height, but MediaQuery.sizeOf does not change when the keyboard opens, while the Scaffold body shrinks by the keyboard height. On an 812 px tall phone a 325 px header and a 200 px form no longer fit into the roughly 370 px left, so the Column overflows.
Window vs body: sizeOf is the whole window, status bar, AppBar and the area under the keyboard included; with the default resizeToAvoidBottomInset: true the Scaffold lays the body out above the keyboard, and only the body's constraints show that
The fix: LayoutBuilder hands over the body's real maxHeight: on a 375 × 812 phone with a 336 px keyboard it is about 370, so the header becomes about 148 px and the form fits
**Not resizeToAvoidBottomInset: false: the stripe goes away because the body no longer shrinks, and the form now sits under the keyboard
When the form itself may not fit**: on a small phone or at 200% text, wrap the screen in a SingleChildScrollView; inside it maxHeight is infinite, so the header then needs a fixed height
Catching it: a widget test that sets tester.view.devicePixelRatio = 1 and then tester.view.viewInsets = const FakeViewPadding(bottom: 336) (insets are physical pixels) fails on this screen, because the test binding reports a RenderFlex overflow as an error
MediaQuery.textScalerOf(context) returns a TextScaler, and scaler.scale(14) is the size a 14 px font is really drawn at. Fix the layout so it grows with the text; clamping the scale is a last resort for the one piece of chrome that cannot reflow.
It is a scaler rather than a number because Android 14 and later scale text non-linearly (large text grows less than small text), which is why textScaleFactor is deprecated.
What breaks first: a fixed height on a box with text, a Row of text without Flexible, labels with maxLines: 1
Fix it with layout: let heights come from content, use Wrap instead of Row, stack icon and label vertically when scale(14) gets large
Clamp locally: MediaQuery.withClampedTextScaling(maxScaleFactor: 1.3, child: …) around a custom bottom bar or a badge only; Material NavigationBar already clamps its labels to 1.3 itself
Common mistake: clamping or disabling scaling at the app root: it overrides an accessibility setting and fails WCAG 1.4.4, which asks for text that can grow to 200%
**No FittedBox around text**: it shrinks the glyphs back down and quietly undoes the user's setting
Four columns of 238 px: the count is (1000 / (240 + 16)).ceil() = 4, and the width is (1000 − 3 × 16) / 4. In a 400 px pane the same code gives two columns of 192 px; the grid reflows from its own width, with no breakpoint list.
Reads its own viewport: not the window, so the same grid works full screen and inside a pane
Height follows width: with childAspectRatio: 0.75 a 238 px card is about 317 px tall, but a 192 px card only 256; content that fits in one overflows in the other, worst at 200% text. mainAxisExtent: 300 fixes the height instead
"Max" is approximate: the spacing is part of the divisor, so at some widths a card comes out a few pixels wider than maxCrossAxisExtent
When a fixed count is right: when the count is the design, like a keypad or a seven-column calendar: SliverGridDelegateWithFixedCrossAxisCount
IntrinsicHeight asks its whole subtree how tall it would like to be before laying it out for real. That is an extra pass over the subtree on every layout, and nested intrinsic widgets multiply the passes; Flutter's docs warn it can be O(N²) in the depth of the tree.
Normal layout is a single pass: constraints go down, sizes come up, each child is laid out once. Intrinsics break that to answer "which child is tallest?" before anyone has a size.
Fine once, wrong per row: three cards in a header cost nothing you can measure; the same wrapper in an itemBuilder pays the extra pass for every row, every time it lays out
Cheaper ways: give the row a bounded height (itemExtent on the list or a fixed SizedBox) and use CrossAxisAlignment.stretch, or mainAxisExtent in a grid, or a Table, which sizes each row to its tallest cell natively
It cannot measure everything: a ListView or a LayoutBuilder inside it throws, because a viewport and a layout callback cannot answer intrinsic size queries
A horizontal ListView gives its children unbounded width, so c.maxWidth is double.infinity, the >= 240 check always picks the Row, and an Expanded inside a Row with unbounded width throws "RenderFlex children have non-zero flex but incoming width constraints are unbounded".
**What LayoutBuilder reports: the constraints from the parent, not the space on screen; a scroll view leaves its scroll axis unbounded, so there the 240 breakpoint compares against infinity
Why the grid works**: a grid cell has a tight width, so maxWidth is the real cell width and both branches are reachable
The fix: the strip decides how wide a card is with SizedBox(width: 280), and the card's breakpoint gets a real number again
Defending the card: a reusable widget can check c.hasBoundedWidth and fall back to its compact layout instead of trusting maxWidth; the same goes for maxHeight in a vertical list
Catching it: the error names the Row and points to the unbounded constraints; a widget test that pumps the card inside a horizontal ListView catches it before release, where the strip would be a grey error box
Keep the selected tab in the router, not in the chrome, and swap only the chrome around one shell: NavigationBar when compact, NavigationRail from 600 dp. With go_router's StatefulShellRoute the shell carries a GlobalKey, so moving it between layouts keeps every tab's stack.
One router: StatefulShellRoute.indexedStack gives each tab its own Navigator and hands your builder a StatefulNavigationShell; its currentIndex and goBranch drive whichever bar is showing
Why state survives: the shell is the same keyed widget in both branches, so Flutter moves its element instead of recreating it, and the tab index lives in the route, not in a widget
One destination list: map it to both NavigationDestination and NavigationRailDestination, so index 2 means the same tab in every chrome
Which chrome where: a bar under 600 dp, a rail from 600 to 839, an extended rail or a permanent NavigationDrawer from 840
Common mistake: an int _tab in a StatefulWidget inside one layout branch, or a hand-made IndexedStack without a key: crossing the breakpoint disposes both
The .adaptive constructors cover a short list of controls that draw their Cupertino twin on iOS and macOS. Page transitions, scroll physics and text selection already follow the platform; app bars, sheets, pickers and context menus you branch yourself.
Covered: Switch.adaptive, Checkbox.adaptive, Radio.adaptive, Slider.adaptive, CircularProgressIndicator.adaptive, RefreshIndicator.adaptive, AlertDialog.adaptive with showAdaptiveDialog, and icons such as Icons.adaptive.share
Free without code: the default page transition, bouncing scroll physics, the text selection toolbar and the scrollbar all follow Theme.of(context).platform
Yours to branch: AppBar vs CupertinoNavigationBar, bottom sheets vs showCupertinoModalPopup, date pickers, context menus, haptics, and dialog actions (CupertinoDialogAction on Apple platforms)
**Branch on Theme.of(context).platform**: not Platform.isIOS: it works on web, and a test can set ThemeData(platform: TargetPlatform.iOS) to check both looks
Platform is not size: .adaptive knows nothing about width; an iPad gets Cupertino controls but still needs a tablet layout
Everything a finger never needed: hover feedback and a pointer cursor, a right-click menu, a visible focus ring with a sensible tab order, and scroll views a mouse can drag where users expect it.
Hover and cursor: InkWell and Material buttons already show hover; on custom widgets MouseRegion gives onEnter, onExit and cursor: SystemMouseCursors.click
Right click: onSecondaryTapUp opens a MenuAnchor at the pointer; keep long press for touch so both reach the same menu
Focus: Tab must reach every control; FocusableActionDetector gives a custom control focus, hover and shortcuts in one widget, and FocusTraversalGroup fixes the order
Mouse drag: the default ScrollBehavior.dragDevices leaves out PointerDeviceKind.mouse, so a PageView carousel cannot be dragged with a mouse until you add it
Density and size: Material already uses a compact VisualDensity on desktop; keep tap targets at 48 dp anyway, because many laptops have touchscreens
Make the selected message state, not a pushed route. Keep selectedId above the width check and derive navigation from it: two panes when wide, a declarative Navigator page list when narrow. A rotation then builds the other branch and there is nothing hidden to pop.
The bug comes from Navigator.push for the detail: when the layout widens, that route stays on the stack under the two-pane view, and back pops something the user cannot see.
Hoist the state: String? selectedId lives in the screen's State or your state manager, above the width check
Wide: a Row of list and detail reading the same id; no detail route exists at all
Narrow: Navigator(pages: […]) with the detail page present only if (selectedId != null); onDidRemovePage clears the id when the user goes back
Back on wide: PopScope(canPop: selectedId == null) turns the first back into "close the message" instead of leaving the inbox
Nested navigator: Android's system back reaches the root navigator first; wrap the inner one in NavigatorPopHandler so it gets the pop
With go_router: the same rule holds: /inbox/42 is the selection, and its builder renders the two-pane view on wide screens instead of pushing a full-page detail
Crossing 840 swaps WideOrders for NarrowOrders. Flutter reuses an element only when the new widget has the same runtimeType and key, so it unmounts the old subtree and disposes every State, ScrollController and TextEditingController in it, selection and scroll offset included.
A teardown, not a re-layout: the new branch mounts fresh State objects that start from their initial values
Hoist what the user did: selection, filters and draft text belong in a State or controller above the LayoutBuilder, passed down as parameters
Scroll offset: give the list the same PageStorageKey in both layouts and it restores its offset from the route's PageStorage; a hoisted ScrollController also works, but only one scrollable may use it at a time
Keeping the live State: to keep the list's own State (focus, running animations) across the swap, give it a GlobalKey; Flutter then moves the element in the same frame. A PageStorageKey only restores the scroll offset into a new element
MediaQuery.displayFeaturesOf(context) lists folds, hinges and cutouts in window coordinates. Split screen and unfolding arrive as an ordinary size change: widgets stay alive and get new constraints, so a layout that reads its size in build simply follows.
**DisplayFeature**: a bounds rect, a type (fold, hinge, cutout) and a state (postureFlat, postureHalfOpened)
Fold vs hinge: a fold with zero width is a crease you may draw across; a hinge with real width is a physical gap, so split the panes at bounds.left and bounds.right
Tabletop posture: half opened with a horizontal fold: content on the top half, controls on the bottom
**DisplayFeatureSubScreen**: places a child inside one sub-screen; showDialog already uses it, so dialogs avoid the hinge for free
Common mistake: caching a width in a late final field; a split-screen window changes size on every drag of the divider
Split the job. "Does it lay out at all?" is a plain widget-test assertion you can loop over every width and text scale for free; "does it look right?" is a golden, and a handful of those is enough.
Set the surface: tester.view.physicalSize and tester.view.devicePixelRatio for the size, tester.platformDispatcher.textScaleFactorTestValue for the text scale; reset both with addTearDown
Loop the matrix: a RenderFlex overflow is a framework error and the test binding fails the test on it, so the loop needs no image files; call tester.takeException() only to assert an error you expect
Assert the arrangement: find.byType(NavigationRail) is findsOneWidget at 1024 and findsNothing at 390
Few goldens: one per component rather than per breakpoint: render it at three widths in one column and capture one image
Blind spot: ellipsized text does not throw; where truncation matters, check didExceedMaxLines on the text's RenderParagraph
Neither: split by layer. Content widgets never ask how wide the window is, and one thin layout per window size class arranges them, so behaviour is written once and only the arrangement differs.
Why the two obvious answers fail
One tree of conditionals: every if (isWide) doubles the states a file can be in, and they multiply across a screen
Three trees: behaviour is copied along with the arrangement, so a bug fixed in the phone layout survives in the tablet one
The layering
Name the widths once: one function maps width to a size class
One switch per screen: a single widget picks the layout; everything below takes parameters and renders at any size
State above the switch: or a resize throws it away
Components adapt locally: a card that changes shape uses its own LayoutBuilder, independent of the screen's class
Width vs platform
Two separate axes: branch on width for layout and on Theme.of(context).platform for conventions; an iPad is iOS and wide, a narrow desktop window is macOS and compact
Testing follows the layers: loop widget tests over the size classes for screens; content widgets need no size setup at all