diff --git a/packages/two_dimensional_scrollables/CHANGELOG.md b/packages/two_dimensional_scrollables/CHANGELOG.md index 6b04d1bf63c3..52ea3b087a7f 100644 --- a/packages/two_dimensional_scrollables/CHANGELOG.md +++ b/packages/two_dimensional_scrollables/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.0 + +* [Breaking change] Adds support for merged cells in the TableView. + ## 0.0.6 * Fixes an error in TableSpanDecoration when one or both axes are reversed. diff --git a/packages/two_dimensional_scrollables/example/lib/main.dart b/packages/two_dimensional_scrollables/example/lib/main.dart index cd23c568ae62..ff714af1e497 100644 --- a/packages/two_dimensional_scrollables/example/lib/main.dart +++ b/packages/two_dimensional_scrollables/example/lib/main.dart @@ -88,9 +88,11 @@ class _TableExampleState extends State { ); } - Widget _buildCell(BuildContext context, TableVicinity vicinity) { - return Center( - child: Text('Tile c: ${vicinity.column}, r: ${vicinity.row}'), + TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) { + return TableViewCell( + child: Center( + child: Text('Tile c: ${vicinity.column}, r: ${vicinity.row}'), + ), ); } diff --git a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart index a8d9a0cc99d3..5d1575b1f5af 100644 --- a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart +++ b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart @@ -24,10 +24,16 @@ import 'table_span.dart'; /// vertically. If there is not enough space for all the columns, it will /// scroll horizontally. /// -/// Each child [Widget] can belong to exactly one row and one column as -/// represented by its [TableVicinity]. The table supports lazy rendering and -/// will only instantiate those cells that are currently visible in the table's -/// viewport and those that extend into the [cacheExtent]. +/// Each child [TableViewCell] can belong to either exactly one row and one +/// column as represented by its [TableVicinity], or it can span multiple rows +/// and columns through merging. The table supports lazy rendering and will only +/// instantiate those cells that are currently visible in the table's viewport +/// and those that extend into the [cacheExtent]. Therefore, when merging cells +/// in a [TableView], the same child must be returned from every vicinity the +/// merged cell contains. The `build` method will only be called once for a +/// merged cell, but since the table's children are lazily laid out, returning +/// the same child ensures the merged cell can be built no matter which part of +/// it is visible. /// /// The layout of the table (e.g. how many rows/columns there are and their /// extents) as well as the content of the individual cells is defined by @@ -169,7 +175,7 @@ class TableView extends TwoDimensionalScrollView { int pinnedColumnCount = 0, required TableSpanBuilder columnBuilder, required TableSpanBuilder rowBuilder, - List> cells = const >[], + List> cells = const >[], }) : assert(pinnedRowCount >= 0), assert(pinnedColumnCount >= 0), super( @@ -280,6 +286,19 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { super.delegate = value; } + // Skipped vicinities for the current frame based on merged cells. + // This prevents multiple build calls for the same cell that spans multiple + // vicinities. + // The key represents a skipped vicinity, the value is the resolved vicinity + // of the merged child. + final Map _mergedVicinities = + {}; + // These contain the indexes of rows/columns that contain merged cells to + // optimize decoration drawing for rows/columns that don't contain merged + // cells. + final List _mergedRows = []; + final List _mergedColumns = []; + // Cached Table metrics Map _columnMetrics = {}; Map _rowMetrics = {}; @@ -595,6 +614,11 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { @override void layoutChildSequence() { + // Reset for a new frame + _mergedVicinities.clear(); + _mergedRows.clear(); + _mergedColumns.clear(); + if (needsDelegateRebuild || didResize) { // Recomputes the table metrics, invalidates any cached information. _updateAllMetrics(); @@ -624,7 +648,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { if (_lastPinnedRow != null && _lastPinnedColumn != null) { // Layout cells that are contained in both pinned rows and columns _layoutCells( - start: const TableVicinity(column: 0, row: 0), + start: TableVicinity.zero, end: TableVicinity(column: _lastPinnedColumn!, row: _lastPinnedRow!), offset: Offset.zero, ); @@ -665,45 +689,175 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { } } + bool _debugCheckMergeBounds({ + required String spanOrientation, + required int currentSpan, + required int spanMergeStart, + required int spanMergeEnd, + required int spanCount, + required int pinnedSpanCount, + required TableVicinity currentVicinity, + }) { + if (spanMergeStart == spanMergeEnd) { + // Not merged + return true; + } + + final String lowerSpanOrientation = spanOrientation.toLowerCase(); + assert( + spanMergeStart <= currentSpan, + 'The ${lowerSpanOrientation}MergeStart of $spanMergeStart is greater ' + 'than the current $lowerSpanOrientation at $currentVicinity.', + ); + assert( + spanMergeEnd < spanCount, + '$spanOrientation merge configuration exceeds number of ' + '${lowerSpanOrientation}s in the table. $spanOrientation merge ' + 'containing $currentVicinity starts at $spanMergeStart, and ends at ' + '$spanMergeEnd. The TableView contains $spanCount.', + ); + if (spanMergeStart < pinnedSpanCount) { + // Merged cells cannot span pinned and unpinned cells. + assert( + spanMergeEnd < pinnedSpanCount, + 'Merged cells cannot span pinned and unpinned cells. $spanOrientation ' + 'merge containing $currentVicinity starts at $spanMergeStart, and ends ' + 'at $spanMergeEnd. ${spanOrientation}s are currently pinned up to ' + '$lowerSpanOrientation ${pinnedSpanCount - 1}.', + ); + } + return true; + } + void _layoutCells({ required TableVicinity start, required TableVicinity end, required Offset offset, }) { - // TODO(Piinks): Assert here or somewhere else merged cells cannot span - // pinned and unpinned cells (for merged cell follow-up), https://github.com/flutter/flutter/issues/131224 _Span colSpan, rowSpan; - double yPaintOffset = -offset.dy; + double rowOffset = -offset.dy; for (int row = start.row; row <= end.row; row += 1) { - double xPaintOffset = -offset.dx; + double columnOffset = -offset.dx; rowSpan = _rowMetrics[row]!; - final double rowHeight = rowSpan.extent; - yPaintOffset += rowSpan.configuration.padding.leading; + final double standardRowHeight = rowSpan.extent; + double? mergedRowHeight; + double? mergedRowOffset; + rowOffset += rowSpan.configuration.padding.leading; + for (int column = start.column; column <= end.column; column += 1) { colSpan = _columnMetrics[column]!; - final double columnWidth = colSpan.extent; - xPaintOffset += colSpan.configuration.padding.leading; + final double standardColumnWidth = colSpan.extent; + double? mergedColumnWidth; + double? mergedColumnOffset; + columnOffset += colSpan.configuration.padding.leading; final TableVicinity vicinity = TableVicinity(column: column, row: row); - // TODO(Piinks): Add back merged cells, https://github.com/flutter/flutter/issues/131224 - - final RenderBox? cell = buildOrObtainChildFor(vicinity); + final RenderBox? cell = _mergedVicinities.keys.contains(vicinity) + ? null + : buildOrObtainChildFor(vicinity); if (cell != null) { final TableViewParentData cellParentData = parentDataOf(cell); + // Merged cell handling + if (cellParentData.rowMergeStart != null || + cellParentData.columnMergeStart != null) { + final int firstRow = cellParentData.rowMergeStart ?? row; + final int lastRow = cellParentData.rowMergeStart == null + ? row + : firstRow + cellParentData.rowMergeSpan! - 1; + assert(_debugCheckMergeBounds( + spanOrientation: 'Row', + currentSpan: row, + spanMergeStart: firstRow, + spanMergeEnd: lastRow, + spanCount: delegate.rowCount, + pinnedSpanCount: delegate.pinnedRowCount, + currentVicinity: vicinity, + )); + + final int firstColumn = cellParentData.columnMergeStart ?? column; + final int lastColumn = cellParentData.columnMergeStart == null + ? column + : firstColumn + cellParentData.columnMergeSpan! - 1; + assert(_debugCheckMergeBounds( + spanOrientation: 'Column', + currentSpan: column, + spanMergeStart: firstColumn, + spanMergeEnd: lastColumn, + spanCount: delegate.columnCount, + pinnedSpanCount: delegate.pinnedColumnCount, + currentVicinity: vicinity, + )); + + // Leading padding on the leading cell, and trailing padding on the + // trailing cell should be excluded. Interim leading/trailing + // paddings are consumed by the merged cell. + // Example: This is one whole cell spanning 2 merged columns. + // l indicates leading padding, t trailing padding + // +---------------------------------------------------------+ + // | l | column extent | t | l | column extent | t | + // +---------------------------------------------------------+ + // | <--------- extent of merged cell ---------> | + + // Compute height and layout offset for merged rows. + mergedRowOffset = -verticalOffset.pixels + + _rowMetrics[firstRow]!.leadingOffset + + _rowMetrics[firstRow]!.configuration.padding.leading; + mergedRowHeight = _rowMetrics[lastRow]!.trailingOffset - + _rowMetrics[firstRow]!.leadingOffset - + _rowMetrics[lastRow]!.configuration.padding.trailing - + _rowMetrics[firstRow]!.configuration.padding.leading; + // Compute width and layout offset for merged columns. + mergedColumnOffset = -horizontalOffset.pixels + + _columnMetrics[firstColumn]!.leadingOffset + + _columnMetrics[firstColumn]!.configuration.padding.leading; + mergedColumnWidth = _columnMetrics[lastColumn]!.trailingOffset - + _columnMetrics[firstColumn]!.leadingOffset - + _columnMetrics[lastColumn]!.configuration.padding.trailing - + _columnMetrics[firstColumn]!.configuration.padding.leading; + + // Collect all of the vicinities that will not need to be built now. + int currentRow = firstRow; + while (currentRow <= lastRow) { + if (cellParentData.rowMergeStart != null) { + _mergedRows.add(currentRow); + } + int currentColumn = firstColumn; + while (currentColumn <= lastColumn) { + if (cellParentData.columnMergeStart != null) { + _mergedColumns.add(currentColumn); + } + final TableVicinity key = TableVicinity( + row: currentRow, + column: currentColumn, + ); + _mergedVicinities[key] = vicinity; + currentColumn++; + } + currentRow++; + } + } + final BoxConstraints cellConstraints = BoxConstraints.tightFor( - width: columnWidth, - height: rowHeight, + width: mergedColumnWidth ?? standardColumnWidth, + height: mergedRowHeight ?? standardRowHeight, ); cell.layout(cellConstraints); - cellParentData.layoutOffset = Offset(xPaintOffset, yPaintOffset); + cellParentData.layoutOffset = Offset( + mergedColumnOffset ?? columnOffset, + mergedRowOffset ?? rowOffset, + ); + mergedRowOffset = null; + mergedRowHeight = null; + mergedColumnOffset = null; + mergedColumnWidth = null; } - xPaintOffset += columnWidth + + columnOffset += standardColumnWidth + _columnMetrics[column]!.configuration.padding.trailing; } - yPaintOffset += - rowHeight + _rowMetrics[row]!.configuration.padding.trailing; + rowOffset += + standardRowHeight + _rowMetrics[row]!.configuration.padding.trailing; } } @@ -748,8 +902,8 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _paintCells( context: context, offset: offset, - leading: _firstNonPinnedCell!, - trailing: _lastNonPinnedCell!, + leadingVicinity: _firstNonPinnedCell!, + trailingVicinity: _lastNonPinnedCell!, ); }, clipBehavior: clipBehavior, @@ -779,8 +933,8 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _paintCells( context: context, offset: offset, - leading: TableVicinity(column: 0, row: _firstNonPinnedRow!), - trailing: TableVicinity( + leadingVicinity: TableVicinity(column: 0, row: _firstNonPinnedRow!), + trailingVicinity: TableVicinity( column: _lastPinnedColumn!, row: _lastNonPinnedRow!), ); }, @@ -811,8 +965,9 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _paintCells( context: context, offset: offset, - leading: TableVicinity(column: _firstNonPinnedColumn!, row: 0), - trailing: TableVicinity( + leadingVicinity: + TableVicinity(column: _firstNonPinnedColumn!, row: 0), + trailingVicinity: TableVicinity( column: _lastNonPinnedColumn!, row: _lastPinnedRow!), ); }, @@ -829,17 +984,47 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _paintCells( context: context, offset: offset, - leading: const TableVicinity(column: 0, row: 0), - trailing: + leadingVicinity: TableVicinity.zero, + trailingVicinity: TableVicinity(column: _lastPinnedColumn!, row: _lastPinnedRow!), ); } } + // If mapMergedVicinityToCanonicalChild is true, it will return the canonical + // child for the merged cell, if false, it will return whatever value in the + // underlying child data structure is, which could be null if the given + // vicinity is covered by a merged cell. + // This is relevant for scenarios like painting, where we only want to paint + // one merged cell. + @override + RenderBox? getChildFor( + ChildVicinity vicinity, { + bool mapMergedVicinityToCanonicalChild = true, + }) { + return super.getChildFor(vicinity) ?? + (mapMergedVicinityToCanonicalChild + ? _getMergedChildFor(vicinity as TableVicinity) + : null); + } + + RenderBox _getMergedChildFor(TableVicinity vicinity) { + // A merged cell spans multiple vicinities, but only lays out one child for + // the full area. Returns the child that has been laid out to span the given + // vicinity. + assert(_mergedVicinities.keys.contains(vicinity)); + final TableVicinity mergedVicinity = _mergedVicinities[vicinity]!; + // This vicinity must resolve to a child, unless something has gone wrong! + return getChildFor( + mergedVicinity, + mapMergedVicinityToCanonicalChild: false, + )!; + } + void _paintCells({ required PaintingContext context, - required TableVicinity leading, - required TableVicinity trailing, + required TableVicinity leadingVicinity, + required TableVicinity trailingVicinity, required Offset offset, }) { // Column decorations @@ -848,19 +1033,92 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { final LinkedHashMap backgroundColumns = LinkedHashMap(); - final TableSpan rowSpan = _rowMetrics[leading.row]!.configuration; - for (int column = leading.column; column <= trailing.column; column++) { - final TableSpan columnSpan = _columnMetrics[column]!.configuration; + final TableSpan rowSpan = _rowMetrics[leadingVicinity.row]!.configuration; + for (int column = leadingVicinity.column; + column <= trailingVicinity.column; + column++) { + TableSpan columnSpan = _columnMetrics[column]!.configuration; if (columnSpan.backgroundDecoration != null || - columnSpan.foregroundDecoration != null) { - final RenderBox leadingCell = getChildFor( - TableVicinity(column: column, row: leading.row), - )!; - final RenderBox trailingCell = getChildFor( - TableVicinity(column: column, row: trailing.row), - )!; - - Rect getColumnRect(bool consumePadding) { + columnSpan.foregroundDecoration != null || + _mergedColumns.contains(column)) { + final List<({RenderBox leading, RenderBox trailing})> decorationCells = + <({RenderBox leading, RenderBox trailing})>[]; + if (_mergedColumns.isEmpty || !_mergedColumns.contains(column)) { + // One decoration across the whole column. + decorationCells.add(( + leading: getChildFor(TableVicinity( + column: column, + row: leadingVicinity.row, + ))!, + trailing: getChildFor(TableVicinity( + column: column, + row: trailingVicinity.row, + ))!, + )); + } else { + // Walk through the rows to separate merged cells for decorating. A + // merged column takes the decoration of its leading column. + // +---------+-------+-------+ + // | | | | + // | 1 rect | | | + // +---------+-------+-------+ + // | merged | | + // | 1 rect | | + // +---------+-------+-------+ + // | 1 rect | | | + // | | | | + // +---------+-------+-------+ + late RenderBox leadingCell; + late RenderBox trailingCell; + int currentRow = leadingVicinity.row; + while (currentRow <= trailingVicinity.row) { + TableVicinity vicinity = TableVicinity( + column: column, + row: currentRow, + ); + leadingCell = getChildFor(vicinity)!; + if (parentDataOf(leadingCell).columnMergeStart != null) { + // Merged portion decorated individually since it exceeds the + // single column width. + decorationCells.add(( + leading: leadingCell, + trailing: leadingCell, + )); + currentRow++; + continue; + } + // If this is not a merged cell, collect up all of the cells leading + // up to, or following after, the merged cell so we can decorate + // efficiently with as few rects as possible. + RenderBox? nextCell = leadingCell; + while (nextCell != null && + parentDataOf(nextCell).columnMergeStart == null) { + final TableViewParentData parentData = parentDataOf(nextCell); + if (parentData.rowMergeStart != null) { + currentRow = + parentData.rowMergeStart! + parentData.rowMergeSpan!; + } else { + currentRow += 1; + } + trailingCell = nextCell; + vicinity = vicinity.copyWith(row: currentRow); + nextCell = getChildFor( + vicinity, + mapMergedVicinityToCanonicalChild: false, + ); + } + decorationCells.add(( + leading: leadingCell, + trailing: trailingCell, + )); + } + } + + Rect getColumnRect({ + required RenderBox leadingCell, + required RenderBox trailingCell, + required bool consumePadding, + }) { final ({double leading, double trailing}) offsetCorrection = axisDirectionIsReversed(verticalAxisDirection) ? ( @@ -868,7 +1126,6 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { trailing: trailingCell.size.height, ) : (leading: 0.0, trailing: 0.0); - return Rect.fromPoints( parentDataOf(leadingCell).paintOffset! + offset - @@ -886,17 +1143,31 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { ); } - if (columnSpan.backgroundDecoration != null) { - final Rect rect = getColumnRect( - columnSpan.backgroundDecoration!.consumeSpanPadding, - ); - backgroundColumns[rect] = columnSpan.backgroundDecoration!; - } - if (columnSpan.foregroundDecoration != null) { - final Rect rect = getColumnRect( - columnSpan.foregroundDecoration!.consumeSpanPadding, - ); - foregroundColumns[rect] = columnSpan.foregroundDecoration!; + for (final ({RenderBox leading, RenderBox trailing}) cell + in decorationCells) { + // If this was a merged cell, the decoration is defined by the leading + // cell, which may come from a different column. + final int columnIndex = parentDataOf(cell.leading).columnMergeStart ?? + parentDataOf(cell.leading).tableVicinity.column; + columnSpan = _columnMetrics[columnIndex]!.configuration; + if (columnSpan.backgroundDecoration != null) { + final Rect rect = getColumnRect( + leadingCell: cell.leading, + trailingCell: cell.trailing, + consumePadding: + columnSpan.backgroundDecoration!.consumeSpanPadding, + ); + backgroundColumns[rect] = columnSpan.backgroundDecoration!; + } + if (columnSpan.foregroundDecoration != null) { + final Rect rect = getColumnRect( + leadingCell: cell.leading, + trailingCell: cell.trailing, + consumePadding: + columnSpan.foregroundDecoration!.consumeSpanPadding, + ); + foregroundColumns[rect] = columnSpan.foregroundDecoration!; + } } } } @@ -906,20 +1177,91 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { LinkedHashMap(); final LinkedHashMap backgroundRows = LinkedHashMap(); - - final TableSpan columnSpan = _columnMetrics[leading.column]!.configuration; - for (int row = leading.row; row <= trailing.row; row++) { - final TableSpan rowSpan = _rowMetrics[row]!.configuration; + final TableSpan columnSpan = + _columnMetrics[leadingVicinity.column]!.configuration; + for (int row = leadingVicinity.row; row <= trailingVicinity.row; row++) { + TableSpan rowSpan = _rowMetrics[row]!.configuration; if (rowSpan.backgroundDecoration != null || - rowSpan.foregroundDecoration != null) { - final RenderBox leadingCell = getChildFor( - TableVicinity(column: leading.column, row: row), - )!; - final RenderBox trailingCell = getChildFor( - TableVicinity(column: trailing.column, row: row), - )!; - - Rect getRowRect(bool consumePadding) { + rowSpan.foregroundDecoration != null || + _mergedRows.contains(row)) { + final List<({RenderBox leading, RenderBox trailing})> decorationCells = + <({RenderBox leading, RenderBox trailing})>[]; + if (_mergedRows.isEmpty || !_mergedRows.contains(row)) { + // One decoration across the whole row. + decorationCells.add(( + leading: getChildFor(TableVicinity( + column: leadingVicinity.column, + row: row, + ))!, // leading + trailing: getChildFor(TableVicinity( + column: trailingVicinity.column, + row: row, + ))!, // trailing + )); + } else { + // Walk through the columns to separate merged cells for decorating. A + // merged row takes the decoration of its leading row. + // +---------+--------+--------+ + // | 1 rect | merged | 1 rect | + // | | 1 rect | | + // +---------+ +--------+ + // | | | | + // | | | | + // +---------+--------+--------+ + // | | | | + // | | | | + // +---------+--------+--------+ + late RenderBox leadingCell; + late RenderBox trailingCell; + int currentColumn = leadingVicinity.column; + while (currentColumn <= trailingVicinity.column) { + TableVicinity vicinity = TableVicinity( + column: currentColumn, + row: row, + ); + leadingCell = getChildFor(vicinity)!; + if (parentDataOf(leadingCell).rowMergeStart != null) { + // Merged portion decorated individually since it exceeds the + // single row height. + decorationCells.add(( + leading: leadingCell, + trailing: leadingCell, + )); + currentColumn++; + continue; + } + // If this is not a merged cell, collect up all of the cells leading + // up to, or following after, the merged cell so we can decorate + // efficiently with as few rects as possible. + RenderBox? nextCell = leadingCell; + while (nextCell != null && + parentDataOf(nextCell).rowMergeStart == null) { + final TableViewParentData parentData = parentDataOf(nextCell); + if (parentData.columnMergeStart != null) { + currentColumn = + parentData.columnMergeStart! + parentData.columnMergeSpan!; + } else { + currentColumn += 1; + } + trailingCell = nextCell; + vicinity = vicinity.copyWith(column: currentColumn); + nextCell = getChildFor( + vicinity, + mapMergedVicinityToCanonicalChild: false, + ); + } + decorationCells.add(( + leading: leadingCell, + trailing: trailingCell, + )); + } + } + + Rect getRowRect({ + required RenderBox leadingCell, + required RenderBox trailingCell, + required bool consumePadding, + }) { final ({double leading, double trailing}) offsetCorrection = axisDirectionIsReversed(horizontalAxisDirection) ? ( @@ -944,15 +1286,29 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { ); } - if (rowSpan.backgroundDecoration != null) { - final Rect rect = - getRowRect(rowSpan.backgroundDecoration!.consumeSpanPadding); - backgroundRows[rect] = rowSpan.backgroundDecoration!; - } - if (rowSpan.foregroundDecoration != null) { - final Rect rect = - getRowRect(rowSpan.foregroundDecoration!.consumeSpanPadding); - foregroundRows[rect] = rowSpan.foregroundDecoration!; + for (final ({RenderBox leading, RenderBox trailing}) cell + in decorationCells) { + // If this was a merged cell, the decoration is defined by the leading + // cell, which may come from a different row. + final int rowIndex = parentDataOf(cell.leading).rowMergeStart ?? + parentDataOf(cell.trailing).tableVicinity.row; + rowSpan = _rowMetrics[rowIndex]!.configuration; + if (rowSpan.backgroundDecoration != null) { + final Rect rect = getRowRect( + leadingCell: cell.leading, + trailingCell: cell.trailing, + consumePadding: rowSpan.backgroundDecoration!.consumeSpanPadding, + ); + backgroundRows[rect] = rowSpan.backgroundDecoration!; + } + if (rowSpan.foregroundDecoration != null) { + final Rect rect = getRowRect( + leadingCell: cell.leading, + trailingCell: cell.trailing, + consumePadding: rowSpan.foregroundDecoration!.consumeSpanPadding, + ); + foregroundRows[rect] = rowSpan.foregroundDecoration!; + } } } } @@ -1007,11 +1363,26 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { } // Cells - for (int column = leading.column; column <= trailing.column; column++) { - for (int row = leading.row; row <= trailing.row; row++) { - final RenderBox cell = getChildFor( - TableVicinity(column: column, row: row), - )!; + for (int column = leadingVicinity.column; + column <= trailingVicinity.column; + column++) { + for (int row = leadingVicinity.row; row <= trailingVicinity.row; row++) { + final TableVicinity vicinity = TableVicinity(column: column, row: row); + final RenderBox? cell = getChildFor( + vicinity, + mapMergedVicinityToCanonicalChild: false, + ); + if (cell == null) { + // Covered by a merged cell + assert( + _mergedVicinities.keys.contains(vicinity), + 'TableViewCell for $vicinity could not be found. If merging ' + 'cells, the same TableViewCell must be returned for every ' + 'TableVicinity that is contained in the merged area of the ' + 'TableView.', + ); + continue; + } final TableViewParentData cellParentData = parentDataOf(cell); if (cellParentData.isVisible) { context.paintChild(cell, offset + cellParentData.paintOffset!); diff --git a/packages/two_dimensional_scrollables/lib/src/table_view/table_cell.dart b/packages/two_dimensional_scrollables/lib/src/table_view/table_cell.dart index d071a2349675..3474c4874c1e 100644 --- a/packages/two_dimensional_scrollables/lib/src/table_view/table_cell.dart +++ b/packages/two_dimensional_scrollables/lib/src/table_view/table_cell.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'table.dart'; @@ -31,13 +32,220 @@ class TableVicinity extends ChildVicinity { /// Equivalent to the [xIndex]. int get column => xIndex; + /// The origin vicinity of the [TableView], (0,0). + static const TableVicinity zero = TableVicinity(row: 0, column: 0); + + /// Returns a new [TableVicinity], copying over the row and column fields with + /// those provided, or maintaining the original values. + TableVicinity copyWith({ + int? row, + int? column, + }) { + return TableVicinity( + row: row ?? this.row, + column: column ?? this.column, + ); + } + @override String toString() => '(row: $row, column: $column)'; } /// Parent data structure used by [RenderTableViewport]. class TableViewParentData extends TwoDimensionalViewportParentData { - // TODO(Piinks): Add back merged cells here, https://github.com/flutter/flutter/issues/131224 /// Converts the [ChildVicinity] to a [TableVicinity] for ease of use. TableVicinity get tableVicinity => vicinity as TableVicinity; + + /// Represents the row index where a merged cell in the table begins. + /// + /// Defaults to null, meaning a non-merged cell. A value must be provided if + /// a value is provided for [rowMergeSpan]. + int? rowMergeStart; + + /// Represents the number of rows spanned by a merged cell. + /// + /// Defaults to null, meaning the cell is not merged. A value must be provided + /// if a value is provided for [rowMergeStart]. + int? rowMergeSpan; + + /// Represents the column index where a merged cell in the table begins. + /// + /// Defaults to null, meaning a non-merged cell. A value must be provided if + /// a value is provided for [columnMergeSpan]. + int? columnMergeStart; + + /// Represents the number of columns spanned by a merged cell. + /// + /// Defaults to null, meaning the cell is not merged. A value must be provided + /// if a value is provided for [columnMergeStart]. + int? columnMergeSpan; + + @override + String toString() { + String mergeDetails = ''; + if (rowMergeStart != null || columnMergeStart != null) { + mergeDetails += ', merged'; + } + if (rowMergeStart != null) { + mergeDetails += ', rowMergeStart=$rowMergeStart, ' + 'rowMergeSpan=$rowMergeSpan'; + } + if (columnMergeStart != null) { + mergeDetails += ', columnMergeStart=$columnMergeStart, ' + 'columnMergeSpan=$columnMergeSpan'; + } + return super.toString() + mergeDetails; + } +} + +/// Creates a cell of the [TableView], along with information regarding merged +/// cells and [RepaintBoundary]s. +/// +/// When merging cells in a [TableView], the same child should be returned from +/// every vicinity the merged cell contains. The `build` method will only be +/// called once for a merged cell, but since the table's children are lazily +/// laid out, returning the same child ensures the merged cell can be built no +/// matter which part of it is visible. +class TableViewCell extends StatelessWidget { + /// Creates a widget that controls how a child of a [TableView] spans across + /// multiple rows or columns. + const TableViewCell({ + super.key, + this.rowMergeStart, + this.rowMergeSpan, + this.columnMergeStart, + this.columnMergeSpan, + this.addRepaintBoundaries = true, + required this.child, + }) : assert( + (rowMergeStart == null && rowMergeSpan == null) || + (rowMergeStart != null && rowMergeSpan != null), + 'Row merge start and span must both be set, or both unset.', + ), + assert(rowMergeStart == null || rowMergeStart >= 0), + assert(rowMergeSpan == null || rowMergeSpan > 0), + assert( + (columnMergeStart == null && columnMergeSpan == null) || + (columnMergeStart != null && columnMergeSpan != null), + 'Column merge start and span must both be set, or both unset.', + ), + assert(columnMergeStart == null || columnMergeStart >= 0), + assert(columnMergeSpan == null || columnMergeSpan > 0); + + /// The child contained in this cell of the [TableView]. + final Widget child; + + /// Represents the row index where a merged cell in the table begins. + /// + /// Defaults to null, meaning a non-merged cell. A value must be provided if + /// a value is provided for [rowMergeSpan]. + final int? rowMergeStart; + + /// Represents the number of rows spanned by a merged cell. + /// + /// Defaults to null, meaning the cell is not merged. A value must be provided + /// if a value is provided for [rowMergeStart]. + final int? rowMergeSpan; + + /// Represents the column index where a merged cell in the table begins. + /// + /// Defaults to null, meaning a non-merged cell. A value must be provided if + /// a value is provided for [columnMergeSpan]. + final int? columnMergeStart; + + /// Represents the number of columns spanned by a merged cell. + /// + /// Defaults to null, meaning the cell is not merged. A value must be provided + /// if a value is provided for [columnMergeStart]. + final int? columnMergeSpan; + + /// Whether to wrap each child in a [RepaintBoundary]. + /// + /// Typically, children in a scrolling container are wrapped in repaint + /// boundaries so that they do not need to be repainted as the list scrolls. + /// If the children are easy to repaint (e.g., solid color blocks or a short + /// snippet of text), it might be more efficient to not add a repaint boundary + /// and instead always repaint the children during scrolling. + /// + /// Defaults to true. + final bool addRepaintBoundaries; + + @override + Widget build(BuildContext context) { + Widget child = this.child; + + if (addRepaintBoundaries) { + child = RepaintBoundary(child: child); + } + + return _TableViewCell( + rowMergeStart: rowMergeStart, + rowMergeSpan: rowMergeSpan, + columnMergeStart: columnMergeStart, + columnMergeSpan: columnMergeSpan, + child: child, + ); + } +} + +class _TableViewCell extends ParentDataWidget { + const _TableViewCell({ + this.rowMergeStart, + this.rowMergeSpan, + this.columnMergeStart, + this.columnMergeSpan, + required super.child, + }); + + final int? rowMergeStart; + final int? rowMergeSpan; + final int? columnMergeStart; + final int? columnMergeSpan; + + @override + void applyParentData(RenderObject renderObject) { + final TableViewParentData parentData = + renderObject.parentData! as TableViewParentData; + bool needsLayout = false; + if (parentData.rowMergeStart != rowMergeStart) { + assert(rowMergeStart == null || rowMergeStart! >= 0); + parentData.rowMergeStart = rowMergeStart; + needsLayout = true; + } + if (parentData.rowMergeSpan != rowMergeSpan) { + assert(rowMergeSpan == null || rowMergeSpan! > 0); + parentData.rowMergeSpan = rowMergeSpan; + needsLayout = true; + } + if (parentData.columnMergeStart != columnMergeStart) { + assert(columnMergeStart == null || columnMergeStart! >= 0); + parentData.columnMergeStart = columnMergeStart; + needsLayout = true; + } + if (parentData.columnMergeSpan != columnMergeSpan) { + assert(columnMergeSpan == null || columnMergeSpan! > 0); + parentData.columnMergeSpan = columnMergeSpan; + needsLayout = true; + } + + if (needsLayout) { + renderObject.parent?.markNeedsLayout(); + } + } + + @override + Type get debugTypicalAncestorWidgetClass => TableViewport; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + if (rowMergeStart != null) { + properties.add(IntProperty('rowMergeStart', rowMergeStart)); + properties.add(IntProperty('rowMergeSpan', rowMergeSpan)); + } + if (columnMergeStart != null) { + properties.add(IntProperty('columnMergeStart', columnMergeStart)); + properties.add(IntProperty('columnMergeSpan', columnMergeSpan)); + } + } } diff --git a/packages/two_dimensional_scrollables/lib/src/table_view/table_delegate.dart b/packages/two_dimensional_scrollables/lib/src/table_view/table_delegate.dart index 275bfb5bae48..39a8d676a549 100644 --- a/packages/two_dimensional_scrollables/lib/src/table_view/table_delegate.dart +++ b/packages/two_dimensional_scrollables/lib/src/table_view/table_delegate.dart @@ -16,12 +16,12 @@ import 'table_span.dart'; /// [TableView]. typedef TableSpanBuilder = TableSpan Function(int index); -/// Signature for a function that creates a child [Widget] for a given +/// Signature for a function that creates a child [TableViewCell] for a given /// [TableVicinity] in a [TableView], but may return null. /// /// Used by [TableCellBuilderDelegate.builder] to build cells on demand for the /// table. -typedef TableViewCellBuilder = Widget? Function( +typedef TableViewCellBuilder = TableViewCell Function( BuildContext context, TableVicinity vicinity, ); @@ -116,6 +116,10 @@ mixin TableCellDelegateMixin on TwoDimensionalChildDelegate { /// A delegate that supplies children for a [TableViewport] on demand using a /// builder callback. +/// +/// Unlike the base [TwoDimensionalChildBuilderDelegate] this delegate does not +/// automatically insert repaint boundaries. Instead, repaint boundaries are +/// controlled by [TableViewCell.addRepaintBoundaries]. class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate with TableCellDelegateMixin { /// Creates a lazy building delegate to use with a [TableView]. @@ -124,7 +128,6 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate required int rowCount, int pinnedColumnCount = 0, int pinnedRowCount = 0, - super.addRepaintBoundaries, super.addAutomaticKeepAlives, required TableViewCellBuilder cellBuilder, required TableSpanBuilder columnBuilder, @@ -144,6 +147,8 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate cellBuilder(context, vicinity as TableVicinity), maxXIndex: columnCount - 1, maxYIndex: rowCount - 1, + // repaintBoundaries handled by TableViewCell + addRepaintBoundaries: false, ); @override @@ -209,15 +214,18 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate /// The [children] are accessed for each [TableVicinity.row] and /// [TableVicinity.column] of the [TwoDimensionalViewport] as /// `children[vicinity.row][vicinity.column]`. +/// +/// Unlike the base [TwoDimensionalChildBuilderDelegate] this delegate does not +/// automatically insert repaint boundaries. Instead, repaint boundaries are +/// controlled by [TableViewCell.addRepaintBoundaries]. class TableCellListDelegate extends TwoDimensionalChildListDelegate with TableCellDelegateMixin { /// Creates a delegate that supplies children for a [TableView]. TableCellListDelegate({ int pinnedColumnCount = 0, int pinnedRowCount = 0, - super.addRepaintBoundaries, super.addAutomaticKeepAlives, - required List> cells, + required List> cells, required TableSpanBuilder columnBuilder, required TableSpanBuilder rowBuilder, }) : assert(pinnedColumnCount >= 0), @@ -226,10 +234,16 @@ class TableCellListDelegate extends TwoDimensionalChildListDelegate _rowBuilder = rowBuilder, _pinnedColumnCount = pinnedColumnCount, _pinnedRowCount = pinnedRowCount, - super(children: cells) { + super( + children: cells, + // repaintBoundaries handled by TableViewCell + addRepaintBoundaries: false, + ) { // Even if there are merged cells, they should be represented by the same - // child in each cell location. So all arrays of cells should have the same - // length. + // child in each cell location. This ensures that no matter which direction + // the merged cell scrolls into view from, we can build the correct child + // without having to explore all possible vicinities of the merged cell + // area. So all arrays of cells should have the same length. assert( children.map((List array) => array.length).toSet().length == 1, 'Each list of Widgets within cells must be of the same length.', diff --git a/packages/two_dimensional_scrollables/lib/src/table_view/table_span.dart b/packages/two_dimensional_scrollables/lib/src/table_view/table_span.dart index faac24b22d7d..e19a0f899b00 100644 --- a/packages/two_dimensional_scrollables/lib/src/table_view/table_span.dart +++ b/packages/two_dimensional_scrollables/lib/src/table_view/table_span.dart @@ -291,6 +291,9 @@ class MinTableSpanExtent extends CombiningTableSpanExtent { } /// A decoration for a [TableSpan]. +/// +/// When decorating merged cells in the [TableView], a merged cell will take its +/// decoration from the leading cell of the merged span. class TableSpanDecoration { /// Creates a [TableSpanDecoration]. const TableSpanDecoration({ diff --git a/packages/two_dimensional_scrollables/pubspec.yaml b/packages/two_dimensional_scrollables/pubspec.yaml index 5d1ede3d73f5..e67277843dee 100644 --- a/packages/two_dimensional_scrollables/pubspec.yaml +++ b/packages/two_dimensional_scrollables/pubspec.yaml @@ -1,6 +1,6 @@ name: two_dimensional_scrollables description: Widgets that scroll using the two dimensional scrolling foundation. -version: 0.0.6 +version: 0.1.0 repository: https://github.com/flutter/packages/tree/main/packages/two_dimensional_scrollables issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+two_dimensional_scrollables%22+ diff --git a/packages/two_dimensional_scrollables/test/table_view/table_cell_test.dart b/packages/two_dimensional_scrollables/test/table_view/table_cell_test.dart index 2eace84b18ac..f68004b8bc7c 100644 --- a/packages/two_dimensional_scrollables/test/table_view/table_cell_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/table_cell_test.dart @@ -2,16 +2,1753 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; +const TableSpan span = TableSpan(extent: FixedTableSpanExtent(100)); + void main() { test('TableVicinity converts ChildVicinity', () { const TableVicinity vicinity = TableVicinity(column: 5, row: 10); expect(vicinity.xIndex, 5); expect(vicinity.yIndex, 10); + expect(vicinity.row, 10); + expect(vicinity.column, 5); expect(vicinity.toString(), '(row: 10, column: 5)'); }); - // TODO(Piinks): TableViewCell tests for merged cells, follow up change. + test('TableVicinity.zero', () { + const TableVicinity vicinity = TableVicinity.zero; + expect(vicinity.xIndex, 0); + expect(vicinity.yIndex, 0); + expect(vicinity.row, 0); + expect(vicinity.column, 0); + expect(vicinity.toString(), '(row: 0, column: 0)'); + }); + + test('TableVicinity.copyWith', () { + TableVicinity vicinity = TableVicinity.zero; + vicinity = vicinity.copyWith(column: 10); + expect(vicinity.xIndex, 10); + expect(vicinity.yIndex, 0); + expect(vicinity.row, 0); + expect(vicinity.column, 10); + expect(vicinity.toString(), '(row: 0, column: 10)'); + vicinity = vicinity.copyWith(row: 20); + expect(vicinity.xIndex, 10); + expect(vicinity.yIndex, 20); + expect(vicinity.row, 20); + expect(vicinity.column, 10); + expect(vicinity.toString(), '(row: 20, column: 10)'); + }); + + group('Merged cells', () { + group('Valid merge assertions', () { + test('TableViewCell asserts nonsensical merge configurations', () { + TableViewCell? cell; + const Widget child = SizedBox.shrink(); + expect( + () { + cell = TableViewCell(rowMergeStart: 0, child: child); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains( + 'Row merge start and span must both be set, or both unset.', + ), + ), + ), + ); + expect( + () { + cell = TableViewCell(rowMergeSpan: 0, child: child); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains( + 'Row merge start and span must both be set, or both unset.', + ), + ), + ), + ); + expect( + () { + cell = TableViewCell( + rowMergeStart: -1, + rowMergeSpan: 2, + child: child, + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('rowMergeStart == null || rowMergeStart >= 0'), + ), + ), + ); + expect( + () { + cell = TableViewCell( + rowMergeStart: 0, + rowMergeSpan: 0, + child: child, + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('rowMergeSpan == null || rowMergeSpan > 0'), + ), + ), + ); + expect( + () { + cell = TableViewCell(columnMergeStart: 0, child: child); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains( + 'Column merge start and span must both be set, or both unset.', + ), + ), + ), + ); + expect( + () { + cell = TableViewCell(columnMergeSpan: 0, child: child); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains( + 'Column merge start and span must both be set, or both unset.', + ), + ), + ), + ); + expect( + () { + cell = TableViewCell( + columnMergeStart: -1, + columnMergeSpan: 2, + child: child, + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('columnMergeStart == null || columnMergeStart >= 0'), + ), + ), + ); + expect( + () { + cell = TableViewCell( + columnMergeStart: 0, + columnMergeSpan: 0, + child: child, + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('columnMergeSpan == null || columnMergeSpan > 0'), + ), + ), + ); + expect(cell, isNull); + }); + + testWidgets('Merge start cannot exceed current index', + (WidgetTester tester) async { + // Merge span start is greater than given index, ex: column 10 has merge + // start at 20. + final List exceptions = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // Row + // +---------+ + // | X err | + // | | + // +---------+ + // | merge | + // | | + // + + + // | | + // | | + // +---------+ + // This cell should only be built for (0, 1) and (0, 2), not (0,0). + TableViewCell cell = const TableViewCell( + rowMergeStart: 1, + rowMergeSpan: 2, + child: SizedBox.shrink(), + ); + await tester.pumpWidget(TableView.builder( + cellBuilder: (_, __) => cell, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 1, + rowCount: 3, + )); + FlutterError.onError = oldHandler; + expect(exceptions.length, 2); + expect( + exceptions.first.toString(), + contains('spanMergeStart <= currentSpan'), + ); + + await tester.pumpWidget(Container()); + exceptions.clear(); + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // Column + // +---------+---------+---------+ + // | X err | merged | + // | | | + // +---------+---------+---------+ + // This cell should only be returned for (1, 0) and (2, 0), not (0,0). + cell = const TableViewCell( + columnMergeStart: 1, + columnMergeSpan: 2, + child: SizedBox.shrink(), + ); + await tester.pumpWidget(TableView.builder( + cellBuilder: (_, __) => cell, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 3, + rowCount: 1, + )); + FlutterError.onError = oldHandler; + expect(exceptions.length, 2); + expect( + exceptions.first.toString(), + contains('spanMergeStart <= currentSpan'), + ); + }); + + testWidgets('Merge cannot exceed table contents', + (WidgetTester tester) async { + // Merge exceeds table content, ex: at column 10, cell spans 4 columns, + // but table only has 12 columns. + final List exceptions = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // Row + TableViewCell cell = const TableViewCell( + rowMergeStart: 0, + rowMergeSpan: 10, // Exceeds the number of rows + child: SizedBox.shrink(), + ); + await tester.pumpWidget(TableView.builder( + cellBuilder: (_, __) => cell, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 1, + rowCount: 3, + )); + FlutterError.onError = oldHandler; + expect(exceptions.length, 2); + expect( + exceptions.first.toString(), + contains('spanMergeEnd < spanCount'), + ); + + await tester.pumpWidget(Container()); + exceptions.clear(); + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // Column + cell = const TableViewCell( + columnMergeStart: 0, + columnMergeSpan: 10, // Exceeds the number of columns + child: SizedBox.shrink(), + ); + await tester.pumpWidget(TableView.builder( + cellBuilder: (_, __) => cell, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 3, + rowCount: 1, + )); + FlutterError.onError = oldHandler; + expect(exceptions.length, 2); + expect( + exceptions.first.toString(), + contains('spanMergeEnd < spanCount'), + ); + }); + + testWidgets('Merge cannot contain pinned and unpinned cells', + (WidgetTester tester) async { + // Merge spans pinned and unpinned cells, ex: column 0 is pinned, 0-2 + // expected merge. + final List exceptions = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // Row + TableViewCell cell = const TableViewCell( + rowMergeStart: 0, + rowMergeSpan: 3, + child: SizedBox.shrink(), + ); + await tester.pumpWidget(TableView.builder( + cellBuilder: (_, __) => cell, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 1, + rowCount: 3, + pinnedRowCount: 1, + )); + FlutterError.onError = oldHandler; + expect(exceptions.length, 2); + expect( + exceptions.first.toString(), + contains('spanMergeEnd < pinnedSpanCount'), + ); + + await tester.pumpWidget(Container()); + exceptions.clear(); + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // Column + cell = const TableViewCell( + columnMergeStart: 0, + columnMergeSpan: 3, + child: SizedBox.shrink(), + ); + await tester.pumpWidget(TableView.builder( + cellBuilder: (_, __) => cell, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 3, + rowCount: 1, + pinnedColumnCount: 1, + )); + FlutterError.onError = oldHandler; + expect(exceptions.length, 2); + expect( + exceptions.first.toString(), + contains('spanMergeEnd < pinnedSpanCount'), + ); + }); + }); + + group('layout', () { + // For TableView.mainAxis vertical (default) and + // For TableView.mainAxis horizontal + // - natural scroll directions + // - vertical reversed + // - horizontal reversed + // - both reversed + + late ScrollController verticalController; + late ScrollController horizontalController; + // Verifies the right constraints for merged cells, and that extra calls + // to build are not made for merged cells. + late Map layoutConstraints; + + // Cluster of merged cells (M) surrounded by regular cells (...). + // +---------+--------+--------+ + // | M(0,0) | M(0, 1) | .... + // | | | + // + +--------+--------+ + // | | M(1,1) | .... + // | | | + // +---------+ + + // | (2,0) | | .... + // | | | + // +---------+--------+--------+ + // ... ... ... + final Map mergedColumns = + { + const TableVicinity(row: 0, column: 1): (1, 2), // M(0, 1) + const TableVicinity(row: 0, column: 2): (1, 2), // M(0, 1) + const TableVicinity(row: 1, column: 1): (1, 2), // M(1, 1) + const TableVicinity(row: 1, column: 2): (1, 2), // M(1, 1) + const TableVicinity(row: 2, column: 1): (1, 2), // M(1, 1) + const TableVicinity(row: 2, column: 2): (1, 2), // M(1, 1) + }; + final Map mergedRows = + { + TableVicinity.zero: (0, 2), // M(0, 0) + TableVicinity.zero.copyWith(row: 1): (0, 2), // M(0,0) + const TableVicinity(row: 1, column: 1): (1, 2), // M(1, 1) + const TableVicinity(row: 1, column: 2): (1, 2), // M(1, 1) + const TableVicinity(row: 2, column: 1): (1, 2), // M(1, 1) + const TableVicinity(row: 2, column: 2): (1, 2), // M(1, 1) + }; + + TableViewCell cellBuilder(BuildContext context, TableVicinity vicinity) { + if (mergedColumns.keys.contains(vicinity) || + mergedRows.keys.contains(vicinity)) { + return TableViewCell( + rowMergeStart: mergedRows[vicinity]?.$1, + rowMergeSpan: mergedRows[vicinity]?.$2, + columnMergeStart: mergedColumns[vicinity]?.$1, + columnMergeSpan: mergedColumns[vicinity]?.$2, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + layoutConstraints[vicinity] = constraints; + return Text( + 'M(${mergedRows[vicinity]?.$1 ?? vicinity.row},' + '${mergedColumns[vicinity]?.$1 ?? vicinity.column})', + ); + }, + ), + ); + } + return TableViewCell( + child: Text('M(${vicinity.row},${vicinity.column})'), + ); + } + + setUp(() { + verticalController = ScrollController(); + horizontalController = ScrollController(); + layoutConstraints = {}; + }); + + tearDown(() { + verticalController.dispose(); + horizontalController.dispose(); + }); + + testWidgets('vertical main axis and natural scroll directions', + (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: TableView.builder( + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + ), + cellBuilder: cellBuilder, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 10, + rowCount: 10, + ), + )); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect(tester.getTopLeft(find.text('M(0,0)')), Offset.zero); + expect(tester.getSize(find.text('M(0,0)')), const Size(100.0, 200.0)); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(100.0, 0.0), + ); + expect(tester.getSize(find.text('M(0,1)')), const Size(200.0, 100.0)); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(100.0, 100.0), + ); + expect(tester.getSize(find.text('M(1,1)')), const Size(200.0, 200.0)); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(0.0, 200.0), + ); + expect(tester.getSize(find.text('M(2,0)')), const Size(100.0, 100.0)); + + // Let's scroll a bit and check the layout + verticalController.jumpTo(25.0); + horizontalController.jumpTo(30.0); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), const Offset(-30.0, -25.0)); + expect(tester.getSize(find.text('M(0,0)')), const Size(100.0, 200.0)); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(70.0, -25.0), + ); + expect(tester.getSize(find.text('M(0,1)')), const Size(200.0, 100.0)); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(70.0, 75.0), + ); + expect(tester.getSize(find.text('M(1,1)')), const Size(200.0, 200.0)); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(-30.0, 175.0), + ); + expect(tester.getSize(find.text('M(2,0)')), const Size(100.0, 100.0)); + }); + + testWidgets('vertical main axis, reversed vertical', + (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: TableView.builder( + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + reverse: true, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + ), + cellBuilder: cellBuilder, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 10, + rowCount: 10, + ), + )); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), + const Offset(0.0, 400.0), + ); + expect(tester.getSize(find.text('M(0,0)')), const Size(100.0, 200.0)); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(100.0, 500.0), + ); + expect(tester.getSize(find.text('M(0,1)')), const Size(200.0, 100.0)); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(100.0, 300.0), + ); + expect(tester.getSize(find.text('M(1,1)')), const Size(200.0, 200.0)); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(0.0, 300.0), + ); + expect(tester.getSize(find.text('M(2,0)')), const Size(100.0, 100.0)); + + // Let's scroll a bit and check the layout + verticalController.jumpTo(25.0); + horizontalController.jumpTo(30.0); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), + const Offset(-30.0, 425.0), + ); + expect(tester.getSize(find.text('M(0,0)')), const Size(100.0, 200.0)); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(70.0, 525.0), + ); + expect(tester.getSize(find.text('M(0,1)')), const Size(200.0, 100.0)); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(70.0, 325.0), + ); + expect(tester.getSize(find.text('M(1,1)')), const Size(200.0, 200.0)); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(-30.0, 325.0), + ); + expect(tester.getSize(find.text('M(2,0)')), const Size(100.0, 100.0)); + }); + + testWidgets('vertical main axis, reversed horizontal', + (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: TableView.builder( + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + reverse: true, + ), + cellBuilder: cellBuilder, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 10, + rowCount: 10, + ), + )); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), + const Offset(700.0, 0.0), + ); + expect(tester.getSize(find.text('M(0,0)')), const Size(100.0, 200.0)); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(500.0, 0.0), + ); + expect(tester.getSize(find.text('M(0,1)')), const Size(200.0, 100.0)); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(500.0, 100.0), + ); + expect(tester.getSize(find.text('M(1,1)')), const Size(200.0, 200.0)); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(700.0, 200.0), + ); + expect(tester.getSize(find.text('M(2,0)')), const Size(100.0, 100.0)); + + // Let's scroll a bit and check the layout + verticalController.jumpTo(25.0); + horizontalController.jumpTo(30.0); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), + const Offset(730.0, -25.0), + ); + expect( + tester.getSize(find.text('M(0,0)')), + const Size(100.0, 200.0), + ); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(530.0, -25.0), + ); + expect( + tester.getSize(find.text('M(0,1)')), + const Size(200.0, 100.0), + ); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(530.0, 75.0), + ); + expect( + tester.getSize(find.text('M(1,1)')), + const Size(200.0, 200.0), + ); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(730.0, 175.0), + ); + expect(tester.getSize(find.text('M(2,0)')), const Size(100.0, 100.0)); + }); + + testWidgets('vertical main axis, both axes reversed', + (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: TableView.builder( + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + reverse: true, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + reverse: true, + ), + cellBuilder: cellBuilder, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 10, + rowCount: 10, + ), + )); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), + const Offset(700.0, 400.0), + ); + expect( + tester.getSize(find.text('M(0,0)')), + const Size(100.0, 200.0), + ); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(500.0, 500.0), + ); + expect( + tester.getSize(find.text('M(0,1)')), + const Size(200.0, 100.0), + ); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(500.0, 300.0), + ); + expect( + tester.getSize(find.text('M(1,1)')), + const Size(200.0, 200.0), + ); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(700.0, 300.0), + ); + expect( + tester.getSize(find.text('M(2,0)')), + const Size(100.0, 100.0), + ); + + // Let's scroll a bit and check the layout + verticalController.jumpTo(25.0); + horizontalController.jumpTo(30.0); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), + const Offset(730.0, 425.0), + ); + expect( + tester.getSize(find.text('M(0,0)')), + const Size(100.0, 200.0), + ); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(530.0, 525.0), + ); + expect( + tester.getSize(find.text('M(0,1)')), + const Size(200.0, 100.0), + ); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(530.0, 325.0), + ); + expect( + tester.getSize(find.text('M(1,1)')), + const Size(200.0, 200.0), + ); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(730.0, 325.0), + ); + expect( + tester.getSize(find.text('M(2,0)')), + const Size(100.0, 100.0), + ); + }); + + testWidgets('horizontal main axis and natural scroll directions', + (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: TableView.builder( + mainAxis: Axis.horizontal, + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + ), + cellBuilder: cellBuilder, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 10, + rowCount: 10, + ), + )); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect(tester.getTopLeft(find.text('M(0,0)')), Offset.zero); + expect(tester.getSize(find.text('M(0,0)')), const Size(100.0, 200.0)); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(100.0, 0.0), + ); + expect(tester.getSize(find.text('M(0,1)')), const Size(200.0, 100.0)); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(100.0, 100.0), + ); + expect(tester.getSize(find.text('M(1,1)')), const Size(200.0, 200.0)); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(0.0, 200.0), + ); + expect(tester.getSize(find.text('M(2,0)')), const Size(100.0, 100.0)); + + // Let's scroll a bit and check the layout + verticalController.jumpTo(25.0); + horizontalController.jumpTo(30.0); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), const Offset(-30.0, -25.0)); + expect(tester.getSize(find.text('M(0,0)')), const Size(100.0, 200.0)); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(70.0, -25.0), + ); + expect(tester.getSize(find.text('M(0,1)')), const Size(200.0, 100.0)); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(70.0, 75.0), + ); + expect(tester.getSize(find.text('M(1,1)')), const Size(200.0, 200.0)); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(-30.0, 175.0), + ); + expect(tester.getSize(find.text('M(2,0)')), const Size(100.0, 100.0)); + }); + + testWidgets('horizontal main axis, reversed vertical', + (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: TableView.builder( + mainAxis: Axis.horizontal, + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + reverse: true, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + ), + cellBuilder: cellBuilder, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 10, + rowCount: 10, + ), + )); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), + const Offset(0.0, 400.0), + ); + expect(tester.getSize(find.text('M(0,0)')), const Size(100.0, 200.0)); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(100.0, 500.0), + ); + expect(tester.getSize(find.text('M(0,1)')), const Size(200.0, 100.0)); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(100.0, 300.0), + ); + expect(tester.getSize(find.text('M(1,1)')), const Size(200.0, 200.0)); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(0.0, 300.0), + ); + expect(tester.getSize(find.text('M(2,0)')), const Size(100.0, 100.0)); + + // Let's scroll a bit and check the layout + verticalController.jumpTo(25.0); + horizontalController.jumpTo(30.0); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), const Offset(-30.0, 425.0)); + expect(tester.getSize(find.text('M(0,0)')), const Size(100.0, 200.0)); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(70.0, 525.0), + ); + expect(tester.getSize(find.text('M(0,1)')), const Size(200.0, 100.0)); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(70.0, 325.0), + ); + expect(tester.getSize(find.text('M(1,1)')), const Size(200.0, 200.0)); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(-30.0, 325.0), + ); + expect(tester.getSize(find.text('M(2,0)')), const Size(100.0, 100.0)); + }); + + testWidgets('horizontal main axis, reversed horizontal', + (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: TableView.builder( + mainAxis: Axis.horizontal, + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + reverse: true, + ), + cellBuilder: cellBuilder, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 10, + rowCount: 10, + ), + )); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), + const Offset(700.0, 0.0), + ); + expect(tester.getSize(find.text('M(0,0)')), const Size(100.0, 200.0)); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(500.0, 0.0), + ); + expect(tester.getSize(find.text('M(0,1)')), const Size(200.0, 100.0)); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(500.0, 100.0), + ); + expect(tester.getSize(find.text('M(1,1)')), const Size(200.0, 200.0)); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(700.0, 200.0), + ); + expect(tester.getSize(find.text('M(2,0)')), const Size(100.0, 100.0)); + + // Let's scroll a bit and check the layout + verticalController.jumpTo(25.0); + horizontalController.jumpTo(30.0); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), + const Offset(730.0, -25.0), + ); + expect( + tester.getSize(find.text('M(0,0)')), + const Size(100.0, 200.0), + ); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(530.0, -25.0), + ); + expect( + tester.getSize(find.text('M(0,1)')), + const Size(200.0, 100.0), + ); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(530.0, 75.0), + ); + expect( + tester.getSize(find.text('M(1,1)')), + const Size(200.0, 200.0), + ); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(730.0, 175.0), + ); + expect(tester.getSize(find.text('M(2,0)')), const Size(100.0, 100.0)); + }); + + testWidgets('horizontal main axis, both axes reversed', + (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: TableView.builder( + mainAxis: Axis.horizontal, + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + reverse: true, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + reverse: true, + ), + cellBuilder: cellBuilder, + columnBuilder: (_) => span, + rowBuilder: (_) => span, + columnCount: 10, + rowCount: 10, + ), + )); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), + const Offset(700.0, 400.0), + ); + expect( + tester.getSize(find.text('M(0,0)')), + const Size(100.0, 200.0), + ); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(500.0, 500.0), + ); + expect( + tester.getSize(find.text('M(0,1)')), + const Size(200.0, 100.0), + ); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(500.0, 300.0), + ); + expect( + tester.getSize(find.text('M(1,1)')), + const Size(200.0, 200.0), + ); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(700.0, 300.0), + ); + expect( + tester.getSize(find.text('M(2,0)')), + const Size(100.0, 100.0), + ); + + // Let's scroll a bit and check the layout + verticalController.jumpTo(25.0); + horizontalController.jumpTo(30.0); + await tester.pumpAndSettle(); + expect(find.text('M(0,0)'), findsOneWidget); + expect(find.text('M(0,1)'), findsOneWidget); + expect(find.text('M(0,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 0, column: 2)], + isNull, + ); + expect(find.text('M(1,0)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 0)], + isNull, + ); + expect(find.text('M(1,1)'), findsOneWidget); + expect(find.text('M(1,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 1, column: 2)], + isNull, + ); + expect(find.text('M(2,0)'), findsOneWidget); + expect(find.text('M(2,1)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 1)], + isNull, + ); + expect(find.text('M(2,2)'), findsNothing); // Merged + expect( + layoutConstraints[const TableVicinity(row: 2, column: 2)], + isNull, + ); + + expect( + tester.getTopLeft(find.text('M(0,0)')), + const Offset(730.0, 425.0), + ); + expect( + tester.getSize(find.text('M(0,0)')), + const Size(100.0, 200.0), + ); + expect( + layoutConstraints[TableVicinity.zero], + BoxConstraints.tight(const Size(100.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(0,1)')), + const Offset(530.0, 525.0), + ); + expect( + tester.getSize(find.text('M(0,1)')), + const Size(200.0, 100.0), + ); + expect( + layoutConstraints[const TableVicinity(row: 0, column: 1)], + BoxConstraints.tight(const Size(200.0, 100.0)), + ); + + expect( + tester.getTopLeft(find.text('M(1,1)')), + const Offset(530.0, 325.0), + ); + expect( + tester.getSize(find.text('M(1,1)')), + const Size(200.0, 200.0), + ); + expect( + layoutConstraints[const TableVicinity(row: 1, column: 1)], + BoxConstraints.tight(const Size(200.0, 200.0)), + ); + + expect( + tester.getTopLeft(find.text('M(2,0)')), + const Offset(730.0, 325.0), + ); + expect( + tester.getSize(find.text('M(2,0)')), + const Size(100.0, 100.0), + ); + }); + }); + }); } diff --git a/packages/two_dimensional_scrollables/test/table_view/table_delegate_test.dart b/packages/two_dimensional_scrollables/test/table_view/table_delegate_test.dart index 5d919222f75d..70f2b1a02594 100644 --- a/packages/two_dimensional_scrollables/test/table_view/table_delegate_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/table_delegate_test.dart @@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; const TableSpan span = TableSpan(extent: FixedTableSpanExtent(50)); -const Widget cell = SizedBox.shrink(); +const TableViewCell cell = TableViewCell(child: SizedBox.shrink()); void main() { group('TableCellBuilderDelegate', () { @@ -163,17 +163,21 @@ void main() { cellBuilder: (_, __) => cell, columnBuilder: (_) => span, rowBuilder: (_) => span, - columnCount: 5, - rowCount: 6, + columnCount: 1, + rowCount: 1, ); - expect(delegate.addRepaintBoundaries, isTrue); + expect(delegate.addRepaintBoundaries, isFalse); + expect(cell.addRepaintBoundaries, isTrue); }); test('Notifies listeners & rebuilds', () { int notified = 0; TableCellBuilderDelegate oldDelegate; TableSpan spanBuilder(int index) => span; - Widget cellBuilder(BuildContext context, TableVicinity vicinity) => cell; + TableViewCell cellBuilder(BuildContext context, TableVicinity vicinity) { + return cell; + } + final TableCellBuilderDelegate delegate = TableCellBuilderDelegate( cellBuilder: cellBuilder, columnBuilder: spanBuilder, @@ -219,7 +223,7 @@ void main() { group('TableCellListDelegate', () { test('exposes addAutomaticKeepAlives from super class', () { final TableCellListDelegate delegate = TableCellListDelegate( - cells: >[[]], + cells: >[[]], columnBuilder: (_) => span, rowBuilder: (_) => span, addAutomaticKeepAlives: false, @@ -232,7 +236,7 @@ void main() { expect( () { delegate = TableCellListDelegate( - cells: >[[]], + cells: >[[]], columnBuilder: (_) => span, rowBuilder: (_) => span, pinnedColumnCount: -1, // asserts @@ -249,7 +253,7 @@ void main() { expect( () { delegate = TableCellListDelegate( - cells: >[[]], + cells: >[[]], columnBuilder: (_) => span, rowBuilder: (_) => span, pinnedRowCount: -1, // asserts @@ -266,9 +270,9 @@ void main() { expect( () { delegate = TableCellListDelegate( - cells: >[ - [cell, cell], - [cell, cell], + cells: >[ + [cell, cell], + [cell, cell], ], columnBuilder: (_) => span, rowBuilder: (_) => span, @@ -286,9 +290,9 @@ void main() { expect( () { delegate = TableCellListDelegate( - cells: >[ - [cell, cell], - [cell, cell], + cells: >[ + [cell, cell], + [cell, cell], ], columnBuilder: (_) => span, rowBuilder: (_) => span, @@ -311,9 +315,9 @@ void main() { expect( () { delegate = TableCellListDelegate( - cells: >[ - [cell, cell], - [cell, cell, cell], + cells: >[ + [cell, cell], + [cell, cell, cell], ], columnBuilder: (_) => span, rowBuilder: (_) => span, @@ -336,9 +340,9 @@ void main() { TableCellListDelegate oldDelegate; TableSpan spanBuilder(int index) => span; TableCellListDelegate delegate = TableCellListDelegate( - cells: >[ - [cell, cell], - [cell, cell], + cells: >[ + [cell, cell], + [cell, cell], ], columnBuilder: spanBuilder, rowBuilder: spanBuilder, @@ -363,9 +367,9 @@ void main() { // columnCount oldDelegate = delegate; delegate = TableCellListDelegate( - cells: >[ - [cell, cell, cell], - [cell, cell, cell], + cells: >[ + [cell, cell, cell], + [cell, cell, cell], ], columnBuilder: spanBuilder, rowBuilder: spanBuilder, @@ -375,9 +379,9 @@ void main() { // columnBuilder oldDelegate = delegate; delegate = TableCellListDelegate( - cells: >[ - [cell, cell, cell], - [cell, cell, cell], + cells: >[ + [cell, cell, cell], + [cell, cell, cell], ], columnBuilder: (int index) => const TableSpan( extent: FixedTableSpanExtent(150), @@ -389,10 +393,10 @@ void main() { // rowCount oldDelegate = delegate; delegate = TableCellListDelegate( - cells: >[ - [cell, cell, cell], - [cell, cell, cell], - [cell, cell, cell], + cells: >[ + [cell, cell, cell], + [cell, cell, cell], + [cell, cell, cell], ], columnBuilder: (int index) => const TableSpan( extent: FixedTableSpanExtent(150), @@ -404,10 +408,10 @@ void main() { // rowBuilder oldDelegate = delegate; delegate = TableCellListDelegate( - cells: >[ - [cell, cell, cell], - [cell, cell, cell], - [cell, cell, cell], + cells: >[ + [cell, cell, cell], + [cell, cell, cell], + [cell, cell, cell], ], columnBuilder: (int index) => const TableSpan( extent: FixedTableSpanExtent(150), @@ -421,10 +425,10 @@ void main() { // pinned row count oldDelegate = delegate; delegate = TableCellListDelegate( - cells: >[ - [cell, cell, cell], - [cell, cell, cell], - [cell, cell, cell], + cells: >[ + [cell, cell, cell], + [cell, cell, cell], + [cell, cell, cell], ], columnBuilder: (int index) => const TableSpan( extent: FixedTableSpanExtent(150), @@ -439,10 +443,10 @@ void main() { // pinned column count oldDelegate = delegate; delegate = TableCellListDelegate( - cells: >[ - [cell, cell, cell], - [cell, cell, cell], - [cell, cell, cell], + cells: >[ + [cell, cell, cell], + [cell, cell, cell], + [cell, cell, cell], ], columnBuilder: (int index) => const TableSpan( extent: FixedTableSpanExtent(150), @@ -461,10 +465,10 @@ void main() { test('Changing pinned row and column counts asserts valid values', () { final TableCellListDelegate delegate = TableCellListDelegate( - cells: >[ - [cell, cell, cell], - [cell, cell, cell], - [cell, cell, cell], + cells: >[ + [cell, cell, cell], + [cell, cell, cell], + [cell, cell, cell], ], columnBuilder: (int index) => const TableSpan( extent: FixedTableSpanExtent(150), diff --git a/packages/two_dimensional_scrollables/test/table_view/table_span_test.dart b/packages/two_dimensional_scrollables/test/table_view/table_span_test.dart index b6b0db398e26..53fe69b816a4 100644 --- a/packages/two_dimensional_scrollables/test/table_view/table_span_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/table_span_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; @@ -192,8 +193,8 @@ void main() { horizontalController.dispose(); }); - Widget buildCell(BuildContext context, TableVicinity vicinity) { - return const SizedBox.shrink(); + TableViewCell buildCell(BuildContext context, TableVicinity vicinity) { + return const TableViewCell(child: SizedBox.shrink()); } TableSpan buildSpan(bool isColumn) { @@ -846,6 +847,849 @@ void main() { ); }); }); + + group('merged cell decorations', () { + // Visualizing test cases in this group of tests ---------- + // For each test configuration, these 3 scenarios are validated. + // Each test represents a permutation of + // TableView.mainAxis vertical (default) and horizontal, with + // - natural scroll directions + // - vertical reversed + // - horizontal reversed + // - both reversed + + // Scenario 1 + // Cluster of merged rows (M) surrounded by regular cells (...). + // This tiered scenario verifies that the correct decoration is applied + // for merged rows. + // +---------+--------+--------+ + // | M(0,0)//|////////|////////| + // |/////////|////////|////////| + // +/////////+--------+--------+ + // |/////////| M(1,1) | | + // |/////////| | | + // +---------+ +--------+ + // | | | M(2,2) | + // | | | | + // +---------+--------+ + + // |*********|********| | + // |*********|********| | + // +---------+--------+--------+ + final Map scenario1MergedRows = + { + TableVicinity.zero: (0, 2), + TableVicinity.zero.copyWith(row: 1): (0, 2), + const TableVicinity(row: 1, column: 1): (1, 2), + const TableVicinity(row: 2, column: 1): (1, 2), + const TableVicinity(row: 2, column: 2): (2, 2), + const TableVicinity(row: 3, column: 2): (2, 2), + }; + + TableView buildScenario1({ + bool reverseVertical = false, + bool reverseHorizontal = false, + }) { + return TableView.builder( + verticalDetails: ScrollableDetails.vertical( + reverse: reverseVertical, + ), + horizontalDetails: ScrollableDetails.horizontal( + reverse: reverseHorizontal, + ), + columnCount: 3, + rowCount: 4, + cellBuilder: (_, TableVicinity vicinity) { + return TableViewCell( + rowMergeStart: scenario1MergedRows[vicinity]?.$1, + rowMergeSpan: scenario1MergedRows[vicinity]?.$2, + child: const SizedBox.expand(), + ); + }, + columnBuilder: (_) { + return const TableSpan(extent: FixedTableSpanExtent(100.0)); + }, + rowBuilder: (int index) { + Color? color; + switch (index) { + case 0: + color = const Color(0xFF2196F3); + case 3: + color = const Color(0xFF4CAF50); + } + return TableSpan( + extent: const FixedTableSpanExtent(100.0), + backgroundDecoration: + color == null ? null : TableSpanDecoration(color: color), + ); + }, + ); + } + + // Scenario 2 + // Cluster of merged cells (M) surrounded by regular cells (...). + // This tiered scenario verifies that the correct decoration is applied + // to merged columns. + // +--------+--------+--------+--------+ + // | M(0,0)//////////|********| | + // |/////////////////|********| | + // +--------+--------+--------+--------+ + // |////////| M(1,1) | | + // |////////| | | + // +--------+--------+--------+--------+ + // |////////| |M(2,2)***********| + // |////////| |*****************| + // +--------+--------+--------+--------+ + final Map scenario2MergedColumns = + { + TableVicinity.zero: (0, 2), + TableVicinity.zero.copyWith(column: 1): (0, 2), + const TableVicinity(row: 1, column: 1): (1, 2), + const TableVicinity(row: 1, column: 2): (1, 2), + const TableVicinity(row: 2, column: 2): (2, 2), + const TableVicinity(row: 2, column: 3): (2, 2), + }; + + TableView buildScenario2({ + bool reverseVertical = false, + bool reverseHorizontal = false, + }) { + return TableView.builder( + verticalDetails: ScrollableDetails.vertical( + reverse: reverseVertical, + ), + horizontalDetails: ScrollableDetails.horizontal( + reverse: reverseHorizontal, + ), + columnCount: 4, + rowCount: 3, + cellBuilder: (_, TableVicinity vicinity) { + return TableViewCell( + columnMergeStart: scenario2MergedColumns[vicinity]?.$1, + columnMergeSpan: scenario2MergedColumns[vicinity]?.$2, + child: const SizedBox.expand(), + ); + }, + rowBuilder: (_) { + return const TableSpan(extent: FixedTableSpanExtent(100.0)); + }, + columnBuilder: (int index) { + Color? color; + switch (index) { + case 0: + color = const Color(0xFF2196F3); + case 2: + color = const Color(0xFF4CAF50); + } + return TableSpan( + extent: const FixedTableSpanExtent(100.0), + backgroundDecoration: + color == null ? null : TableSpanDecoration(color: color), + ); + }, + ); + } + + // Scenario 3 + // Cluster of merged cells (M) surrounded by regular cells (...). + // This tiered scenario verifies that the correct decoration is applied + // for merged cells over both rows and columns. + // \\ = blue + // // = green + // XX = intersection + // +--------+--------+--------+--------+ + // | M(0,0)XXXXXXXXXX|\\\\\\\\|XXXXXXXX| + // |XXXXXXXXXXXXXXXXX|\\\\\\\\|XXXXXXXX| + // +XXXXXXXXXXXXXXXXX+--------+--------+ + // |XXXXXXXXXXXXXXXXX| M(1,2) | + // |XXXXXXXXXXXXXXXXX| | + // +--------+--------+ | + // |////////| | | + // |////////| | | + // +--------+--------+--------+--------+ + final Map scenario3MergedRows = + { + TableVicinity.zero: (0, 2), + const TableVicinity(row: 1, column: 0): (0, 2), + const TableVicinity(row: 0, column: 1): (0, 2), + const TableVicinity(row: 1, column: 1): (0, 2), + const TableVicinity(row: 1, column: 2): (1, 2), + const TableVicinity(row: 2, column: 2): (1, 2), + const TableVicinity(row: 1, column: 3): (1, 2), + const TableVicinity(row: 2, column: 3): (1, 2), + }; + final Map scenario3MergedColumns = + { + TableVicinity.zero: (0, 2), + const TableVicinity(row: 1, column: 0): (0, 2), + const TableVicinity(row: 0, column: 1): (0, 2), + const TableVicinity(row: 1, column: 1): (0, 2), + const TableVicinity(row: 1, column: 2): (2, 2), + const TableVicinity(row: 2, column: 2): (2, 2), + const TableVicinity(row: 1, column: 3): (2, 2), + const TableVicinity(row: 2, column: 3): (2, 2), + }; + + TableView buildScenario3({ + Axis mainAxis = Axis.vertical, + bool reverseVertical = false, + bool reverseHorizontal = false, + }) { + return TableView.builder( + mainAxis: mainAxis, + verticalDetails: ScrollableDetails.vertical( + reverse: reverseVertical, + ), + horizontalDetails: ScrollableDetails.horizontal( + reverse: reverseHorizontal, + ), + columnCount: 4, + rowCount: 3, + cellBuilder: (_, TableVicinity vicinity) { + return TableViewCell( + columnMergeStart: scenario3MergedColumns[vicinity]?.$1, + columnMergeSpan: scenario3MergedColumns[vicinity]?.$2, + rowMergeStart: scenario3MergedRows[vicinity]?.$1, + rowMergeSpan: scenario3MergedRows[vicinity]?.$2, + child: const SizedBox.expand(), + ); + }, + rowBuilder: (int index) { + Color? color; + switch (index) { + case 0: + color = const Color(0xFF2196F3); + } + return TableSpan( + extent: const FixedTableSpanExtent(100.0), + backgroundDecoration: + color == null ? null : TableSpanDecoration(color: color), + ); + }, + columnBuilder: (int index) { + Color? color; + switch (index) { + case 0: + case 3: + color = const Color(0xFF4CAF50); + } + return TableSpan( + extent: const FixedTableSpanExtent(100.0), + backgroundDecoration: + color == null ? null : TableSpanDecoration(color: color), + ); + }, + ); + } + + testWidgets('Vertical main axis, natural scroll directions', + (WidgetTester tester) async { + // Scenario 1 + await tester.pumpWidget(buildScenario1()); + expect( + find.byType(TableViewport), + paints + // Top row decorations + ..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 200.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first row + rect: const Rect.fromLTRB(100.0, 0.0, 300.0, 100.0), + color: const Color(0xFF2196F3), + ) + // Bottom row decoration, does not extend into last column + ..rect( + rect: const Rect.fromLTRB(0.0, 300.0, 200.0, 400.0), + color: const Color(0xff4caf50), + )); + + // Scenario 2 + await tester.pumpWidget(buildScenario2()); + expect( + find.byType(TableViewport), + paints + // First column decorations + ..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 100.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first column + rect: const Rect.fromLTRB(0.0, 100.0, 100.0, 300.0), + color: const Color(0xFF2196F3), + ) + // Third column decorations, does not extend into last column + ..rect( + // Unmerged section + rect: const Rect.fromLTRB(200.0, 0.0, 300.0, 100.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(200.0, 200.0, 400.0, 300.0), // M(2,2) + color: const Color(0xff4caf50), + ), + ); + + // Scenario 3 + await tester.pumpWidget(buildScenario3()); + expect( + find.byType(TableViewport), + paints + // Row decorations + ..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first row + rect: const Rect.fromLTRB(200.0, 0.0, 400.0, 100.0), + color: const Color(0xFF2196F3), + ) + // Column decorations + ..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), // M(0,0) + color: const Color(0xff4caf50), + ) + ..rect( + // Rest of the first column + rect: const Rect.fromLTRB(0.0, 200.0, 100.0, 300.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(300.0, 0.0, 400.0, 100.0), // Last column + color: const Color(0xff4caf50), + ), + ); + }); + + testWidgets('Vertical main axis, vertical reversed', + (WidgetTester tester) async { + // Scenario 1 + await tester.pumpWidget(buildScenario1(reverseVertical: true)); + expect( + find.byType(TableViewport), + paints + // Bottom row decorations + ..rect( + rect: const Rect.fromLTRB(0.0, 400.0, 100.0, 600.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first row + rect: const Rect.fromLTRB(100.0, 500.0, 300.0, 600.0), + color: const Color(0xFF2196F3), + ) + // Top row decoration, does not extend into last column + ..rect( + rect: const Rect.fromLTRB(0.0, 200.0, 200.0, 300.0), + color: const Color(0xff4caf50), + ), + ); + + // Scenario 2 + await tester.pumpWidget(buildScenario2(reverseVertical: true)); + expect( + find.byType(TableViewport), + paints + // First column decorations + ..rect( + rect: const Rect.fromLTRB(0.0, 500.0, 200.0, 600.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first column + rect: const Rect.fromLTRB(0.0, 300.0, 100.0, 500.0), + color: const Color(0xFF2196F3), + ) + // Third column decorations, does not extend into last column + ..rect( + // Unmerged section + rect: const Rect.fromLTRB(200.0, 500.0, 300.0, 600.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(200.0, 300.0, 400.0, 400.0), // M(2,2) + color: const Color(0xff4caf50), + ), + ); + + // Scenario 3 + await tester.pumpWidget(buildScenario3(reverseVertical: true)); + expect( + find.byType(TableViewport), + paints + // Row decorations + ..rect( + rect: const Rect.fromLTRB(0.0, 400.0, 200.0, 600.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first row + rect: const Rect.fromLTRB(200.0, 500.0, 400.0, 600.0), + color: const Color(0xFF2196F3), + ) + // Column decorations + ..rect( + rect: const Rect.fromLTRB(0.0, 400.0, 200.0, 600.0), // M(0,0) + color: const Color(0xff4caf50), + ) + ..rect( + // Rest of the first column + rect: const Rect.fromLTRB(0.0, 300.0, 100.0, 400.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: + const Rect.fromLTRB(300.0, 500.0, 400.0, 600.0), // Last column + color: const Color(0xff4caf50), + ), + ); + }); + + testWidgets('Vertical main axis, horizontal reversed', + (WidgetTester tester) async { + // Scenario 1 + await tester.pumpWidget(buildScenario1(reverseHorizontal: true)); + expect( + find.byType(TableViewport), + paints + // Top row decorations + ..rect( + rect: const Rect.fromLTRB(700.0, 0.0, 800.0, 200.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first row + rect: const Rect.fromLTRB(500.0, 0.0, 700.0, 100.0), + color: const Color(0xFF2196F3), + ) + // Bottom row decoration, does not extend into last column + ..rect( + rect: const Rect.fromLTRB(600.0, 300.0, 800.0, 400.0), + color: const Color(0xff4caf50), + ), + ); + + // Scenario 2 + await tester.pumpWidget(buildScenario2(reverseHorizontal: true)); + expect( + find.byType(TableViewport), + paints + // First column decorations + ..rect( + rect: const Rect.fromLTRB(600.0, 0.0, 800.0, 100.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first column + rect: const Rect.fromLTRB(700.0, 100.0, 800.0, 300.0), + color: const Color(0xFF2196F3), + ) + // Third column decorations, does not extend into last column + ..rect( + // Unmerged section + rect: const Rect.fromLTRB(500.0, 0.0, 600.0, 100.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(400.0, 200.0, 600.0, 300.0), // M(2,2) + color: const Color(0xff4caf50), + ), + ); + + // Scenario 3 + await tester.pumpWidget(buildScenario3(reverseHorizontal: true)); + expect( + find.byType(TableViewport), + paints + // Row decorations + ..rect( + rect: const Rect.fromLTRB(600.0, 0.0, 800.0, 200.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first row + rect: const Rect.fromLTRB(400.0, 0.0, 600.0, 100.0), + color: const Color(0xFF2196F3), + ) + // Column decorations + ..rect( + rect: const Rect.fromLTRB(600.0, 0.0, 800.0, 200.0), // M(0,0) + color: const Color(0xff4caf50), + ) + ..rect( + // Rest of the first column + rect: const Rect.fromLTRB(700.0, 200.0, 800.0, 300.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(400.0, 0.0, 500.0, 100.0), // Last column + color: const Color(0xff4caf50), + ), + ); + }); + + testWidgets('Vertical main axis, both reversed', + (WidgetTester tester) async { + // Scenario 1 + await tester.pumpWidget(buildScenario1( + reverseHorizontal: true, + reverseVertical: true, + )); + expect( + find.byType(TableViewport), + paints + // Top row decorations + ..rect( + rect: const Rect.fromLTRB(700.0, 400.0, 800.0, 600.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first row + rect: const Rect.fromLTRB(500.0, 500.0, 700.0, 600.0), + color: const Color(0xFF2196F3), + ) + // Bottom row decoration, does not extend into last column + ..rect( + rect: const Rect.fromLTRB(600.0, 200.0, 800.0, 300.0), + color: const Color(0xff4caf50), + ), + ); + + // Scenario 2 + await tester.pumpWidget(buildScenario2( + reverseHorizontal: true, + reverseVertical: true, + )); + expect( + find.byType(TableViewport), + paints + // First column decorations + ..rect( + rect: const Rect.fromLTRB(600.0, 500.0, 800.0, 600.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first column + rect: const Rect.fromLTRB(700.0, 300.0, 800.0, 500.0), + color: const Color(0xFF2196F3), + ) + // Third column decorations, does not extend into last column + ..rect( + // Unmerged section + rect: const Rect.fromLTRB(500.0, 500.0, 600.0, 600.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(400.0, 300.0, 600.0, 400.0), // M(2,2) + color: const Color(0xff4caf50), + ), + ); + + // Scenario 3 + await tester.pumpWidget(buildScenario3( + reverseHorizontal: true, + reverseVertical: true, + )); + expect( + find.byType(TableViewport), + paints + // Row decorations + ..rect( + rect: const Rect.fromLTRB(600.0, 400.0, 800.0, 600.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first row + rect: const Rect.fromLTRB(400.0, 500.0, 600.0, 600.0), + color: const Color(0xFF2196F3), + ) + // Column decorations + ..rect( + rect: const Rect.fromLTRB(600.0, 400.0, 800.0, 600.0), // M(0,0) + color: const Color(0xff4caf50), + ) + ..rect( + // Rest of the first column + rect: const Rect.fromLTRB(700.0, 300.0, 800.0, 400.0), + color: const Color(0xff4caf50), + ) + ..rect( + // Last column + rect: const Rect.fromLTRB(400.0, 500.0, 500.0, 600.0), + color: const Color(0xff4caf50), + ), + ); + }); + + testWidgets('Horizontal main axis, natural scroll directions', + (WidgetTester tester) async { + // Scenarios 1 & 2 do not mix column and row decorations, so main axis + // does not affect them. + + // Scenario 3 + await tester.pumpWidget(buildScenario3(mainAxis: Axis.horizontal)); + expect( + find.byType(TableViewport), + paints + // Column decorations + ..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), // M(0,0) + color: const Color(0xff4caf50), + ) + ..rect( + // Rest of the first column + rect: const Rect.fromLTRB(0.0, 200.0, 100.0, 300.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(300.0, 0.0, 400.0, 100.0), // Last column + color: const Color(0xff4caf50), + ) + // Row decorations + ..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first row + rect: const Rect.fromLTRB(200.0, 0.0, 400.0, 100.0), + color: const Color(0xFF2196F3), + ), + ); + }); + + testWidgets('Horizontal main axis, vertical reversed', + (WidgetTester tester) async { + // Scenarios 1 & 2 do not mix column and row decorations, so main axis + // does not affect them. + + // Scenario 3 + await tester.pumpWidget(buildScenario3( + reverseVertical: true, + mainAxis: Axis.horizontal, + )); + expect( + find.byType(TableViewport), + paints + // Column decorations + ..rect( + rect: const Rect.fromLTRB(0.0, 400.0, 200.0, 600.0), // M(0,0) + color: const Color(0xff4caf50), + ) + ..rect( + // Rest of the first column + rect: const Rect.fromLTRB(0.0, 300.0, 100.0, 400.0), + color: const Color(0xff4caf50), + ) + ..rect( + // Last column + rect: const Rect.fromLTRB(300.0, 500.0, 400.0, 600.0), + color: const Color(0xff4caf50), + ) + // Row decorations + ..rect( + rect: const Rect.fromLTRB(0.0, 400.0, 200.0, 600.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first row + rect: const Rect.fromLTRB(200.0, 500.0, 400.0, 600.0), + color: const Color(0xFF2196F3), + ), + ); + }); + + testWidgets('Horizontal main axis, horizontal reversed', + (WidgetTester tester) async { + // Scenarios 1 & 2 do not mix column and row decorations, so main axis + // does not affect them. + + // Scenario 3 + await tester.pumpWidget(buildScenario3( + reverseHorizontal: true, + mainAxis: Axis.horizontal, + )); + expect( + find.byType(TableViewport), + paints + // Column decorations + ..rect( + rect: const Rect.fromLTRB(600.0, 0.0, 800.0, 200.0), // M(0,0) + color: const Color(0xff4caf50), + ) + ..rect( + // Rest of the first column + rect: const Rect.fromLTRB(700.0, 200.0, 800.0, 300.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(400.0, 0.0, 500.0, 100.0), // Last column + color: const Color(0xff4caf50), + ) + // Row decorations + ..rect( + rect: const Rect.fromLTRB(600.0, 0.0, 800.0, 200.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first row + rect: const Rect.fromLTRB(400.0, 0.0, 600.0, 100.0), + color: const Color(0xFF2196F3), + ), + ); + }); + + testWidgets('Horizontal main axis, both reversed', + (WidgetTester tester) async { + // Scenarios 1 & 2 do not mix column and row decorations, so main axis + // does not affect them. + + // Scenario 3 + await tester.pumpWidget(buildScenario3( + reverseHorizontal: true, + reverseVertical: true, + mainAxis: Axis.horizontal, + )); + expect( + find.byType(TableViewport), + paints + // Column decorations + ..rect( + rect: const Rect.fromLTRB(600.0, 400.0, 800.0, 600.0), // M(0,0) + color: const Color(0xff4caf50), + ) + ..rect( + // Rest of the first column + rect: const Rect.fromLTRB(700.0, 300.0, 800.0, 400.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: + const Rect.fromLTRB(400.0, 500.0, 500.0, 600.0), // Last column + color: const Color(0xff4caf50), + ) + // Row decorations + ..rect( + rect: const Rect.fromLTRB(600.0, 400.0, 800.0, 600.0), // M(0,0) + color: const Color(0xFF2196F3), + ) + ..rect( + // Rest of the unmerged first row + rect: const Rect.fromLTRB(400.0, 500.0, 600.0, 600.0), + color: const Color(0xFF2196F3), + ), + ); + }); + }); + + testWidgets('merged cells account for row/column padding', + (WidgetTester tester) async { + // Leading padding on the leading cell, and trailing padding on the + // trailing cell should be excluded. Interim leading/trailing + // paddings are consumed by the merged cell. + // Example: This is one whole cell spanning 2 merged columns. + // l indicates leading padding, t trailing padding + // +---------------------------------------------------------+ + // | l | column extent | t | l | column extent | t | + // +---------------------------------------------------------+ + // | <--------- extent of merged cell ---------> | + + // Merged Row + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TableView.builder( + rowCount: 2, + columnCount: 1, + cellBuilder: (_, __) { + return const TableViewCell( + rowMergeStart: 0, + rowMergeSpan: 2, + child: Text('M(0,0)'), + ); + }, + columnBuilder: (_) => + const TableSpan(extent: FixedTableSpanExtent(100.0)), + rowBuilder: (_) { + return const TableSpan( + extent: FixedTableSpanExtent(100.0), + padding: TableSpanPadding(leading: 10.0, trailing: 15.0), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('M(0,0)')), const Offset(0.0, 10.0)); + expect(tester.getSize(find.text('M(0,0)')), const Size(100.0, 225.0)); + + // Merged Column + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TableView.builder( + rowCount: 1, + columnCount: 2, + cellBuilder: (_, __) { + return const TableViewCell( + columnMergeStart: 0, + columnMergeSpan: 2, + child: Text('M(0,0)'), + ); + }, + rowBuilder: (_) => const TableSpan( + extent: FixedTableSpanExtent(100.0), + ), + columnBuilder: (_) { + return const TableSpan( + extent: FixedTableSpanExtent(100.0), + padding: TableSpanPadding(leading: 10.0, trailing: 15.0), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('M(0,0)')), const Offset(10.0, 0)); + expect(tester.getSize(find.text('M(0,0)')), const Size(225.0, 100.0)); + + // Merged Square + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TableView.builder( + rowCount: 2, + columnCount: 2, + cellBuilder: (_, __) { + return const TableViewCell( + rowMergeStart: 0, + rowMergeSpan: 2, + columnMergeStart: 0, + columnMergeSpan: 2, + child: Text('M(0,0)'), + ); + }, + columnBuilder: (_) { + return const TableSpan( + extent: FixedTableSpanExtent(100.0), + padding: TableSpanPadding(leading: 10.0, trailing: 15.0), + ); + }, + rowBuilder: (_) { + return const TableSpan( + extent: FixedTableSpanExtent(100.0), + padding: TableSpanPadding(leading: 10.0, trailing: 15.0), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('M(0,0)')), const Offset(10.0, 10.0)); + expect(tester.getSize(find.text('M(0,0)')), const Size(225.0, 225.0)); + }); } class TestCanvas implements Canvas { diff --git a/packages/two_dimensional_scrollables/test/table_view/table_test.dart b/packages/two_dimensional_scrollables/test/table_view/table_test.dart index 670caf00b620..7d59ad8aa573 100644 --- a/packages/two_dimensional_scrollables/test/table_view/table_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/table_test.dart @@ -10,7 +10,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; const TableSpan span = TableSpan(extent: FixedTableSpanExtent(100)); -const Widget cell = SizedBox.shrink(); +const TableViewCell cell = TableViewCell(child: SizedBox.shrink()); TableSpan getTappableSpan(int index, VoidCallback callback) { return TableSpan( @@ -59,7 +59,7 @@ void main() { expect( delegate.builder( _NullBuildContext(), - const TableVicinity(row: 0, column: 0), + TableVicinity.zero, ), cell, ); @@ -188,9 +188,9 @@ void main() { final TableView tableView = TableView.list( rowBuilder: (_) => span, columnBuilder: (_) => span, - cells: const >[ - [cell, cell, cell], - [cell, cell, cell] + cells: const >[ + [cell, cell, cell], + [cell, cell, cell] ], ); final TableCellListDelegate delegate = @@ -209,8 +209,8 @@ void main() { expect( () { tableView = TableView.list( - cells: const >[ - [cell] + cells: const >[ + [cell] ], columnBuilder: (_) => span, rowBuilder: (_) => span, @@ -228,8 +228,8 @@ void main() { expect( () { tableView = TableView.list( - cells: const >[ - [cell] + cells: const >[ + [cell] ], columnBuilder: (_) => span, rowBuilder: (_) => span, @@ -261,7 +261,12 @@ void main() { rowBuilder: (_) => span, cellBuilder: (_, TableVicinity vicinity) { childKeys[vicinity] = childKeys[vicinity] ?? UniqueKey(); - return SizedBox.square(key: childKeys[vicinity], dimension: 200); + return TableViewCell( + child: SizedBox.square( + key: childKeys[vicinity], + dimension: 200, + ), + ); }, ); TableViewParentData parentDataOf(RenderBox child) { @@ -276,7 +281,7 @@ void main() { ); expect(viewport.mainAxis, Axis.vertical); // first child - TableVicinity vicinity = const TableVicinity(column: 0, row: 0); + TableVicinity vicinity = TableVicinity.zero; TableViewParentData parentData = parentDataOf( viewport.firstChild!, ); @@ -343,7 +348,12 @@ void main() { rowBuilder: (_) => rowSpan, cellBuilder: (_, TableVicinity vicinity) { childKeys[vicinity] = childKeys[vicinity] ?? UniqueKey(); - return SizedBox.square(key: childKeys[vicinity], dimension: 200); + return TableViewCell( + child: SizedBox.square( + key: childKeys[vicinity], + dimension: 200, + ), + ); }, ); TableViewParentData parentDataOf(RenderBox child) { @@ -357,7 +367,7 @@ void main() { childKeys.values.first, ); // first child - TableVicinity vicinity = const TableVicinity(column: 0, row: 0); + TableVicinity vicinity = TableVicinity.zero; TableViewParentData parentData = parentDataOf( viewport.firstChild!, ); @@ -406,7 +416,12 @@ void main() { rowBuilder: (_) => rowSpan, cellBuilder: (_, TableVicinity vicinity) { childKeys[vicinity] = childKeys[vicinity] ?? UniqueKey(); - return SizedBox.square(key: childKeys[vicinity], dimension: 200); + return TableViewCell( + child: SizedBox.square( + key: childKeys[vicinity], + dimension: 200, + ), + ); }, ); @@ -417,7 +432,7 @@ void main() { childKeys.values.first, ); // first child - vicinity = const TableVicinity(column: 0, row: 0); + vicinity = TableVicinity.zero; parentData = parentDataOf( viewport.firstChild!, ); @@ -454,9 +469,11 @@ void main() { ) : span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 100, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 100, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, ); @@ -498,9 +515,11 @@ void main() { ) : span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 100, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 100, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, ); @@ -548,9 +567,11 @@ void main() { ) : span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 100, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 100, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, ); @@ -605,9 +626,11 @@ void main() { ) : span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 100, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 100, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, ); @@ -638,7 +661,9 @@ void main() { columnBuilder: (_) => TableSpan(extent: columnExtent), rowBuilder: (_) => TableSpan(extent: rowExtent), cellBuilder: (_, TableVicinity vicinity) { - return const SizedBox.square(dimension: 100); + return const TableViewCell( + child: SizedBox.square(dimension: 100), + ); }, verticalDetails: ScrollableDetails.vertical( controller: verticalController, @@ -693,9 +718,11 @@ void main() { ), rowBuilder: (_) => span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 100, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 100, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, ); @@ -724,9 +751,11 @@ void main() { ), columnBuilder: (_) => span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 100, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 100, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, ); @@ -757,9 +786,11 @@ void main() { ), rowBuilder: (_) => span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 200, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 200, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, ); @@ -785,9 +816,11 @@ void main() { ), rowBuilder: (_) => span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 200, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 200, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, ); @@ -813,9 +846,11 @@ void main() { ), columnBuilder: (_) => span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 200, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 200, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, ); @@ -840,9 +875,11 @@ void main() { ), columnBuilder: (_) => span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 200, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 200, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, ); @@ -872,9 +909,11 @@ void main() { columnBuilder: (_) => span, rowBuilder: (_) => span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 100, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 100, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, verticalDetails: ScrollableDetails.vertical( @@ -950,9 +989,11 @@ void main() { columnBuilder: (_) => span, rowBuilder: (_) => span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 100, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 100, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, verticalDetails: ScrollableDetails.vertical( @@ -1028,9 +1069,11 @@ void main() { columnBuilder: (_) => span, rowBuilder: (_) => span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 100, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 100, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, verticalDetails: ScrollableDetails.vertical( @@ -1107,9 +1150,11 @@ void main() { columnBuilder: (_) => span, rowBuilder: (_) => span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 100, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 100, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, verticalDetails: ScrollableDetails.vertical( @@ -1185,9 +1230,11 @@ void main() { columnBuilder: (_) => span, rowBuilder: (_) => span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 100, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 100, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, verticalDetails: ScrollableDetails.vertical( @@ -1285,10 +1332,12 @@ void main() { ), ), cellBuilder: (_, TableVicinity vicinity) { - return Container( - height: 200, - width: 200, - color: Colors.grey.withOpacity(0.5), + return TableViewCell( + child: Container( + height: 200, + width: 200, + color: Colors.grey.withOpacity(0.5), + ), ); }, ); @@ -1442,10 +1491,12 @@ void main() { ), ), cellBuilder: (_, TableVicinity vicinity) { - return Container( - height: 200, - width: 200, - color: Colors.grey.withOpacity(0.5), + return TableViewCell( + child: Container( + height: 200, + width: 200, + color: Colors.grey.withOpacity(0.5), + ), ); }, ); @@ -1541,10 +1592,12 @@ void main() { extent: FixedTableSpanExtent(200.0), ), cellBuilder: (_, TableVicinity vicinity) { - return Container( - height: 200, - width: 200, - color: Colors.grey.withOpacity(0.5), + return TableViewCell( + child: Container( + height: 200, + width: 200, + color: Colors.grey.withOpacity(0.5), + ), ); }, ); @@ -1587,10 +1640,12 @@ void main() { extent: FixedTableSpanExtent(200.0), ), cellBuilder: (_, TableVicinity vicinity) { - return Container( - height: 200, - width: 200, - color: Colors.grey.withOpacity(0.5), + return TableViewCell( + child: Container( + height: 200, + width: 200, + color: Colors.grey.withOpacity(0.5), + ), ); }, ); @@ -1634,9 +1689,11 @@ void main() { ) : span, cellBuilder: (_, TableVicinity vicinity) { - return SizedBox.square( - dimension: 100, - child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + return TableViewCell( + child: SizedBox.square( + dimension: 100, + child: Text('Row: ${vicinity.row} Column: ${vicinity.column}'), + ), ); }, );