scale (1.1.0)
Installation
dart pub add scale:1.1.0 --hosted-url=About this package
Dart.Package.Scale
Interface size for Flutter apps, published as scale on the Platform pub feed. A rider can make everything in the app larger or smaller, and no screen overflows on a small phone however they choose.
It lays the app out on a canvas that is percent smaller or larger than the window and draws it scaled, so every hard-coded size, icon, padding and text grows together, and breakpoints, MediaQuery.sizeOf and LayoutBuilder see the scaled area. 150 percent on a 1920 wide window lays out like a 1280 wide one.
What is in it
UiScaleCanvas(percent:, policy:, child:) |
The wrapper an app puts in MaterialApp.builder. Works out the scale from the percent, the window and the policy. |
UiScale |
The policy: the choices (80 to 200 percent), the default, and the one rule that keeps small screens safe. |
ScaledCanvas(scale:, child:) |
The primitive under UiScaleCanvas, for a fixed scale (a TV canvas is ScaledCanvas(scale: width / 1280)). |
NativeScale(child:) |
Wraps a WebView or any platform view so it keeps its native resolution. |
package:scale/testing.dart |
sweepForOverflow and auditLayout, for tests. |
Wiring it into an app
MaterialApp(
builder: (context, child) => UiScaleCanvas(percent: settings.uiScalePercent, child: child!),
home: const Home(),
)
Keep the percent in the app's own settings, with defaultUiScalePercent as the field's default (it is a constant, so a const settings class can use it). UiScale.standard.percentFrom(stored) reads a stored value and falls back to 100 for anything that is not a choice. A settings screen offers UiScale.standard.choicesFor(size) where size is the real window, View.of(context).physicalSize / View.of(context).devicePixelRatio, not MediaQuery.sizeOf (which is already scaled).
Put UiScaleCanvas in the tree at every choice, 100 percent included. A wrapper that appears at 110 percent would remount the Navigator and drop whatever the rider is doing.
The clamp
Whatever the choice, the layout area never gets smaller than 320 by 568 points, in either orientation (UiScale.minLayoutShortSide and minLayoutLongSide). So a 320 wide phone cannot be made larger at all, a 360 wide one stops at 110 percent, and a 1920 by 1080 window can have any choice. Making things smaller is always allowed, and a window already below the minimum is simply not enlarged. Set the minimum to the smallest size your own overflow sweep passes at.
WebViews and other platform views
A platform view sizes its texture from the real device pixel ratio, so inside a scaled canvas it would be stretched into a blur. NativeScale lays the view out at the real screen size and draws it at 1 over the scale, so the texture is exactly as large as it is drawn.
Tests
testWidgets('no screen overflows, upright or sideways, from 320 points to desktop', (tester) async {
final findings = await sweepForOverflow(tester, cases: [
SweepCase('home', (tester) async {
await tester.pumpWidget(MaterialApp(
builder: (context, child) => UiScaleCanvas(percent: 200, child: child!),
home: const Home(),
));
await tester.pumpAndSettle();
}),
]);
expect(findings, isEmpty, reason: findings.join('\n'));
});
sweepForOverflow runs each case at nine window sizes (sweepSizes: 320 by 568 up to 1920 by 1080, upright and sideways) and returns one line per overflow with the file and line that overflowed. Run it at 100 percent and at the largest choice: the clamp keeps the second run's layout area at or above the smallest size, but it is a different area than the first.
auditLayout(tester) walks the rendered tree and lists what was shrunk to fit (a FittedBox drawing its child below 97 percent) and what was cut off with an ellipsis. It finds the thing a sweep cannot: content that fits only because it was squeezed, so two sibling cards end up with different type sizes.
Rules that make this work
The tooling can only keep a layout honest if the layout follows these.
- Width changes the layout, not the size of things. A button, a row and a touch target are the same size on a phone and on a monitor. Extra width becomes a second pane or another column.
- Decide the tier once, then draw every sibling the same. Compute what a strip shows (which rows, which details) from its width in one place and hand the result to every card, so none decides for itself.
- A strip is as tall as its rows added up. Give each row a fixed height and sum them. Do not let a card measure its content and shrink it.
FittedBox(fit: BoxFit.scaleDown)guards one row, never a card. Around a whole card it shrinks each card by a different amount, and equal cards stop being equal.- Floating cards share one decoration. One surface, one radius, one padding.
- Anything the user can make bigger must still fit at 320 by 568. That is what the sweep proves.