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. Compact GridBox dashboard

-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:

Wide GridBox card catalog 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);