diff --git a/examples/rendering/render_grid.dart b/examples/rendering/render_grid.dart index 86292ed8900..04aed4d6c7e 100644 --- a/examples/rendering/render_grid.dart +++ b/examples/rendering/render_grid.dart @@ -20,7 +20,10 @@ Color randomColor() { RenderBox buildGridExample() { List children = new List.generate(30, (_) => new RenderSolidColorBox(randomColor())); - return new RenderGrid(children: children, maxChildExtent: 100.0); + return new RenderGrid( + children: children, + delegate: new MaxTileWidthGridDelegate(maxTileWidth: 100.0) + ); } main() => new RenderingFlutterBinding(root: buildGridExample()); diff --git a/examples/widgets/media_query.dart b/examples/widgets/media_query.dart index a54fd859285..dfde1ba157d 100644 --- a/examples/widgets/media_query.dart +++ b/examples/widgets/media_query.dart @@ -62,7 +62,7 @@ class AdaptiveItem { } class MediaQueryExample extends StatelessComponent { - static const double _maxChildExtent = 150.0; + static const double _maxTileWidth = 150.0; static const double _gridViewBreakpoint = 450.0; Widget _buildBody(BuildContext context) { @@ -78,9 +78,9 @@ class MediaQueryExample extends StatelessComponent { } else { return new Block( [ - new Grid( + new MaxTileWidthGrid( items.map((AdaptiveItem item) => item.toCard()).toList(), - maxChildExtent: _maxChildExtent + maxTileWidth: _maxTileWidth ) ] ); diff --git a/packages/flutter/lib/src/painting/edge_dims.dart b/packages/flutter/lib/src/painting/edge_dims.dart index cbda59da8ff..5a0990f9172 100644 --- a/packages/flutter/lib/src/painting/edge_dims.dart +++ b/packages/flutter/lib/src/painting/edge_dims.dart @@ -43,8 +43,17 @@ class EdgeDims { /// Whether every dimension is non-negative. bool get isNonNegative => top >= 0.0 && right >= 0.0 && bottom >= 0.0 && left >= 0.0; - /// The size that this edge dims would occupy with an empty interior. - ui.Size get collapsedSize => new ui.Size(left + right, top + bottom); + /// The total offset in the vertical direction. + double get horizontal => left + right; + + /// The total offset in the horizontal direction. + double get vertical => top + bottom; + + /// The size that this EdgeDims would occupy with an empty interior. + ui.Size get collapsedSize => new ui.Size(horizontal, vertical); + + /// An EdgeDims with top and bottom as well as left and right flipped. + EdgeDims get flipped => new EdgeDims.TRBL(bottom, left, top, right); ui.Rect inflateRect(ui.Rect rect) { return new ui.Rect.fromLTRB(rect.left - left, rect.top - top, rect.right + right, rect.bottom + bottom); diff --git a/packages/flutter/lib/src/rendering/box.dart b/packages/flutter/lib/src/rendering/box.dart index a38940ea71a..5443b3d45bd 100644 --- a/packages/flutter/lib/src/rendering/box.dart +++ b/packages/flutter/lib/src/rendering/box.dart @@ -374,7 +374,7 @@ abstract class RenderBox extends RenderObject { return constraints.constrainWidth(0.0); } - /// Return the minimum height that this box could be without failing to render + /// Return the minimum height that this box could be without failing to paint /// its contents within itself. /// /// Override in subclasses that implement [performLayout]. diff --git a/packages/flutter/lib/src/rendering/grid.dart b/packages/flutter/lib/src/rendering/grid.dart index e1e4d1382dd..84a446ecf77 100644 --- a/packages/flutter/lib/src/rendering/grid.dart +++ b/packages/flutter/lib/src/rendering/grid.dart @@ -5,67 +5,325 @@ import 'box.dart'; import 'object.dart'; -class _GridMetrics { - // Grid is width-in, height-out. We fill the max width and adjust height - // accordingly. - factory _GridMetrics({ double width, int childCount, double maxChildExtent }) { - assert(width != null); - assert(childCount != null); - assert(maxChildExtent != null); - double childExtent = maxChildExtent; - int childrenPerRow = (width / childExtent).floor(); - // If the child extent divides evenly into the width use that, otherwise + 1 - if (width / childExtent != childrenPerRow.toDouble()) childrenPerRow += 1; - double totalPadding = 0.0; - if (childrenPerRow * childExtent > width) { - // TODO(eseidel): We should snap to pixel bounderies. - childExtent = width / childrenPerRow; - } else { - totalPadding = width - (childrenPerRow * childExtent); +bool _debugIsMonotonic(List offsets) { + bool result = true; + assert(() { + double current = 0.0; + for (double offset in offsets) { + if (current > offset) { + result = false; + break; + } + current = offset; } - double childPadding = totalPadding / (childrenPerRow + 1.0); - int rowCount = (childCount / childrenPerRow).ceil(); + return true; + }); + return result; +} - double height = childPadding * (rowCount + 1) + (childExtent * rowCount); - Size childSize = new Size(childExtent, childExtent); - Size size = new Size(width, height); - return new _GridMetrics._(size, childSize, childrenPerRow, childPadding, rowCount); +List _generateRegularOffsets(int count, double size) { + int length = count + 1; + List result = new List(length); + for (int i = 0; i < length; ++i) + result[i] = i * size; + return result; +} + +class GridSpecification { + /// Creates a grid specification from an explicit list of offsets. + GridSpecification.fromOffsets({ + this.columnOffsets, + this.rowOffsets, + this.padding: EdgeDims.zero + }) { + assert(_debugIsMonotonic(columnOffsets)); + assert(_debugIsMonotonic(rowOffsets)); + assert(padding != null); } - const _GridMetrics._(this.size, this.childSize, this.childrenPerRow, this.childPadding, this.rowCount); + /// Creates a grid specification containing a certain number of equally sized tiles. + GridSpecification.fromRegularTiles({ + double tileWidth, + double tileHeight, + int columnCount, + int rowCount, + this.padding: EdgeDims.zero + }) : columnOffsets = _generateRegularOffsets(columnCount, tileWidth), + rowOffsets = _generateRegularOffsets(rowCount, tileHeight) { + assert(_debugIsMonotonic(columnOffsets)); + assert(_debugIsMonotonic(rowOffsets)); + assert(padding != null); + } - final Size size; - final Size childSize; - final int childrenPerRow; // aka columnCount - final double childPadding; - final int rowCount; + /// The offsets of the column boundaries in the grid. + /// + /// The first offset is the offset of the left edge of the left-most column + /// from the left edge of the interior of the grid's padding (usually 0.0). + /// The last offset is the offset of the right edge of the right-most column + /// from the left edge of the interior of the grid's padding. + /// + /// If there are n columns in the grid, there should be n + 1 entries in this + /// list (because there's an entry before the first column and after the last + /// column). + final List columnOffsets; + + /// The offsets of the row boundaries in the grid. + /// + /// The first offset is the offset of the top edge of the top-most row from + /// the top edge of the interior of the grid's padding (usually 0.0). The + /// last offset is the offset of the bottom edge of the bottom-most column + /// from the top edge of the interior of the grid's padding. + /// + /// If there are n rows in the grid, there should be n + 1 entries in this + /// list (because there's an entry before the first row and after the last + /// row). + final List rowOffsets; + + /// The interior padding of the grid. + /// + /// The grid's size encloses the rows and columns and is then inflated by the + /// padding. + final EdgeDims padding; + + /// The size of the grid. + Size get gridSize => new Size(columnOffsets.last + padding.horizontal, rowOffsets.last + padding.vertical); +} + +/// Where to place a child within a grid. +class GridChildPlacement { + GridChildPlacement({ + this.column, + this.row, + this.columnSpan: 1, + this.rowSpan: 1, + this.padding: EdgeDims.zero + }) { + assert(column != null); + assert(row != null); + assert(columnSpan != null); + assert(rowSpan != null); + assert(padding != null); + } + + /// The column in which to place the child. + final int column; + + /// The row in which to place the child. + final int row; + + /// How many columns the child should span. + final int columnSpan; + + /// How many rows the child should span. + final int rowSpan; + + /// How much the child should be inset from the column and row boundaries. + final EdgeDims padding; +} + +/// An abstract interface to control the layout of a [RenderGrid]. +abstract class GridDelegate { + /// Override this function to control size of the columns and rows. + GridSpecification getGridSpecification(BoxConstraints constraints, int childCount); + + /// Override this function to control where children are placed in the grid. + GridChildPlacement getChildPlacement(GridSpecification specification, int index, Object placementData); + + /// Override this method to return true when the children need to be laid out. + bool shouldRelayout(GridDelegate oldDelegate) => true; + + Size _getGridSize(BoxConstraints constraints, int childCount) { + return getGridSpecification(constraints, childCount).gridSize; + } + + /// Returns the minimum width that this grid could be without failing to paint + /// its contents within itself. + double getMinIntrinsicWidth(BoxConstraints constraints, int childCount) { + return constraints.constrainWidth(_getGridSize(constraints, childCount).width); + } + + /// Returns the smallest width beyond which increasing the width never + /// decreases the height. + double getMaxIntrinsicWidth(BoxConstraints constraints, int childCount) { + return constraints.constrainWidth(_getGridSize(constraints, childCount).width); + } + + /// Return the minimum height that this grid could be without failing to paint + /// its contents within itself. + double getMinIntrinsicHeight(BoxConstraints constraints, int childCount) { + return constraints.constrainHeight(_getGridSize(constraints, childCount).height); + } + + /// Returns the smallest height beyond which increasing the height never + /// decreases the width. + double getMaxIntrinsicHeight(BoxConstraints constraints, int childCount) { + return constraints.constrainHeight(_getGridSize(constraints, childCount).height); + } +} + +/// A [GridDelegate] the places its children in order throughout the grid. +abstract class GridDelegateWithInOrderChildPlacement extends GridDelegate { + GridDelegateWithInOrderChildPlacement({ this.padding: EdgeDims.zero }); + + /// The amount of padding to apply to each child. + final EdgeDims padding; + + GridChildPlacement getChildPlacement(GridSpecification specification, int index, Object placementData) { + int columnCount = specification.columnOffsets.length - 1; + return new GridChildPlacement( + column: index % columnCount, + row: index ~/ columnCount, + padding: padding + ); + } + + bool shouldRelayout(GridDelegateWithInOrderChildPlacement oldDelegate) { + return padding != oldDelegate.padding; + } +} + +/// A [GridDelegate] that divides the grid's width evenly amount a fixed number of columns. +class FixedColumnCountGridDelegate extends GridDelegateWithInOrderChildPlacement { + FixedColumnCountGridDelegate({ + this.columnCount, + this.tileAspectRatio: 1.0, + EdgeDims padding: EdgeDims.zero + }) : super(padding: padding); + + /// The number of columns in the grid. + final int columnCount; + + /// The ratio of the width to the height of each tile in the grid. + final double tileAspectRatio; + + GridSpecification getGridSpecification(BoxConstraints constraints, int childCount) { + assert(constraints.maxWidth < double.INFINITY); + int rowCount = (childCount / columnCount).ceil(); + double tileWidth = constraints.maxWidth / columnCount; + double tileHeight = tileWidth / tileAspectRatio; + return new GridSpecification.fromRegularTiles( + tileWidth: tileWidth, + tileHeight: tileHeight, + columnCount: columnCount, + rowCount: rowCount, + padding: padding.flipped + ); + } + + bool shouldRelayout(FixedColumnCountGridDelegate oldDelegate) { + return columnCount != oldDelegate.columnCount + || tileAspectRatio != oldDelegate.tileAspectRatio + || super.shouldRelayout(oldDelegate); + } + + double getMinIntrinsicWidth(BoxConstraints constraints, int childCount) { + return constraints.constrainWidth(0.0); + } + + double getMaxIntrinsicWidth(BoxConstraints constraints, int childCount) { + return constraints.constrainWidth(0.0); + } +} + +/// A [GridDelegate] that fills the width with a variable number of tiles. +/// +/// This delegate will select a tile width that is as large as possible subject +/// to the following conditions: +/// +/// - The tile width evenly divides the width of the grid. +/// - The tile width is at most [maxTileWidth]. +/// +class MaxTileWidthGridDelegate extends GridDelegateWithInOrderChildPlacement { + MaxTileWidthGridDelegate({ + this.maxTileWidth, + this.tileAspectRatio: 1.0, + EdgeDims padding: EdgeDims.zero + }) : super(padding: padding); + + /// The maximum width of a tile in the grid. + final double maxTileWidth; + + /// The ratio of the width to the height of each tile in the grid. + final double tileAspectRatio; + + GridSpecification getGridSpecification(BoxConstraints constraints, int childCount) { + assert(constraints.maxWidth < double.INFINITY); + double gridWidth = constraints.maxWidth; + int columnCount = (gridWidth / maxTileWidth).ceil(); + int rowCount = (childCount / columnCount).ceil(); + double tileWidth = gridWidth / columnCount; + double tileHeight = tileWidth / tileAspectRatio; + return new GridSpecification.fromRegularTiles( + tileWidth: tileWidth, + tileHeight: tileHeight, + columnCount: columnCount, + rowCount: rowCount, + padding: padding.flipped + ); + } + + bool shouldRelayout(MaxTileWidthGridDelegate oldDelegate) { + return maxTileWidth != oldDelegate.maxTileWidth + || tileAspectRatio != oldDelegate.tileAspectRatio + || super.shouldRelayout(oldDelegate); + } + + double getMinIntrinsicWidth(BoxConstraints constraints, int childCount) { + return constraints.constrainWidth(0.0); + } + + double getMaxIntrinsicWidth(BoxConstraints constraints, int childCount) { + return constraints.constrainWidth(maxTileWidth * childCount); + } } /// Parent data for use with [RenderGrid] -class GridParentData extends ContainerBoxParentDataMixin {} +class GridParentData extends ContainerBoxParentDataMixin { + /// Opaque data passed to the getChildPlacement method of the grid's [GridDelegate]. + Object placementData; + + void merge(GridParentData other) { + if (other.placementData != null) + placementData = other.placementData; + super.merge(other); + } + + String toString() => '${super.toString()}; placementData=$placementData'; +} /// Implements the grid layout algorithm /// -/// In grid layout, children are arranged into rows and collumns in on a two -/// dimensional grid. The grid determines how many children will be placed in -/// each row by making the children as wide as possible while still respecting -/// the given [maxChildExtent]. +/// In grid layout, children are arranged into rows and columns in on a two +/// dimensional grid. The [GridDelegate] determines how to arrange the +/// children on the grid. +/// +/// The arrangment of rows and columns in the grid cannot depend on the contents +/// of the tiles in the grid, which makes grid layout most useful for images and +/// card-like layouts rather than for document-like layouts that adjust to the +/// amount of text contained in the tiles. +/// +/// Additionally, grid layout materializes all of its children, which makes it +/// most useful for grids containing a moderate number of tiles. class RenderGrid extends RenderBox with ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { - RenderGrid({ List children, double maxChildExtent }) { + RenderGrid({ + List children, + GridDelegate delegate + }) : _delegate = delegate { + assert(delegate != null); addAll(children); - _maxChildExtent = maxChildExtent; } - double _maxChildExtent; - bool _hasVisualOverflow = false; - - double get maxChildExtent => _maxChildExtent; - void set maxChildExtent (double value) { - if (_maxChildExtent != value) { - _maxChildExtent = value; + /// The delegate that controls the layout of the children. + GridDelegate get delegate => _delegate; + GridDelegate _delegate; + void set delegate (GridDelegate newDelegate) { + assert(newDelegate != null); + if (_delegate == newDelegate) + return; + if (newDelegate.runtimeType != _delegate.runtimeType || newDelegate.shouldRelayout(_delegate)) markNeedsLayout(); - } + _delegate = newDelegate; } void setupParentData(RenderBox child) { @@ -75,63 +333,72 @@ class RenderGrid extends RenderBox with ContainerRenderObjectMixin size.width || gridSize.height > size.height) _hasVisualOverflow = true; - int row = 0; - int column = 0; + double gridTopPadding = _specification.padding.top; + double gridLeftPadding = _specification.padding.left; + int index = 0; RenderBox child = firstChild; while (child != null) { - child.layout(new BoxConstraints.tight(metrics.childSize)); - - double x = (column + 1) * metrics.childPadding + (column * metrics.childSize.width); - double y = (row + 1) * metrics.childPadding + (row * metrics.childSize.height); final GridParentData childParentData = child.parentData; - childParentData.offset = new Offset(x, y); - column += 1; - if (column >= metrics.childrenPerRow) { - row += 1; - column = 0; - } + GridChildPlacement placement = delegate.getChildPlacement(_specification, index, childParentData.placementData); + assert(placement.column >= 0); + assert(placement.row >= 0); + assert(placement.column + placement.columnSpan < _specification.columnOffsets.length); + assert(placement.row + placement.rowSpan < _specification.rowOffsets.length); + + double tileLeft = _specification.columnOffsets[placement.column] + gridLeftPadding; + double tileRight = _specification.columnOffsets[placement.column + placement.columnSpan] + gridLeftPadding; + double tileTop = _specification.rowOffsets[placement.row] + gridTopPadding; + double tileBottom = _specification.rowOffsets[placement.row + placement.rowSpan] + gridTopPadding; + + double childWidth = tileRight - tileLeft - placement.padding.horizontal; + double childHeight = tileBottom - tileTop - placement.padding.vertical; + + child.layout(new BoxConstraints( + minWidth: childWidth, + maxWidth: childWidth, + minHeight: childHeight, + maxHeight: childHeight + )); + + childParentData.offset = new Offset( + tileLeft + placement.padding.left, + tileTop + placement.padding.top + ); + + ++index; assert(child.parentData == childParentData); child = childParentData.nextSibling; diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 7f2dd605c5c..dd99940181d 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -33,6 +33,7 @@ export 'package:flutter/rendering.dart' show FontWeight, FractionalOffset, Gradient, + GridDelegate, HitTestBehavior, ImageFit, ImageRepeat, @@ -1125,21 +1126,125 @@ class Positioned extends ParentDataWidget { } } +abstract class GridRenderObjectWidgetBase extends MultiChildRenderObjectWidget { + GridRenderObjectWidgetBase({ + List children, + Key key + }) : super(key: key, children: children) { + _delegate = createDelegate(); + } + + GridDelegate _delegate; + + /// The delegate that controls the layout of the children. + GridDelegate createDelegate(); + + RenderGrid createRenderObject() => new RenderGrid(delegate: _delegate); + + void updateRenderObject(RenderGrid renderObject, GridRenderObjectWidgetBase oldWidget) { + renderObject.delegate = _delegate; + } +} + /// Uses the grid layout algorithm for its children. /// /// For details about the grid layout algorithm, see [RenderGrid]. -class Grid extends MultiChildRenderObjectWidget { - Grid(List children, { Key key, this.maxChildExtent }) +class CustomGrid extends GridRenderObjectWidgetBase { + CustomGrid(List children, { Key key, this.delegate }) : super(key: key, children: children) { - assert(maxChildExtent != null); + assert(delegate != null); } - final double maxChildExtent; + /// The delegate that controls the layout of the children. + final GridDelegate delegate; - RenderGrid createRenderObject() => new RenderGrid(maxChildExtent: maxChildExtent); + GridDelegate createDelegate() => delegate; +} - void updateRenderObject(RenderGrid renderObject, Grid oldWidget) { - renderObject.maxChildExtent = maxChildExtent; +/// Uses a grid layout with a fixed column count. +/// +/// For details about the grid layout algorithm, see [MaxTileWidthGridDelegate]. +class FixedColumnCountGrid extends GridRenderObjectWidgetBase { + FixedColumnCountGrid(List children, { + Key key, + this.columnCount, + this.tileAspectRatio: 1.0, + this.padding: EdgeDims.zero + }) : super(key: key, children: children) { + assert(columnCount != null); + } + + /// The number of columns in the grid. + final int columnCount; + + /// The ratio of the width to the height of each tile in the grid. + final double tileAspectRatio; + + /// The amount of padding to apply to each child. + final EdgeDims padding; + + FixedColumnCountGridDelegate createDelegate() { + return new FixedColumnCountGridDelegate( + columnCount: columnCount, + tileAspectRatio: tileAspectRatio, + padding: padding + ); + } +} + +/// Uses a grid layout with a max tile width. +/// +/// For details about the grid layout algorithm, see [MaxTileWidthGridDelegate]. +class MaxTileWidthGrid extends GridRenderObjectWidgetBase { + MaxTileWidthGrid(List children, { + Key key, + this.maxTileWidth, + this.tileAspectRatio: 1.0, + this.padding: EdgeDims.zero + }) : super(key: key, children: children) { + assert(maxTileWidth != null); + } + + /// The maximum width of a tile in the grid. + final double maxTileWidth; + + /// The ratio of the width to the height of each tile in the grid. + final double tileAspectRatio; + + /// The amount of padding to apply to each child. + final EdgeDims padding; + + MaxTileWidthGridDelegate createDelegate() { + return new MaxTileWidthGridDelegate( + maxTileWidth: maxTileWidth, + tileAspectRatio: tileAspectRatio, + padding: padding + ); + } +} + +/// Supplies per-child data to the grid's [GridDelegate]. +class GridPlacementData extends ParentDataWidget { + GridPlacementData({ Key key, this.placementData, Widget child }) + : super(key: key, child: child); + + /// Opaque data passed to the getChildPlacement method of the grid's [GridDelegate]. + final DataType placementData; + + void applyParentData(RenderObject renderObject) { + assert(renderObject.parentData is GridParentData); + final GridParentData parentData = renderObject.parentData; + if (parentData.placementData != placementData) { + parentData.placementData = placementData; + AbstractNode targetParent = renderObject.parent; + if (targetParent is RenderObject) + targetParent.markNeedsLayout(); + } + } + + void debugFillDescription(List description) { + super.debugFillDescription(description); + description.add('placementData: $placementData'); } } diff --git a/packages/flutter/test/rendering/grid_test.dart b/packages/flutter/test/rendering/grid_test.dart index 45bd4856754..f2d885f1ca5 100644 --- a/packages/flutter/test/rendering/grid_test.dart +++ b/packages/flutter/test/rendering/grid_test.dart @@ -16,7 +16,10 @@ void main() { new RenderDecoratedBox(decoration: new BoxDecoration()) ]; - RenderGrid grid = new RenderGrid(children: children, maxChildExtent: 100.0); + RenderGrid grid = new RenderGrid( + children: children, + delegate: new MaxTileWidthGridDelegate(maxTileWidth: 100.0) + ); layout(grid, constraints: const BoxConstraints(maxWidth: 200.0)); children.forEach((RenderBox child) { @@ -28,7 +31,7 @@ void main() { expect(grid.size.height, equals(200.0), reason: "grid height"); expect(grid.needsLayout, equals(false)); - grid.maxChildExtent = 60.0; + grid.delegate = new MaxTileWidthGridDelegate(maxTileWidth: 60.0); expect(grid.needsLayout, equals(true)); pumpFrame();