checkLayout function

Future<LayoutReport> checkLayout(
  1. WidgetTester tester,
  2. Widget screen, {
  3. List<DeviceSize> devices = DeviceSize.common,
  4. List<double> textScales = const <double>[1.0],
  5. bool settle = false,
  6. bool wrapInMaterialApp = true,
})

Render screen on every size in devices (optionally at several text scales) inside a widget test and report which combinations overflow.

This is the CI-facing entry point. Call it from inside testWidgets:

testWidgets('checkout fits every phone', (tester) async {
  final report = await checkLayout(
    tester,
    const CheckoutScreen(),
    devices: DeviceSize.all,
    textScales: const [1.0, 1.3], // also test large-font users
    settle: true,                 // let async content finish loading
  );
  expectNoOverflow(report);
});
  • devices: which screen sizes to render at. Defaults to DeviceSize.common.
  • textScales: font scale factors to test at. Large-font accessibility settings are a common overflow cause, so testing at e.g. 1.3 catches bugs a default-size check misses. Requires wrapInMaterialApp = true for any value other than 1.0.
  • settle: if true, calls pumpAndSettle after the first frame so screens that load data asynchronously render their real content before the check.
  • wrapInMaterialApp: wraps screen in a MaterialApp for realistic full-window constraints. Set false if screen is already a full app.

Implementation

Future<LayoutReport> checkLayout(
  WidgetTester tester,
  Widget screen, {
  List<DeviceSize> devices = DeviceSize.common,
  List<double> textScales = const <double>[1.0],
  bool settle = false,
  bool wrapInMaterialApp = true,
}) async {
  assert(textScales.isNotEmpty, 'Provide at least one text scale.');
  assert(
    wrapInMaterialApp || textScales.every((s) => s == 1.0),
    'textScales other than 1.0 require wrapInMaterialApp: true.',
  );

  final results = <DeviceResult>[];

  // Make sure the fake screen is restored no matter what the test does next.
  addTearDown(tester.view.resetPhysicalSize);
  addTearDown(tester.view.resetDevicePixelRatio);

  for (final device in devices) {
    for (final scale in textScales) {
      final recorder = OverflowRecorder()..start();

      tester.view.devicePixelRatio = device.pixelRatio;
      tester.view.physicalSize = device.physicalSize;

      final Widget content = wrapInMaterialApp
          ? MaterialApp(key: UniqueKey(), home: _withTextScale(screen, scale))
          : KeyedSubtree(key: UniqueKey(), child: screen);

      try {
        // Tear the previous tree down and give the new one a unique key so
        // Flutter builds FRESH render objects for every combination. Without
        // this, render objects are reused and Flutter de-duplicates the
        // overflow error -- so only the first overflowing size is caught.
        await tester.pumpWidget(const SizedBox.shrink());
        await tester.pumpWidget(content);
        if (settle) {
          await tester.pumpAndSettle();
        }
      } finally {
        recorder.stop();
        // Overflow was intercepted by the recorder, so nothing is queued --
        // but drain defensively so a stray non-overflow error can't fail a
        // later step of the test unexpectedly.
        tester.takeException();
      }

      results.add(
        DeviceResult(
          device: device,
          textScale: scale,
          overflows: recorder.issues,
        ),
      );
    }
  }

  return LayoutReport(results);
}