Responsive Design with flutter_screenutil
Introduction
I’m building a Flutter app that has to work on tablets, foldables, and both orientations.
I can’t test every combination, and a screen I never tried shouldn’t look or behave much differently from one I did. The responsive setup I had wasn’t good enough.
This post lays out the layers and patterns I settled on in a flutter_screenutil based app, along with the problems I hit along the way.
Two ways to be responsive
When the screen gets bigger, there are two things you can do.
| Scale | Adapt | |
|---|---|---|
| Approach | Stretch one design proportionally | Change the layout itself |
| Result | Bigger | More (two columns, grids, master-detail) |
| Typical tools | flutter_screenutil | LayoutBuilder, breakpoints, flutter_adaptive_scaffold |
| Upside | Pixel-perfect reproduction of a single design, cheap | Uses the screen properly, the native way |
| Downside | Just gets bigger on large screens | Design and implementation cost |
They aren’t mutually exclusive. Usually adaptation handles the layout, and scaling handles the continuous change within it.
The layers
Global responsiveness stacks up roughly like this.
1
2
3
4
5
Layer 0. Device class - what device and orientation is this (profile)
Layer 1. Scale reference - scale relative to what, and by how much (designSize)
Layer 2. Content width - how wide is content allowed to get (maxWidth cap)
Layer 3. Scale ↔ width - make scaling respect the cap (.wc)
Layer 4. Text - how far to defend
Layer 0 is the adaptation axis (choosing which design to use). Layers 1 to 4 deal with the problems that come up when you stretch that design.
Layer 0. Device class
Split the axes, decide in one place
Device classification multiplies two axes, device class and orientation, into a profile.
1
device class (phone/tablet) × orientation (portrait/landscape) = 4 profiles
1
2
3
4
5
6
7
8
9
10
11
12
13
enum AppDesignProfile { phonePortrait, phoneLandscape, tabletPortrait, tabletLandscape }
static AppDesignProfile designProfileForWindow(Size size) {
final isTablet = size.shortestSide >= 600; // ① device class
if (!isTablet) {
return size.width > size.height // ② orientation
? AppDesignProfile.phoneLandscape
: AppDesignProfile.phonePortrait;
}
return size.width > size.height
? AppDesignProfile.tabletLandscape
: AppDesignProfile.tabletPortrait;
}
- 600 is the line Android’s
sw600dpand Material’s window size classes use. - It keys off
shortestSide, so rotating a device changes the orientation but never flips phone and tablet.
The decision lives in one place. The whole app calls AppBreakpoints.designProfile(context) and everything else branches on the profile enum.
Start with four profiles and add a middle tier (Material’s medium 600–840, say) only when a real device looks awkward. Every extra profile means touching every switch that branches on it.
Layer 1. Scale reference
A baseline per profile
ScreenUtil scales every value by the ratio of the actual screen to the design reference.
| Unit | Factor | Used for |
|---|---|---|
.w | screenWidth / designWidth | widths, horizontal distances |
.h | screenHeight / designHeight | heights, vertical distances |
.r | min(scaleW, scaleH) | squares, icons, radii |
.sp | min(scaleW, scaleH) with minTextAdapt: true | fonts |
The reference is set per profile so that a typical device in each profile lands close to a factor of 1.
1
2
3
4
phonePortrait = Size(360, 738)
phoneLandscape = Size(738, 360)
tabletPortrait = Size(680, 1024)
tabletLandscape = Size(1024, 680)
When the window’s aspect ratio doesn’t match the baseline
Resizing the window in a tablet’s desktop mode broke the UI.
1
2
portrait window 592×765 → scaleW 1.65 / scaleH 1.04 → stretched horizontally only
landscape window 575×556 → scaleW 0.78 / scaleH 1.55 → stretched vertically only, text shrank
The baselines are 0.49 (tall) or 2.05 (wide), but a resized window sits near 1:1 and matches neither. Real devices (phones around 0.46, tablets around 1.6) are close enough to the baseline that the problem never showed. Arbitrary window sizes exposed it.
It isn’t only resizable windows. The Galaxy Z Fold and Flip produce all sorts of aspect ratios depending on their state (the Fold’s inner screen is nearly square, the Flip’s cover screen is very tall).
Clamp the factor by adjusting designSize
You can’t clamp .w directly. It’s a global singleton and an extension on num. Instead, adjust the denominator, designSize, so the resulting factor stays inside a band.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static const double minDesignScale = 1.0; // never shrink below the design
static const double maxDesignScale = 1.25; // grow to 1.25× at most
static Size designSizeForWindow(Size size) {
final base = baseDesignSizeForWindow(size); // fixed baseline per profile (phonePortrait, tabletPortrait ...)
return Size(
_clampDesignDim(size.width, base.width),
_clampDesignDim(size.height, base.height),
);
}
static double _clampDesignDim(double window, double baseDesign) {
if (window <= 0 || baseDesign <= 0) return baseDesign;
final scale = (window / baseDesign).clamp(minDesignScale, maxDesignScale).toDouble();
return window / scale; // if the factor leaves the band, move designSize to cancel it
}
| Window | Before | After (band 1.0~1.25) |
|---|---|---|
| portrait 592×765 | design 360×738 / scaleW 1.65 / scaleH 1.04 | design 473.6×738 / scaleW 1.25 / scaleH 1.04 |
| landscape 575×556 | design 738×360 / scaleW 0.78 / scaleH 1.55 | design 575×444.8 / scaleW 1.00 / scaleH 1.25 |
| ordinary phones and tablets | factor ≈ 1.0~1.25 | unchanged |
.w, .h, .r, and .sp all derive from designSize, so adjusting that one denominator pulls every scaled value into the band. No call site changes.
What the 1.0 floor means
The 1.25 ceiling is cosmetic, it just stops things from growing too much. The floor is different. A factor below 1 means the window is narrower than the canvas, and you either shrink (text and touch targets get smaller) or hold (content overflows). Overflow can be absorbed with Flexible, wrapping, and scrolling. Shrunken body text and touch targets under 40dp can’t be recovered. So the floor is 1.0 and overflow is the chosen failure. That’s why the band isn’t symmetric like [0.8, 1.2].
This decision is tied to the baseline. The phonePortrait baseline of 360 is effectively the narrowest phone in service, so the only windows that hit the floor are split-screen slices and resized windows. Had the baseline been 414 (a large phone), every 360 phone would hit the floor and overflow on a daily basis. A 1.0 floor is only painless when the baseline sits at the bottom of the profile’s range.
Layer 2. Content width
Why it’s needed
With scaling alone, a single button becomes 800dp wide on a tablet. The Layer 1 clamp doesn’t help. Full-width elements laid out with double.infinity or Expanded follow the window width regardless of the scale factor. So there’s a max-width container that limits content width and centers it.
1
2
3
4
5
6
double get contentMaxWidth => switch (this) {
AppDesignProfile.phonePortrait => double.infinity, // no cap makes sense on a phone
AppDesignProfile.phoneLandscape => 600,
AppDesignProfile.tabletPortrait => 800,
AppDesignProfile.tabletLandscape => 800,
};
“No cap” is double.infinity rather than null. Expressions like min(screen, cap) just work.
The cap is applied in one place, the shared scaffold. Not per page.
- When the cap is finite, the entire scaffold, app bar included, is centered with
Align(topCenter) + ConstrainedBox(maxWidth). Capping only the body while the app bar stays full-width makes the edges disagree. - The scaffold background is transparent, with a full-screen background layer behind it. Otherwise the gutters on either side show up as blank white.
- When the cap is
∞, there’s no wrapper at all and the existing Scaffold is returned as is. Nothing about the layout changes.
Where the drawer lives
Capping the whole scaffold traps the endDrawer inside it. The fix is one more full-screen Scaffold on the outside that owns the drawer. The inner scaffold is transparent with no drawer, and the app bar button opens the outer drawer through a GlobalKey<ScaffoldState>.
Layer 3. Scale ↔ width
.w doesn’t know about the cap
1
2
3
4
// inside flutter_screenutil
extension SizeExtension on num {
double get w => ScreenUtil().setWidth(this); // global singleton, no BuildContext
}
.w reads the full window width from a global singleton. It has no way to see a ConstrainedBox in the widget tree. So even with content pinned to 600, this happens.
| Device width | Content box | scaleW (raw → clamped) | actual 20.w |
|---|---|---|---|
| 1024 | 600 (cap) | 1.0 → 1.0 | 20 |
| 1280 | 600 (cap) | 1.25 → 1.25 | ≈ 25 |
| 1600 | 600 (cap) | 1.56 → 1.25 | ≈ 25 (≈31 without the clamp) |
The box stopped at 600, but the padding inside it keeps growing with the screen. The Layer 1 clamp stops the runaway (it holds at 25 even on 1600), but from the 600 box’s point of view the value is still 25% off. The clamp keeps the factor sane relative to the screen. It knows nothing about the cap.
.wc, measured against the cap
The library can’t be changed, so there’s a second horizontal scale just for capped areas. Where .w divides the actual screen width by the mockup’s screen width, .wc divides the actual content box width by the mockup’s content box width.
1
2
3
4
5
6
7
8
.w = value × screenWidth / designWidth
screenWidth actual screen width
designWidth the screen width the mockup was drawn at (profile baseline, clamped in Layer 1)
.wc = value × clamp(contentWidth / contentDesignWidth, 1.0, 1.25)
contentWidth = min(screenWidth, cap) actual content box width
contentDesignWidth = min(baselineWidth, cap) content box width in the mockup
clamp same band as Layer 1. Why is covered under ".wc and the clamp band"
One thing to watch. A scale factor is the actual size of a thing divided by its size in the mockup, and it has to be the same thing on both sides. Put designWidth in the denominator and you’re comparing the actual box (800) to the mockup’s screen (1024). On tabletLandscape at 1280, the box is 800, exactly as in the mockup, yet 20 × 800 / 1024 = 15.6 shrinks it by more than 20%. Compare box to box and it’s 800 / 800 = 1, so 20 stays 20.
In numbers, with baseline 1024 and cap 600.
| Screen width | scaleW | 20.w | contentWidth / contentDesignWidth | 20.wc |
|---|---|---|---|---|
| 1024 | 1.0 | 20 | 600 / 600 | 20 |
| 1280 | 1.25 | 25 | 600 / 600 | 20 |
.w follows the screen width, .wc follows the box width. Past the cap the box is pinned at 600, so .wc stops at 20.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ContentWidthScope extends InheritedWidget {
final double widthFactor; // multiplier applied to .w
static double factorFor({
required double screenWidth, // ScreenUtil().screenWidth
required double maxWidth, // profile.contentMaxWidth
required double baselineWidth, // profile baseline width (before clamping)
required double scaleWidth, // ScreenUtil().scaleWidth
}) {
final contentWidth = math.min(screenWidth, maxWidth);
final contentDesignWidth = math.min(baselineWidth, maxWidth);
final contentScale = (contentWidth / contentDesignWidth)
.clamp(AppBreakpoints.minDesignScale, AppBreakpoints.maxDesignScale); // 1.0 ~ 1.25
return contentScale / scaleWidth; // divide out the scaleWidth that .w applied
}
static double factorOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ContentWidthScope>()?.widthFactor ?? 1.0;
}
extension CappedWidthExt on num {
double wc(BuildContext context) => w * ContentWidthScope.factorOf(context);
}
It’s written as a multiplier on .w so the existing .w extension stays untouched and this sits on top. Because the factor divides scaleWidth back out, the result is value × clamp(contentWidth / contentDesignWidth), independent of whatever .w scaled by.
The part that matters more than the formula is the ?? 1.0 in factorOf.
- Called outside the scope, the factor is 1 →
.wc == .w - Inside the scope with no cap (∞), contentWidth / contentDesignWidth = screen / baseline, and that ratio clamped to the band is exactly Layer 1’s scaleWidth, so the factor is 1 →
.wc == .w. That’s phonePortrait.
Since the default equals plain .w, rolling it out globally changes nothing, and shared widgets can use it as is. No big-bang migration. Capped pages can switch over one at a time.
Call site and context
.wc(context) walks up from the given context, parents only, looking for a ContentWidthScope. It never looks down. But the scope is put in place by the shared scaffold, and the page is what wraps that scaffold. In tree terms, the scope sits below the page.
1
2
3
4
HomePage ← build's context is here
└ CommonScaffoldScrollBool
└ ContentWidthScope ← the scope
└ Padding(child) ← the child the page passed in
So calling .wc at the top of a page’s build doesn’t find the scope.
1
2
3
4
5
Widget build(BuildContext context) {
// ❌ this context is at HomePage. Walking up finds no scope → factor 1 → same as .w
final padding = EdgeInsets.symmetric(horizontal: 20.wc(context));
return CommonScaffoldScrollBool(child: Padding(padding: padding, ...));
}
factorOf returns 1 via ?? 1.0, so it renders without the cap correction, and there’s no error to point you at it. You just end up wondering why the cap isn’t working.
The fix is to make sure the context you call .wc with lives below the scope. Builder creates a fresh context at its own position, so put a Builder inside the child and call from there.
1
2
3
4
5
6
7
// ✅ Builder's context is below the scope
return CommonScaffoldScrollBool(
child: Builder(builder: (context) {
final padding = EdgeInsets.symmetric(horizontal: 20.wc(context));
return Padding(padding: padding, ...);
}),
);
Pass raw numbers down and apply .wc inside the widget that actually draws. Stick to that and the call site is always below the scope.
There’s no .hc. The cap only applies horizontally.
The two mechanisms are independent
By now there are two mechanisms that adjust scale factors. They solve different problems.
| ① designSize clamp | ② .wc | |
|---|---|---|
| Problem | factor runs away when the window’s aspect is far from the baseline | horizontal values ignore the content maxWidth cap |
| Reference | window / designBaseline, banded | min(screen, cap) / min(baseline, cap), same band |
| Axis | both width and height | width only |
| Reach | app-wide (all of .w/.h/.r/.sp) | below the scope, only where .wc is used |
| Mechanism | adjust designSize (the denominator) | divide out .w’s factor and apply its own |
① is the global safeguard against runaway factors. ② is the local correction that makes horizontal values respect the cap. Neither solves the other’s problem.
Because ② divides out the factor inside .w, nothing of ① survives in the final .wc value.
1
2
3
4
① designSize clamp → scaleW held to [1.0, 1.25]
↓
.w = value × scaleW ← ① only
.wc = value × clamp(min(screen, cap) / min(baseline, cap)) ← ② only, scaleW cancels
.wc and the clamp band
With ① gone from .wc, its floor and ceiling are gone too. So .wc’s ratio gets the same [1.0, 1.25] band.
With no cap, the ratio is screen / baseline, and banded that’s exactly ①’s scaleWidth, so .wc equals .w precisely. With a cap, values above the cap are unchanged and the floor only kicks in below it.
The cost is that on narrow screens values no longer shrink with the box. That’s the same trade-off already made when .w got its 1.0 floor (overflow rather than shrink).
| Case | scaleW | 20.w | 20.wc no band | 20.wc banded |
|---|---|---|---|---|
| phonePortrait 360 (typical device) | 1.0 | 20 | 20 | 20 |
| phonePortrait 592 resized window (wide, no cap) | 1.25 (ceiling) | 25 | 32.9 | 25 |
| tabletPortrait 600 (narrow, baseline 680) | 1.0 (floor) | 20 | 17.6 | 20 |
| tabletLandscape 1280 (above the 800 cap) | 1.25 (ceiling) | 25 | 20 | 20 |
Layer 4. Text
.sp doesn’t know about the cap either, same as .w. I still didn’t add a .spc. Three reasons.
- Text size should follow the screen, not the box. On a 1280 tablet,
16.spgrows to 20. A.spcwould hold it at 16. Small text on a big screen feels cramped. - It’s already on the safe side. The Layer 1 clamp caps
.spat 1.25×, and withminTextAdapt: trueit uses the smaller of the two axes, so a window stretched along one axis doesn’t even get that. - Too many places to touch.
.spusually lives inside context-free style helpers likebaseStyle(fontSize: 16.sp). Using.spc(context)means threading a context through those helpers and every one of their callers. Nothing about the logic changes, only the plumbing, across hundreds of sites.
Where text breaks
| Situation | Result |
|---|---|
| Wrapping text gets bigger | more lines → scroll absorbs it, nothing breaks |
| Text inside a fixed-height box | may clip vertically |
A rigid Row (no Flexible) | horizontal overflow |
A global mechanism is only worth it when breakage is structurally unavoidable. For text the failure modes are specific, as above, so it’s enough to guard those spots with Flexible or ellipsis.