diff --git a/packages/mix/CHANGELOG.md b/packages/mix/CHANGELOG.md
index c16eb1aba..39c880a9c 100644
--- a/packages/mix/CHANGELOG.md
+++ b/packages/mix/CHANGELOG.md
@@ -2,6 +2,12 @@
### New features
+- **`GridTrack.auto()`:** Content-sized row tracks for `rows` and `autoRows`.
+ An auto row sizes to its tallest child's natural height at the resolved
+ column width, then stretches shorter children to fill the row. Use it — or
+ omit `autoRows` — when child heights are unknown, such as a card grid in a
+ scroll view. `auto` is vertical-only; `columns` rejects it. Grids built only
+ from fixed and `fr` tracks are unaffected and still lay each child out once.
- **Generated Styler field metadata:** Every generated Styler now exposes its
complete source-field inventory through
`StylerFieldMetadata.$stylerFieldNames`, allowing schema tooling to validate
@@ -9,6 +15,17 @@
previously generated Stylers do not implement this capability until they opt
in or are regenerated.
+### Breaking changes
+
+- **Omitted GridBox `autoRows` no longer throws:** Children needing more rows
+ than were declared previously required an explicit `autoRows` track, or the
+ Grid threw. Omitted `autoRows` now defaults to `GridTrack.auto()`, so
+ implicit rows size to their tallest child — both when no rows are declared
+ and when explicit rows run out. Fractional rows still require a bounded
+ height, and fixed tracks remain hard constraints. If you relied on the throw
+ to catch an under-declared Grid, declare `rows` explicitly or set `autoRows`
+ to the track you want repeated.
+
## 2.2.0-beta.4
### Fixes
diff --git a/packages/mix/README.md b/packages/mix/README.md
index 9f6bd176b..89c6aefb6 100644
--- a/packages/mix/README.md
+++ b/packages/mix/README.md
@@ -165,14 +165,14 @@ the runnable [WrapBox example](example/README.md).
### Grid Layouts
-`GridBox` uses fixed and fractional tracks and can adapt to the width offered by
-its own parent. Dot shorthand keeps nested track and breakpoint declarations
-compact:
+`GridBox` uses fixed and fractional columns plus vertical auto rows, and can
+adapt to the width offered by its own parent. Dot shorthand keeps nested track
+and breakpoint declarations compact. Omit `autoRows` (or set `.autoRows(.auto())`)
+when row height should follow the tallest child:
```dart
final GridBoxStyler dashboardGrid = .equalColumns(3)
.gap(16)
- .autoRows(.fixed(220))
.onConstraints(
.maxWidth(720),
.equalColumns(1).gap(12),
diff --git a/packages/mix/doc/grid-layout.md b/packages/mix/doc/grid-layout.md
index 21bf24126..93b919863 100644
--- a/packages/mix/doc/grid-layout.md
+++ b/packages/mix/doc/grid-layout.md
@@ -1,8 +1,9 @@
# Grid layout
-`GridBox` arranges children in row-major order using fixed and fractional
-tracks. Its style supports ordinary Mix variants and modifiers, plus
-`onConstraints` for layout decisions based on the Grid's own available space.
+`GridBox` arranges children in row-major order using fixed, fractional, and
+vertical auto tracks. Its style supports ordinary Mix variants and modifiers,
+plus `onConstraints` for layout decisions based on the Grid's own available
+space.
## Basic usage
@@ -12,16 +13,19 @@ entry point, then chain the remaining geometry:
```dart
final GridBoxStyler style = .equalColumns(3)
.gap(16)
- .autoRows(.fixed(160));
+ .autoRows(.auto());
GridBox(style: style, children: cards);
```
-`GridTrack.fixed(size)` keeps its logical-pixel size. `GridTrack.fr(fraction)`
-receives that fraction of the free space left after fixed tracks and gaps.
-For example, with 300 pixels of remaining space, `[.fr(2), .fr(1)]` produces
-tracks of 200 and 100 pixels. Fractional tracks require a bounded parent extent
-on their axis. Use `columns` directly when tracks are intentionally mixed:
+`GridTrack.fixed(size)` keeps its logical-pixel size and is a hard constraint:
+children in a fixed row are stretched or clipped to that height.
+`GridTrack.fr(fraction)` receives that fraction of the free space left after
+fixed tracks, auto tracks, and gaps. For example, with 300 pixels of remaining space, `[.fr(2), .fr(1)]`
+produces tracks of 200 and 100 pixels. Fractional tracks require a bounded
+parent extent on their axis. `GridTrack.auto()` is vertical-only: the row
+sizes to its tallest child's natural height at the resolved column width.
+Use `columns` directly when tracks are intentionally mixed:
```dart
final GridBoxStyler sidebarAndContent = .columns([
@@ -38,7 +42,6 @@ the bounded maximum size offered to this Grid:
```dart
final GridBoxStyler cardGrid = .equalColumns(3)
.gap(16)
- .autoRows(.fixed(220))
.onConstraints(
.maxWidth(760),
.equalColumns(2).gap(12),
@@ -69,22 +72,43 @@ Two GridBoxes on the same screen can therefore select different
Children fill columns from left to right, then continue on the next row.
Provide explicit `rows` for known row geometry, or `autoRows` for each repeated
-row needed beyond the explicit list:
+row needed beyond the explicit list. Omitted `autoRows` defaults to
+`GridTrack.auto()`, so a two-column Grid with no row declaration sizes each
+implicit row to its tallest child:
```dart
-final GridBoxStyler gallery = .equalColumns(2).rows([
+final GridBoxStyler gallery = .equalColumns(2).gap(12);
+
+final GridBoxStyler mixed = .equalColumns(2).rows([
.fixed(180),
-]).autoRows(.fixed(180)).columnGap(12).rowGap(12);
+]).autoRows(.auto()).columnGap(12).rowGap(12);
```
-With five children and two columns, the example needs three rows: the first
-uses the explicit 180-pixel row and the next two repeat `autoRows`. When
-`rows` is empty, every required row uses `autoRows`.
+With five children and two columns, `mixed` needs three rows: the first uses
+the explicit 180-pixel row and the next two repeat `autoRows`. When `rows` is
+empty, every required row uses `autoRows` (or `auto` when it is omitted).
+
+Use `GridTrack.auto()` — or omit `autoRows` — in a vertical
+`SingleChildScrollView` when child heights are unknown. Auto-row children are
+measured at their column width, and a child shorter than the resolved row is
+then laid out a second time so it fills the row. A child that already fills
+its row — always the tallest, and every child when a row holds one — is handed
+back the constraint it was measured with, so its subtree is not relaid out.
+That measure pass runs only for children in auto rows; explicit fixed/`fr`
+Grids still lay each child out once.
+
+Nesting therefore stays flat rather than compounding: a leaf inside three
+nested auto Grids is laid out once, the same as inside one. The extra pass is
+paid only by the shorter siblings a row actually has to stretch, so rows of
+uneven children cost more than uniform ones.
-If children require more rows than declared and `autoRows` is absent, GridBox
-reports an actionable layout error rather than guessing a content-sizing rule.
-Use fixed rows on an unbounded vertical axis such as `SingleChildScrollView`.
-Fractional rows and fractional `autoRows` require bounded height.
+Fixed rows stay hard heights. Fractional rows and fractional `autoRows` still
+require bounded height. Auto rows require children with a finite natural
+height; `Expanded`, `Spacer`, or another expanding child inside an auto row
+in a scroll view is an error.
+
+`GridTrack.auto()` is rejected on `columns`. Content-sized columns need a
+separate two-axis design and are not part of this API.
## Design tokens
@@ -151,6 +175,7 @@ Animation compatibility is positional:
- fixed tracks interpolate with fixed tracks;
- fractional tracks interpolate with fractional tracks;
+- auto tracks stay compatible with auto tracks;
- the two track lists must keep the same length and track kinds;
- compatible `autoRows`, `columnGap`, and `rowGap` values interpolate too.
@@ -179,11 +204,27 @@ contained:
final GridBoxStyler clipped = .clipBehavior(.hardEdge);
```
+Auto rows are diagnosed the same way. Measured row heights that add up to more
+than a bounded parent offers overflow the Grid's box and report the same
+indicator, so putting tall content in a bounded parent is still visible rather
+than silent.
+
+A child that outgrows its own cell is not diagnosed. The Grid compares its
+total track extent against the space the parent offered; it never asks whether
+an individual child fits the cell it was given. A fixed track is therefore a
+hard constraint in exactly the way a tight `SizedBox` is, and content taller
+than that track is constrained without a warning. This is deliberate: probing
+every fixed cell for its natural height would cost a speculative measure pass
+per child and would break children that require a bounded height. Use `auto`
+rows whenever the content height is unknown, and keep `.fixed(...)` for cells
+where constraining the child is the intent.
+
## Current track model
-GridBox intentionally supports fixed and fractional tracks with row-major
-auto-placement. Content-sized tracks, spans, named areas, direction-aware
-placement, and baseline alignment are not part of the current API.
+GridBox supports fixed and fractional tracks on both axes and content-sized
+`auto` tracks on rows only, with row-major auto-placement. Content-sized
+columns, spans, named areas, direction-aware placement, and baseline alignment
+are not part of the current API.
Run the card, dashboard, gallery, and animation examples from
`packages/mix/example`:
diff --git a/packages/mix/example/.gitignore b/packages/mix/example/.gitignore
new file mode 100644
index 000000000..02a517ea2
--- /dev/null
+++ b/packages/mix/example/.gitignore
@@ -0,0 +1 @@
+test/failures/
diff --git a/packages/mix/example/README.md b/packages/mix/example/README.md
index e4ce2fb4c..5486ec7bf 100644
--- a/packages/mix/example/README.md
+++ b/packages/mix/example/README.md
@@ -24,7 +24,6 @@ rather than the viewport:
```dart
final GridBoxStyler cardGrid = .equalColumns(3)
.gap(16)
- .autoRows(.fixed(220))
.onConstraints(
.maxWidth(760),
.equalColumns(2).gap(12),
@@ -45,7 +44,8 @@ columns to one.
-The same track model handles repeated product cards and a denser media gallery:
+The card catalog omits `autoRows` so implicit rows size to unequal copy. The
+media gallery keeps fixed automatic rows for a dense, even tile height:
diff --git a/packages/mix/example/lib/grid_example.dart b/packages/mix/example/lib/grid_example.dart
index 7c8dcf73b..9724888d6 100644
--- a/packages/mix/example/lib/grid_example.dart
+++ b/packages/mix/example/lib/grid_example.dart
@@ -444,50 +444,58 @@ class CatalogGridPreview extends StatelessWidget {
Widget build(BuildContext context) {
final GridBoxStyler style = .equalColumns(3)
.gap(16)
- .autoRows(.fixed(220))
.onConstraints(.maxWidth(760), .equalColumns(2).gap(12))
- .onConstraints(
- .maxWidth(520),
- .equalColumns(1).gap(10).autoRows(.fixed(190)),
- );
+ .onConstraints(.maxWidth(520), .equalColumns(1).gap(10));
return GridBox(
key: const Key('catalog-grid'),
style: style,
children: const [
_ProductCard(
+ key: Key('catalog-card-0'),
'Canvas tote',
r'$48',
+ 'A roomy everyday bag.',
Icons.shopping_bag_rounded,
Color(0xFFE9E6FF),
),
_ProductCard(
+ key: Key('catalog-card-1'),
'Desk lamp',
r'$72',
+ 'Adjustable arm and a warm-dim LED. Built for late editing sessions when the rest of the room has gone dark.',
Icons.light_rounded,
Color(0xFFFFE9D8),
),
_ProductCard(
+ key: Key('catalog-card-2'),
'Travel mug',
r'$32',
+ 'Keeps coffee hot through a standup.',
Icons.coffee_rounded,
Color(0xFFDDF4EE),
),
_ProductCard(
+ key: Key('catalog-card-3'),
'Studio clock',
r'$64',
+ 'Quiet sweep, high-contrast face.',
Icons.schedule_rounded,
Color(0xFFDDEBFA),
),
_ProductCard(
+ key: Key('catalog-card-4'),
'Wool throw',
r'$96',
+ 'Heavyweight merino for the couch.',
Icons.bed_rounded,
Color(0xFFF6E2EB),
),
_ProductCard(
+ key: Key('catalog-card-5'),
'Plant stand',
r'$58',
+ 'Three-tier oak riser.',
Icons.eco_rounded,
Color(0xFFE4F2D8),
),
@@ -497,10 +505,18 @@ class CatalogGridPreview extends StatelessWidget {
}
class _ProductCard extends StatelessWidget {
- const _ProductCard(this.name, this.price, this.icon, this.tint);
+ const _ProductCard(
+ this.name,
+ this.price,
+ this.blurb,
+ this.icon,
+ this.tint, {
+ super.key,
+ });
final String name;
final String price;
+ final String blurb;
final IconData icon;
final Color tint;
@@ -508,6 +524,7 @@ class _ProductCard extends StatelessWidget {
Widget build(BuildContext context) {
return _CardSurface(
child: Column(
+ mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
@@ -519,15 +536,15 @@ class _ProductCard extends StatelessWidget {
),
child: Icon(icon, color: const Color(0xFF3C3D4A)),
),
- const Spacer(),
+ const SizedBox(height: 16),
Text(
name,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
- const Text(
- 'Essential collection',
- style: TextStyle(color: Color(0xFF77798A), fontSize: 12),
+ Text(
+ blurb,
+ style: const TextStyle(color: Color(0xFF77798A), fontSize: 12),
),
const SizedBox(height: 12),
Row(
diff --git a/packages/mix/example/test/goldens/grid_catalog_compact.png b/packages/mix/example/test/goldens/grid_catalog_compact.png
index 880faeabe..a50877f4a 100644
Binary files a/packages/mix/example/test/goldens/grid_catalog_compact.png and b/packages/mix/example/test/goldens/grid_catalog_compact.png differ
diff --git a/packages/mix/example/test/goldens/grid_catalog_wide.png b/packages/mix/example/test/goldens/grid_catalog_wide.png
index 63c646a6c..3dbc435d3 100644
Binary files a/packages/mix/example/test/goldens/grid_catalog_wide.png and b/packages/mix/example/test/goldens/grid_catalog_wide.png differ
diff --git a/packages/mix/example/test/grid_box_example_test.dart b/packages/mix/example/test/grid_box_example_test.dart
index 86d24595f..ff479a080 100644
--- a/packages/mix/example/test/grid_box_example_test.dart
+++ b/packages/mix/example/test/grid_box_example_test.dart
@@ -28,6 +28,12 @@ void main() {
await tester.tap(find.text('Compact'));
await tester.pumpAndSettle();
expect(tester.getSize(find.byKey(const Key('catalog-grid'))).width, 334);
+ expect(
+ tester.getSize(find.byKey(const Key('catalog-card-1'))).height,
+ greaterThan(
+ tester.getSize(find.byKey(const Key('catalog-card-0'))).height,
+ ),
+ );
await tester.tap(find.text('Media gallery'));
await tester.pumpAndSettle();
@@ -38,6 +44,40 @@ void main() {
expect(find.byKey(const Key('animated-grid')), findsOneWidget);
});
+ testWidgets(
+ 'catalog rows follow unequal card content instead of one height',
+ (tester) async {
+ await tester.binding.setSurfaceSize(const Size(1200, 900));
+ addTearDown(() => tester.binding.setSurfaceSize(null));
+
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: Center(
+ child: SizedBox(width: 1120, child: CatalogGridPreview()),
+ ),
+ ),
+ ),
+ );
+
+ final rowZero = tester
+ .getSize(find.byKey(const Key('catalog-card-0')))
+ .height;
+ final rowOne = tester
+ .getSize(find.byKey(const Key('catalog-card-3')))
+ .height;
+ expect(rowZero, isNot(rowOne));
+ expect(
+ tester.getSize(find.byKey(const Key('catalog-card-1'))).height,
+ rowZero,
+ );
+ expect(
+ tester.getSize(find.byKey(const Key('catalog-card-4'))).height,
+ rowOne,
+ );
+ },
+ );
+
testWidgets('animation example interpolates tracks, rows, and gaps', (
tester,
) async {
diff --git a/packages/mix/lib/src/layout/grid_box.dart b/packages/mix/lib/src/layout/grid_box.dart
index 06f9dfb51..07693a639 100644
--- a/packages/mix/lib/src/layout/grid_box.dart
+++ b/packages/mix/lib/src/layout/grid_box.dart
@@ -39,6 +39,9 @@ class GridBoxStyler extends MixStyler
final List? $rows;
/// Unresolved track repeated for rows required beyond [$rows].
+ ///
+ /// `null` means no override, in which case implicit rows use
+ /// [GridTrack.auto].
final GridTrack? $autoRows;
/// Unresolved logical-pixel gap between columns.
@@ -95,8 +98,9 @@ class GridBoxStyler extends MixStyler
/// Creates a style that repeats [autoRows] for undeclared rows.
///
/// Children are placed row-major. Each row needed beyond [rows] uses this
- /// track. A fixed track works on an unbounded vertical axis, such as a
- /// vertical scroll view; a fractional track requires bounded height.
+ /// track. Omitting it leaves implicit rows on [GridTrack.auto], so they
+ /// size to their tallest child. A fixed track is a hard height; a
+ /// fractional track requires bounded height.
factory GridBoxStyler.autoRows(GridTrack autoRows) =>
GridBoxStyler(autoRows: autoRows);
@@ -143,6 +147,9 @@ class GridBoxStyler extends MixStyler
merge(GridBoxStyler(rows: value));
/// Sets the track repeated for each row required beyond explicit [rows].
+ ///
+ /// Use [GridTrack.auto] for unknown content height. Omit this method to
+ /// get the same default on implicit rows.
GridBoxStyler autoRows(GridTrack value) =>
merge(GridBoxStyler(autoRows: value));
@@ -184,11 +191,12 @@ class GridBoxStyler extends MixStyler
/// Adds implicit animation to compatible Grid geometry changes.
///
- /// Fixed sizes, fractional weights, row tracks, `autoRows`, and gaps
- /// interpolate when their topology is compatible. Track-count or track-kind
- /// changes, clipping, and constraint patches snap at the midpoint. A local
- /// [onConstraints] branch switch remains immediate because it occurs during
- /// layout without producing a new resolved style.
+ /// Fixed sizes, fractional weights, row tracks, compatible `autoRows`, and
+ /// gaps interpolate when their topology is compatible. Auto-to-auto stays
+ /// constant. Track-count or track-kind changes, clipping, and constraint
+ /// patches snap at the midpoint. A local [onConstraints] branch switch
+ /// remains immediate because it occurs during layout without producing a
+ /// new resolved style.
@override
GridBoxStyler animate(AnimationConfig value) =>
merge(GridBoxStyler(animation: value));
@@ -310,6 +318,7 @@ GridTrack _resolveGridTrack(BuildContext context, GridTrack track) {
FrGridTrack(:final fraction) => GridTrack.fr(
_resolveGridDouble(context, fraction),
),
+ AutoGridTrack() => track,
};
}
diff --git a/packages/mix/lib/src/layout/grid_box_spec.dart b/packages/mix/lib/src/layout/grid_box_spec.dart
index 51ecd0566..9c8dcfdad 100644
--- a/packages/mix/lib/src/layout/grid_box_spec.dart
+++ b/packages/mix/lib/src/layout/grid_box_spec.dart
@@ -127,6 +127,9 @@ final class GridBoxSpec extends Spec with Diagnosticable {
final List rows;
/// Track repeated for each row required beyond [rows].
+ ///
+ /// `null` means no override, in which case implicit rows use
+ /// [GridTrack.auto].
final GridTrack? autoRows;
/// Logical-pixel gap between adjacent columns.
@@ -205,11 +208,12 @@ final class GridBoxSpec extends Spec with Diagnosticable {
/// Interpolates compatible Grid geometry toward [other].
///
- /// Fixed tracks interpolate with fixed tracks and fractional tracks with
- /// fractional tracks when their lists have the same length and kinds.
- /// Gaps also interpolate. Incompatible track lists, nullable or mismatched
- /// [autoRows], [clipBehavior], and [constraintBranches] switch at `t = 0.5`.
- /// Progress outside the `0...1` interval is clamped so geometry stays valid.
+ /// Fixed tracks interpolate with fixed tracks, fractional tracks with
+ /// fractional tracks, and auto tracks with auto tracks when their lists
+ /// have the same length and kinds. Gaps also interpolate. Incompatible
+ /// track lists, nullable or mismatched [autoRows], [clipBehavior], and
+ /// [constraintBranches] switch at `t = 0.5`. Progress outside the `0...1`
+ /// interval is clamped so geometry stays valid.
@override
GridBoxSpec lerp(GridBoxSpec? other, double t) {
if (other == null) return this;
@@ -297,6 +301,7 @@ bool _gridTracksAreCompatible(GridTrack start, GridTrack end) {
return switch ((start, end)) {
(FixedGridTrack(), FixedGridTrack()) => true,
(FrGridTrack(), FrGridTrack()) => true,
+ (AutoGridTrack(), AutoGridTrack()) => true,
_ => false,
};
}
@@ -313,6 +318,7 @@ GridTrack _lerpCompatibleGridTrack(GridTrack start, GridTrack end, double t) {
FrGridTrack(fraction: final endFraction),
) =>
GridTrack.fr(ui.lerpDouble(startFraction, endFraction, t)!),
+ (AutoGridTrack(), AutoGridTrack()) => start,
_ => throw StateError('Grid track interpolation requires matching types.'),
};
}
@@ -328,9 +334,9 @@ void _validateGridSpecGeometry(GridBoxSpec spec) {
}
_validateTracks(columns, axisLabel: 'columns');
- _validateTracks(spec.rows, axisLabel: 'rows');
+ _validateTracks(spec.rows, axisLabel: 'rows', allowAuto: true);
if (spec.autoRows case final track?) {
- _validateTracks([track], axisLabel: 'autoRows');
+ _validateTracks([track], axisLabel: 'autoRows', allowAuto: true);
}
_validateGap(spec.columnGap, label: 'columnGap');
_validateGap(spec.rowGap, label: 'rowGap');
@@ -371,10 +377,14 @@ void _validateGridSpecGeometry(GridBoxSpec spec) {
_validateTracks(patchColumns, axisLabel: 'patch.columns');
}
if (patchRows != null) {
- _validateTracks(patchRows, axisLabel: 'patch.rows');
+ _validateTracks(patchRows, axisLabel: 'patch.rows', allowAuto: true);
}
if (patchAutoRows != null) {
- _validateTracks([patchAutoRows], axisLabel: 'patch.autoRows');
+ _validateTracks(
+ [patchAutoRows],
+ axisLabel: 'patch.autoRows',
+ allowAuto: true,
+ );
}
if (patchColumnGap != null) {
_validateGap(patchColumnGap, label: 'patch.columnGap');
@@ -464,7 +474,11 @@ GridLayoutPatch _snapshotPatch(GridLayoutPatch patch) {
);
}
-void _validateTracks(List tracks, {required String axisLabel}) {
+void _validateTracks(
+ List tracks, {
+ required String axisLabel,
+ bool allowAuto = false,
+}) {
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
switch (track) {
@@ -489,6 +503,18 @@ void _validateTracks(List tracks, {required String axisLabel}) {
ErrorHint('Use GridTrack.fr with a finite fraction > 0.'),
]);
}
+ case AutoGridTrack():
+ if (!allowAuto) {
+ throw FlutterError.fromParts([
+ ErrorSummary('GridTrack.auto() is only valid for rows.'),
+ ErrorDescription('$axisLabel[$i] used GridTrack.auto().'),
+ ErrorHint(
+ 'GridTrack.auto() is vertical-only. Use it in rows or autoRows '
+ 'after column widths are known. Content-sized columns are not '
+ 'supported.',
+ ),
+ ]);
+ }
}
}
}
diff --git a/packages/mix/lib/src/layout/grid_track.dart b/packages/mix/lib/src/layout/grid_track.dart
index 795d7be21..cdcbb7306 100644
--- a/packages/mix/lib/src/layout/grid_track.dart
+++ b/packages/mix/lib/src/layout/grid_track.dart
@@ -2,8 +2,18 @@ import 'package:flutter/foundation.dart';
/// How a grid track is sized.
///
-/// Grid currently supports fixed and fractional tracks. Content-sized tracks
-/// are intentionally outside this API.
+/// Rows accept fixed, fractional, and content-sized [GridTrack.auto] tracks.
+/// Columns accept only fixed and fractional tracks; content-sized columns
+/// are intentionally outside this API. Because one type covers both axes,
+/// "auto is rows-only" is enforced at runtime rather than by the type system.
+// Design note, deliberately not part of the published API docs: splitting this
+// into row and column hierarchies was considered and rejected. It would not
+// remove the equivalent check in the wire codec — JSON is untyped, so the
+// schema must reject `auto` under `columns` regardless of the Dart type — and
+// the factories below have static type `GridTrack`, so axis-specific lists
+// would force duplicate factories or give up the dot-shorthand
+// (`.columns([.fr(1)])`) this API is built around. The trade is one runtime
+// throw against those two costs; do not split without re-checking both.
@immutable
sealed class GridTrack {
/// Creates the base value for a concrete Grid track.
@@ -18,11 +28,16 @@ sealed class GridTrack {
/// A track with [fraction] shares of the remaining free space.
///
- /// Remaining space is calculated after fixed tracks and gaps. For example,
- /// `fr(2)` receives twice as much remaining space as `fr(1)`. [fraction]
- /// must resolve to a finite value greater than zero, and the track's axis
- /// must be bounded.
+ /// Remaining space is calculated after fixed tracks, auto tracks, and gaps.
+ /// For example, `fr(2)` receives twice as much remaining space as `fr(1)`.
+ /// [fraction] must resolve to a finite value greater than zero, and the
+ /// track's axis must be bounded.
const factory GridTrack.fr(double fraction) = FrGridTrack;
+
+ /// A vertical track sized to the tallest child assigned to that row.
+ ///
+ /// Valid only in `rows` and `autoRows`. Column tracks reject this kind.
+ const factory GridTrack.auto() = AutoGridTrack;
}
/// Track with a fixed size in logical pixels.
@@ -71,3 +86,20 @@ final class FrGridTrack extends GridTrack {
@override
int get hashCode => fraction.hashCode;
}
+
+/// Vertical track sized to the tallest assigned child's natural height.
+@immutable
+final class AutoGridTrack extends GridTrack {
+ /// Creates a content-sized row track.
+ const AutoGridTrack();
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) || other is AutoGridTrack;
+
+ @override
+ String toString() => 'GridTrack.auto()';
+
+ @override
+ int get hashCode => runtimeType.hashCode;
+}
diff --git a/packages/mix/lib/src/layout/internal/grid_geometry.dart b/packages/mix/lib/src/layout/internal/grid_geometry.dart
index d475fa4b7..11c1bcc8e 100644
--- a/packages/mix/lib/src/layout/internal/grid_geometry.dart
+++ b/packages/mix/lib/src/layout/internal/grid_geometry.dart
@@ -7,11 +7,14 @@ import '../grid_track.dart';
import 'grid_validation.dart';
/// Geometry after branch selection and before track sizing and placement.
+///
+/// Omitted [autoRows] becomes [GridTrack.auto] here. Styler and patch fields
+/// stay nullable so a missing value still means "no override".
@immutable
final class GridResolvedGeometry {
final List columns;
final List rows;
- final GridTrack? autoRows;
+ final GridTrack autoRows;
final double columnGap;
final double rowGap;
@@ -61,7 +64,7 @@ extension GridBoxSpecGeometry on GridBoxSpec {
return GridResolvedGeometry(
columns: resolvedColumns,
rows: resolvedRows,
- autoRows: resolvedAutoRows,
+ autoRows: resolvedAutoRows ?? const GridTrack.auto(),
columnGap: resolvedColumnGap,
rowGap: resolvedRowGap,
);
diff --git a/packages/mix/lib/src/layout/internal/grid_validation.dart b/packages/mix/lib/src/layout/internal/grid_validation.dart
index 40ae032e3..f131bcf40 100644
--- a/packages/mix/lib/src/layout/internal/grid_validation.dart
+++ b/packages/mix/lib/src/layout/internal/grid_validation.dart
@@ -8,6 +8,7 @@ String gridTracksToString(List tracks) {
switch (track) {
FixedGridTrack(:final size) => 'GridTrack.fixed($size)',
FrGridTrack(:final fraction) => 'GridTrack.fr($fraction)',
+ AutoGridTrack() => 'GridTrack.auto()',
},
].join(', ');
}
@@ -38,7 +39,7 @@ void rejectFractionalGridTracksOnUnboundedAxis({
'• Replace fractional tracks with GridTrack.fixed on this axis.\n'
'• Place the grid under a bounded constraint '
'(SizedBox, Expanded in a bounded Flex, etc.).\n'
- '• Content-sized tracks are not supported by GridBox.',
+ '• Use GridTrack.auto() for content-sized rows (not columns).',
),
]);
}
diff --git a/packages/mix/lib/src/layout/render_grid.dart b/packages/mix/lib/src/layout/render_grid.dart
index 44fabecc1..2a48ac912 100644
--- a/packages/mix/lib/src/layout/render_grid.dart
+++ b/packages/mix/lib/src/layout/render_grid.dart
@@ -1,3 +1,5 @@
+import 'dart:math' as math;
+
import 'package:flutter/foundation.dart' show precisionErrorTolerance;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
@@ -7,22 +9,27 @@ import 'grid_track.dart';
import 'internal/grid_geometry.dart';
import 'internal/grid_validation.dart';
-/// Computes concrete track sizes for fixed + fr tracks under a free-space axis.
+/// Computes concrete track sizes for fixed, auto, and fr tracks.
///
-/// Shared by live layout and dry layout so both paths return the same sizes.
+/// Auto tracks contribute [autoExtents] at the matching index, then remaining
+/// free space is shared by fractional tracks. Shared by live layout, dry
+/// layout, and the fixed/fr fast path.
List computeTrackSizes({
required List tracks,
required double freeSpace,
required double gap,
+ List? autoExtents,
}) {
if (tracks.isEmpty) return const [];
var fixedSum = 0.0;
var frSum = 0.0;
- for (final track in tracks) {
- switch (track) {
+ for (var index = 0; index < tracks.length; index++) {
+ switch (tracks[index]) {
case FixedGridTrack(:final size):
fixedSum += size;
+ case AutoGridTrack():
+ fixedSum += _autoExtentAt(autoExtents, index);
case FrGridTrack(:final fraction):
frSum += fraction;
}
@@ -36,14 +43,32 @@ List computeTrackSizes({
final frUnit = frSum > 0 ? remaining / frSum : 0.0;
return [
- for (final track in tracks)
- switch (track) {
+ for (var index = 0; index < tracks.length; index++)
+ switch (tracks[index]) {
FixedGridTrack(:final size) => size,
+ AutoGridTrack() => _autoExtentAt(autoExtents, index),
FrGridTrack(:final fraction) => frUnit * fraction,
},
];
}
+double _autoExtentAt(List? autoExtents, int index) {
+ if (autoExtents == null || index >= autoExtents.length) {
+ throw FlutterError.fromParts([
+ ErrorSummary('GridTrack.auto() requires a measured row extent.'),
+ ErrorDescription(
+ 'Track $index is auto but no measured height was provided.',
+ ),
+ ErrorHint(
+ 'Measure auto-row children at their column width before resolving '
+ 'row sizes.',
+ ),
+ ]);
+ }
+
+ return autoExtents[index];
+}
+
/// Total size along an axis for the given track sizes and gap.
double axisExtent(List sizes, double gap) {
if (sizes.isEmpty) return 0;
@@ -91,6 +116,12 @@ class GridLayoutResult {
final Size contentSize;
final List columnSizes;
final List rowSizes;
+
+ /// Row tracks after implicit rows were resolved, parallel to [rowSizes].
+ ///
+ /// [RenderMixGrid.performLayout] needs the track *kind*, not just the size,
+ /// to decide which children may keep a tight cell constraint.
+ final List rowTracks;
final List cells;
const GridLayoutResult({
@@ -98,38 +129,33 @@ class GridLayoutResult {
required this.contentSize,
required this.columnSizes,
required this.rowSizes,
+ required this.rowTracks,
required this.cells,
});
}
-/// Computes grid geometry without touching children.
+/// Computes grid geometry from tracks, gaps, and optional auto-row extents.
///
-/// Children are placed row-major into a matrix of [columns] × enough rows.
-/// Track sizes use only fixed/fr rules and parent constraints — no content
-/// measurement in the currently supported track model.
+/// Children are placed row-major into a matrix of [columns] × [rows].
+/// [rows] must already include implicit tracks. Callers that grow rows from
+/// [childCount] resolve that list first so live and shared layout cannot
+/// disagree. When no row is [GridTrack.auto], sizes use only fixed/fr rules
+/// and parent constraints. Auto rows consume [autoRowHeights] at the matching
+/// row index before remaining bounded height is given to fractional rows.
///
-/// When [childCount] is 0 and [rows] is empty, no auto rows are produced.
-/// When children exceed the explicit row capacity, [autoRows] provides the
-/// repeated track used for every additional row.
+/// When [childCount] is 0 and [rows] is empty, no automatic rows are produced.
GridLayoutResult computeGridLayout({
required BoxConstraints constraints,
required List columns,
required List rows,
- GridTrack? autoRows,
required double columnGap,
required double rowGap,
required int childCount,
+ List? autoRowHeights,
}) {
assert(columns.isNotEmpty, 'Grid requires at least one column track.');
final colCount = columns.length;
- final effectiveRows = _resolveEffectiveRows(
- constraints: constraints,
- columnCount: colCount,
- rows: rows,
- autoRows: autoRows,
- childCount: childCount,
- );
// Resolve free space: prefer max constraint when finite; else min.
final freeWidth = constraints.hasBoundedWidth
@@ -145,9 +171,10 @@ GridLayoutResult computeGridLayout({
gap: columnGap,
);
final rowSizes = computeTrackSizes(
- tracks: effectiveRows,
+ tracks: rows,
freeSpace: freeHeight,
gap: rowGap,
+ autoExtents: autoRowHeights,
);
final intrinsicWidth = axisExtent(columnSizes, columnGap);
@@ -176,35 +203,23 @@ GridLayoutResult computeGridLayout({
contentSize: Size(intrinsicWidth, intrinsicHeight),
columnSizes: columnSizes,
rowSizes: rowSizes,
+ rowTracks: rows,
cells: cells,
);
}
-List _resolveEffectiveRows({
+/// Expands explicit [rows] with [autoRows] until every child has a row.
+List _resolveEffectiveGridRows({
required BoxConstraints constraints,
required int columnCount,
required List rows,
- required GridTrack? autoRows,
+ required GridTrack autoRows,
required int childCount,
}) {
final requiredRowCount = childCount == 0
? 0
: ((childCount + columnCount - 1) ~/ columnCount);
final missingRowCount = requiredRowCount - rows.length;
- if (missingRowCount > 0 && autoRows == null) {
- throw FlutterError.fromParts([
- ErrorSummary('Grid auto-placement requires an autoRows track.'),
- ErrorDescription(
- '$childCount children across $columnCount columns require '
- '$requiredRowCount rows, but only ${rows.length} explicit rows were '
- 'provided.',
- ),
- ErrorHint(
- 'Provide enough explicit rows or set autoRows to the GridTrack used '
- 'for each additional row.',
- ),
- ]);
- }
if (missingRowCount > 0 &&
autoRows is FrGridTrack &&
!constraints.hasBoundedHeight) {
@@ -217,26 +232,25 @@ List _resolveEffectiveRows({
'but autoRows is $autoRows.',
),
ErrorHint(
- 'Use GridTrack.fixed for autoRows or place the grid under a bounded '
- 'height.',
+ 'Use GridTrack.fixed or GridTrack.auto() for autoRows, or place the '
+ 'grid under a bounded height.',
),
]);
}
- final effectiveRows = [
+ return [
...rows,
- if (missingRowCount > 0) ...List.filled(missingRowCount, autoRows!),
+ if (missingRowCount > 0) ...List.filled(missingRowCount, autoRows),
];
-
- return effectiveRows;
}
/// Multi-child render object for [GridBoxSpec].
///
-/// Supports fixed + fr tracks, row/column gaps, row-major auto-placement, and
-/// render-time constraint branch selection via [GridBoxSpec]. Fixed-track
-/// overflow is diagnosed on both axes and optionally clipped by the spec.
-/// Excludes spans, named areas, content-sized tracks, RTL, and baseline.
+/// Supports fixed, fractional, and vertical auto tracks, row/column gaps,
+/// row-major auto-placement, and render-time constraint branch selection via
+/// [GridBoxSpec]. Fixed-track overflow is diagnosed on both axes and
+/// optionally clipped by the spec. Excludes spans, named areas, content-sized
+/// columns, RTL, and baseline.
class RenderMixGrid extends RenderBox
with
ContainerRenderObjectMixin,
@@ -252,24 +266,83 @@ class RenderMixGrid extends RenderBox
addAll(children);
}
- GridLayoutResult _compute(BoxConstraints constraints) {
+ GridLayoutResult _compute(
+ BoxConstraints constraints, {
+ required ChildLayouter measureChild,
+ }) {
final geometry = _spec.resolveGeometryForConstraints(constraints);
+ final effectiveRows = _resolveEffectiveGridRows(
+ constraints: constraints,
+ columnCount: geometry.columns.length,
+ rows: geometry.rows,
+ autoRows: geometry.autoRows,
+ childCount: childCount,
+ );
+ final autoRowHeights = _hasAutoTrack(effectiveRows)
+ ? _measureAutoRowHeights(
+ constraints: constraints,
+ geometry: geometry,
+ effectiveRows: effectiveRows,
+ measureChild: measureChild,
+ )
+ : null;
return computeGridLayout(
constraints: constraints,
columns: geometry.columns,
- rows: geometry.rows,
- autoRows: geometry.autoRows,
+ rows: effectiveRows,
columnGap: geometry.columnGap,
rowGap: geometry.rowGap,
childCount: childCount,
+ autoRowHeights: autoRowHeights,
);
}
- /// Intrinsic extent for fixed-only tracks after branch selection.
+ List _measureAutoRowHeights({
+ required BoxConstraints constraints,
+ required GridResolvedGeometry geometry,
+ required List effectiveRows,
+ required ChildLayouter measureChild,
+ }) {
+ final freeWidth = constraints.hasBoundedWidth
+ ? constraints.maxWidth
+ : constraints.minWidth;
+ final columnSizes = computeTrackSizes(
+ tracks: geometry.columns,
+ freeSpace: freeWidth,
+ gap: geometry.columnGap,
+ );
+ final heights = List.filled(effectiveRows.length, 0);
+ final columnCount = geometry.columns.length;
+
+ var index = 0;
+ var child = firstChild;
+ while (child != null) {
+ final row = index ~/ columnCount;
+ if (row < effectiveRows.length && effectiveRows[row] is AutoGridTrack) {
+ final column = index % columnCount;
+ final measured = measureChild(
+ child,
+ BoxConstraints.tightFor(width: columnSizes[column]),
+ );
+ if (!measured.height.isFinite) {
+ throw _autoRowNeedsFiniteHeight(row, measured.height);
+ }
+ heights[row] = math.max(heights[row], measured.height);
+ }
+ child = (child.parentData! as MultiChildLayoutParentData).nextSibling;
+ index++;
+ }
+
+ return heights;
+ }
+
+ /// Intrinsic extent after branch selection.
///
/// Fractional tracks on the queried axis throw the same actionable
- /// [FlutterError] as unbounded layout (no LayoutBuilder cascade).
+ /// [FlutterError] as unbounded layout (no LayoutBuilder cascade). Vertical
+ /// auto rows use each child's max intrinsic height at the resolved column
+ /// width.
double _computeIntrinsicExtent({
required Axis axis,
required double crossExtent,
@@ -285,7 +358,7 @@ class RenderMixGrid extends RenderBox
final geometry = _spec.resolveGeometryForConstraints(constraints);
final effectiveTracks = axis == .horizontal
? geometry.columns
- : _resolveEffectiveRows(
+ : _resolveEffectiveGridRows(
constraints: constraints,
columnCount: geometry.columns.length,
rows: geometry.rows,
@@ -299,11 +372,24 @@ class RenderMixGrid extends RenderBox
constraints: constraints,
);
+ if (axis == .vertical && _hasAutoTrack(effectiveTracks)) {
+ return _computeAutoRowIntrinsicHeight(
+ geometry: geometry,
+ effectiveRows: effectiveTracks,
+ width: crossExtent,
+ );
+ }
+
var sum = 0.0;
for (final track in effectiveTracks) {
switch (track) {
case FixedGridTrack(:final size):
sum += size;
+ // An auto track never reaches here — the branch above returns first,
+ // and columns reject auto during validation. The case exists only to
+ // satisfy the sealed exhaustiveness check. A fractional track has no
+ // intrinsic contribution because its size comes from free space.
+ case AutoGridTrack():
case FrGridTrack():
break;
}
@@ -316,6 +402,93 @@ class RenderMixGrid extends RenderBox
return sum;
}
+ double _computeAutoRowIntrinsicHeight({
+ required GridResolvedGeometry geometry,
+ required List effectiveRows,
+ required double width,
+ }) {
+ final columnSizes = computeTrackSizes(
+ tracks: geometry.columns,
+ freeSpace: width.isFinite ? width : 0.0,
+ gap: geometry.columnGap,
+ );
+ final columnCount = geometry.columns.length;
+ final autoRowHeights = List.filled(effectiveRows.length, 0);
+
+ // Mirrors _measureAutoRowHeights so the intrinsic answer and the laid-out
+ // size are derived the same way; only the per-child measurement differs.
+ var index = 0;
+ var child = firstChild;
+ while (child != null) {
+ final row = index ~/ columnCount;
+ if (row < effectiveRows.length && effectiveRows[row] is AutoGridTrack) {
+ final column = index % columnCount;
+ autoRowHeights[row] = math.max(
+ autoRowHeights[row],
+ child.getMaxIntrinsicHeight(columnSizes[column]),
+ );
+ }
+ child = (child.parentData! as MultiChildLayoutParentData).nextSibling;
+ index++;
+ }
+
+ final rowExtents = [
+ for (var row = 0; row < effectiveRows.length; row++)
+ switch (effectiveRows[row]) {
+ FixedGridTrack(:final size) => size,
+ AutoGridTrack() => autoRowHeights[row],
+ // A fractional row never reaches here: an intrinsic height query is
+ // always built with an unbounded max height, and
+ // rejectFractionalGridTracksOnUnboundedAxis has already thrown on
+ // these tracks. The case exists only to satisfy the sealed
+ // exhaustiveness check; 0.0 matches what free space would give.
+ FrGridTrack() => 0.0,
+ },
+ ];
+
+ return axisExtent(rowExtents, geometry.rowGap);
+ }
+
+ /// Final layout constraint for [child] in [cell], used by [performLayout].
+ ///
+ /// Fixed and fr rows keep the tight cell: their height never depends on the
+ /// child, and tightness is the documented hard-constraint contract.
+ ///
+ /// An auto row's height is derived from its children, so those children must
+ /// stay inside the grid's relayout boundary. A fully tight constraint would
+ /// make the child its own boundary (see [RenderObject.layout]), so a later
+ /// child-only change would never reach the grid and the row would keep the
+ /// height measured on the first pass. Leaving max height unbounded keeps the
+ /// constraint loose enough to propagate, while the min height stretches
+ /// shorter siblings to fill the row.
+ ///
+ /// A child that already fills its row gets back the *exact* constraint
+ /// [_measureAutoRowHeights] used, so [RenderObject.layout] takes its
+ /// `!_needsLayout && constraints == _constraints` early return instead of
+ /// running the subtree a second time. Every auto row has at least one such
+ /// child, which is what stops nested auto grids from multiplying layout
+ /// passes per level. Keep this constraint identical to the measure-pass one;
+ /// widening it here would silently reintroduce the second pass.
+ BoxConstraints _cellConstraints(
+ RenderBox child,
+ GridCellGeometry cell, {
+ required bool isAutoRow,
+ }) {
+ if (!isAutoRow) return BoxConstraints.tight(cell.size);
+ // Safe to read: auto-row children were laid out with parentUsesSize during
+ // the measure pass. A row's height is the max of those measurements, so
+ // `>=` only ever matches a child that needs no stretching.
+ if (child.size.height >= cell.size.height) {
+ return BoxConstraints.tightFor(width: cell.size.width);
+ }
+
+ return BoxConstraints(
+ minWidth: cell.size.width,
+ maxWidth: cell.size.width,
+ minHeight: cell.size.height,
+ );
+ }
+
bool get _hasVisualOverflow =>
_contentSize.width - size.width > precisionErrorTolerance ||
_contentSize.height - size.height > precisionErrorTolerance;
@@ -362,12 +535,18 @@ class RenderMixGrid extends RenderBox
@override
Size computeDryLayout(BoxConstraints constraints) {
- return _compute(constraints).size;
+ return _compute(
+ constraints,
+ measureChild: ChildLayoutHelper.dryLayoutChild,
+ ).size;
}
@override
void performLayout() {
- final result = _compute(constraints);
+ final result = _compute(
+ constraints,
+ measureChild: ChildLayoutHelper.layoutChild,
+ );
size = result.size;
_contentSize = result.contentSize;
assert(result.cells.length == childCount);
@@ -377,7 +556,11 @@ class RenderMixGrid extends RenderBox
while (child != null) {
final parentData = child.parentData! as MultiChildLayoutParentData;
final cell = result.cells[index];
- child.layout(BoxConstraints.tight(cell.size), parentUsesSize: false);
+ final isAutoRow = result.rowTracks[cell.row] is AutoGridTrack;
+ child.layout(
+ _cellConstraints(child, cell, isAutoRow: isAutoRow),
+ parentUsesSize: isAutoRow,
+ );
parentData.offset = cell.offset;
child = parentData.nextSibling;
index++;
@@ -427,8 +610,8 @@ class RenderMixGrid extends RenderBox
'Clip.none deliberately leaves overflow visible.',
),
ErrorHint(
- 'Content-sized tracks and implicit content-sized rows are not '
- 'supported by GridBox.',
+ 'Use GridTrack.auto() for unknown content height. Fixed tracks are '
+ 'hard constraints and do not grow to fit overflowing children.',
),
],
);
@@ -456,6 +639,26 @@ class RenderMixGrid extends RenderBox
}
}
+bool _hasAutoTrack(Iterable tracks) =>
+ tracks.any((track) => track is AutoGridTrack);
+
+FlutterError _autoRowNeedsFiniteHeight(int row, double measuredHeight) {
+ return FlutterError.fromParts([
+ ErrorSummary('Grid auto rows require children with a finite height.'),
+ ErrorDescription(
+ 'A child in row $row could not determine a finite height under '
+ 'unbounded vertical constraints.',
+ ),
+ ErrorDescription('Measured height: $measuredHeight'),
+ ErrorHint(
+ 'Auto rows measure children at their column width with a loose '
+ 'height. Use finite-height content such as text or intrinsic boxes. '
+ 'Do not place Expanded, Spacer, or fractional-height layout inside '
+ 'an auto row in a vertical scroll view.',
+ ),
+ ]);
+}
+
/// Internal widget host for [RenderMixGrid].
class MixGrid extends MultiChildRenderObjectWidget {
const MixGrid({super.key, required this.spec, super.children});
diff --git a/packages/mix/test/src/layout/grid_box_test.dart b/packages/mix/test/src/layout/grid_box_test.dart
index 1be4c6fc7..f93a712ca 100644
--- a/packages/mix/test/src/layout/grid_box_test.dart
+++ b/packages/mix/test/src/layout/grid_box_test.dart
@@ -1,5 +1,7 @@
// ignore_for_file: implementation_imports
+import 'dart:math' as math;
+
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -52,8 +54,11 @@ void main() {
final result = computeGridLayout(
constraints: const BoxConstraints(maxWidth: 100),
columns: const [GridTrack.fixed(100)],
- rows: const [],
- autoRows: const GridTrack.fixed(40),
+ rows: const [
+ GridTrack.fixed(40),
+ GridTrack.fixed(40),
+ GridTrack.fixed(40),
+ ],
columnGap: 0,
rowGap: 8,
childCount: 3,
@@ -63,70 +68,73 @@ void main() {
expect(result.size, const Size(100, 136));
});
- test('missing auto rows reports the undeclared row strategy', () {
- expect(
- () => computeGridLayout(
- constraints: const BoxConstraints(maxWidth: 100, maxHeight: 200),
- columns: const [GridTrack.fixed(100)],
- rows: const [],
+ test(
+ 'auto rows use provided measured heights before fractional remainder',
+ () {
+ final result = computeGridLayout(
+ constraints: const BoxConstraints.tightFor(width: 200, height: 200),
+ columns: const [GridTrack.fr(1), GridTrack.fr(1)],
+ rows: const [GridTrack.auto(), GridTrack.auto()],
columnGap: 0,
- rowGap: 0,
- childCount: 1,
- ),
- throwsA(
- isA()
- .having(
- (error) => error.toString(),
- 'message',
- contains('autoRows'),
- )
- .having(
- (error) => error.toString(),
- 'message',
- isNot(contains('fractional')),
- ),
- ),
- );
- });
+ rowGap: 10,
+ childCount: 4,
+ autoRowHeights: const [40, 70],
+ );
+
+ expect(result.rowSizes, [40.0, 70.0]);
+ expect(result.cells[2].offset.dy, 50);
+ expect(result.contentSize.height, 120);
+ },
+ );
test('fractional auto rows name the configured track when unbounded', () {
- expect(
- () => computeGridLayout(
- constraints: const BoxConstraints(maxWidth: 100),
+ final child = RenderConstrainedBox(
+ additionalConstraints: const BoxConstraints.tightFor(
+ width: 10,
+ height: 10,
+ ),
+ );
+ final render = RenderMixGrid(
+ spec: GridBoxSpec(
columns: const [GridTrack.fixed(100)],
- rows: const [],
autoRows: const GridTrack.fr(1),
- columnGap: 0,
- rowGap: 0,
- childCount: 1,
- ),
- throwsA(
- isA()
- .having(
- (error) => error.toString(),
- 'message',
- contains('autoRows'),
- )
- .having(
- (error) => error.toString(),
- 'message',
- contains('bounded height'),
- )
- .having(
- (error) => error.toString(),
- 'message',
- contains('GridTrack.fr(1'),
- ),
),
+ children: [child],
);
+ try {
+ expect(
+ () => render.getDryLayout(const BoxConstraints(maxWidth: 100)),
+ throwsA(
+ isA()
+ .having(
+ (error) => error.toString(),
+ 'message',
+ contains('autoRows'),
+ )
+ .having(
+ (error) => error.toString(),
+ 'message',
+ contains('bounded height'),
+ )
+ .having(
+ (error) => error.toString(),
+ 'message',
+ contains('GridTrack.fr(1'),
+ ),
+ ),
+ );
+ } finally {
+ render.removeAll();
+ render.dispose();
+ child.dispose();
+ }
});
test('row-major auto-placement', () {
final result = computeGridLayout(
constraints: const BoxConstraints.tightFor(width: 300, height: 200),
columns: const [GridTrack.fr(1), GridTrack.fr(1), GridTrack.fr(1)],
- rows: const [],
- autoRows: const GridTrack.fr(1),
+ rows: const [GridTrack.fr(1), GridTrack.fr(1)],
columnGap: 0,
rowGap: 0,
childCount: 5,
@@ -448,6 +456,31 @@ void main() {
expect(start.lerp(differentCount, 0.49).columns, start.columns);
expect(start.lerp(differentCount, 0.5).columns, differentCount.columns);
});
+
+ test('lerp keeps auto-to-auto stable and snaps auto-to-numeric', () {
+ final auto = GridBoxSpec(
+ columns: const [GridTrack.fixed(100)],
+ rows: const [GridTrack.auto()],
+ autoRows: const GridTrack.auto(),
+ );
+ final alsoAuto = GridBoxSpec(
+ columns: const [GridTrack.fixed(100)],
+ rows: const [GridTrack.auto()],
+ autoRows: const GridTrack.auto(),
+ );
+ final fixed = GridBoxSpec(
+ columns: const [GridTrack.fixed(100)],
+ rows: const [GridTrack.fixed(40)],
+ autoRows: const GridTrack.fixed(20),
+ );
+
+ expect(auto.lerp(alsoAuto, 0.5).rows, const [GridTrack.auto()]);
+ expect(auto.lerp(alsoAuto, 0.5).autoRows, const GridTrack.auto());
+ expect(auto.lerp(fixed, 0.49).rows, const [GridTrack.auto()]);
+ expect(auto.lerp(fixed, 0.49).autoRows, const GridTrack.auto());
+ expect(auto.lerp(fixed, 0.5).rows, const [GridTrack.fixed(40)]);
+ expect(auto.lerp(fixed, 0.5).autoRows, const GridTrack.fixed(20));
+ });
});
group('GridBoxSpec validation', () {
@@ -1494,23 +1527,1026 @@ void main() {
},
);
});
-}
-class _LayoutCallCounter extends SingleChildRenderObjectWidget {
- const _LayoutCallCounter({super.key});
+ group('content-sized auto rows', () {
+ test('resolved geometry defaults omitted autoRows to auto', () {
+ final spec = GridBoxSpec(
+ columns: const [GridTrack.fr(1), GridTrack.fr(1)],
+ );
+ final geometry = spec.resolveGeometryForConstraints(
+ const BoxConstraints.tightFor(width: 400, height: 200),
+ );
- @override
- _RenderLayoutCallCounter createRenderObject(BuildContext context) {
- return _RenderLayoutCallCounter();
- }
-}
+ expect(geometry.autoRows, const GridTrack.auto());
+ });
-class _RenderLayoutCallCounter extends RenderProxyBox {
- int layoutCount = 0;
+ testWidgets('omitted autoRows sizes implicit rows to content', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 200,
+ child: GridBox(
+ style: GridBoxStyler(
+ columns: [GridTrack.fr(1), GridTrack.fr(1)],
+ ),
+ children: [
+ SizedBox(key: Key('a'), height: 30),
+ SizedBox(key: Key('b'), height: 50),
+ SizedBox(key: Key('c'), height: 20),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
- @override
- void layout(Constraints constraints, {bool parentUsesSize = false}) {
- layoutCount++;
- super.layout(constraints, parentUsesSize: parentUsesSize);
+ expect(tester.takeException(), isNull);
+ final render = tester.renderObject(find.byType(MixGrid));
+ expect(render.size, const Size(200, 70));
+ expect(tester.getSize(find.byKey(const Key('a'))), const Size(100, 50));
+ expect(tester.getSize(find.byKey(const Key('b'))), const Size(100, 50));
+ expect(tester.getSize(find.byKey(const Key('c'))), const Size(100, 20));
+ expect(
+ tester.getTopLeft(find.byKey(const Key('c'))).dy,
+ tester.getTopLeft(find.byKey(const Key('a'))).dy + 50,
+ );
+ });
+
+ testWidgets('explicit autoRows and rows use the same content rule', (
+ tester,
+ ) async {
+ Future pump(GridBoxStyler style) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 120,
+ child: GridBox(
+ style: style,
+ children: const [
+ SizedBox(key: Key('left'), height: 18),
+ SizedBox(key: Key('right'), height: 42),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ return tester.renderObject(find.byType(MixGrid)).size;
+ }
+
+ final omitted = await pump(
+ const GridBoxStyler(columns: [GridTrack.fr(1), GridTrack.fr(1)]),
+ );
+ final automatic = await pump(
+ const GridBoxStyler(
+ columns: [GridTrack.fr(1), GridTrack.fr(1)],
+ autoRows: GridTrack.auto(),
+ ),
+ );
+ final explicit = await pump(
+ const GridBoxStyler(
+ columns: [GridTrack.fr(1), GridTrack.fr(1)],
+ rows: [GridTrack.auto()],
+ ),
+ );
+
+ expect(omitted, const Size(120, 42));
+ expect(automatic, omitted);
+ expect(explicit, omitted);
+ });
+
+ testWidgets(
+ 'scroll-view two-column auto rows size to independently measured text',
+ (tester) async {
+ const texts = [
+ 'Short',
+ 'This catalog card wraps to several lines at a 194-pixel width so the first row is taller than a single line.',
+ 'A medium-length product blurb that still wraps once.',
+ 'Tiny',
+ ];
+ const gap = 12.0;
+ const width = 400.0;
+ const cellWidth = 194.0;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: width,
+ height: 600,
+ child: SingleChildScrollView(
+ child: GridBox(
+ style: const GridBoxStyler(
+ columns: [GridTrack.fr(1), GridTrack.fr(1)],
+ columnGap: gap,
+ rowGap: gap,
+ ),
+ children: [
+ for (var index = 0; index < texts.length; index++)
+ Text(texts[index], key: Key('copy-$index')),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ final render = tester.renderObject(find.byType(MixGrid));
+ final naturalHeights = [
+ for (var index = 0; index < texts.length; index++)
+ tester
+ .renderObject(find.byKey(Key('copy-$index')))
+ .getDryLayout(const BoxConstraints.tightFor(width: cellWidth))
+ .height,
+ ];
+ final firstRow = math.max(naturalHeights[0], naturalHeights[1]);
+ final secondRow = math.max(naturalHeights[2], naturalHeights[3]);
+ final firstOrigin = tester.getTopLeft(find.byKey(const Key('copy-0')));
+ final secondOrigin = tester.getTopLeft(find.byKey(const Key('copy-2')));
+
+ // Guards the fixture rather than the Grid: the assertions below are
+ // written against measured text, so if font metrics ever made every
+ // string fit one line they would all still pass while proving nothing
+ // about content sizing. Both rows must stay genuinely unequal, and
+ // each row must be taller than its shortest member.
+ expect(firstRow, greaterThan(secondRow));
+ expect(firstRow, greaterThan(naturalHeights[0]));
+ expect(secondRow, greaterThan(naturalHeights[3]));
+
+ expect(
+ tester.getSize(find.byKey(const Key('copy-0'))).width,
+ cellWidth,
+ );
+ expect(
+ tester.getSize(find.byKey(const Key('copy-0'))).height,
+ firstRow,
+ );
+ expect(
+ tester.getSize(find.byKey(const Key('copy-1'))).height,
+ firstRow,
+ );
+ expect(
+ tester.getSize(find.byKey(const Key('copy-2'))).height,
+ secondRow,
+ );
+ expect(secondOrigin.dy, firstOrigin.dy + firstRow + gap);
+ expect(render.size, Size(width, firstRow + gap + secondRow));
+ },
+ );
+
+ testWidgets('mixed auto and fixed rows keep both rules', (tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 100,
+ child: GridBox(
+ style: GridBoxStyler(
+ columns: [GridTrack.fixed(100)],
+ rows: [GridTrack.auto(), GridTrack.fixed(50)],
+ ),
+ children: [
+ SizedBox(key: Key('auto'), height: 24),
+ SizedBox(key: Key('fixed'), height: 10),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ tester.getSize(find.byKey(const Key('auto'))),
+ const Size(100, 24),
+ );
+ expect(
+ tester.getSize(find.byKey(const Key('fixed'))),
+ const Size(100, 50),
+ );
+ expect(
+ tester.renderObject(find.byType(MixGrid)).size,
+ const Size(100, 74),
+ );
+ });
+
+ testWidgets('mixed auto and fr rows consume remaining bounded height', (
+ tester,
+ ) async {
+ Future pump({required bool tight}) async {
+ final grid = GridBox(
+ style: const GridBoxStyler(
+ columns: [GridTrack.fixed(100)],
+ rows: [GridTrack.auto(), GridTrack.fr(1)],
+ rowGap: 10,
+ ),
+ children: const [
+ SizedBox(key: Key('auto'), height: 40),
+ SizedBox(key: Key('flex')),
+ ],
+ );
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: tight
+ ? SizedBox(width: 100, height: 200, child: grid)
+ : SizedBox(
+ width: 100,
+ height: 200,
+ child: OverflowBox(
+ maxHeight: 200,
+ minHeight: 0,
+ alignment: Alignment.topLeft,
+ child: grid,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ await pump(tight: true);
+ expect(tester.getSize(find.byKey(const Key('auto'))).height, 40);
+ expect(tester.getSize(find.byKey(const Key('flex'))).height, 150);
+
+ await pump(tight: false);
+ expect(tester.getSize(find.byKey(const Key('auto'))).height, 40);
+ expect(tester.getSize(find.byKey(const Key('flex'))).height, 150);
+ });
+
+ testWidgets(
+ 'auto-row content that exceeds a bounded parent still overflows',
+ (tester) async {
+ final overflowErrors = [];
+ final previousOnError = FlutterError.onError;
+ FlutterError.onError = overflowErrors.add;
+ try {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 100,
+ height: 40,
+ child: GridBox(
+ style: GridBoxStyler(columns: [GridTrack.fixed(100)]),
+ children: [SizedBox(height: 90)],
+ ),
+ ),
+ ),
+ ),
+ );
+ } finally {
+ FlutterError.onError = previousOnError;
+ }
+
+ expect(overflowErrors, isNotEmpty);
+ expect(
+ overflowErrors.first.exception.toString(),
+ contains('RenderMixGrid overflowed'),
+ );
+ expect(
+ tester.renderObject(find.byType(MixGrid)).size,
+ const Size(100, 40),
+ );
+ },
+ );
+
+ testWidgets(
+ 'onConstraints remeasures auto rows at the new cell width without rebuilds',
+ (tester) async {
+ await tester.binding.setSurfaceSize(const Size(800, 600));
+ addTearDown(() => tester.binding.setSurfaceSize(null));
+
+ var childBuilds = 0;
+ final children = [
+ Builder(
+ builder: (context) {
+ childBuilds++;
+
+ return const _WidthDrivenHeight(
+ key: Key('wide-child'),
+ factor: 0.5,
+ );
+ },
+ ),
+ ];
+ final grid = MixGrid(
+ spec: GridBoxSpec(
+ columns: const [GridTrack.fr(1), GridTrack.fr(1)],
+ constraintBranches: [
+ GridConstraintBranch(
+ breakpoint: Breakpoint.maxWidth(500),
+ patch: const GridLayoutPatch(columns: [GridTrack.fr(1)]),
+ ),
+ ],
+ ),
+ children: children,
+ );
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(width: 700, height: 400, child: grid),
+ ),
+ ),
+ );
+
+ expect(childBuilds, 1);
+ expect(tester.getSize(find.byKey(const Key('wide-child'))).height, 175);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(width: 400, height: 400, child: grid),
+ ),
+ ),
+ );
+
+ expect(childBuilds, 1);
+ expect(tester.getSize(find.byKey(const Key('wide-child'))).height, 200);
+ },
+ );
+
+ testWidgets('a child that already fills its auto row is not relaid out', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Center(
+ child: SizedBox(
+ width: 100,
+ height: 200,
+ child: MixGrid(
+ spec: GridBoxSpec(
+ columns: const [GridTrack.fixed(100)],
+ rows: const [GridTrack.auto(), GridTrack.fixed(40)],
+ ),
+ children: const [
+ _LayoutCallCounter(key: Key('auto'), height: 30),
+ _LayoutCallCounter(key: Key('fixed'), height: 10),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final counters = tester
+ .renderObjectList<_RenderLayoutCallCounter>(
+ find.byType(_LayoutCallCounter),
+ )
+ .toList();
+ // The auto-row child is measured, then handed the same constraint again
+ // so RenderObject.layout early-returns: two calls, one relayout.
+ expect(counters[0].layoutCount, 2);
+ expect(counters[0].performLayoutCount, 1);
+ expect(counters[1].layoutCount, 1);
+ expect(counters[1].performLayoutCount, 1);
+ });
+
+ testWidgets('a shorter auto-row sibling is relaid out to fill the row', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Center(
+ child: SizedBox(
+ width: 200,
+ child: MixGrid(
+ spec: GridBoxSpec(
+ columns: const [GridTrack.fixed(100), GridTrack.fixed(100)],
+ ),
+ children: const [
+ _LayoutCallCounter(key: Key('short'), height: 20),
+ _LayoutCallCounter(key: Key('tall'), height: 60),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final counters = tester
+ .renderObjectList<_RenderLayoutCallCounter>(
+ find.byType(_LayoutCallCounter),
+ )
+ .toList();
+ expect(counters[0].performLayoutCount, 2, reason: 'stretched to 60');
+ expect(counters[1].performLayoutCount, 1, reason: 'already 60 tall');
+ expect(tester.getSize(find.byKey(const Key('short'))).height, 60);
+ });
+
+ testWidgets('nesting auto grids does not multiply leaf layout passes', (
+ tester,
+ ) async {
+ // The measure pass hands each level the same constraint its parent used,
+ // so a leaf that fills its row stays at one relayout no matter how deep
+ // the nesting goes. Without that, cost is 2^depth.
+ Widget nest(int depth) {
+ Widget current = const _LayoutCallCounter(key: Key('leaf'), height: 25);
+ for (var level = 0; level < depth; level++) {
+ current = GridBox(
+ style: const GridBoxStyler(columns: [GridTrack.fixed(100)]),
+ children: [current],
+ );
+ }
+
+ return current;
+ }
+
+ for (final depth in [1, 2, 3]) {
+ // Force a fresh render object so counts never carry across depths.
+ await tester.pumpWidget(const SizedBox());
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(width: 100, child: nest(depth)),
+ ),
+ ),
+ );
+
+ final leaf = tester.renderObject<_RenderLayoutCallCounter>(
+ find.byKey(const Key('leaf')),
+ );
+ expect(leaf.performLayoutCount, 1, reason: 'depth=$depth');
+ expect(tester.getSize(find.byKey(const Key('leaf'))).height, 25);
+ }
+ });
+
+ testWidgets('live and dry sizes match for deterministic auto-row boxes', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 200,
+ child: MixGrid(
+ spec: GridBoxSpec(
+ columns: const [GridTrack.fixed(100), GridTrack.fixed(100)],
+ autoRows: const GridTrack.auto(),
+ rowGap: 8,
+ ),
+ children: const [
+ SizedBox(height: 20),
+ SizedBox(height: 36),
+ SizedBox(height: 12),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final render = tester.renderObject(find.byType(MixGrid));
+ final dry = render.getDryLayout(const BoxConstraints(maxWidth: 200));
+ expect(render.size, const Size(200, 56));
+ expect(dry, render.size);
+ });
+
+ testWidgets('vertical intrinsic height is the per-row max plus gaps', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 200,
+ child: MixGrid(
+ spec: GridBoxSpec(
+ columns: const [GridTrack.fr(1), GridTrack.fr(1)],
+ rowGap: 8,
+ ),
+ children: const [
+ SizedBox(height: 20),
+ SizedBox(height: 36),
+ SizedBox(height: 12),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final render = tester.renderObject(find.byType(MixGrid));
+ expect(render.getMaxIntrinsicHeight(200), 56);
+ expect(render.getMinIntrinsicHeight(200), 56);
+ });
+
+ testWidgets('incomplete last auto row stays well-defined', (tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 200,
+ child: GridBox(
+ style: GridBoxStyler(
+ columns: [GridTrack.fr(1), GridTrack.fr(1)],
+ rowGap: 6,
+ ),
+ children: [
+ SizedBox(height: 10),
+ SizedBox(height: 14),
+ SizedBox(key: Key('last'), height: 22),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final render = tester.renderObject(find.byType(MixGrid));
+ expect(render.size, const Size(200, 42));
+ expect(
+ tester.getSize(find.byKey(const Key('last'))),
+ const Size(100, 22),
+ );
+ });
+
+ testWidgets(
+ 'a child that cannot dry-layout reports Flutter dry-layout failure',
+ (tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 200,
+ height: 200,
+ child: GridBox(
+ style: const GridBoxStyler(columns: [GridTrack.fixed(200)]),
+ children: [
+ LayoutBuilder(
+ builder: (context, constraints) {
+ return const SizedBox(height: 40);
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final render = tester.renderObject(find.byType(MixGrid));
+ expect(
+ () => render.getDryLayout(
+ const BoxConstraints.tightFor(width: 200, height: 200),
+ ),
+ throwsA(
+ isA()
+ .having(
+ (error) => error.toString(),
+ 'message',
+ contains('dry layout'),
+ )
+ .having(
+ (error) => error.toString(),
+ 'message',
+ isNot(contains('finite height')),
+ ),
+ ),
+ );
+ },
+ );
+
+ testWidgets(
+ 'an expanding auto-row child reports Flutter unbounded-flex error',
+ (tester) async {
+ final captured = [];
+ final previousOnError = FlutterError.onError;
+ FlutterError.onError = (details) {
+ captured.add(details.exceptionAsString());
+ };
+ try {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 200,
+ child: SingleChildScrollView(
+ child: GridBox(
+ style: GridBoxStyler(columns: [GridTrack.fr(1)]),
+ children: [
+ Column(children: [Expanded(child: SizedBox())]),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ } finally {
+ FlutterError.onError = previousOnError;
+ }
+ final leftover = tester.takeException();
+ if (leftover != null) {
+ captured.add(leftover.toString());
+ }
+
+ final messages = captured.join('\n');
+ expect(messages, contains('non-zero flex'));
+ expect(messages, contains('incoming height constraints are unbounded'));
+ expect(
+ messages,
+ isNot(
+ contains('Grid auto rows require children with a finite height'),
+ ),
+ );
+ },
+ );
+
+ testWidgets(
+ 'an auto-row child that throws StateError reports that StateError',
+ (tester) async {
+ final captured = [];
+ final previousOnError = FlutterError.onError;
+ FlutterError.onError = (details) {
+ captured.add(details.exceptionAsString());
+ };
+ try {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 200,
+ child: GridBox(
+ style: GridBoxStyler(columns: [GridTrack.fixed(200)]),
+ children: [_ThrowingLayoutBox()],
+ ),
+ ),
+ ),
+ ),
+ );
+ } finally {
+ FlutterError.onError = previousOnError;
+ }
+ final leftover = tester.takeException();
+ if (leftover != null) {
+ captured.add(leftover.toString());
+ }
+
+ final messages = captured.join('\n');
+ expect(messages, contains('child-specific bug: index out of range'));
+ expect(
+ messages,
+ isNot(
+ contains('Grid auto rows require children with a finite height'),
+ ),
+ );
+ },
+ );
+
+ testWidgets(
+ 'a child that does not implement computeDryLayout reports Flutter dry-layout failure',
+ (tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 200,
+ height: 200,
+ child: GridBox(
+ style: GridBoxStyler(columns: [GridTrack.fixed(200)]),
+ children: [_NoDryLayoutBox()],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final render = tester.renderObject(find.byType(MixGrid));
+ expect(
+ () => render.getDryLayout(
+ const BoxConstraints.tightFor(width: 200, height: 200),
+ ),
+ throwsA(
+ isA()
+ .having(
+ (error) => error.toString(),
+ 'message',
+ contains('does not implement "computeDryLayout"'),
+ )
+ .having(
+ (error) => error.toString(),
+ 'message',
+ isNot(contains('finite height')),
+ ),
+ ),
+ );
+ },
+ );
+
+ testWidgets(
+ 'omitted autoRows grows a content-sized row beyond explicit rows',
+ (tester) async {
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 100,
+ child: GridBox(
+ style: GridBoxStyler(
+ columns: [GridTrack.fixed(100)],
+ rows: [GridTrack.fixed(40)],
+ ),
+ children: [
+ SizedBox(height: 10),
+ SizedBox(key: Key('tall'), height: 72),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final render = tester.renderObject(find.byType(MixGrid));
+ expect(render.size.height, isNot(40));
+ expect(render.size.height, 112);
+ expect(
+ tester.getSize(find.byKey(const Key('tall'))),
+ const Size(100, 72),
+ );
+ },
+ );
+
+ testWidgets('an auto row re-measures when only the child changes', (
+ tester,
+ ) async {
+ // A tight cell constraint would make the child its own relayout
+ // boundary, so a child-only change would never reach the grid and the
+ // row would keep its first measured height.
+ Widget build(double height) => MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 100,
+ child: GridBox(
+ style: const GridBoxStyler(columns: [GridTrack.fixed(100)]),
+ children: [SizedBox(key: const Key('a'), height: height)],
+ ),
+ ),
+ ),
+ );
+
+ await tester.pumpWidget(build(30));
+ expect(tester.getSize(find.byKey(const Key('a'))), const Size(100, 30));
+ expect(
+ tester.renderObject(find.byType(MixGrid)).size.height,
+ 30,
+ );
+
+ await tester.pumpWidget(build(80));
+ expect(tester.getSize(find.byKey(const Key('a'))), const Size(100, 80));
+ expect(
+ tester.renderObject(find.byType(MixGrid)).size.height,
+ 80,
+ );
+
+ await tester.pumpWidget(build(15));
+ expect(tester.getSize(find.byKey(const Key('a'))), const Size(100, 15));
+ expect(
+ tester.renderObject(find.byType(MixGrid)).size.height,
+ 15,
+ );
+ });
+
+ testWidgets('an auto row grows when a child resizes itself via setState', (
+ tester,
+ ) async {
+ // The test above drives the height from a parent rebuild, which reaches
+ // the grid through the widget tree regardless of the constraint. This
+ // one changes height entirely below the grid, with GridBoxSpec untouched
+ // so MixGrid.updateRenderObject no-ops. The only path back to the grid is
+ // markNeedsLayout propagating past the child, which a tight cell would
+ // have stopped by making the child its own relayout boundary. Models the
+ // real case: an async image or an expanding tile inside a scroll view.
+ final key = GlobalKey<_ResizableState>();
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 100,
+ child: GridBox(
+ style: const GridBoxStyler(columns: [GridTrack.fixed(100)]),
+ children: [_Resizable(key: key, initialHeight: 30)],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final grid = tester.renderObject(find.byType(MixGrid));
+ expect(grid.size.height, 30);
+
+ key.currentState!.setHeight(90);
+ await tester.pump();
+ expect(grid.size.height, 90, reason: 'row must follow the child upward');
+
+ key.currentState!.setHeight(20);
+ await tester.pump();
+ expect(grid.size.height, 20, reason: 'and back down');
+ });
+
+ testWidgets('an auto-row child still stretches to fill the taller row', (
+ tester,
+ ) async {
+ // The auto-row cell is loose on max height so the child stays inside the
+ // grid's relayout boundary. Min height must still stretch the child, and
+ // the child must distribute that stretched extent internally.
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 200,
+ child: GridBox(
+ style: GridBoxStyler(
+ columns: [GridTrack.fixed(100), GridTrack.fixed(100)],
+ ),
+ children: [
+ Column(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ SizedBox(key: Key('top'), height: 10, width: 10),
+ SizedBox(key: Key('bottom'), height: 10, width: 10),
+ ],
+ ),
+ SizedBox(key: Key('tall'), height: 90),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.getSize(find.byKey(const Key('tall'))).height, 90);
+ expect(tester.getTopLeft(find.byKey(const Key('top'))).dy, 0);
+ expect(tester.getTopLeft(find.byKey(const Key('bottom'))).dy, 80);
+ });
+
+ testWidgets('a fixed row still hard-constrains a changing child', (
+ tester,
+ ) async {
+ Widget build(double height) => MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 100,
+ child: GridBox(
+ style: const GridBoxStyler(
+ columns: [GridTrack.fixed(100)],
+ rows: [GridTrack.fixed(40)],
+ ),
+ children: [SizedBox(key: const Key('a'), height: height)],
+ ),
+ ),
+ ),
+ );
+
+ await tester.pumpWidget(build(10));
+ expect(tester.getSize(find.byKey(const Key('a'))), const Size(100, 40));
+
+ await tester.pumpWidget(build(90));
+ expect(tester.getSize(find.byKey(const Key('a'))), const Size(100, 40));
+ });
+ });
+}
+
+class _WidthDrivenHeight extends StatelessWidget {
+ const _WidthDrivenHeight({super.key, required this.factor});
+
+ final double factor;
+
+ @override
+ Widget build(BuildContext context) {
+ return AspectRatio(aspectRatio: 1 / factor);
+ }
+}
+
+class _LayoutCallCounter extends LeafRenderObjectWidget {
+ const _LayoutCallCounter({super.key, this.height = 0});
+
+ /// Natural height, so the counter can act as a tall or short auto-row child.
+ final double height;
+
+ @override
+ _RenderLayoutCallCounter createRenderObject(BuildContext context) {
+ return _RenderLayoutCallCounter(height);
+ }
+
+ @override
+ void updateRenderObject(
+ BuildContext context,
+ _RenderLayoutCallCounter renderObject,
+ ) {
+ renderObject.naturalHeight = height;
+ }
+}
+
+class _RenderLayoutCallCounter extends RenderBox {
+ _RenderLayoutCallCounter(this._naturalHeight);
+
+ double _naturalHeight;
+
+ int layoutCount = 0;
+
+ /// Times the subtree actually relaid out.
+ ///
+ /// [layout] can be called without doing any work when the constraints are
+ /// unchanged, so this is the metric that tracks real cost.
+ int performLayoutCount = 0;
+
+ set naturalHeight(double value) {
+ if (_naturalHeight == value) return;
+ _naturalHeight = value;
+ markNeedsLayout();
+ }
+
+ @override
+ void layout(Constraints constraints, {bool parentUsesSize = false}) {
+ layoutCount++;
+ super.layout(constraints, parentUsesSize: parentUsesSize);
+ }
+
+ @override
+ Size computeDryLayout(BoxConstraints constraints) =>
+ constraints.constrain(Size(constraints.minWidth, _naturalHeight));
+
+ @override
+ void performLayout() {
+ performLayoutCount++;
+ size = computeDryLayout(constraints);
+ }
+}
+
+/// Changes its own height from below the grid, without any parent rebuild.
+class _Resizable extends StatefulWidget {
+ const _Resizable({super.key, required this.initialHeight});
+
+ final double initialHeight;
+
+ @override
+ State<_Resizable> createState() => _ResizableState();
+}
+
+class _ResizableState extends State<_Resizable> {
+ late double _height = widget.initialHeight;
+
+ void setHeight(double value) => setState(() => _height = value);
+
+ @override
+ Widget build(BuildContext context) => SizedBox(height: _height);
+}
+
+class _ThrowingLayoutBox extends LeafRenderObjectWidget {
+ const _ThrowingLayoutBox();
+
+ @override
+ RenderBox createRenderObject(BuildContext context) => _RenderThrowingLayout();
+}
+
+class _RenderThrowingLayout extends RenderBox {
+ @override
+ void performLayout() {
+ throw StateError('child-specific bug: index out of range');
+ }
+}
+
+class _NoDryLayoutBox extends LeafRenderObjectWidget {
+ const _NoDryLayoutBox();
+
+ @override
+ RenderBox createRenderObject(BuildContext context) => _RenderNoDryLayout();
+}
+
+class _RenderNoDryLayout extends RenderBox {
+ @override
+ void performLayout() {
+ size = constraints.constrain(const Size(10, 10));
}
}
diff --git a/packages/mix/test/src/layout/grid_layout_property_test.dart b/packages/mix/test/src/layout/grid_layout_property_test.dart
index 700e7460e..83705adf8 100644
--- a/packages/mix/test/src/layout/grid_layout_property_test.dart
+++ b/packages/mix/test/src/layout/grid_layout_property_test.dart
@@ -40,8 +40,7 @@ void main() {
final result = computeGridLayout(
constraints: constraints,
columns: columns,
- rows: rows,
- autoRows: autoRows,
+ rows: effectiveRows,
columnGap: columnGap,
rowGap: rowGap,
childCount: childCount,
@@ -111,6 +110,82 @@ void main() {
}
}
});
+
+ test('seeded auto rows size to the per-row child maximum', () {
+ const seed = 0x4155544F;
+ const iterations = 200;
+ final random = Random(seed);
+
+ for (var iteration = 0; iteration < iterations; iteration++) {
+ final columnCount = 1 + random.nextInt(4);
+ final childCount = 1 + random.nextInt(12);
+ final requiredRows = (childCount + columnCount - 1) ~/ columnCount;
+ final childHeights = List.generate(
+ childCount,
+ (_) => 8 + random.nextInt(80).toDouble(),
+ );
+ final expectedRows = List.filled(requiredRows, 0);
+ for (var index = 0; index < childCount; index++) {
+ final row = index ~/ columnCount;
+ expectedRows[row] = max(expectedRows[row], childHeights[index]);
+ }
+ final rowGap = random.nextInt(17).toDouble();
+ final width = 80.0 + random.nextInt(401);
+ final constraints = BoxConstraints(maxWidth: width);
+ final reason = 'seed=$seed iteration=$iteration';
+
+ final result = computeGridLayout(
+ constraints: constraints,
+ columns: List.filled(columnCount, const GridTrack.fr(1)),
+ rows: List.filled(requiredRows, const GridTrack.auto()),
+ columnGap: 0,
+ rowGap: rowGap,
+ childCount: childCount,
+ autoRowHeights: expectedRows,
+ );
+
+ expect(result.rowSizes, expectedRows, reason: reason);
+ expect(
+ result.contentSize.height,
+ closeTo(_extent(expectedRows, rowGap), 1e-8),
+ reason: reason,
+ );
+ expect(result.cells, hasLength(childCount), reason: reason);
+ for (var index = 0; index < result.cells.length; index++) {
+ final cell = result.cells[index];
+ expect(cell.row, index ~/ columnCount, reason: reason);
+ expect(
+ cell.offset.dy,
+ closeTo(_origin(result.rowSizes, rowGap, cell.row), 1e-8),
+ reason: reason,
+ );
+ expect(cell.size.height, expectedRows[cell.row], reason: reason);
+ }
+
+ final children = [
+ for (final height in childHeights)
+ RenderConstrainedBox(
+ additionalConstraints: BoxConstraints.tightFor(height: height),
+ ),
+ ];
+ final render = RenderMixGrid(
+ spec: GridBoxSpec(
+ columns: List.filled(columnCount, const GridTrack.fr(1)),
+ rowGap: rowGap,
+ ),
+ children: children,
+ );
+ final drySize = render.getDryLayout(constraints);
+ render.layout(constraints);
+ expect(render.size, drySize, reason: reason);
+ expect(render.size.height, result.contentSize.height, reason: reason);
+ render.removeAll();
+ render.dispose();
+ for (final child in children) {
+ child.dispose();
+ }
+ }
+ });
}
List _tracks(Random random, int count) {
diff --git a/packages/mix/test/src/layout/grid_public_api_test.dart b/packages/mix/test/src/layout/grid_public_api_test.dart
index 622d48ec8..e9315d0ab 100644
--- a/packages/mix/test/src/layout/grid_public_api_test.dart
+++ b/packages/mix/test/src/layout/grid_public_api_test.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart' show FlutterError;
import 'package:flutter/widgets.dart' show Clip;
import 'package:flutter_test/flutter_test.dart';
import 'package:mix/mix.dart';
@@ -43,6 +44,35 @@ void main() {
);
});
+ test('GridTrack.auto is a fieldless row track', () {
+ expect(const GridTrack.auto(), const AutoGridTrack());
+ expect(const GridTrack.auto(), const GridTrack.auto());
+ expect(const GridTrack.auto().toString(), 'GridTrack.auto()');
+ expect(
+ const GridBoxStyler().autoRows(.auto()).$autoRows,
+ const GridTrack.auto(),
+ );
+ expect(const GridBoxStyler().rows([.auto()]).$rows, const [
+ GridTrack.auto(),
+ ]);
+ expect(
+ () => GridBoxSpec(columns: const [GridTrack.auto()]),
+ throwsA(
+ isA()
+ .having(
+ (error) => error.toString(),
+ 'message',
+ contains('vertical-only'),
+ )
+ .having(
+ (error) => error.toString(),
+ 'message',
+ contains('rows or autoRows'),
+ ),
+ ),
+ );
+ });
+
test('equalColumns creates repeated one-fraction tracks', () {
final GridBoxStyler factory = .equalColumns(3);
final chained = GridBoxStyler().equalColumns(2);
diff --git a/packages/mix_chart_protocol/test/mix_chart_vocabulary_test.dart b/packages/mix_chart_protocol/test/mix_chart_vocabulary_test.dart
index d61d2e404..a02ddb43b 100644
--- a/packages/mix_chart_protocol/test/mix_chart_vocabulary_test.dart
+++ b/packages/mix_chart_protocol/test/mix_chart_vocabulary_test.dart
@@ -519,7 +519,7 @@ void main() {
expect(
_fnv1a64(utf8.encode(jsonEncode(schema))),
- 3081541222728341371,
+ -1622746188329387277,
reason: 'Changing this fingerprint changes the declared chart v1 schema.',
);
diff --git a/packages/mix_protocol/CHANGELOG.md b/packages/mix_protocol/CHANGELOG.md
index 622f9769b..1e32e10bc 100644
--- a/packages/mix_protocol/CHANGELOG.md
+++ b/packages/mix_protocol/CHANGELOG.md
@@ -2,6 +2,8 @@
### New features
+- Added additive v1 `{"type":"auto"}` Grid tracks for `rows`, `autoRows`, and
+ constraint patches. Auto tracks have no token references.
- Added immutable, deterministic package-contributed styler vocabularies,
namespaced versioned discriminators, explicit `MixProtocol.compose`, and an
Ack-free codec-authoring façade.
diff --git a/packages/mix_protocol/GUIDE.md b/packages/mix_protocol/GUIDE.md
index fd2cc55cb..cd3ecd113 100644
--- a/packages/mix_protocol/GUIDE.md
+++ b/packages/mix_protocol/GUIDE.md
@@ -172,7 +172,7 @@ local constraint branches carry a `Breakpoint` plus a geometry-only patch:
{ "type": "fixed", "size": 220 },
{ "type": "fr", "fraction": 2 }
],
- "autoRows": { "type": "fixed", "size": 96 },
+ "autoRows": { "type": "auto" },
"columnGap": 16,
"rowGap": 12,
"constraintBranches": [
diff --git a/packages/mix_protocol/WIRE_CONTRACT.md b/packages/mix_protocol/WIRE_CONTRACT.md
index 7073f2981..9de759912 100644
--- a/packages/mix_protocol/WIRE_CONTRACT.md
+++ b/packages/mix_protocol/WIRE_CONTRACT.md
@@ -305,10 +305,12 @@ A Grid track is one of:
```json
{ "type": "fixed", "size": 220 }
{ "type": "fr", "fraction": 2 }
+{ "type": "auto" }
```
-Fixed `size` is non-negative. Fractional `fraction` is greater than zero. Each
-numeric value also accepts the standard numeric token form. Space tokens are
+Fixed `size` is non-negative. Fractional `fraction` is greater than zero.
+`auto` is fieldless and valid on `rows` and `autoRows` only. Each numeric
+value also accepts the standard numeric token form. Space tokens are
recommended for fixed sizes and gaps; double tokens are recommended for
fractional weights:
diff --git a/packages/mix_protocol/lib/src/schema/grid_box_styler_codec.dart b/packages/mix_protocol/lib/src/schema/grid_box_styler_codec.dart
index 8ede3f7ab..31ebc2d6c 100644
--- a/packages/mix_protocol/lib/src/schema/grid_box_styler_codec.dart
+++ b/packages/mix_protocol/lib/src/schema/grid_box_styler_codec.dart
@@ -13,7 +13,7 @@ SchemaObject gridBoxStylerSchema({
}) {
final columns = directField>(
'columns',
- Ack.list(_gridTrackCodec()).nonEmpty(),
+ Ack.list(_gridTrackCodec(allowAuto: false)).nonEmpty(),
(value) => value.$columns,
schemaSemantics: const SchemaFieldSemantics(
doubleTokenPaths: [
@@ -24,7 +24,7 @@ SchemaObject gridBoxStylerSchema({
);
final rows = directField>(
'rows',
- Ack.list(_gridTrackCodec()),
+ Ack.list(_gridTrackCodec(allowAuto: true)),
(value) => value.$rows,
schemaSemantics: const SchemaFieldSemantics(
doubleTokenPaths: [
@@ -35,7 +35,7 @@ SchemaObject gridBoxStylerSchema({
);
final autoRows = directField(
'autoRows',
- _gridTrackCodec(),
+ _gridTrackCodec(allowAuto: true),
(value) => value.$autoRows,
schemaSemantics: const SchemaFieldSemantics(
doubleTokenPaths: [
@@ -106,7 +106,7 @@ SchemaObject gridBoxStylerSchema({
);
}
-AckSchema _gridTrackCodec() {
+AckSchema _gridTrackCodec({required bool allowAuto}) {
return Ack.discriminated(
discriminatorKey: 'type',
schemas: {
@@ -138,6 +138,20 @@ AckSchema _gridTrackCodec() {
return {'fraction': track.fraction};
},
),
+ if (allowAuto)
+ gridTrackTypeAuto: Ack.object({}).codec(
+ decode: (_) => const GridTrack.auto(),
+ encode: (track) {
+ if (track is! AutoGridTrack) {
+ throw UnsupportedEncodeValueError(
+ track,
+ 'Expected AutoGridTrack.',
+ );
+ }
+
+ return const {};
+ },
+ ),
},
);
}
@@ -185,9 +199,11 @@ AckSchema _gridBreakpointCodec() {
AckSchema _gridLayoutPatchCodec() {
return Ack.object({
- 'columns': Ack.list(_gridTrackCodec()).nonEmpty().optional(),
- 'rows': Ack.list(_gridTrackCodec()).optional(),
- 'autoRows': _gridTrackCodec().optional(),
+ 'columns': Ack.list(
+ _gridTrackCodec(allowAuto: false),
+ ).nonEmpty().optional(),
+ 'rows': Ack.list(_gridTrackCodec(allowAuto: true)).optional(),
+ 'autoRows': _gridTrackCodec(allowAuto: true).optional(),
'columnGap': nonNegativeDoubleTokenCodec().optional(),
'rowGap': nonNegativeDoubleTokenCodec().optional(),
})
diff --git a/packages/mix_protocol/lib/src/schema/wire_discriminators.dart b/packages/mix_protocol/lib/src/schema/wire_discriminators.dart
index ed2f122b0..fcab1e1ab 100644
--- a/packages/mix_protocol/lib/src/schema/wire_discriminators.dart
+++ b/packages/mix_protocol/lib/src/schema/wire_discriminators.dart
@@ -12,6 +12,7 @@ const schemaTypeGridBox = 'grid_box';
const gridTrackTypeFixed = 'fixed';
const gridTrackTypeFraction = 'fr';
+const gridTrackTypeAuto = 'auto';
const modifierTypeAlign = 'align';
const modifierTypeAspectRatio = 'aspect_ratio';
diff --git a/packages/mix_protocol/lib/src/tokens/token_reference_walker.dart b/packages/mix_protocol/lib/src/tokens/token_reference_walker.dart
index 72b71e0a3..d4900e606 100644
--- a/packages/mix_protocol/lib/src/tokens/token_reference_walker.dart
+++ b/packages/mix_protocol/lib/src/tokens/token_reference_walker.dart
@@ -316,6 +316,8 @@ final class _TokenReferenceWalker {
visit(size);
case FrGridTrack(:final fraction):
visit(fraction);
+ case AutoGridTrack():
+ return;
case null:
return;
}
diff --git a/packages/mix_protocol/test/grid_box_styler_codec_test.dart b/packages/mix_protocol/test/grid_box_styler_codec_test.dart
index 9dc19a1fb..9aa244eeb 100644
--- a/packages/mix_protocol/test/grid_box_styler_codec_test.dart
+++ b/packages/mix_protocol/test/grid_box_styler_codec_test.dart
@@ -74,6 +74,103 @@ void main() {
});
});
+ test('grid_box round-trips fieldless auto tracks without token refs', () {
+ final payload = {
+ 'v': 1,
+ 'type': 'grid_box',
+ 'columns': [
+ {'type': 'fr', 'fraction': 1.0},
+ {'type': 'fr', 'fraction': 1.0},
+ ],
+ 'rows': [
+ {'type': 'auto'},
+ ],
+ 'autoRows': {'type': 'auto'},
+ 'constraintBranches': [
+ {
+ 'breakpoint': {'maxWidth': 520.0},
+ 'patch': {
+ 'autoRows': {'type': 'auto'},
+ 'rows': [
+ {'type': 'auto'},
+ ],
+ },
+ },
+ ],
+ };
+
+ final decoded = decode(payload);
+
+ expect(encode(decoded), payload);
+ expect(decoded.$rows, const [GridTrack.auto()]);
+ expect(decoded.$autoRows, const GridTrack.auto());
+ expect(
+ decoded.$constraintBranches!.single.patch.autoRows,
+ const GridTrack.auto(),
+ );
+ expect(tokenReferencesOf(decoded), isEmpty);
+ });
+
+ test('lenient decode drops the smallest unrecognized grid track', () {
+ (GridBoxStyler, List) lenient(JsonMap payload) {
+ final result = contract.decodeStyle(
+ payload,
+ options: const MixProtocolDecodeOptions(
+ mode: MixProtocolDecodeMode.lenient,
+ ),
+ );
+
+ return switch (result) {
+ MixProtocolSuccess(:final value, :final warnings) => (
+ value,
+ [for (final warning in warnings) warning.path],
+ ),
+ MixProtocolFailure(:final errors) => fail('$errors'),
+ };
+ }
+
+ // A list entry loses only that entry; sibling tracks survive.
+ final (rowStyle, rowWarnings) = lenient({
+ 'v': 1,
+ 'type': 'grid_box',
+ 'columns': [
+ {'type': 'fr', 'fraction': 1.0},
+ ],
+ 'rows': [
+ {'type': 'fixed', 'size': 40.0},
+ {'type': 'minmax', 'min': 1.0, 'max': 2.0},
+ ],
+ });
+ expect(rowWarnings, ['/rows/1/type']);
+ expect(rowStyle.$rows, const [GridTrack.fixed(40)]);
+
+ // A scalar field loses the whole field, which resolves back to the
+ // implicit GridTrack.auto() default rather than a decode failure.
+ final (autoStyle, autoWarnings) = lenient({
+ 'v': 1,
+ 'type': 'grid_box',
+ 'columns': [
+ {'type': 'fr', 'fraction': 1.0},
+ ],
+ 'autoRows': {'type': 'minmax', 'min': 1.0, 'max': 2.0},
+ });
+ expect(autoWarnings, ['/autoRows/type']);
+ expect(autoStyle.$autoRows, isNull);
+
+ // `auto` is unrecognized under columns, so lenient mode drops that column
+ // instead of failing the way strict mode does.
+ final (columnStyle, columnWarnings) = lenient({
+ 'v': 1,
+ 'type': 'grid_box',
+ 'columns': [
+ {'type': 'fr', 'fraction': 1.0},
+ {'type': 'auto'},
+ ],
+ });
+ expect(columnWarnings, ['/columns/1/type']);
+ expect(columnStyle.$columns, const [GridTrack.fr(1)]);
+ });
+
test('runtime grid style round-trips without losing branch order', () {
final style = GridBoxStyler(
columns: const [GridTrack.fixed(200), GridTrack.fr(2)],
@@ -186,6 +283,9 @@ void main() {
'infinite fraction': [
{'type': 'fr', 'fraction': double.infinity},
],
+ 'auto column': [
+ {'type': 'auto'},
+ ],
};
for (final MapEntry(key: label, value: columns) in invalidFields.entries) {
diff --git a/packages/mix_protocol/test/schema_export_golden_test.dart b/packages/mix_protocol/test/schema_export_golden_test.dart
index 65c89298d..e10e0a6ec 100644
--- a/packages/mix_protocol/test/schema_export_golden_test.dart
+++ b/packages/mix_protocol/test/schema_export_golden_test.dart
@@ -8,7 +8,7 @@ void main() {
test('core schema export has a byte-for-byte v1 fingerprint', () {
final encoded = jsonEncode(mixProtocol.exportStyleJsonSchema());
- expect(_fnv1a64(utf8.encode(encoded)), -531352329917363246);
+ expect(_fnv1a64(utf8.encode(encoded)), -7566329063571446550);
});
test('schema export structurally describes every built-in branch', () {
@@ -264,6 +264,20 @@ void main() {
isTrue,
reason: 'grid_box tracks accept numeric tokens',
);
+ expect(
+ _matchesJsonSchema(_object(gridProperties['rows']), [
+ {'type': 'auto'},
+ ], definitions),
+ isTrue,
+ reason: 'grid_box rows accept fieldless auto tracks',
+ );
+ expect(
+ _matchesJsonSchema(_object(gridProperties['autoRows']), {
+ 'type': 'auto',
+ }, definitions),
+ isTrue,
+ reason: 'grid_box autoRows accepts fieldless auto tracks',
+ );
for (final field in ['columnGap', 'rowGap']) {
expect(
_matchesJsonSchema(_object(gridProperties[field]), {
@@ -290,6 +304,21 @@ void main() {
isTrue,
reason: 'grid_box constraint patches accept numeric tokens',
);
+ expect(
+ _matchesJsonSchema(_object(gridProperties['constraintBranches']), [
+ {
+ 'breakpoint': {'maxWidth': 600},
+ 'patch': {
+ 'autoRows': {'type': 'auto'},
+ 'rows': [
+ {'type': 'auto'},
+ ],
+ },
+ },
+ ], definitions),
+ isTrue,
+ reason: 'grid_box constraint patches accept fieldless auto tracks',
+ );
expect(
_matchesJsonSchema(
_object(_properties(branchesByType['flex']!)['spacing']),
diff --git a/packages/mix_protocol/test/styler_round_trip_test.dart b/packages/mix_protocol/test/styler_round_trip_test.dart
index 632715356..176a8b84b 100644
--- a/packages/mix_protocol/test/styler_round_trip_test.dart
+++ b/packages/mix_protocol/test/styler_round_trip_test.dart
@@ -201,7 +201,7 @@ void main() {
expectRoundTrips(
GridBoxStyler(
columns: const [GridTrack.fixed(220), GridTrack.fr(2)],
- autoRows: const GridTrack.fixed(96),
+ autoRows: const GridTrack.auto(),
columnGap: 16,
rowGap: 12,
clipBehavior: Clip.hardEdge,
diff --git a/skills/mix/SKILL.md b/skills/mix/SKILL.md
index 17f90c357..b7238b352 100644
--- a/skills/mix/SKILL.md
+++ b/skills/mix/SKILL.md
@@ -62,7 +62,7 @@ Resolution pipeline: `StyleWidget` → `StyleBuilder` → merge active variants
| `FlexBoxStyler` | `FlexBoxSpec` | `FlexBox`/`RowBox`/`ColumnBox` | `Column`/`Row` + `Container` |
| `WrapStyler` | `WrapSpec` | — (layout) | `Wrap` |
| `WrapBoxStyler` | `WrapBoxSpec` | `WrapBox` | `Wrap` + `Container` |
-| `GridBoxStyler` | `GridBoxSpec` | `GridBox` | Fixed/`fr` track grid |
+| `GridBoxStyler` | `GridBoxSpec` | `GridBox` | Fixed/`fr` columns; fixed/`fr`/auto rows |
| `StackStyler` | `StackSpec` | — (layout) | `Stack` |
| `StackBoxStyler` | `StackBoxSpec` | `StackBox` | `Stack` + `Container` |
| `IconStyler` | `IconSpec` | `StyledIcon` | `Icon` |
diff --git a/skills/mix/references/fluent-api.md b/skills/mix/references/fluent-api.md
index 0c1a7b1ac..4f3ccf7e8 100644
--- a/skills/mix/references/fluent-api.md
+++ b/skills/mix/references/fluent-api.md
@@ -153,7 +153,7 @@ selection. Prefer an explicitly typed factory initializer:
```dart
final GridBoxStyler grid = .equalColumns(3)
.gap(16)
- .autoRows(.fixed(220));
+ .autoRows(.auto());
```
Use `columns`, `equalColumns`, `rows`, `autoRows`, `gap`, `columnGap`,
diff --git a/skills/mix/references/layout.md b/skills/mix/references/layout.md
index 4348390bd..65090b83b 100644
--- a/skills/mix/references/layout.md
+++ b/skills/mix/references/layout.md
@@ -29,7 +29,7 @@ installed version exposes the referenced classes.
| One child with size, padding, constraints, or decoration | `Box` + `BoxStyler` | Owns single-child box styling |
| One non-wrapping horizontal or vertical sequence | `RowBox`, `ColumnBox`, or `FlexBox` + `FlexBoxStyler` | Combines Flex geometry with outer Box styling |
| Intrinsic items that should flow onto additional runs | `WrapBox` + `WrapBoxStyler` | Models tags, chips, and button groups without fixed tracks |
-| Explicit two-dimensional rows and columns | `GridBox` + `GridBoxStyler` | Models dashboards, card catalogs, and galleries with fixed/`fr` tracks |
+| Explicit two-dimensional rows and columns | `GridBox` + `GridBoxStyler` | Models dashboards, card catalogs, and galleries with fixed/`fr` columns and fixed, `fr`, or auto rows |
| Overlapping or positioned children | `StackBox` + `StackBoxStyler` | Combines Stack geometry with outer Box styling |
Prefer the simplest primitive that represents the layout semantics. Do not use
@@ -123,7 +123,7 @@ shorthand to make its topology visible:
```dart
final GridBoxStyler cardGridStyle = .equalColumns(3)
.gap(16)
- .autoRows(.fixed(220));
+ .autoRows(.auto());
GridBox(style: cardGridStyle, children: cards);
```
@@ -141,9 +141,15 @@ final GridBoxStyler reportGridStyle = .columns([
Interpret tracks as follows:
-- `.fixed(240)` consumes 240 logical pixels and does not shrink.
-- `.fr(2)` receives twice the remaining space of `.fr(1)` after fixed tracks
- and gaps.
+- `.fixed(240)` consumes 240 logical pixels and does not shrink. On a row it
+ is a hard height: children stretch or clip to that cell.
+- `.fr(2)` receives twice the remaining space of `.fr(1)` after fixed tracks,
+ auto tracks, and gaps.
+- `.auto()` is vertical-only and sizes the row to its tallest child's natural
+ height at the resolved column width. Auto-row children are measured at that
+ width; only a child shorter than the resolved row is laid out again to
+ stretch it. That extra pass is only for children in auto rows, and nesting
+ auto Grids does not compound it.
- Fractional columns require bounded width.
- Fractional rows and fractional `autoRows` require bounded height.
@@ -151,20 +157,24 @@ Interpret tracks as follows:
Children fill columns left to right, then advance row by row. Provide enough
explicit `.rows([...])` tracks for every required row or set `.autoRows(...)`
-for the remaining rows. A non-empty Grid with no applicable row track reports
-an error instead of guessing content-sized geometry.
+for the remaining rows. Omitted `autoRows` defaults to `.auto()`, so a
+non-empty Grid with columns and no row declaration sizes implicit rows to
+content.
```dart
final GridBoxStyler galleryStyle = .equalColumns(2)
.rows([.fixed(180)])
- .autoRows(.fixed(180))
+ .autoRows(.auto())
.columnGap(12)
.rowGap(12);
```
With five children and two columns, this Grid needs three rows. The first uses
the explicit track and the next two repeat `autoRows`. In a vertical
-`SingleChildScrollView`, use fixed row tracks because height is unbounded.
+`SingleChildScrollView`, omit `autoRows` or use `.autoRows(.auto())`. Keep
+`.fixed(...)` only when clipping or constraining height is intentional. Auto
+rows require finite-height children; do not put `Expanded` or `Spacer` in an
+auto row inside a scroll view.
### Respond to the offered container
@@ -174,7 +184,6 @@ by its own parent:
```dart
final GridBoxStyler responsiveCards = .equalColumns(3)
.gap(16)
- .autoRows(.fixed(220))
.onConstraints(
.maxWidth(760),
.equalColumns(2).gap(12),
@@ -203,13 +212,13 @@ size through `MediaQuery`. Two Grids in the same viewport can select different
Use responsive `GridBox` for:
- metric dashboards whose panels collapse from four to two to one column;
-- product catalogs with equal card widths and repeated fixed row heights;
+- product catalogs with equal card widths and content-sized or fixed rows;
- media galleries with aligned tracks and controlled clipping;
- asymmetric report layouts using fixed sidebar tracks plus fractional content;
- nested components that should respond to their container rather than the
whole device viewport.
-Avoid the current Grid API when the design requires content-sized tracks,
+Avoid the current Grid API when the design requires content-sized columns,
spans, named areas, masonry packing, direction-aware placement, or baseline
alignment. Those features are intentionally outside the current contract. Use
a more suitable Flutter layout or redesign the track model rather than
@@ -230,9 +239,10 @@ final GridBoxStyler animatedGrid = .columns([
```
Keep track lists the same length and keep each positional track kind compatible
-(`fixed` with `fixed`, `fr` with `fr`) for continuous interpolation. Compatible
-rows, `autoRows`, and gaps interpolate too. Track-count or track-kind changes,
-clipping, and constraint-patch lists switch at the midpoint. A live
+(`fixed` with `fixed`, `fr` with `fr`, `auto` with `auto`) for continuous
+interpolation. Compatible rows, `autoRows`, and gaps interpolate too.
+Track-count or track-kind changes, clipping, and constraint-patch lists
+switch at the midpoint. A live
`onConstraints` branch change remains immediate because selection happens
during layout rather than creating a new animation target.
diff --git a/skills/mix/references/styler-api-policy.md b/skills/mix/references/styler-api-policy.md
index faf12ec50..ea7d3751e 100644
--- a/skills/mix/references/styler-api-policy.md
+++ b/skills/mix/references/styler-api-policy.md
@@ -41,7 +41,7 @@ case in the current API:
// CORRECT — the declared type supplies shorthand context
final GridBoxStyler cards = .equalColumns(3)
.gap(16)
- .autoRows(.fixed(220));
+ .autoRows(.auto());
// WRONG — no contextual type for the leading shorthand
final cards = .equalColumns(3);