diff --git a/lib/src/flutter/pixel_boundary_box.dart b/lib/src/flutter/pixel_boundary_box.dart new file mode 100644 index 0000000..082515a --- /dev/null +++ b/lib/src/flutter/pixel_boundary_box.dart @@ -0,0 +1,58 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/rendering.dart'; + +/// A widget that sizes itself and its [child] such that the child occupies an integer +/// value width and height. +/// +/// When laying out a [PixelBoundaryBox], the incoming constraints are pushed to the nearest +/// integer boundary. The minimum width/height are made larger, to the nearest integer. The +/// maximum width/height are made smaller, to the nearest integer. Then, the [child] is laid +/// out within these integer bounds. +/// +/// If the [child] reports a size that also has an integer width and height then that size +/// is honored and layout finishes. However, the [child] chooses a size that include a fractional +/// width and/or height, then the [child] is laid out a second time, with tight constraints, which +/// are set to the nearest larger integer for the [child]'s originally reported width and height. +class PixelBoundaryBox extends SingleChildRenderObjectWidget { + const PixelBoundaryBox({ + super.key, + required super.child, + }); + + @override + RenderPixelBoundaryBox createRenderObject(BuildContext context) { + return RenderPixelBoundaryBox(); + } +} + +class RenderPixelBoundaryBox extends RenderProxyBox { + @override + void performLayout() { + if (child == null) { + size = Size.zero; + return; + } + + final integerConstraints = constraints.copyWith( + minWidth: constraints.minWidth.ceilToDouble(), + maxWidth: constraints.maxWidth < double.infinity ? constraints.maxWidth.floorToDouble() : double.infinity, + minHeight: constraints.minHeight.ceilToDouble(), + maxHeight: constraints.maxHeight < double.infinity ? constraints.maxHeight.floorToDouble() : double.infinity, + ); + + // Let child choose its size within our parent's integer bounded constraints. + child!.layout(integerConstraints, parentUsesSize: true); + + // Check if the child chose a non-integer size. If it did, re-run layout, forcing it + // to the nearest larger integer size. + final childSize = child!.size; + if (childSize.width != childSize.width.round() || childSize.height != childSize.height.round()) { + child!.layout( + BoxConstraints.tight(Size(childSize.width.ceilToDouble(), childSize.height.ceilToDouble())), + parentUsesSize: true, + ); + } + + size = child!.size; + } +} diff --git a/lib/src/scenes/film_strip.dart b/lib/src/scenes/film_strip.dart index 79a0c34..a84c07b 100644 --- a/lib/src/scenes/film_strip.dart +++ b/lib/src/scenes/film_strip.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart' hide Image; import 'package:flutter/material.dart' as m; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_goldens/src/flutter/flutter_test_extensions.dart'; +import 'package:flutter_test_goldens/src/flutter/pixel_boundary_box.dart'; import 'package:flutter_test_goldens/src/goldens/golden_camera.dart'; import 'package:flutter_test_goldens/src/goldens/golden_collections.dart'; import 'package:flutter_test_goldens/src/goldens/golden_comparisons.dart'; @@ -55,7 +56,11 @@ class FilmStrip { _setup = _FilmStripSetup((tester) async { final widgetTree = sceneBuilder(); - await _tester.pumpWidget(widgetTree); + await _tester.pumpWidget( + PixelBoundaryBox( + child: widgetTree, + ), + ); }); return this; diff --git a/lib/src/scenes/gallery.dart b/lib/src/scenes/gallery.dart index 3ef10b0..dd799e2 100644 --- a/lib/src/scenes/gallery.dart +++ b/lib/src/scenes/gallery.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart' hide Image; import 'package:flutter/material.dart' as m; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_goldens/src/flutter/flutter_test_extensions.dart'; +import 'package:flutter_test_goldens/src/flutter/pixel_boundary_box.dart'; import 'package:flutter_test_goldens/src/goldens/golden_camera.dart'; import 'package:flutter_test_goldens/src/goldens/golden_collections.dart'; import 'package:flutter_test_goldens/src/goldens/golden_comparisons.dart'; @@ -153,30 +154,34 @@ class Gallery { } else if (item.builder != null) { // Pump this gallery item, deferring to a `WidgetBuilder` for the content. await _tester.pumpWidget( - _itemScaffold( - _tester, - GoldenImageBounds( - child: _itemDecorator != null - ? _itemDecorator.call( - _tester, - Builder(builder: item.builder!), - ) - : Builder(builder: item.builder!), + PixelBoundaryBox( + child: _itemScaffold( + _tester, + GoldenImageBounds( + child: _itemDecorator != null + ? _itemDecorator.call( + _tester, + Builder(builder: item.builder!), + ) + : Builder(builder: item.builder!), + ), ), ), ); } else { // Pump this gallery item, deferring to a `Widget` for the content. await _tester.pumpWidget( - _itemScaffold( - _tester, - GoldenImageBounds( - child: _itemDecorator != null - ? _itemDecorator.call( - _tester, - item.child!, - ) - : item.child!, + PixelBoundaryBox( + child: _itemScaffold( + _tester, + GoldenImageBounds( + child: _itemDecorator != null + ? _itemDecorator.call( + _tester, + item.child!, + ) + : item.child!, + ), ), ), ); diff --git a/lib/src/scenes/golden_scene.dart b/lib/src/scenes/golden_scene.dart index 2f6c447..f18e6a4 100644 --- a/lib/src/scenes/golden_scene.dart +++ b/lib/src/scenes/golden_scene.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show Colors; import 'package:flutter/widgets.dart'; +import 'package:flutter_test_goldens/src/flutter/pixel_boundary_box.dart'; import 'package:flutter_test_goldens/src/goldens/golden_camera.dart'; class GoldenScene extends StatelessWidget { diff --git a/test/flutter/pixel_boundary_box_test.dart b/test/flutter/pixel_boundary_box_test.dart new file mode 100644 index 0000000..4b8735a --- /dev/null +++ b/test/flutter/pixel_boundary_box_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_goldens/src/flutter/pixel_boundary_box.dart'; + +void main() { + group("Pixel boundary box >", () { + testWidgets("does not modify incoming integer constraints", (tester) async { + final pixelBoundaryKey = GlobalKey(debugLabel: "pixel-boundary"); + final childKey = GlobalKey(debugLabel: "child"); + + await _pumpScaffold( + tester, + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 100, maxHeight: 75), + child: PixelBoundaryBox( + key: pixelBoundaryKey, + child: Container( + key: childKey, + width: double.infinity, + height: double.infinity, + color: Colors.red, + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(pixelBoundaryKey)), Size(100, 75)); + expect(tester.getSize(find.byKey(childKey)), Size(100, 75)); + }); + + testWidgets("adjusts incoming non-integer constraints", (tester) async { + final pixelBoundaryKey = GlobalKey(debugLabel: "pixel-boundary"); + final childKey = GlobalKey(debugLabel: "child"); + + await _pumpScaffold( + tester, + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 100.7, maxHeight: 75.8), + child: PixelBoundaryBox( + key: pixelBoundaryKey, + child: Container( + key: childKey, + width: double.infinity, + height: double.infinity, + color: Colors.red, + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(pixelBoundaryKey)), Size(100, 75)); + expect(tester.getSize(find.byKey(childKey)), Size(100, 75)); + }); + + testWidgets("adjusts non-integer child size", (tester) async { + final pixelBoundaryKey = GlobalKey(debugLabel: "pixel-boundary"); + final childKey = GlobalKey(debugLabel: "child"); + + // First, try a bounded size that's larger than the child. + await _pumpScaffold( + tester, + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 100, maxHeight: 75), + child: PixelBoundaryBox( + key: pixelBoundaryKey, + child: Container( + key: childKey, + width: 88.5, + height: 48.2, + color: Colors.red, + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(pixelBoundaryKey)), Size(89, 49)); + expect(tester.getSize(find.byKey(childKey)), Size(89, 49)); + + // Second, try an unbounded parent. + await _pumpScaffold( + tester, + ConstrainedBox( + constraints: BoxConstraints(maxWidth: double.infinity, maxHeight: double.infinity), + child: PixelBoundaryBox( + key: pixelBoundaryKey, + child: Container( + key: childKey, + width: 88.5, + height: 48.2, + color: Colors.red, + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(pixelBoundaryKey)), Size(89, 49)); + expect(tester.getSize(find.byKey(childKey)), Size(89, 49)); + }); + }); +} + +Future _pumpScaffold(WidgetTester tester, Widget content) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: content, + ), + ), + ), + ); +} diff --git a/test_goldens/flutter/buttons/button_extended_fab_gallery.png b/test_goldens/flutter/buttons/button_extended_fab_gallery.png index 96feecd..0fae111 100644 Binary files a/test_goldens/flutter/buttons/button_extended_fab_gallery.png and b/test_goldens/flutter/buttons/button_extended_fab_gallery.png differ diff --git a/test_goldens/flutter/buttons/buttons_test.dart b/test_goldens/flutter/buttons/buttons_test.dart index 548dfc0..306e91f 100644 --- a/test_goldens/flutter/buttons/buttons_test.dart +++ b/test_goldens/flutter/buttons/buttons_test.dart @@ -166,11 +166,17 @@ void main() { tester, sceneName: "button_extended_fab_gallery", layout: SceneLayout.row, - itemDecorator: (context, child) { + itemScaffold: (context, child) { return FlutterWidgetScaffold( child: child, ); }, + itemDecorator: (context, child) { + return Padding( + padding: const EdgeInsets.all(24), + child: child, + ); + }, goldenBackground: Image.memory( backgroundImageBytes, fit: BoxFit.cover, diff --git a/test_goldens/flutter/textfield/textfield_interactions.png b/test_goldens/flutter/textfield/textfield_interactions.png index cab5b4c..21f3c76 100644 Binary files a/test_goldens/flutter/textfield/textfield_interactions.png and b/test_goldens/flutter/textfield/textfield_interactions.png differ