From f9200118945729ff1b974f382ea75185f2352836 Mon Sep 17 00:00:00 2001 From: vam Date: Tue, 25 Aug 2026 16:50:14 +0800 Subject: [PATCH 1/4] Fix GridTile crash when the incoming constraints are unbounded GridTile placed every child of its Stack in a Positioned widget, including the tile content. A Stack without any non-positioned child sizes itself to the biggest allowed size, which is infinite along the main axis of an unbounded parent such as a horizontally scrolling ListView. RenderStack then failed its size.isFinite assertion. The tile content is now a non-positioned child of the Stack, and the Stack uses StackFit.passthrough so the content still receives the constraints the tile receives. The tile keeps filling the available space when the incoming constraints are tight (the GridView case), and shrink-wraps its content when they are unbounded instead of crashing. Ported from flutter/flutter#191656, which is blocked by the Material code freeze. Fixes https://github.com/flutter/flutter/issues/82055 --- packages/material_ui/lib/src/grid_tile.dart | 7 ++- .../change_2026_08_25_port191656.yaml | 3 + .../material_ui/test/grid_title_test.dart | 56 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 packages/material_ui/pending_changelogs/change_2026_08_25_port191656.yaml diff --git a/packages/material_ui/lib/src/grid_tile.dart b/packages/material_ui/lib/src/grid_tile.dart index c625f5c766f4..4fcb6aeef7fb 100644 --- a/packages/material_ui/lib/src/grid_tile.dart +++ b/packages/material_ui/lib/src/grid_tile.dart @@ -46,9 +46,14 @@ class GridTile extends StatelessWidget { return child; } + // The tile sizes itself to its child so that it can be used where the + // incoming constraints are unbounded, e.g. in a horizontally scrolling + // ListView. A Stack whose children are all positioned would try to expand + // to the biggest allowed size, which is infinite in that case. return Stack( + fit: StackFit.passthrough, children: [ - Positioned.fill(child: child), + child, if (header != null) Positioned(top: 0.0, left: 0.0, right: 0.0, child: header!), if (footer != null) Positioned(left: 0.0, bottom: 0.0, right: 0.0, child: footer!), ], diff --git a/packages/material_ui/pending_changelogs/change_2026_08_25_port191656.yaml b/packages/material_ui/pending_changelogs/change_2026_08_25_port191656.yaml new file mode 100644 index 000000000000..c9250ba5305c --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_2026_08_25_port191656.yaml @@ -0,0 +1,3 @@ +changelog: | + - Fix `GridTile` crash when the incoming constraints are unbounded (e.g. inside a horizontally scrolling `ListView`). +version: patch diff --git a/packages/material_ui/test/grid_title_test.dart b/packages/material_ui/test/grid_title_test.dart index 1e0fe9e74133..ddb7faf49115 100644 --- a/packages/material_ui/test/grid_title_test.dart +++ b/packages/material_ui/test/grid_title_test.dart @@ -59,6 +59,62 @@ void main() { expect(tester.getSize(find.byType(GridTile)), Size.zero); }); + // Regression test for https://github.com/flutter/flutter/issues/82055 + testWidgets('GridTile with header and footer sizes itself to its child when unconstrained', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + height: 100.0, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 1, + itemBuilder: (BuildContext context, int index) { + return GridTile( + header: const Text('Header'), + footer: const Text('Footer'), + child: Container(height: 100.0, width: 80.0, color: Colors.red), + ); + }, + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(GridTile)), const Size(80.0, 100.0)); + expect(find.text('Header'), findsOneWidget); + expect(find.text('Footer'), findsOneWidget); + }); + + testWidgets('GridTile child fills the tile when the constraints are tight', ( + WidgetTester tester, + ) async { + final Key childKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 200.0, + height: 150.0, + child: GridTile( + header: const Text('Header'), + footer: const Text('Footer'), + child: ColoredBox(key: childKey, color: Colors.red), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(GridTile)), const Size(200.0, 150.0)); + expect(tester.getSize(find.byKey(childKey)), const Size(200.0, 150.0)); + }); + testWidgets('GridTileBar does not crash at zero area', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( From 0f9454fbe5590852b7dd02a47d6c9d33f30c198c Mon Sep 17 00:00:00 2001 From: vam Date: Wed, 23 Sep 2026 09:52:32 +0800 Subject: [PATCH 2/4] Keep GridTile layout for bounded loose constraints Only let the tile shrink-wrap its child along unbounded axes, which is the case that previously crashed. Along bounded axes the child keeps filling the available space, and the tile keeps reporting the same intrinsic sizes as before. --- packages/material_ui/lib/src/grid_tile.dart | 63 +++++++++++++++++-- .../material_ui/test/grid_title_test.dart | 24 +++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/packages/material_ui/lib/src/grid_tile.dart b/packages/material_ui/lib/src/grid_tile.dart index 4fcb6aeef7fb..aa4e660246a4 100644 --- a/packages/material_ui/lib/src/grid_tile.dart +++ b/packages/material_ui/lib/src/grid_tile.dart @@ -5,6 +5,7 @@ /// @docImport 'grid_tile_bar.dart'; library; +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; /// A tile in a Material Design grid list. @@ -46,17 +47,69 @@ class GridTile extends StatelessWidget { return child; } - // The tile sizes itself to its child so that it can be used where the - // incoming constraints are unbounded, e.g. in a horizontally scrolling - // ListView. A Stack whose children are all positioned would try to expand - // to the biggest allowed size, which is infinite in that case. + // The child is not positioned so that the Stack can size itself to it + // along an unbounded axis (e.g. in a horizontally scrolling ListView), + // where a Stack of only positioned children would try to be infinitely + // large. Along bounded axes, the child still fills the available space. return Stack( fit: StackFit.passthrough, children: [ - child, + _FillBoundedAxes(child: child), if (header != null) Positioned(top: 0.0, left: 0.0, right: 0.0, child: header!), if (footer != null) Positioned(left: 0.0, bottom: 0.0, right: 0.0, child: footer!), ], ); } } + +/// Makes its child as big as the incoming constraints allow along every +/// bounded axis, and lets the child pick its own size along unbounded axes. +class _FillBoundedAxes extends SingleChildRenderObjectWidget { + const _FillBoundedAxes({required Widget super.child}); + + @override + _RenderFillBoundedAxes createRenderObject(BuildContext context) => _RenderFillBoundedAxes(); +} + +class _RenderFillBoundedAxes extends RenderProxyBox { + static BoxConstraints _fillBoundedAxes(BoxConstraints constraints) { + return constraints.copyWith( + minWidth: constraints.hasBoundedWidth ? constraints.maxWidth : constraints.minWidth, + minHeight: constraints.hasBoundedHeight ? constraints.maxHeight : constraints.minHeight, + ); + } + + // The tile used to report zero intrinsic sizes because its child was + // positioned, which the Stack ignores for intrinsics. Keep doing so. + @override + double computeMinIntrinsicWidth(double height) => 0.0; + + @override + double computeMaxIntrinsicWidth(double height) => 0.0; + + @override + double computeMinIntrinsicHeight(double width) => 0.0; + + @override + double computeMaxIntrinsicHeight(double width) => 0.0; + + @override + Size computeDryLayout(BoxConstraints constraints) { + final BoxConstraints childConstraints = _fillBoundedAxes(constraints); + return constraints.constrain( + child?.getDryLayout(childConstraints) ?? childConstraints.smallest, + ); + } + + @override + void performLayout() { + final BoxConstraints childConstraints = _fillBoundedAxes(constraints); + final RenderBox? child = this.child; + if (child == null) { + size = constraints.constrain(childConstraints.smallest); + return; + } + child.layout(childConstraints, parentUsesSize: true); + size = constraints.constrain(child.size); + } +} diff --git a/packages/material_ui/test/grid_title_test.dart b/packages/material_ui/test/grid_title_test.dart index ddb7faf49115..45dfcee93e24 100644 --- a/packages/material_ui/test/grid_title_test.dart +++ b/packages/material_ui/test/grid_title_test.dart @@ -115,6 +115,30 @@ void main() { expect(tester.getSize(find.byKey(childKey)), const Size(200.0, 150.0)); }); + testWidgets('GridTile child fills the tile when the constraints are bounded but loose', ( + WidgetTester tester, + ) async { + final Key childKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200.0, maxHeight: 150.0), + child: GridTile( + header: const Text('Header'), + footer: const Text('Footer'), + child: Container(key: childKey, width: 80.0, height: 100.0, color: Colors.red), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(GridTile)), const Size(200.0, 150.0)); + expect(tester.getSize(find.byKey(childKey)), const Size(200.0, 150.0)); + }); + testWidgets('GridTileBar does not crash at zero area', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( From 6ddc5c0e8235605be6d753d41c5942568e3f6f33 Mon Sep 17 00:00:00 2001 From: vam Date: Sun, 27 Sep 2026 20:26:00 +0800 Subject: [PATCH 3/4] Override computeDryBaseline in GridTile's fill wrapper RenderProxyBox.computeDryBaseline forwarded the raw constraints to the child, while performLayout and computeDryLayout tighten the bounded axes. Use the same child constraints for the dry baseline, and add a test that the dry baseline matches the actual baseline under bounded loose constraints. --- packages/material_ui/lib/src/grid_tile.dart | 5 +++ .../material_ui/test/grid_title_test.dart | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/material_ui/lib/src/grid_tile.dart b/packages/material_ui/lib/src/grid_tile.dart index aa4e660246a4..24770647ee3f 100644 --- a/packages/material_ui/lib/src/grid_tile.dart +++ b/packages/material_ui/lib/src/grid_tile.dart @@ -101,6 +101,11 @@ class _RenderFillBoundedAxes extends RenderProxyBox { ); } + @override + double? computeDryBaseline(BoxConstraints constraints, TextBaseline baseline) { + return child?.getDryBaseline(_fillBoundedAxes(constraints), baseline); + } + @override void performLayout() { final BoxConstraints childConstraints = _fillBoundedAxes(constraints); diff --git a/packages/material_ui/test/grid_title_test.dart b/packages/material_ui/test/grid_title_test.dart index 45dfcee93e24..ff5a38df429d 100644 --- a/packages/material_ui/test/grid_title_test.dart +++ b/packages/material_ui/test/grid_title_test.dart @@ -139,6 +139,43 @@ void main() { expect(tester.getSize(find.byKey(childKey)), const Size(200.0, 150.0)); }); + testWidgets( + 'GridTile dry baseline matches its baseline when the constraints are bounded but loose', + (WidgetTester tester) async { + const constraints = BoxConstraints(maxWidth: 200.0, maxHeight: 150.0); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: ConstrainedBox( + constraints: constraints, + child: const GridTile( + footer: Text('Footer'), + // Shrink-wraps the text vertically under loose constraints, but + // centers it vertically when forced to fill the tile. + child: Align(alignment: Alignment.centerLeft, heightFactor: 1.0, child: Text('X')), + ), + ), + ), + ), + ); + + // The tile's baseline is the baseline of the child's text, which is above + // the footer's. + final RenderBox text = tester.renderObject(find.text('X')); + final double expectedBaseline = + tester.getTopLeft(find.text('X')).dy - + tester.getTopLeft(find.byType(GridTile)).dy + + text.getDryBaseline(const BoxConstraints(), TextBaseline.alphabetic)!; + expect(tester.getSize(find.byType(GridTile)), const Size(200.0, 150.0)); + + final RenderBox tile = tester.renderObject( + find.descendant(of: find.byType(GridTile), matching: find.byType(Stack)), + ); + expect(tile.getDryBaseline(constraints, TextBaseline.alphabetic), expectedBaseline); + }, + ); + testWidgets('GridTileBar does not crash at zero area', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( From 0f5a19ae7e681a0d108a4d4e9135130ea9dfc0e4 Mon Sep 17 00:00:00 2001 From: vam Date: Sat, 10 Oct 2026 17:26:34 +0800 Subject: [PATCH 4/4] Preserve GridTile dry layout for bounded constraints --- packages/material_ui/lib/src/grid_tile.dart | 3 + .../material_ui/test/grid_title_test.dart | 97 ++++++++++++++----- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/packages/material_ui/lib/src/grid_tile.dart b/packages/material_ui/lib/src/grid_tile.dart index 24770647ee3f..46c65602eb55 100644 --- a/packages/material_ui/lib/src/grid_tile.dart +++ b/packages/material_ui/lib/src/grid_tile.dart @@ -96,6 +96,9 @@ class _RenderFillBoundedAxes extends RenderProxyBox { @override Size computeDryLayout(BoxConstraints constraints) { final BoxConstraints childConstraints = _fillBoundedAxes(constraints); + if (childConstraints.isTight) { + return childConstraints.smallest; + } return constraints.constrain( child?.getDryLayout(childConstraints) ?? childConstraints.smallest, ); diff --git a/packages/material_ui/test/grid_title_test.dart b/packages/material_ui/test/grid_title_test.dart index ff5a38df429d..79a2040ec583 100644 --- a/packages/material_ui/test/grid_title_test.dart +++ b/packages/material_ui/test/grid_title_test.dart @@ -60,35 +60,36 @@ void main() { }); // Regression test for https://github.com/flutter/flutter/issues/82055 - testWidgets('GridTile with header and footer sizes itself to its child when unconstrained', ( - WidgetTester tester, - ) async { - await tester.pumpWidget( - MaterialApp( - home: Center( - child: SizedBox( - height: 100.0, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: 1, - itemBuilder: (BuildContext context, int index) { - return GridTile( - header: const Text('Header'), - footer: const Text('Footer'), - child: Container(height: 100.0, width: 80.0, color: Colors.red), - ); - }, + testWidgets( + 'GridTile with header and footer sizes itself to its child when the width is unbounded', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + height: 100.0, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 1, + itemBuilder: (BuildContext context, int index) { + return GridTile( + header: const Text('Header'), + footer: const Text('Footer'), + child: Container(height: 100.0, width: 80.0, color: Colors.red), + ); + }, + ), ), ), ), - ), - ); + ); - expect(tester.takeException(), isNull); - expect(tester.getSize(find.byType(GridTile)), const Size(80.0, 100.0)); - expect(find.text('Header'), findsOneWidget); - expect(find.text('Footer'), findsOneWidget); - }); + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(GridTile)), const Size(80.0, 100.0)); + expect(find.text('Header'), findsOneWidget); + expect(find.text('Footer'), findsOneWidget); + }, + ); testWidgets('GridTile child fills the tile when the constraints are tight', ( WidgetTester tester, @@ -176,6 +177,52 @@ void main() { }, ); + testWidgets('GridTile with header and footer reports zero intrinsic sizes', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: GridTile( + header: Text('Header'), + footer: Text('Footer'), + child: SizedBox(width: 80.0, height: 100.0), + ), + ), + ); + + final RenderBox tile = tester.renderObject(find.byType(GridTile)); + expect(tile.getMinIntrinsicWidth(150.0), 0.0); + expect(tile.getMaxIntrinsicWidth(150.0), 0.0); + expect(tile.getMinIntrinsicHeight(200.0), 0.0); + expect(tile.getMaxIntrinsicHeight(200.0), 0.0); + }); + + testWidgets('GridTile dry layout does not query its child when both axes are bounded', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: GridTile( + header: const Text('Header'), + footer: const Text('Footer'), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) => const SizedBox(), + ), + ), + ), + ); + + final RenderBox tile = tester.renderObject(find.byType(GridTile)); + expect( + tile.getDryLayout(const BoxConstraints(maxWidth: 200.0, maxHeight: 150.0)), + const Size(200.0, 150.0), + ); + expect( + tile.getDryLayout(const BoxConstraints.tightFor(width: 200.0, height: 150.0)), + const Size(200.0, 150.0), + ); + }); + testWidgets('GridTileBar does not crash at zero area', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp(