diff --git a/packages/material_ui/lib/src/grid_tile.dart b/packages/material_ui/lib/src/grid_tile.dart index c625f5c766f4..46c65602eb55 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,12 +47,77 @@ class GridTile extends StatelessWidget { return child; } + // 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: [ - Positioned.fill(child: 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); + if (childConstraints.isTight) { + return childConstraints.smallest; + } + return constraints.constrain( + child?.getDryLayout(childConstraints) ?? childConstraints.smallest, + ); + } + + @override + double? computeDryBaseline(BoxConstraints constraints, TextBaseline baseline) { + return child?.getDryBaseline(_fillBoundedAxes(constraints), baseline); + } + + @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/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..79a2040ec583 100644 --- a/packages/material_ui/test/grid_title_test.dart +++ b/packages/material_ui/test/grid_title_test.dart @@ -59,6 +59,170 @@ 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 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); + }, + ); + + 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('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( + '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('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(