Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion packages/material_ui/lib/src/grid_tile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: <Widget>[
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;
Comment on lines +82 to +94

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since reporting zero intrinsics is deliberate it would be nice to have a small test for it so a future refactor doesn't change it by accident.


@override
Comment thread
kaluli123123 marked this conversation as resolved.
Resolved
Size computeDryLayout(BoxConstraints constraints) {
final BoxConstraints childConstraints = _fillBoundedAxes(constraints);
if (childConstraints.isTight) {
return childConstraints.smallest;
}
return constraints.constrain(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getDryLayout now asks the child, and main didn't. So for a tile with a header or footer whose child can't compute a dry layout (for example a LayoutBuilder), getDryLayout now throws instead of returning the full size of the constraints, as main does. This is rare (a GridView never asks for a dry layout), but when both axes are bounded the size is already known, so returning early in computeDryLayout would keep the old behavior:

Suggested change
return constraints.constrain(
if (childConstraints.isTight) {
return childConstraints.smallest;
}
return constraints.constrain(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f5a19a. computeDryLayout now returns the known size immediately when filling the bounded axes produces tight constraints, without asking the child for dry layout. Unbounded axes still use the child's dry size.

Added a regression with a LayoutBuilder child and both loose bounded and tight constraints. Before the fix it fails with _RenderLayoutBuilder class does not support dry layout; after the fix it passes. Also added all four zero-intrinsic assertions and renamed the existing regression to specify that only the width is unbounded.

Verification with Flutter 3.47.0 / Dart 3.13:

  • flutter test test/grid_title_test.dart: 9 passed.
  • Full material_ui suite: 8,227 passed, 12 skipped.
  • Repository analyze, validate --base-branch origin/main, publish-check, and license-check passed. The publish dry-run reports an informational hint that this branch's batched version predates the latest published version.
  • Changed-file formatting and git diff --check passed.

These are local results; current-head CI and maintainer review remain separate gates.

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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
changelog: |
- Fix `GridTile` crash when the incoming constraints are unbounded (e.g. inside a horizontally scrolling `ListView`).
version: patch
164 changes: 164 additions & 0 deletions packages/material_ui/test/grid_title_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<RenderBox>(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<RenderBox>(
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<RenderBox>(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<RenderBox>(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(
Expand Down
Loading