screen_size_adapter
简体中文 | English
Binding-level screen-size adaptation for Flutter. Your app code writes plain numbers in design units and the custom binding adjusts the view's devicePixelRatio. The standard single-view runApp path is stable; same-engine secondary-view integration is experimental.
Why this design
Most adapter packages add 100.dp / 14.sp extensions on num that read a global singleton. That couples every numeric literal to mutable global state, cannot be unit-tested in isolation, and cannot select a view from the caller's BuildContext.
screen_size_adapter performs scaling at the binding level by overriding WidgetsFlutterBinding.createViewConfigurationFor and multiplying the view's effective devicePixelRatio by the computed scale. The exact coordinate contract is MediaQuery.size = originSize / scale. Without clamping, only the axis selected by scaleAxis aligns with designSize; when minScale or maxScale applies, neither dimension may equal designSize. App code can still use plain design-unit values such as Container(width: 100) without extension methods.
Platform and verification boundary
“Stable” describes the integration contract; it does not mean every platform already has runtime evidence. The 1.0.0 boundary is below.
| Target/path | Contract maturity | Current 1.0.0 evidence/status |
|---|---|---|
Standard implicit-view runApp |
Stable integration boundary | Package and contract tests; see the platform rows for runtime evidence |
| Android | Stable path, release-gated | A debug build is build evidence only; manual interaction smoke on the exact release-candidate commit is required before publication |
| iOS | Stable path, release-gated | A remote CI simulator build plus a manual pre-publication smoke; a build does not replace interaction testing |
| macOS | Stable path, locally verified | packaged profile/release first-frame checks for the checked-in runner |
| Windows / Linux / Web | Platform-neutral API; runtime unverified for 1.0.0 |
No checked-in runner or runtime evidence, so 1.0.0 makes no runtime claim |
| Same-engine secondary views | Experimental | A real two-view host is required for future graduation to stable, but is not a 1.0.0 release gate |
The application must have one global WidgetsBinding; a second custom global binding cannot be installed alongside this package's binding. Host-created same-engine secondary views must also follow the experimental registration and scope contract below.
Quick start
import 'package:flutter/material.dart';
import 'package:screen_size_adapter/screen_size_adapter.dart';
void main() {
ScreenSizeWidgetsFlutterBinding.ensureInitialized(
const ScreenSizeAdapterConfig(designSize: Size(360, 690)),
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: HomePage());
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) => Scaffold(
body: Center(
child: Container(
width: 200,
height: 100,
padding: const EdgeInsets.all(16),
color: Colors.blue,
child: const Text('Hello', style: TextStyle(fontSize: 14)),
),
),
);
}
Configuration
void configureAdapter() {
ScreenSizeWidgetsFlutterBinding.ensureInitialized(
const ScreenSizeAdapterConfig(
designSize: Size(360, 690),
scaleAxis: ScaleAxis.width,
minScale: null,
maxScale: null,
enableDesktopScaling: false,
),
);
}
scaleAxis controls which axis derives the scale factor:
width—scale = origin.width / design.width. Default. Orientation behavior: in portraitorigin.widthis the device's short side; in landscape it's the long side, so the scale grows. The benefit isMediaQuery.width == designSize.widthin both orientations ("two 180-wide rectangles always fill the width"). The cost is that vertical content scales by the same factor in landscape, so it can overflow the now-compressed view height — see Orientation. If you need "long-side-to-long-side" semantics, choose the design size withMediaQuery.orientationOf(context), then update after the frame only when the context is still mounted, the orientation is still current, and the active config actually differs.height—scale = origin.height / design.height. Mirror ofwidth: pinsMediaQuery.heighttodesignSize.heightinstead.shorter— uses the smaller of the two ratios. The design canvas is always fully visible (no overflow), but the width is no longer pinned, and the scale differs across orientations. Suitable when "design must be fully visible" trumps "width consistency" (full-screen illustrations, modal dialogs). Not suitable for the "two 180s fill the width" contract.longer— uses the larger ratio. At least one design edge fills the screen; the other overflows. Pairs withmaxScalefor crop-style layouts.
Every axis follows MediaQuery.size = originSize / scale. Without clamping, width aligns only the width, height aligns only the height, and shorter / longer preserve their selected ratio relationship. When minScale or maxScale clamps the result, both dimensions may differ from designSize.
Experimental secondary-view integration
The standard implicit view used by runApp is the stable support boundary. Desktop multi-window, embedded View widgets, and Add-to-App scenarios with same-engine secondary FlutterViews require explicit registration. That path is experimental; it is not fully verified or advertised as stable multi-view support.
This package manages FlutterViews created by the host; it does not create desktop windows or secondary views. Validate a real same-engine secondary view in the relevant desktop or Add-to-App host using tool/verification/desktop_multi_view.md. Registry unit tests are not a substitute for that host-level check.
void registerSecondaryView(FlutterView secondaryView) {
final binding = ScreenSizeWidgetsFlutterBinding.instance;
binding.attachView(
view: secondaryView,
config: const ScreenSizeAdapterConfig(
designSize: Size(800, 600),
scaleAxis: ScaleAxis.shorter,
),
);
binding.updateView(
view: secondaryView,
config: const ScreenSizeAdapterConfig(
designSize: Size(1024, 768),
scaleAxis: ScaleAxis.shorter,
),
);
binding.detachView(secondaryView);
}
ensureInitialized automatically registers only PlatformDispatcher.implicitView. If the host has no implicit view, the package does not guess views.first; every host-created view must call attachView explicitly. Unregistered views fall through to stock Flutter behavior — no scaling.
Non-primary views (those mounted via runWidget or ViewAnchor) do not get the auto-injected MediaQuery scaling. Wrap each subtree manually with ScreenSizeAdapterScope:
Widget buildSecondaryView(FlutterView secondaryView) {
return View(
view: secondaryView,
child: const ScreenSizeAdapterScope(
child: Directionality(
textDirection: TextDirection.ltr,
child: Text('Secondary view'),
),
),
);
}
The implicit (primary) view used by runApp is wrapped automatically by the binding's wrapWithDefaultView, so app code needs no manual wrapping.
Orientation
Without scale-bound clamping, the default ScaleAxis.width makes MediaQuery.width equal designSize.width in portrait and landscape. A Container(width: 180) on a 360-wide design then occupies half the width. The trade-off is a different scale across orientations and possible vertical overflow. Choose the product-appropriate mitigation:
Future<void> lockPortraitAndRun() async {
ScreenSizeWidgetsFlutterBinding.ensureInitialized(
const ScreenSizeAdapterConfig(designSize: Size(360, 690)),
);
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
runApp(const ExampleApp());
}
Widget buildScrollableContent() => const SingleChildScrollView(
child: Column(children: [Text('Scrollable content')]),
);
Widget buildOrientationAwareHome() => const OrientationAwareHome();
class OrientationAwareHome extends StatelessWidget {
const OrientationAwareHome({super.key});
@override
Widget build(BuildContext context) {
final orientation = MediaQuery.orientationOf(context);
final design =
orientation == Orientation.landscape
? const Size(640, 360)
: const Size(360, 640);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
final liveOrientation = MediaQuery.orientationOf(context);
if (liveOrientation != orientation) return;
final binding = ScreenSizeWidgetsFlutterBinding.instance;
final view = View.of(context);
if (binding.configForView(view)?.designSize == design) return;
ScreenSizeAdapter.setDesignSize(context, design);
});
return const ExampleHome();
}
}
If your goal is "the entire design canvas must be visible" (no overflow, possibly with empty space) rather than "width always matches designSize.width", switch to ScaleAxis.shorter — these are different trade-offs, choose by app type.
Responsive breakpoints
Once adaptation is active, MediaQuery.sizeOf(context) reports originSize / scale. It describes the adapted coordinate space, not the native logical device size, so breakpoint logic should read originSizeOf instead:
Widget responsiveLayout(BuildContext context) {
final origin = ScreenSizeAdapter.originSizeOf(context);
if (origin.shortestSide >= 600) {
return const TabletLayout();
}
return const PhoneLayout();
}
originSizeOf is equivalent to view.physicalSize / view.devicePixelRatio and is not scaled by the binding.
Runtime updates
void updateAdapter(BuildContext context) {
ScreenSizeAdapter.setDesignSize(context, const Size(414, 896));
ScreenSizeAdapter.reset(context);
final scale = ScreenSizeAdapter.scaleOf(context);
debugPrint('Current scale: $scale');
}
setDesignSize and reset resolve the active view via View.of(context), so they target the FlutterView that owns the calling widget. reset clears that view's minScale / maxScale and guarantees native 1.0 scaling.
Integration limits
ScreenSizeWidgetsFlutterBinding.ensureInitialized(...)must run beforerunAppand before any code that initializesWidgetsBinding. This package works by installing a custom binding, so it cannot replace another binding after one is already active.- If your app or test harness already uses another custom
WidgetsBinding, decide which binding ownscreateViewConfigurationForand pointer-event handling. Two bindings cannot both be the global binding. testWidgetsuses Flutter's test binding, so it cannot install the production binding.ScreenSizeTestEnvironmentsimulates only the adaptedMediaQuery; useScreenSizeTestViewportexplicitly for layout assertions.- Non-primary
FlutterViews need both steps: register the view withScreenSizeWidgetsFlutterBinding.instance.attachView(...), and wrap thatViewsubtree withScreenSizeAdapterScope.
Testing
ScreenSizeTestEnvironment is MediaQuery-only and does not replace the test binding's root constraints. ScreenSizeTestViewport additionally gives its wrapped subtree tight constraints equal to MediaQuery.size, which is useful for layout and overlay assertions. Neither helper installs a RenderView, creates an engine-backed FlutterView, proves root hit testing, or executes the production pointer converter.
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:screen_size_adapter/screen_size_adapter.dart';
void main() {
testWidgets('layout in design units', (tester) async {
await tester.pumpWidget(
const ScreenSizeTestViewport(
config: ScreenSizeAdapterConfig(designSize: Size(360, 690)),
simulatedDeviceSize: Size(720, 1380),
child: Directionality(
textDirection: TextDirection.ltr,
child: Text('Hello'),
),
),
);
expect(find.text('Hello'), findsOneWidget);
});
}
For pure unit tests of the math, call ScreenSizeAdapter.computeScale(...) directly:
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:screen_size_adapter/screen_size_adapter.dart';
void main() {
test('scale on a 2x-wide device', () {
final scale = ScreenSizeAdapter.computeScale(
origin: const Size(720, 1280),
config: const ScreenSizeAdapterConfig(designSize: Size(360, 690)),
isDesktop: false,
);
expect(scale, 2.0);
});
}
Requirements
- Flutter
>=3.29.2 - Dart
^3.7.2
Security
This package does not process network data or secrets. For security-sensitive reports, please use the repository maintainer contact path if one is listed.
License
See LICENSE.
Libraries
- screen_size_adapter
- Screen-size adaptation for Flutter — scales the widget tree to a design size so layout code can use design-unit dimensions.