blob: 958a68db5a85b659e91d5cf24953d11d7548f94c [file] [log] [blame]
// Copyright (c) 2011, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
part of layout;
/// Implements a grid-based layout system based on:
/// [http://dev.w3.org/csswg/css3-grid-align/]
///
/// This layout is designed to support animations and work on browsers that
/// don't support grid natively. As such, we implement it on top of absolute
/// positioning.
// TODO(jmesserly): the DOM integration still needs work:
// - The grid assumes it is absolutely positioned in its container.
// Because of that, the grid doesn't work right unless it has at least one
// fractional size in each dimension. In other words, only "top down" grids
// work at the moment, because the grid can't determine its own size.
// The core algorithm supports computing min breadth; the issue is about how
// to integrate it into our View layer.
// - Unless a child element is "display: inline-block" we can't get its
// horizontal content size.
// - Once we set an element's size to "position: absolute", we lose the
// ability to get its original content size. If the width or height gets
// set to something other than the content size, we can't recover the
// original content size.
// - There's some rounding to ints when we want to set the positions of our
// tracks. I don't think we necessarily need to do that.
//
// TODO(jmesserly): Some features of the spec are unimplemented:
// - grid-flow & items that have row and column set to 'auto'.
// - Grid writing modes (right to left languages, etc)
// - We don't do a second calculation pass if min content size of a grid-item
// changes due to column width.
// - The CSS parsing is not 100% complete, see the parser TODOs.
// - We don't implement error recovery for invalid combinations of CSS
// properties, or invalid CSS property values. Instead we throw an error.
//
// TODO(jmesserly): high level performance optimizations we could do:
// - Optimize for the common case of spanCount = 1
// - Optimize for the vbox/hbox case (1 row or 1 column)
// - Optimize for the case of no content sized tracks
// - Optimize for the "incremental update" cases
class GridLayout extends ViewLayout {
/// Configuration parameters defined in CSS. */
final GridTrackList? rows;
final GridTrackList? columns;
final GridTemplate? template;
/// The default sizing for new rows. */
final TrackSizing rowSizing;
/// The default sizing for new columns. */
final TrackSizing columnSizing;
/// This stores the grid's size during a layout.
/// Used for rows/columns with % or fr units.
int? _gridWidth, _gridHeight;
/// During a layout, this stores all row/column size information.
/// Because grid-items can implicitly specify their own rows/columns, we can't
/// compute this until we know the set of items.
late List<GridTrack> _rowTracks, _columnTracks;
/// During a layout, tracks which dimension we're processing. */
Dimension? _dimension;
GridLayout(Positionable view)
: rows = _GridTrackParser.parse(view.customStyle['grid-rows']),
columns = _GridTrackParser.parse(view.customStyle['grid-columns']),
template = _GridTemplateParser.parse(view.customStyle['grid-template']),
rowSizing = _GridTrackParser.parseTrackSizing(
view.customStyle['grid-row-sizing']),
columnSizing = _GridTrackParser.parseTrackSizing(
view.customStyle['grid-column-sizing']),
super(view) {
_rowTracks = rows?.tracks ?? [];
_columnTracks = columns?.tracks ?? [];
}
@override
int? get currentWidth => _gridWidth;
@override
int? get currentHeight => _gridHeight;
@override
void cacheExistingBrowserLayout() {
// We don't need to do anything as we don't rely on the _cachedViewRect
// when the grid layout is used.
}
// TODO(jacobr): cleanup this method so that it returns a Future
// rather than taking a Completer as an argument.
/// The main entry point for layout computation. */
@override
void measureLayout(Future<Size> size, Completer<bool>? changed) {
_ensureAllTracks();
size.then((value) {
_gridWidth = value.width as int?;
_gridHeight = value.height as int?;
if (_rowTracks.isNotEmpty && _columnTracks.isNotEmpty) {
_measureTracks();
_setBoundsOfChildren();
if (changed != null) {
changed.complete(true);
}
}
});
}
/// The top level measurement function.
/// [http://dev.w3.org/csswg/css3-grid-align/#calculating-size-of-grid-tracks]
void _measureTracks() {
// Resolve logical width, then height. Width comes first so we can use
// the width when determining the content-sized height.
try {
_dimension = Dimension.WIDTH;
_computeUsedBreadthOfTracks(_columnTracks);
_dimension = Dimension.HEIGHT;
_computeUsedBreadthOfTracks(_rowTracks);
} finally {
_dimension = null;
}
// TODO(jmesserly): we're supposed to detect a min-content size change
// due to our computed width and trigger a new layout.
// How do we implement that?
}
num _getRemainingSpace(List<GridTrack> tracks) {
num remaining = _getGridContentSize()!;
remaining -= CollectionUtils.sum(tracks, (t) => t.usedBreadth);
return Math.max(0, remaining);
}
/// This is the core Grid Track sizing algorithm. It is run for Grid columns
/// and Grid rows. The goal of the function is to ensure:
/// 1. That each Grid Track satisfies its minSizing
/// 2. That each Grid Track grows from the breadth which satisfied its
/// minSizing to a breadth which satifies its
/// maxSizing, subject to RemainingSpace.
// Note: spec does not correctly doc all the parameters to this function.
void _computeUsedBreadthOfTracks(List<GridTrack> tracks) {
// TODO(jmesserly): as a performance optimization we could cache this
final items = view.childViews.map((view_) => view_.layout).toList();
CollectionUtils.sortBy(items, (item) => _getSpanCount(item)!);
// 1. Initialize per Grid Track variables
for (final t in tracks) {
// percentage or length sizing functions will return a value
// min-content, max-content, or a fraction will be set to 0
t.usedBreadth = t.minSizing.resolveLength(_getGridContentSize());
t.maxBreadth = t.maxSizing.resolveLength(_getGridContentSize());
t.updatedBreadth = 0;
}
// 2. Resolve content-based MinTrackSizingFunctions
final USEDBREADTH = const _UsedBreadthAccumulator();
final MAXBREADTH = const _MaxBreadthAccumulator();
_distributeSpaceBySpanCount(items, ContentSizeMode.MIN, USEDBREADTH);
_distributeSpaceBySpanCount(items, ContentSizeMode.MAX, USEDBREADTH);
// 3. Ensure that maxBreadth is as big as usedBreadth for each track
for (final t in tracks) {
if (t.maxBreadth < t.usedBreadth) {
t.maxBreadth = t.usedBreadth;
}
}
// 4. Resolve content-based MaxTrackSizingFunctions
_distributeSpaceBySpanCount(items, ContentSizeMode.MIN, MAXBREADTH);
_distributeSpaceBySpanCount(items, ContentSizeMode.MAX, MAXBREADTH);
// 5. Grow all Grid Tracks in GridTracks from their usedBreadth up to their
// maxBreadth value until RemainingSpace is exhausted.
// Note: it's not spec'd what to pass as the accumulator, but usedBreadth
// seems right.
_distributeSpaceToTracks(
tracks, _getRemainingSpace(tracks), USEDBREADTH, false);
// Spec wording is confusing about which direction this assignment happens,
// but this is the way that makes sense.
for (final t in tracks) {
t.usedBreadth = t.updatedBreadth;
}
// 6. Grow all Grid Tracks having a fraction as their maxSizing
final tempBreadth = _calcNormalizedFractionBreadth(tracks);
for (final t in tracks) {
t.usedBreadth =
Math.max(t.usedBreadth, tempBreadth * t.maxSizing.fractionValue);
}
_computeTrackPositions(tracks);
}
/// Final steps to finish positioning tracks. Takes the track size and uses
/// it to get start and end positions. Also rounds the positions to integers.
void _computeTrackPositions(List<GridTrack> tracks) {
// Compute start positions of tracks, as well as the final position
num position = 0;
for (final t in tracks) {
t.start = position;
position += t.usedBreadth;
}
// Now, go through and round each position to an integer. Then
// compute the sizes based on those integers.
num finalPosition = position;
for (int i = 0; i < tracks.length; i++) {
int startEdge = tracks[i].start as int;
int endEdge;
if (i < tracks.length - 1) {
endEdge = tracks[i + 1].start.round();
tracks[i + 1].start = endEdge;
} else {
endEdge = finalPosition.round();
}
int breadth = endEdge - startEdge;
// check that we're not off by >= 1px.
assert((endEdge - startEdge - tracks[i].usedBreadth).abs() < 1);
tracks[i].usedBreadth = breadth;
}
}
/// This method computes a '1fr' value, referred to as the
/// tempBreadth, for a set of Grid Tracks. The value computed
/// will ensure that when the tempBreadth is multiplied by the
/// fractions associated with tracks, that the UsedBreadths of tracks
/// will increase by an amount equal to the maximum of zero and the specified
/// freeSpace less the sum of the current UsedBreadths.
num _calcNormalizedFractionBreadth(List<GridTrack> tracks) {
final fractionTracks = tracks.where((t) => t.maxSizing.isFraction).toList();
// Note: the spec has various bugs in this function, such as mismatched
// identifiers and names that aren't defined. For the most part it's
// possible to figure out the meaning. It's also a bit confused about
// how to compute spaceNeededFromFractionTracks, but that should just be the
// set to the remaining free space after usedBreadth is accounted for.
// We use the tempBreadth field to store the normalized fraction breadth
for (final t in fractionTracks) {
t.tempBreadth = t.usedBreadth / t.maxSizing.fractionValue;
}
CollectionUtils.sortBy(fractionTracks, (t) => t.tempBreadth);
num spaceNeededFromFractionTracks = _getRemainingSpace(tracks);
num? currentBandFractionBreadth = 0;
num accumulatedFractions = 0;
for (final t in fractionTracks) {
if (t.tempBreadth != currentBandFractionBreadth) {
if (t.tempBreadth * accumulatedFractions >
spaceNeededFromFractionTracks) {
break;
}
currentBandFractionBreadth = t.tempBreadth;
}
accumulatedFractions += t.maxSizing.fractionValue;
spaceNeededFromFractionTracks += t.usedBreadth;
}
return spaceNeededFromFractionTracks / accumulatedFractions;
}
/// Ensures that for each Grid Track in tracks, a value will be
/// computed, updatedBreadth, that represents the Grid Track's share of
/// freeSpace.
void _distributeSpaceToTracks(List<GridTrack?> tracks, num? freeSpace,
_BreadthAccumulator breadth, bool ignoreMaxBreadth) {
// TODO(jmesserly): in some cases it would be safe to sort the passed in
// list in place. Not always though.
tracks = CollectionUtils.orderBy(
tracks, (t) => t.maxBreadth - breadth.getSize(t));
// Give each Grid Track an equal share of the space, but without exceeding
// their maxBreadth values. Because there are different MaxBreadths
// assigned to the different Grid Tracks, this can result in uneven growth.
for (int i = 0; i < tracks.length; i++) {
num share = freeSpace! / (tracks.length - i);
share = Math.min(share, tracks[i]!.maxBreadth);
tracks[i]!.tempBreadth = share;
freeSpace -= share;
}
// If the first loop completed having grown every Grid Track to its
// maxBreadth, and there is still freeSpace, then divide that space
// evenly and assign it to each Grid Track without regard for its
// maxBreadth. This phase of growth will always be even, but only occurs
// when the ignoreMaxBreadth flag is true.
if (freeSpace! > 0 && ignoreMaxBreadth) {
for (int i = 0; i < tracks.length; i++) {
final share = freeSpace! / (tracks.length - i);
tracks[i]!.tempBreadth = tracks[i]!.tempBreadth + share;
freeSpace -= share;
}
}
// Note: the spec has us updating all grid tracks, not just the passed in
// tracks, but I think that's a spec bug.
for (final t in tracks) {
t!.updatedBreadth = Math.max(t.updatedBreadth, t.tempBreadth);
}
}
/// This function prioritizes the distribution of space driven by Grid Items
/// in content-sized Grid Tracks by the Grid Item's spanCount. That is, Grid
/// Items having a lower spanCount have an opportunity to increase the size of
/// the Grid Tracks they cover before those with larger SpanCounts.
///
/// Note: items are assumed to be already sorted in increasing span count
void _distributeSpaceBySpanCount(List<ViewLayout> items,
ContentSizeMode sizeMode, _BreadthAccumulator breadth) {
items = items
.where((item) =>
_hasContentSizedTracks(_getTracks(item), sizeMode, breadth))
.toList();
var tracks = [];
for (int i = 0; i < items.length; i++) {
final item = items[i];
final itemTargetSize = item.measureContent(this, _dimension, sizeMode);
final spannedTracks = _getTracks(item);
_distributeSpaceToTracks(spannedTracks, itemTargetSize, breadth, true);
// Remember that we need to update the sizes on these tracks
tracks.addAll(spannedTracks);
// Each time we transition to a spanCount, update any modified tracks
bool spanCountFinished = false;
if (i + 1 == items.length) {
spanCountFinished = true;
} else if (_getSpanCount(item) != _getSpanCount(items[i + 1])) {
spanCountFinished = true;
}
if (spanCountFinished) {
for (final t in tracks) {
breadth.setSize(t, Math.max(breadth.getSize(t)!, t.updatedBreadth));
}
tracks = [];
}
}
}
/// Returns true if we have an appropriate content sized dimension, and don't
/// cross a fractional track.
static bool _hasContentSizedTracks(Iterable<GridTrack?> tracks,
ContentSizeMode sizeMode, _BreadthAccumulator breadth) {
for (final t in tracks) {
final fn = breadth.getSizingFunction(t);
if (sizeMode == ContentSizeMode.MAX && fn.isMaxContentSized ||
sizeMode == ContentSizeMode.MIN && fn.isContentSized) {
// Make sure we don't cross a fractional track
return tracks.length == 1 || !tracks.any((t_) => t_!.isFractional);
}
}
return false;
}
/// Ensures that the numbered track exists. */
void _ensureTrack(
List<GridTrack> tracks, TrackSizing sizing, int start, int span) {
// Start is 1-based. Make it 0-based.
start -= 1;
// Grow the list if needed
int length = start + span;
int first = Math.min(start, tracks.length);
tracks.length = Math.max(tracks.length, length);
// Fill in tracks
for (int i = first; i < length; i++) {
if (tracks[i] == null) {
tracks[i] = GridTrack(sizing);
}
}
}
/// Scans children creating GridLayoutParams as needed, and creates all of the
/// rows and columns that we will need.
///
/// Note: this can potentially create new qrows/columns, so this needs to be
/// run before the track sizing algorithm.
void _ensureAllTracks() {
final items = view.childViews.map((view_) => view_.layout);
for (final child in items) {
if (child.layoutParams == null) {
final p = GridLayoutParams(child.view, this);
_ensureTrack(_rowTracks, rowSizing, p.row!, p.rowSpan!);
_ensureTrack(_columnTracks, columnSizing, p.column!, p.columnSpan!);
child.layoutParams = p;
}
child.cacheExistingBrowserLayout();
}
}
/// Given the track sizes that were computed, position children in the grid.
void _setBoundsOfChildren() {
final items = view.childViews.map((view_) => view_.layout);
for (final item in items) {
GridLayoutParams childLayout = item.layoutParams as GridLayoutParams;
var xPos = _getTrackLocationX(childLayout);
var yPos = _getTrackLocationY(childLayout);
int? left = xPos.start, width = xPos.length;
int? top = yPos.start, height = yPos.length;
// Somewhat counterintuitively (at least to me):
// grid-col-align is the horizontal alignment
// grid-row-align is the vertical alignment
xPos = childLayout.columnAlign.align(xPos, item.currentWidth);
yPos = childLayout.rowAlign.align(yPos, item.currentHeight);
item.setBounds(xPos.start, yPos.start, xPos.length!, yPos.length!);
}
}
num? _getGridContentSize() {
if (_dimension == Dimension.WIDTH) {
return _gridWidth;
} else if (_dimension == Dimension.HEIGHT) {
return _gridHeight;
}
return null;
}
_GridLocation _getTrackLocationX(GridLayoutParams childLayout) {
int start = childLayout.column! - 1;
int end = start + childLayout.columnSpan! - 1;
start = _columnTracks[start].start as int;
end = _columnTracks[end].end as int;
return _GridLocation(start, end - start);
}
_GridLocation _getTrackLocationY(GridLayoutParams childLayout) {
int start = childLayout.row! - 1;
int end = start + childLayout.rowSpan! - 1;
start = _rowTracks[start].start as int;
end = _rowTracks[end].end as int;
return _GridLocation(start, end - start);
}
/// Gets the tracks that this item crosses. */
// TODO(jmesserly): might be better to return an iterable
List<GridTrack?> _getTracks(ViewLayout item) {
GridLayoutParams? childLayout = item.layoutParams as GridLayoutParams?;
int? start, span;
List<GridTrack>? tracks;
if (_dimension == Dimension.WIDTH) {
start = childLayout!.column! - 1;
span = childLayout.columnSpan;
tracks = _columnTracks;
} else if (_dimension == Dimension.HEIGHT) {
start = childLayout!.row! - 1;
span = childLayout.rowSpan;
tracks = _rowTracks;
}
assert(start! >= 0 && span! >= 1);
final result = List<GridTrack>.generate(span!, (i) => tracks![start! + i]);
return result;
}
int? _getSpanCount(ViewLayout item) {
GridLayoutParams? childLayout = item.layoutParams as GridLayoutParams?;
return (_dimension == Dimension.WIDTH
? childLayout!.columnSpan
: childLayout!.rowSpan);
}
}