diff --git a/packages/two_dimensional_scrollables/CHANGELOG.md b/packages/two_dimensional_scrollables/CHANGELOG.md index 222468fa177..e1541589d45 100644 --- a/packages/two_dimensional_scrollables/CHANGELOG.md +++ b/packages/two_dimensional_scrollables/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.0 + +* Adds support for infinite rows and columns in TableView. + ## 0.1.2 * Fixes a layout issue for unpinned merged cells that follow pinned table spans. 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 506204f917a..9f2caa667d4 100644 --- a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart +++ b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart @@ -41,6 +41,18 @@ import 'table_span.dart'; /// the [TableCellDelegateMixin]. The [TableView.builder] and [TableView.list] /// constructors create their own delegate. /// +/// A table with infinite rows and columns can be made by using a +/// [TableCellBuilderDelegate], or the [TableView.builder] constructor, and +/// omitting the row or column count. Returning null from the +/// [columnBuilder] or [rowBuilder] in this case will terminate +/// the row or column at that index, representing the end of the table in that +/// axis. In this scenario, until the potential end of the table in either +/// dimension is reached by returning null, the +/// [ScrollPosition.maxScrollExtent] will reflect [double.infinity]. This is +/// because as the table is built lazily, it will not know the end has been +/// reached until the [ScrollPosition] arrives there. This is similar to +/// returning null from [ListView.builder] to signify the end of the list. +/// /// This example shows a TableView of 100 children, all sized 100 by 100 /// pixels with a few [TableSpanDecoration]s like background colors and borders. /// The `builder` constructor is called on demand for the cells that are visible @@ -116,6 +128,16 @@ class TableView extends TwoDimensionalScrollView { /// This constructor generates a [TableCellBuilderDelegate] for building /// children on demand using the required [cellBuilder], /// [columnBuilder], and [rowBuilder]. + /// + /// For infinite rows and columns, omit providing [columnCount] or [rowCount]. + /// Returning null from the [columnBuilder] or [rowBuilder] will terminate + /// the row or column at that index, representing the end of the table in that + /// axis. In this scenario, until the potential end of the table in either + /// dimension is reached by returning null, the + /// [ScrollPosition.maxScrollExtent] will reflect [double.infinity]. This is + /// because as the table is built lazily, it will not know the end has been + /// reached until the [ScrollPosition] arrives there. This is similar to + /// returning null from [ListView.builder] to signify the end of the list. TableView.builder({ super.key, super.primary, @@ -129,17 +151,17 @@ class TableView extends TwoDimensionalScrollView { super.clipBehavior, int pinnedRowCount = 0, int pinnedColumnCount = 0, - required int columnCount, - required int rowCount, + int? columnCount, + int? rowCount, required TableSpanBuilder columnBuilder, required TableSpanBuilder rowBuilder, required TableViewCellBuilder cellBuilder, }) : assert(pinnedRowCount >= 0), - assert(rowCount >= 0), - assert(rowCount >= pinnedRowCount), - assert(columnCount >= 0), + assert(rowCount == null || rowCount >= 0), + assert(rowCount == null || rowCount >= pinnedRowCount), + assert(columnCount == null || columnCount >= 0), assert(pinnedColumnCount >= 0), - assert(columnCount >= pinnedColumnCount), + assert(columnCount == null || columnCount >= pinnedColumnCount), super( delegate: TableCellBuilderDelegate( columnCount: columnCount, @@ -302,13 +324,33 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { final List _mergedColumns = []; // Cached Table metrics - Map _columnMetrics = {}; - Map _rowMetrics = {}; + final Map _columnMetrics = {}; + final Map _rowMetrics = {}; int? _firstNonPinnedRow; int? _firstNonPinnedColumn; int? _lastNonPinnedRow; int? _lastNonPinnedColumn; + int? _columnNullTerminatedIndex; + bool get _columnsAreInfinite => delegate.columnCount == null; + // How far columns should be laid out in a given frame. + double get _targetColumnPixel { + return cacheExtent + + horizontalOffset.pixels + + viewportDimension.width - + _pinnedColumnsExtent; + } + + int? _rowNullTerminatedIndex; + bool get _rowsAreInfinite => delegate.rowCount == null; + // How far rows should be laid out in a given frame. + double get _targetRowPixel { + return cacheExtent + + verticalOffset.pixels + + viewportDimension.height - + _pinnedRowsExtent; + } + TableVicinity? get _firstNonPinnedCell { if (_firstNonPinnedRow == null || _firstNonPinnedColumn == null) { return null; @@ -406,31 +448,79 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { return false; } - // Updates the cached metrics for the table. - // - // Will iterate through all columns and rows to define the layout pattern of - // the cells of the table. + // Updates the cached column metrics for the table. // - // TODO(Piinks): Add back infinite separately for easier review, https://github.com/flutter/flutter/issues/131226 - // Only relevant when the number of rows and columns is finite - void _updateAllMetrics() { - assert(needsDelegateRebuild || didResize); - - _firstNonPinnedColumn = null; - _lastNonPinnedColumn = null; - double startOfRegularColumn = 0; - double startOfPinnedColumn = 0; + // By default, existing column metrics will be updated if they have changed. + // Setting `appendColumns` to false will preserve existing metrics, adding + // additional metrics up to the visible+cacheExtent or up to a provided index, + // `toColumnIndex`. Appending is only relevant when the number of columns is + // infinite. + void _updateColumnMetrics({bool appendColumns = false, int? toColumnIndex}) { + assert(() { + if (toColumnIndex != null) { + // If we are computing up to an index, we must be appending. + return appendColumns; + } + return true; + }()); + double startOfRegularColumn = 0.0; + double startOfPinnedColumn = 0.0; + if (appendColumns) { + // We are only adding to the metrics we already know, since we are lazily + // compiling metrics. This should only be the case when the + // number of columns is infinite, and saves us going through all the + // columns we already know about. + assert(_columnsAreInfinite); + assert(_columnMetrics.isNotEmpty); + startOfPinnedColumn = + _columnMetrics[_firstNonPinnedColumn]?.trailingOffset ?? 0.0; + startOfRegularColumn = + _columnMetrics[_lastNonPinnedColumn]?.trailingOffset ?? 0.0; + } + // If we are computing up to a specific index, we are getting info for a + // merged cell, do not change the visible cells. + _firstNonPinnedColumn = + toColumnIndex == null ? null : _firstNonPinnedColumn; + _lastNonPinnedColumn = toColumnIndex == null ? null : _lastNonPinnedColumn; + int column = appendColumns ? _columnMetrics.length : 0; + + bool reachedColumnEnd() { + if (_columnsAreInfinite) { + if (toColumnIndex != null) { + // Column metrics should be computed up to the provided index. + // Only relevant when we are filling in missing column metrics in an + // infinite context. + return _columnMetrics.length > toColumnIndex; + } + // There are infinite columns, and no target index, compute metrics + // up to what is visible and in the cache extent, or the index that null + // terminates. + return _lastNonPinnedColumn != null || + _columnNullTerminatedIndex != null; + } + // Compute all the metrics if the columns are finite. + return column == delegate.columnCount!; + } - final Map newColumnMetrics = {}; - for (int column = 0; column < delegate.columnCount; column++) { + while (!reachedColumnEnd()) { final bool isPinned = column < delegate.pinnedColumnCount; final double leadingOffset = isPinned ? startOfPinnedColumn : startOfRegularColumn; _Span? span = _columnMetrics.remove(column); - assert(needsDelegateRebuild || span != null); - final TableSpan configuration = needsDelegateRebuild - ? delegate.buildColumn(column) - : span!.configuration; + final TableSpan? configuration = + span?.configuration ?? delegate.buildColumn(column); + if (configuration == null) { + // We have reached the end of columns based on a null termination. This + // This happens when a column count has not been specified. + assert(_columnsAreInfinite); + _lastNonPinnedColumn ??= column - 1; + _columnNullTerminatedIndex = column; + final bool acceptedDimension = _updateHorizontalScrollBounds(); + if (!acceptedDimension) { + _updateFirstAndLastVisibleCell(); + } + break; + } span ??= _Span(); span.update( isPinned: isPinned, @@ -443,17 +533,13 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { ), ), ); - newColumnMetrics[column] = span; + _columnMetrics[column] = span; if (!isPinned) { if (span.trailingOffset >= horizontalOffset.pixels && _firstNonPinnedColumn == null) { _firstNonPinnedColumn = column; } - final double targetColumnPixel = cacheExtent + - horizontalOffset.pixels + - viewportDimension.width - - startOfPinnedColumn; - if (span.trailingOffset >= targetColumnPixel && + if (span.trailingOffset >= _targetColumnPixel && _lastNonPinnedColumn == null) { _lastNonPinnedColumn = column; } @@ -461,27 +547,82 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { } else { startOfPinnedColumn = span.trailingOffset; } + column++; } - assert(newColumnMetrics.length >= delegate.pinnedColumnCount); - for (final _Span span in _columnMetrics.values) { - span.dispose(); - } - _columnMetrics = newColumnMetrics; - _firstNonPinnedRow = null; - _lastNonPinnedRow = null; - double startOfRegularRow = 0; - double startOfPinnedRow = 0; + assert(_columnMetrics.length >= delegate.pinnedColumnCount); + } + + // Updates the cached row metrics for the table. + // + // By default, existing row metrics will be updated if they have changed. + // Setting `appendRows` to false will preserve existing metrics, adding + // additional metrics up to the visible+cacheExtent or up to a provided index, + // `toRowIndex`. Appending is only relevant when the number of rows is + // infinite. + void _updateRowMetrics({bool appendRows = false, int? toRowIndex}) { + assert(() { + if (toRowIndex != null) { + // If we are computing up to an index, we must be appending. + return appendRows; + } + return true; + }()); + double startOfRegularRow = 0.0; + double startOfPinnedRow = 0.0; + if (appendRows) { + // We are only adding to the metrics we already know, since we are lazily + // compiling metrics. This should only be the case when the + // number of rows is infinite, and saves us going through all the + // rows we already know about. + assert(_rowsAreInfinite); + assert(_rowMetrics.isNotEmpty); + startOfPinnedRow = _rowMetrics[_firstNonPinnedRow]?.trailingOffset ?? 0.0; + startOfRegularRow = _rowMetrics[_lastNonPinnedRow]?.trailingOffset ?? 0.0; + } + // If we are computing up to a specific index, we are getting info for a + // merged cell, do not change the visible cells. + _firstNonPinnedRow = toRowIndex == null ? null : _firstNonPinnedRow; + _lastNonPinnedRow = toRowIndex == null ? null : _lastNonPinnedRow; + int row = appendRows ? _rowMetrics.length : 0; + + bool reachedRowEnd() { + if (_rowsAreInfinite) { + if (toRowIndex != null) { + // Row metrics should be computed up to the provided index. + // Only relevant when we are filling in missing column metrics in an + // infinite context. + return _rowMetrics.length > toRowIndex; + } + // There are infinite row, and no target index, compute metrics + // up to what is visible and in the cache extent, or the index that null + // terminates. + return _lastNonPinnedRow != null || _rowNullTerminatedIndex != null; + } + // Compute all the metrics if the rows are finite. + return row == delegate.rowCount!; + } - final Map newRowMetrics = {}; - for (int row = 0; row < delegate.rowCount; row++) { + while (!reachedRowEnd()) { final bool isPinned = row < delegate.pinnedRowCount; final double leadingOffset = isPinned ? startOfPinnedRow : startOfRegularRow; _Span? span = _rowMetrics.remove(row); - assert(needsDelegateRebuild || span != null); - final TableSpan configuration = - needsDelegateRebuild ? delegate.buildRow(row) : span!.configuration; + final TableSpan? configuration = + span?.configuration ?? delegate.buildRow(row); + if (configuration == null) { + // We have reached the end of rows based on a null termination. This + // This happens when a row count has not been specified, but we have + // reached the end. + assert(_rowsAreInfinite); + _lastNonPinnedRow ??= row - 1; + _rowNullTerminatedIndex = row; + final bool acceptedDimension = _updateVerticalScrollBounds(); + if (!acceptedDimension) { + _updateFirstAndLastVisibleCell(); + } + break; + } span ??= _Span(); span.update( isPinned: isPinned, @@ -494,17 +635,13 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { ), ), ); - newRowMetrics[row] = span; + _rowMetrics[row] = span; if (!isPinned) { if (span.trailingOffset >= verticalOffset.pixels && _firstNonPinnedRow == null) { _firstNonPinnedRow = row; } - final double targetRowPixel = cacheExtent + - verticalOffset.pixels + - viewportDimension.height - - startOfPinnedRow; - if (span.trailingOffset >= targetRowPixel && + if (span.trailingOffset > _targetRowPixel && _lastNonPinnedRow == null) { _lastNonPinnedRow = row; } @@ -512,32 +649,26 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { } else { startOfPinnedRow = span.trailingOffset; } + row++; } - assert(newRowMetrics.length >= delegate.pinnedRowCount); - for (final _Span span in _rowMetrics.values) { - span.dispose(); - } - _rowMetrics = newRowMetrics; - final double maxVerticalScrollExtent; - if (_rowMetrics.length <= delegate.pinnedRowCount) { - assert(_firstNonPinnedRow == null && _lastNonPinnedRow == null); - maxVerticalScrollExtent = 0.0; - } else { - final int lastRow = _rowMetrics.length - 1; - if (_firstNonPinnedRow != null) { - _lastNonPinnedRow ??= lastRow; - } - maxVerticalScrollExtent = math.max( - 0.0, - _rowMetrics[lastRow]!.trailingOffset - - viewportDimension.height + - startOfPinnedRow, - ); + assert(_rowMetrics.length >= delegate.pinnedRowCount); + } + + void _updateScrollBounds() { + final bool acceptedDimension = + _updateHorizontalScrollBounds() && _updateVerticalScrollBounds(); + if (!acceptedDimension) { + _updateFirstAndLastVisibleCell(); } + } + bool _updateHorizontalScrollBounds() { final double maxHorizontalScrollExtent; - if (_columnMetrics.length <= delegate.pinnedColumnCount) { + if (_columnsAreInfinite && _columnNullTerminatedIndex == null) { + maxHorizontalScrollExtent = double.infinity; + } else if (!_columnsAreInfinite && + _columnMetrics.length <= delegate.pinnedColumnCount) { assert(_firstNonPinnedColumn == null && _lastNonPinnedColumn == null); maxHorizontalScrollExtent = 0.0; } else { @@ -549,29 +680,61 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { 0.0, _columnMetrics[lastColumn]!.trailingOffset - viewportDimension.width + - startOfPinnedColumn, + _pinnedColumnsExtent, ); } + return horizontalOffset.applyContentDimensions( + 0.0, + maxHorizontalScrollExtent, + ); + } - final bool acceptedDimension = horizontalOffset.applyContentDimensions( - 0.0, maxHorizontalScrollExtent) && - verticalOffset.applyContentDimensions(0.0, maxVerticalScrollExtent); - if (!acceptedDimension) { - _updateFirstAndLastVisibleCell(); + bool _updateVerticalScrollBounds() { + final double maxVerticalScrollExtent; + if (_rowsAreInfinite && _rowNullTerminatedIndex == null) { + maxVerticalScrollExtent = double.infinity; + } else if (!_rowsAreInfinite && + _rowMetrics.length <= delegate.pinnedRowCount) { + assert(_firstNonPinnedRow == null && _lastNonPinnedRow == null); + maxVerticalScrollExtent = 0.0; + } else { + final int lastRow = _rowMetrics.length - 1; + if (_firstNonPinnedRow != null) { + _lastNonPinnedRow ??= lastRow; + } + maxVerticalScrollExtent = math.max( + 0.0, + _rowMetrics[lastRow]!.trailingOffset - + viewportDimension.height + + _pinnedRowsExtent, + ); } + return verticalOffset.applyContentDimensions( + 0.0, + maxVerticalScrollExtent, + ); } - // Uses the cached metrics to update the currently visible cells - // - // TODO(Piinks): Add back infinite separately for easier review, https://github.com/flutter/flutter/issues/131226 - // Only relevant when the number of rows and columns is finite + // Uses the cached metrics to update the currently visible cells. If the + // number of rows or columns are infinite, the layout is computed lazily, so + // this will call for an update to the metrics if we have scrolled beyond the + // layout portion we know about. void _updateFirstAndLastVisibleCell() { + if (_columnMetrics.isNotEmpty) { + _Span lastKnownColumn = _columnMetrics[_columnMetrics.length - 1]!; + if (_columnsAreInfinite && + lastKnownColumn.trailingOffset < _targetColumnPixel) { + // This will add the column metrics we do not know about up to the + // _targetColumnPixel, while keeping the ones we already know about. + _updateColumnMetrics(appendColumns: true); + lastKnownColumn = _columnMetrics[_columnMetrics.length - 1]!; + assert(_columnMetrics.length == delegate.columnCount || + lastKnownColumn.trailingOffset >= _targetColumnPixel || + _columnNullTerminatedIndex != null); + } + } _firstNonPinnedColumn = null; _lastNonPinnedColumn = null; - final double targetColumnPixel = cacheExtent + - horizontalOffset.pixels + - viewportDimension.width - - _pinnedColumnsExtent; for (int column = 0; column < _columnMetrics.length; column++) { if (_columnMetrics[column]!.isPinned) { continue; @@ -581,7 +744,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _firstNonPinnedColumn == null) { _firstNonPinnedColumn = column; } - if (endOfColumn >= targetColumnPixel && _lastNonPinnedColumn == null) { + if (endOfColumn >= _targetColumnPixel && _lastNonPinnedColumn == null) { _lastNonPinnedColumn = column; break; } @@ -590,12 +753,20 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _lastNonPinnedColumn ??= _columnMetrics.length - 1; } + if (_rowMetrics.isNotEmpty) { + _Span lastKnownRow = _rowMetrics[_rowMetrics.length - 1]!; + if (_rowsAreInfinite && lastKnownRow.trailingOffset < _targetRowPixel) { + // This will add the row metrics we do not know about up to the + // _targetRowPixel, while keeping the ones we already know about. + _updateRowMetrics(appendRows: true); + lastKnownRow = _rowMetrics[_rowMetrics.length - 1]!; + assert(_rowMetrics.length == delegate.rowCount || + lastKnownRow.trailingOffset >= _targetRowPixel || + _rowNullTerminatedIndex != null); + } + } _firstNonPinnedRow = null; _lastNonPinnedRow = null; - final double targetRowPixel = cacheExtent + - verticalOffset.pixels + - viewportDimension.height - - _pinnedRowsExtent; for (int row = 0; row < _rowMetrics.length; row++) { if (_rowMetrics[row]!.isPinned) { continue; @@ -604,7 +775,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { if (endOfRow >= verticalOffset.pixels && _firstNonPinnedRow == null) { _firstNonPinnedRow = row; } - if (endOfRow >= targetRowPixel && _lastNonPinnedRow == null) { + if (endOfRow >= _targetRowPixel && _lastNonPinnedRow == null) { _lastNonPinnedRow = row; break; } @@ -617,13 +788,27 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { @override void layoutChildSequence() { // Reset for a new frame + // We always reset the null terminating indices in case rows or columns have + // been added or removed. + _rowNullTerminatedIndex = null; + _columnNullTerminatedIndex = null; _mergedVicinities.clear(); _mergedRows.clear(); _mergedColumns.clear(); if (needsDelegateRebuild || didResize) { // Recomputes the table metrics, invalidates any cached information. - _updateAllMetrics(); + for (final _Span span in _columnMetrics.values) { + span.dispose(); + } + _columnMetrics.clear(); + for (final _Span span in _rowMetrics.values) { + span.dispose(); + } + _rowMetrics.clear(); + _updateColumnMetrics(); + _updateRowMetrics(); + _updateScrollBounds(); } else { // Updates the visible cells based on cached table metrics. _updateFirstAndLastVisibleCell(); @@ -696,7 +881,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { required int currentSpan, required int spanMergeStart, required int spanMergeEnd, - required int spanCount, + required int? spanCount, required int pinnedSpanCount, required TableVicinity currentVicinity, }) { @@ -712,7 +897,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { 'than the current $lowerSpanOrientation at $currentVicinity.', ); assert( - spanMergeEnd < spanCount, + spanCount == null || spanMergeEnd < spanCount, '$spanOrientation merge configuration exceeds number of ' '${lowerSpanOrientation}s in the table. $spanOrientation merge ' 'containing $currentVicinity starts at $spanMergeStart, and ends at ' @@ -740,6 +925,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { double rowOffset = -offset.dy; for (int row = start.row; row <= end.row; row += 1) { double columnOffset = -offset.dx; + assert(row < _rowMetrics.length); rowSpan = _rowMetrics[row]!; final double standardRowHeight = rowSpan.extent; double? mergedRowHeight; @@ -747,6 +933,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { rowOffset += rowSpan.configuration.padding.leading; for (int column = start.column; column <= end.column; column += 1) { + assert(column < _columnMetrics.length); colSpan = _columnMetrics[column]!; final double standardColumnWidth = colSpan.extent; double? mergedColumnWidth; @@ -819,6 +1006,19 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { mergedRowOffset = baseRowOffset + _rowMetrics[firstRow]!.leadingOffset + _rowMetrics[firstRow]!.configuration.padding.leading; + if (_rowsAreInfinite && _rowMetrics[lastRow] == null) { + // The number of rows is infinte, and we have not calculated + // the metrics to the full extent of the merged cell. Update the + // metrics so we have all the information for the merged area. + _updateRowMetrics(appendRows: true, toRowIndex: lastRow); + } + assert( + _rowMetrics[lastRow] != null, + 'The merged cell containing $vicinity is missing TableSpan ' + 'information necessary for layout. The rowBuilder returned ' + 'null, signifying the end, at row $_rowNullTerminatedIndex but the ' + 'merged cell is configured to end with row $lastRow.', + ); mergedRowHeight = _rowMetrics[lastRow]!.trailingOffset - _rowMetrics[firstRow]!.leadingOffset - _rowMetrics[lastRow]!.configuration.padding.trailing - @@ -840,6 +1040,23 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { mergedColumnOffset = baseColumnOffset + _columnMetrics[firstColumn]!.leadingOffset + _columnMetrics[firstColumn]!.configuration.padding.leading; + + if (_columnsAreInfinite && _columnMetrics[lastColumn] == null) { + // The number of columns is infinte, and we have not calculated + // the metrics to the full extent of the merged cell. Update the + // metrics so we have all the information for the merged area. + _updateColumnMetrics( + appendColumns: true, + toColumnIndex: lastColumn, + ); + } + assert( + _columnMetrics[lastColumn] != null, + 'The merged cell containing $vicinity is missing TableSpan ' + 'information necessary for layout. The columnBuilder returned ' + 'null, signifying the end, at column $_columnNullTerminatedIndex but ' + 'the merged cell is configured to end with column $lastColumn.', + ); mergedColumnWidth = _columnMetrics[lastColumn]!.trailingOffset - _columnMetrics[firstColumn]!.leadingOffset - _columnMetrics[lastColumn]!.configuration.padding.trailing - @@ -1040,7 +1257,10 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { // 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)); + assert( + _mergedVicinities.keys.contains(vicinity), + 'The vicinity $vicinity is not accounted for as covered by a merged cell.', + ); final TableVicinity mergedVicinity = _mergedVicinities[vicinity]!; // This vicinity must resolve to a child, unless something has gone wrong! return getChildFor( @@ -1468,6 +1688,12 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _clipPinnedRowsHandle.layer = null; _clipPinnedColumnsHandle.layer = null; _clipCellsHandle.layer = null; + for (final _Span span in _rowMetrics.values) { + span.dispose(); + } + for (final _Span span in _columnMetrics.values) { + span.dispose(); + } super.dispose(); } } 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 39a8d676a54..11ea2459ea0 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 @@ -14,7 +14,10 @@ import 'table_span.dart'; /// Used by the [TableCellDelegateMixin.buildColumn] and /// [TableCellDelegateMixin.buildRow] to configure rows and columns in the /// [TableView]. -typedef TableSpanBuilder = TableSpan Function(int index); +/// +/// Returning null from this builder signifies the end of rows or columns being +/// built if a row or column count has not been specified for the table. +typedef TableSpanBuilder = TableSpan? Function(int index); /// Signature for a function that creates a child [TableViewCell] for a given /// [TableVicinity] in a [TableView], but may return null. @@ -33,9 +36,8 @@ mixin TableCellDelegateMixin on TwoDimensionalChildDelegate { /// /// The [buildColumn] method will be called for indices smaller than the value /// provided here to learn more about the extent and visual appearance of a - /// particular column. - // TODO(Piinks): land infinite separately, https://github.com/flutter/flutter/issues/131226 - // If null, the table will have an infinite number of columns. + /// particular column. If null, the table will have an infinite number of + /// columns, unless [buildColumn] returns null to signify the end. /// /// The value returned by this getter may be an estimate of the total /// available columns, but [buildColumn] method must provide a valid @@ -46,15 +48,18 @@ mixin TableCellDelegateMixin on TwoDimensionalChildDelegate { /// /// If the value returned by this getter changes throughout the lifetime of /// the delegate object, [notifyListeners] must be called. - int get columnCount; + /// + /// When null, the number of columns will be infinite in number, unless null + /// is returned from [TableCellBuilderDelegate.columnBuilder]. The + /// [TableCellListDelegate] does not support an infinite number of columns. + int? get columnCount; /// The number of rows that the table has content for. /// /// The [buildRow] method will be called for indices smaller than the value /// provided here to learn more about the extent and visual appearance of a - /// particular row. - // TODO(Piinks): land infinite separately, https://github.com/flutter/flutter/issues/131226 - // If null, the table will have an infinite number of rows. + /// particular row. If null, the table will have an infinite number of rows, + /// unless [buildRow] returns null to signify the end. /// /// The value returned by this getter may be an estimate of the total /// available rows, but [buildRow] method must provide a valid @@ -65,7 +70,11 @@ mixin TableCellDelegateMixin on TwoDimensionalChildDelegate { /// /// If the value returned by this getter changes throughout the lifetime of /// the delegate object, [notifyListeners] must be called. - int get rowCount; + /// + /// When null, the number of rows will be infinite in number, unless null + /// is returned from [TableCellBuilderDelegate.rowBuilder]. The + /// [TableCellListDelegate] does not support an infinite number of rows. + int? get rowCount; /// The number of columns that are permanently shown on the leading vertical /// edge of the viewport. @@ -104,14 +113,18 @@ mixin TableCellDelegateMixin on TwoDimensionalChildDelegate { /// Builds the [TableSpan] that describes the column at the provided index. /// /// The builder must return a valid [TableSpan] for all indices smaller than - /// [columnCount]. - TableSpan buildColumn(int index); + /// [columnCount]. If [columnCount] is null, the number of columns will be + /// infinite, unless this builder returns null to signal the end of the + /// columns. + TableSpan? buildColumn(int index); /// Builds the [TableSpan] that describe the row at the provided index. /// /// The builder must return a valid [TableSpan] for all indices smaller than - /// [rowCount]. - TableSpan buildRow(int index); + /// [rowCount]. If [rowCount] is null, the number of rows will be + /// infinite, unless this builder returns null to signal the end of the + /// columns. + TableSpan? buildRow(int index); } /// A delegate that supplies children for a [TableViewport] on demand using a @@ -120,12 +133,17 @@ mixin TableCellDelegateMixin on TwoDimensionalChildDelegate { /// Unlike the base [TwoDimensionalChildBuilderDelegate] this delegate does not /// automatically insert repaint boundaries. Instead, repaint boundaries are /// controlled by [TableViewCell.addRepaintBoundaries]. +/// +/// If the [rowCount] or [columnCount] is not provided, the number of rows +/// and/or columns will be infinite. Returning null from the [columnBuilder] +/// and/or [rowBuilder] in this case can terminate the number of rows and +/// columns at the given index. class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate with TableCellDelegateMixin { /// Creates a lazy building delegate to use with a [TableView]. TableCellBuilderDelegate({ - required int columnCount, - required int rowCount, + int? columnCount, + int? rowCount, int pinnedColumnCount = 0, int pinnedRowCount = 0, super.addAutomaticKeepAlives, @@ -134,10 +152,10 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate required TableSpanBuilder rowBuilder, }) : assert(pinnedColumnCount >= 0), assert(pinnedRowCount >= 0), - assert(rowCount >= 0), - assert(columnCount >= 0), - assert(pinnedColumnCount <= columnCount), - assert(pinnedRowCount <= rowCount), + assert(rowCount == null || rowCount >= 0), + assert(columnCount == null || columnCount >= 0), + assert(columnCount == null || pinnedColumnCount <= columnCount), + assert(rowCount == null || pinnedRowCount <= rowCount), _rowBuilder = rowBuilder, _columnBuilder = columnBuilder, _pinnedColumnCount = pinnedColumnCount, @@ -145,33 +163,36 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate super( builder: (BuildContext context, ChildVicinity vicinity) => cellBuilder(context, vicinity as TableVicinity), - maxXIndex: columnCount - 1, - maxYIndex: rowCount - 1, + maxXIndex: columnCount == null ? columnCount : columnCount - 1, + maxYIndex: rowCount == null ? rowCount : rowCount - 1, // repaintBoundaries handled by TableViewCell addRepaintBoundaries: false, ); @override - int get columnCount => maxXIndex! + 1; - set columnCount(int value) { - assert(pinnedColumnCount <= value); - maxXIndex = value - 1; + int? get columnCount => maxXIndex == null ? null : maxXIndex! + 1; + + set columnCount(int? value) { + assert(value == null || pinnedColumnCount <= value); + maxXIndex = value == null ? null : value - 1; } /// Builds the [TableSpan] that describes the column at the provided index. /// /// The builder must return a valid [TableSpan] for all indices smaller than - /// [columnCount]. + /// [columnCount]. If [columnCount] is null, the number of columns will be + /// infinite, unless this builder returns null to signal the end of the + /// columns. final TableSpanBuilder _columnBuilder; @override - TableSpan buildColumn(int index) => _columnBuilder(index); + TableSpan? buildColumn(int index) => _columnBuilder(index); @override int get pinnedColumnCount => _pinnedColumnCount; int _pinnedColumnCount; set pinnedColumnCount(int value) { assert(value >= 0); - assert(value <= columnCount); + assert(columnCount == null || value <= columnCount!); if (pinnedColumnCount == value) { return; } @@ -180,26 +201,29 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate } @override - int get rowCount => maxYIndex! + 1; - set rowCount(int value) { - assert(pinnedRowCount <= value); - maxYIndex = value - 1; + int? get rowCount => maxYIndex == null ? null : maxYIndex! + 1; + + set rowCount(int? value) { + assert(value == null || pinnedRowCount <= value); + maxYIndex = value == null ? null : value - 1; } /// Builds the [TableSpan] that describes the row at the provided index. /// /// The builder must return a valid [TableSpan] for all indices smaller than - /// [rowCount]. + /// [rowCount]. If [rowCount] is null, the number of rows will be + /// infinite, unless this builder returns null to signal the end of the + /// rows. final TableSpanBuilder _rowBuilder; @override - TableSpan buildRow(int index) => _rowBuilder(index); + TableSpan? buildRow(int index) => _rowBuilder(index); @override int get pinnedRowCount => _pinnedRowCount; int _pinnedRowCount; set pinnedRowCount(int value) { assert(value >= 0); - assert(value <= rowCount); + assert(rowCount == null || value <= rowCount!); if (pinnedRowCount == value) { return; } @@ -261,7 +285,13 @@ class TableCellListDelegate extends TwoDimensionalChildListDelegate /// [columnCount]. final TableSpanBuilder _columnBuilder; @override - TableSpan buildColumn(int index) => _columnBuilder(index); + TableSpan? buildColumn(int index) { + if (index >= columnCount) { + // The list delegate has a finite number of columns. + return null; + } + return _columnBuilder(index); + } @override int get pinnedColumnCount => _pinnedColumnCount; @@ -285,7 +315,13 @@ class TableCellListDelegate extends TwoDimensionalChildListDelegate /// [rowCount]. final TableSpanBuilder _rowBuilder; @override - TableSpan buildRow(int index) => _rowBuilder(index); + TableSpan? buildRow(int index) { + if (index >= rowCount) { + // The list deleagte has a finite number of rows. + return null; + } + return _rowBuilder(index); + } @override int get pinnedRowCount => _pinnedRowCount; diff --git a/packages/two_dimensional_scrollables/pubspec.yaml b/packages/two_dimensional_scrollables/pubspec.yaml index 45f1d77ea6e..fb9dc192f70 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.1.2 +version: 0.2.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_test.dart b/packages/two_dimensional_scrollables/test/table_view/table_test.dart index de08873cfb2..ce879b75b27 100644 --- a/packages/two_dimensional_scrollables/test/table_view/table_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/table_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/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -181,6 +182,1861 @@ void main() { ); expect(tableView, isNull); }); + + group('Infinite spans - ', () { + late ScrollController verticalController; + late ScrollController horizontalController; + const TableSpan largeSpan = TableSpan(extent: FixedTableSpanExtent(200)); + + setUp(() { + verticalController = ScrollController(); + horizontalController = ScrollController(); + }); + + tearDown(() { + verticalController.dispose(); + horizontalController.dispose(); + }); + + TableView getTableView({ + int? columnCount, + int? rowCount, + TableSpanBuilder? columnBuilder, + TableSpanBuilder? rowBuilder, + TableViewCellBuilder? cellBuilder, + int pinnedColumnCount = 0, + int pinnedRowCount = 0, + }) { + return TableView.builder( + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + ), + columnCount: columnCount, + pinnedColumnCount: pinnedColumnCount, + columnBuilder: columnBuilder ?? (_) => largeSpan, + rowCount: rowCount, + pinnedRowCount: pinnedRowCount, + rowBuilder: rowBuilder ?? (_) => largeSpan, + cellBuilder: cellBuilder ?? + (_, TableVicinity vicinity) { + return TableViewCell( + child: Text('R${vicinity.row}:C${vicinity.column}'), + ); + }, + ); + } + + testWidgets('infinite rows, columns are finite', + (WidgetTester tester) async { + // Nothing pinned --- + await tester.pumpWidget(MaterialApp( + home: getTableView(columnCount: 10), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C4')), + const Rect.fromLTRB(800.0, 800.0, 1000.0, 1000.0), + ); + // No rows laid out beyond row 4. + expect(find.text('R5:C0'), findsNothing); + // Change the vertical scroll offset, validate more rows were populated. + verticalController.jumpTo(1000.0); + await tester.pump(); + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R9:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R9:C4')), + const Rect.fromLTRB(800.0, 800.0, 1000.0, 1000.0), + ); + // No rows laid out before row 5, or after row 9. + expect(find.text('R0:C0'), findsNothing); + expect(find.text('R10:C0'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned columns --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + columnCount: 10, + pinnedColumnCount: 1, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C4')), + const Rect.fromLTRB(800.0, 800.0, 1000.0, 1000.0), + ); + // No rows laid out beyond row 4. + expect(find.text('R5:C0'), findsNothing); + // Change the vertical scroll offset, validate more rows were populated. + verticalController.jumpTo(1000.0); + await tester.pump(); + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R9:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R9:C4')), + const Rect.fromLTRB(800.0, 800.0, 1000.0, 1000.0), + ); + // No rows laid out before row 5, or after row 9. + expect(find.text('R0:C0'), findsNothing); + expect(find.text('R10:C0'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned rows --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + columnCount: 10, + pinnedRowCount: 1, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C4')), + const Rect.fromLTRB(800.0, 800.0, 1000.0, 1000.0), + ); + // No rows laid out beyond row 4. + expect(find.text('R5:C0'), findsNothing); + // Change the vertical scroll offset, validate more rows were populated. + verticalController.jumpTo(1000.0); + await tester.pump(); + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R9:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R9:C4')), + const Rect.fromLTRB(800.0, 800.0, 1000.0, 1000.0), + ); + // No rows laid out before row 5, or after row 9, except for pinned row + // 0. + expect(find.text('R0:C0'), findsOneWidget); + expect(find.text('R1:C0'), findsNothing); + expect(find.text('R10:C0'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned columns and rows --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + columnCount: 10, + pinnedColumnCount: 1, + pinnedRowCount: 1, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C4')), + const Rect.fromLTRB(800.0, 800.0, 1000.0, 1000.0), + ); + // No rows laid out beyond row 4. + expect(find.text('R5:C0'), findsNothing); + // Change the vertical scroll offset, validate more rows were populated. + verticalController.jumpTo(1000.0); + await tester.pump(); + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R9:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R9:C4')), + const Rect.fromLTRB(800.0, 800.0, 1000.0, 1000.0), + ); + // No rows laid out before row 5, or after row 9, except for pinned row + // 0. + expect(find.text('R0:C0'), findsOneWidget); + expect(find.text('R1:C0'), findsNothing); + expect(find.text('R10:C0'), findsNothing); + }); + + testWidgets('infinite columns, rows are finite', + (WidgetTester tester) async { + // Nothing pinned --- + await tester.pumpWidget(MaterialApp( + home: getTableView(rowCount: 10), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5. + expect(find.text('R0:C6'), findsNothing); + // Change the horizontal scroll offset, validate more columns were + // populated. + horizontalController.jumpTo(1200.0); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 1200.0); + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C11'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C11')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out before column 5, or after column 12. + expect(find.text('R0:C4'), findsNothing); + expect(find.text('R0:C12'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned columns --- + await tester.pumpWidget(MaterialApp( + home: getTableView(rowCount: 10, pinnedColumnCount: 1), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5. + expect(find.text('R0:C6'), findsNothing); + // Change the horizontal scroll offset, validate more columns were + // populated. + horizontalController.jumpTo(1200.0); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 1200.0); + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C11'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C11')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out before column 5, or after column 12, except for + // pinned column. + expect(find.text('R0:C0'), findsOneWidget); + expect(find.text('R0:C4'), findsNothing); + expect(find.text('R0:C12'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned rows --- + await tester.pumpWidget(MaterialApp( + home: getTableView(rowCount: 10, pinnedRowCount: 1), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5. + expect(find.text('R0:C6'), findsNothing); + // Change the horizontal scroll offset, validate more columns were + // populated. + horizontalController.jumpTo(1200.0); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 1200.0); + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C11'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C11')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out before column 5, or after column 12. + expect(find.text('R0:C4'), findsNothing); + expect(find.text('R0:C12'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned columns and rows --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowCount: 10, + pinnedRowCount: 1, + pinnedColumnCount: 1, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5. + expect(find.text('R0:C6'), findsNothing); + // Change the horizontal scroll offset, validate more columns were + // populated. + horizontalController.jumpTo(1200.0); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 1200.0); + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C11'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C11')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out before column 5, or after column 12, except for + // pinned column. + expect(find.text('R0:C0'), findsOneWidget); + expect(find.text('R0:C4'), findsNothing); + expect(find.text('R0:C12'), findsNothing); + }); + + testWidgets('infinite rows & columns', (WidgetTester tester) async { + // No pinned --- + await tester.pumpWidget(MaterialApp( + home: getTableView(), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5, no rows beyond row 4. + expect(find.text('R0:C6'), findsNothing); + expect(find.text('R5:C0'), findsNothing); + // Change both scroll offsets, validate more columns and rows were + // populated. + horizontalController.jumpTo(1200.0); + verticalController.jumpTo(1000.0); + await tester.pump(); + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 1200.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R5:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R9:C11'), findsOneWidget); + expect( + tester.getRect(find.text('R9:C11')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out before column 5, or after column 12. + expect(find.text('R5:C4'), findsNothing); + expect(find.text('R5:C12'), findsNothing); + // No rows laid out before row 4, or after row 9. + expect(find.text('R3:C6'), findsNothing); + expect(find.text('R10:C6'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned columns --- + await tester.pumpWidget(MaterialApp( + home: getTableView(pinnedColumnCount: 1), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5, no rows beyond row 4. + expect(find.text('R0:C6'), findsNothing); + expect(find.text('R5:C0'), findsNothing); + // Change both scroll offsets, validate more columns and rows were + // populated. + horizontalController.jumpTo(1200.0); + verticalController.jumpTo(1000.0); + await tester.pump(); + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 1200.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R5:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R9:C11'), findsOneWidget); + expect( + tester.getRect(find.text('R9:C11')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out before column 5, or after column 12, except for + // pinned first column. + expect(find.text('R5:C0'), findsOneWidget); + expect(find.text('R5:C4'), findsNothing); + expect(find.text('R5:C12'), findsNothing); + // No rows laid out before row 4, or after row 9. + expect(find.text('R3:C6'), findsNothing); + expect(find.text('R10:C6'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned Rows --- + await tester.pumpWidget(MaterialApp( + home: getTableView(pinnedRowCount: 1), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5, no rows beyond row 4. + expect(find.text('R0:C6'), findsNothing); + expect(find.text('R5:C0'), findsNothing); + // Change both scroll offsets, validate more columns and rows were + // populated. + horizontalController.jumpTo(1200.0); + verticalController.jumpTo(1000.0); + await tester.pump(); + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 1200.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R5:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R9:C11'), findsOneWidget); + expect( + tester.getRect(find.text('R9:C11')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out before column 5, or after column 12. + expect(find.text('R5:C4'), findsNothing); + expect(find.text('R5:C12'), findsNothing); + // No rows laid out before row 4, or after row 9, except for pinned + // first row. + expect(find.text('R0:C6'), findsOneWidget); + expect(find.text('R3:C6'), findsNothing); + expect(find.text('R10:C6'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned columns and rows --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + pinnedRowCount: 1, + pinnedColumnCount: 1, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5, no rows beyond row 4. + expect(find.text('R0:C6'), findsNothing); + expect(find.text('R5:C0'), findsNothing); + // Change both scroll offsets, validate more columns and rows were + // populated. + horizontalController.jumpTo(1200.0); + verticalController.jumpTo(1000.0); + await tester.pump(); + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 1200.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R5:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R9:C11'), findsOneWidget); + expect( + tester.getRect(find.text('R9:C11')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out before column 5, or after column 12, except for + // pinned first column. + expect(find.text('R5:C0'), findsOneWidget); + expect(find.text('R5:C4'), findsNothing); + expect(find.text('R5:C12'), findsNothing); + // No rows laid out before row 4, or after row 9, except for pinned + // first row. + expect(find.text('R0:C6'), findsOneWidget); + expect(find.text('R3:C6'), findsNothing); + expect(find.text('R10:C6'), findsNothing); + }); + + testWidgets('infinite rows can null terminate', + (WidgetTester tester) async { + // Nothing pinned --- + bool calledOutOfBounds = false; + await tester.pumpWidget(MaterialApp( + home: getTableView( + columnCount: 10, + rowBuilder: (int index) { + // There will only be 8 rows. + if (index == 8) { + return null; + } + if (index > 8) { + calledOutOfBounds = true; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C4')), + const Rect.fromLTRB(800.0, 800.0, 1000.0, 1000.0), + ); + // No rows laid out beyond row 4. + expect(find.text('R5:C0'), findsNothing); + // Change the vertical scroll offset, validate more rows were populated. + // This exceeds the bounds of the scroll view once the rows have been + // null terminated. + verticalController.jumpTo(1200.0); + await tester.pumpAndSettle(); + // Position was corrected. + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 0.0); + // After null terminating, the builder was not called further. + expect(calledOutOfBounds, isFalse); + // Max scroll extent was updated to reflect reaching the end of the rows + // after returning null. + expect(verticalController.position.maxScrollExtent, 1000.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R7:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R7:C4')), + const Rect.fromLTRB(800.0, 400.0, 1000.0, 600.0), + ); + // No rows laid out before row 5, or after row 7. + expect(find.text('R0:C0'), findsNothing); + expect(find.text('R8:C0'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned columns --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + columnCount: 10, + pinnedColumnCount: 1, + rowBuilder: (int index) { + // There will only be 8 rows. + if (index == 8) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C4')), + const Rect.fromLTRB(800.0, 800.0, 1000.0, 1000.0), + ); + // No rows laid out beyond row 4. + expect(find.text('R5:C0'), findsNothing); + // Change the vertical scroll offset, validate more rows were populated. + // This exceeds the bounds of the scroll view once the rows have been + // null terminated. + verticalController.jumpTo(1200.0); + await tester.pumpAndSettle(); + // Position was corrected. + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 0.0); + // Max scroll extent was updated to reflect reaching the end of the rows + // after returning null. + expect(verticalController.position.maxScrollExtent, 1000.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R7:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R7:C4')), + const Rect.fromLTRB(800.0, 400.0, 1000.0, 600.0), + ); + // No rows laid out before row 5, or after row 7. + expect(find.text('R0:C0'), findsNothing); + expect(find.text('R8:C0'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned rows --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + columnCount: 10, + pinnedRowCount: 1, + rowBuilder: (int index) { + // There will only be 8 rows. + if (index == 8) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C4')), + const Rect.fromLTRB(800.0, 800.0, 1000.0, 1000.0), + ); + // No rows laid out beyond row 4. + expect(find.text('R5:C0'), findsNothing); + // Change the vertical scroll offset, validate more rows were populated. + // This exceeds the bounds of the scroll view once the rows have been + // null terminated. + verticalController.jumpTo(1200.0); + await tester.pumpAndSettle(); + // Position was corrected. + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 0.0); + // Max scroll extent was updated to reflect reaching the end of the rows + // after returning null. + expect(verticalController.position.maxScrollExtent, 1000.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R7:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R7:C4')), + const Rect.fromLTRB(800.0, 400.0, 1000.0, 600.0), + ); + // No rows laid out before row 5, or after row 7, except for the first + // pinned row. + expect(find.text('R0:C0'), findsOneWidget); + expect(find.text('R1:C0'), findsNothing); + expect(find.text('R8:C0'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned columns and rows --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + columnCount: 10, + pinnedColumnCount: 1, + pinnedRowCount: 1, + rowBuilder: (int index) { + // There will only be 8 rows. + if (index == 8) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C4')), + const Rect.fromLTRB(800.0, 800.0, 1000.0, 1000.0), + ); + // No rows laid out beyond row 4. + expect(find.text('R5:C0'), findsNothing); + // Change the vertical scroll offset, validate more rows were populated. + // This exceeds the bounds of the scroll view once the rows have been + // null terminated. + verticalController.jumpTo(1200.0); + await tester.pumpAndSettle(); + // Position was corrected. + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 0.0); + // Max scroll extent was updated to reflect reaching the end of the rows + // after returning null. + expect(verticalController.position.maxScrollExtent, 1000.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R7:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R7:C4')), + const Rect.fromLTRB(800.0, 400.0, 1000.0, 600.0), + ); + // No rows laid out before row 5, or after row 7, except for the first + // pinned row. + expect(find.text('R0:C0'), findsOneWidget); + expect(find.text('R1:C0'), findsNothing); + expect(find.text('R8:C0'), findsNothing); + }); + + testWidgets('Null terminated rows will update', + (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: getTableView( + columnCount: 10, + rowBuilder: (int index) { + // There will only be 8 rows. + if (index == 8) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + // Change the vertical scroll offset, validate more rows were populated. + // This exceeds the bounds of the scroll view once the rows have been + // null terminated. + verticalController.jumpTo(1200.0); + await tester.pumpAndSettle(); + // Position was corrected. + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 0.0); + // Max scroll extent was updated to reflect reaching the end of the rows + // after returning null. + expect(verticalController.position.maxScrollExtent, 1000.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R7:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R7:C4')), + const Rect.fromLTRB(800.0, 400.0, 1000.0, 600.0), + ); + // No rows laid out before row 5, or after row 7. + expect(find.text('R0:C0'), findsNothing); + expect(find.text('R8:C0'), findsNothing); + + // Increase the number of rows + await tester.pumpWidget(MaterialApp( + home: getTableView( + columnCount: 10, + rowBuilder: (int index) { + // There will only be 16 rows. + if (index == 16) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + + // The position should not have changed. + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 0.0); + // Max scroll extent was updated to reflect we no longer know where the + // end is, until the rowBuilder returns null again. + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, 1200.0); + // The layout should not have changed. + expect(find.text('R5:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R7:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R7:C4')), + const Rect.fromLTRB(800.0, 400.0, 1000.0, 600.0), + ); + // No rows laid out before row 5, but more rows were laid out into the + // newly updated cacheExtent (row 8). + expect(find.text('R0:C0'), findsNothing); + expect(find.text('R8:C0'), findsOneWidget); + // This exceeds the new bounds. + verticalController.jumpTo(3200.0); + await tester.pumpAndSettle(); + // Position was corrected. + expect(verticalController.position.pixels, 2600.0); + expect(horizontalController.position.pixels, 0.0); + // Max scroll extent was updated to reflect reaching the end of the rows + // after returning null again at the new index. + expect(verticalController.position.maxScrollExtent, 2600.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + + // Decrease the number of rows + await tester.pumpWidget(MaterialApp( + home: getTableView( + columnCount: 10, + rowBuilder: (int index) { + // There will only be 5 rows. + if (index == 5) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + + // The position should have changed. + expect(verticalController.position.pixels, 400.0); + expect(horizontalController.position.pixels, 0.0); + // Max scroll extent was updated to the new end we have corrected to. + expect(verticalController.position.maxScrollExtent, 400.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + // The layout updated. + expect(find.text('R2:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R2:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C4')), + const Rect.fromLTRB(800.0, 400.0, 1000.0, 600.0), + ); + // No rows laid out after row 5. + expect(find.text('R5:C0'), findsNothing); + }); + + testWidgets('Null terminated columns will update', + (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowCount: 10, + columnBuilder: (int index) { + // There will only be 8 columns. + if (index == 8) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + // Change the horizontal scroll offset, validate more columns were + // populated. This exceeds the bounds of the scroll view once the + // columns have been null terminated. + horizontalController.jumpTo(1400.0); + await tester.pumpAndSettle(); + // Position was corrected. + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 800.0); + // Max scroll extent was updated to reflect reaching the end of the + // columns after returning null. + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, 800.0); + expect(find.text('R0:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C5')), + const Rect.fromLTRB(200.0, 0.0, 400.0, 200.0), + ); + expect(find.text('R4:C7'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C7')), + const Rect.fromLTRB(600.0, 800.0, 800.0, 1000.0), + ); + // No columns laid out before column 3, or after column 7. + expect(find.text('R0:C2'), findsNothing); + expect(find.text('R0:C8'), findsNothing); + + // Increase the number of rows + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowCount: 10, + columnBuilder: (int index) { + // There will only be 16 column. + if (index == 16) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + + // The position should not have changed. + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 800.0); + // Max scroll extent was updated to reflect we no longer know where the + // end is, until the rowBuilder returns null again. + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, double.infinity); + // The layout should not have changed. + expect(find.text('R0:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C5')), + const Rect.fromLTRB(200.0, 0.0, 400.0, 200.0), + ); + expect(find.text('R4:C7'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C7')), + const Rect.fromLTRB(600.0, 800.0, 800.0, 1000.0), + ); + // No columns laid out before column 3, but after column 7 we have added + // new columns. + expect(find.text('R0:C2'), findsNothing); + expect(find.text('R0:C8'), findsOneWidget); + // This exceeds the new bounds. + horizontalController.jumpTo(3200.0); + await tester.pumpAndSettle(); + // Position was corrected. + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 2400.0); + // Max scroll extent was updated to reflect reaching the end of the + // columns after returning null again at the new index. + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, 2400.0); + + // Decrease the number of columns + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowCount: 10, + columnBuilder: (int index) { + // There will only be 5 columns. + if (index == 5) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + + // The position should have changed. + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 200.0); + // Max scroll extent was updated to the new end we have corrected to. + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, 200.0); + // The layout updated. + expect(find.text('R0:C2'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C2')), + const Rect.fromLTRB(200.0, 0.0, 400.0, 200.0), + ); + expect(find.text('R4:C4'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C4')), + const Rect.fromLTRB(600.0, 800.0, 800.0, 1000.0), + ); + // No columns laid out after column 5. + expect(find.text('R0:C5'), findsNothing); + }); + + testWidgets('infinite columns can null terminate', + (WidgetTester tester) async { + // Nothing pinned --- + bool calledOutOfBounds = false; + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowCount: 10, + columnBuilder: (int index) { + // There will only be 10 columns. + if (index == 10) { + return null; + } + if (index > 10) { + calledOutOfBounds = true; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, 1400.00); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5. + expect(find.text('R0:C6'), findsNothing); + // Change the horizontal scroll offset, validate more columns were + // populated. This exceeds the bounds of the scroll view once the + // columns have been null terminated. + horizontalController.jumpTo(1400.0); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + // Position was corrected. + expect(horizontalController.position.pixels, 1200.0); + // After null terminating, the builder was not called further. + expect(calledOutOfBounds, isFalse); + // Max scroll extent was updated to reflect reaching the end of the + // columns after returning null. + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R0:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C9'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C9')), + const Rect.fromLTRB(600.0, 800.0, 800.0, 1000.0), + ); + // No columns laid out before column 5, or after column 9. + expect(find.text('R0:C4'), findsNothing); + expect(find.text('R0:C10'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned columns --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowCount: 10, + pinnedColumnCount: 1, + columnBuilder: (int index) { + // There will only be 10 columns. + if (index == 10) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, 1400.00); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5. + expect(find.text('R0:C6'), findsNothing); + // Change the horizontal scroll offset, validate more columns were + // populated. This exceeds the bounds of the scroll view once the + // columns have been null terminated. + horizontalController.jumpTo(1400.0); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + // Position was corrected. + expect(horizontalController.position.pixels, 1200.0); + // Max scroll extent was updated to reflect reaching the end of the + // columns after returning null. + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R0:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C9'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C9')), + const Rect.fromLTRB(600.0, 800.0, 800.0, 1000.0), + ); + // No columns laid out before column 5, or after column 9, except for + // the pinned first column. + expect(find.text('R0:C0'), findsOneWidget); + expect(find.text('R0:C4'), findsNothing); + expect(find.text('R0:C10'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned rows --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowCount: 10, + pinnedRowCount: 1, + columnBuilder: (int index) { + // There will only be 10 columns. + if (index == 10) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, 1400.00); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5. + expect(find.text('R0:C6'), findsNothing); + // Change the horizontal scroll offset, validate more columns were + // populated. This exceeds the bounds of the scroll view once the + // columns have been null terminated. + horizontalController.jumpTo(1400.0); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + // Position was corrected. + expect(horizontalController.position.pixels, 1200.0); + // Max scroll extent was updated to reflect reaching the end of the + // columns after returning null. + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R0:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C9'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C9')), + const Rect.fromLTRB(600.0, 800.0, 800.0, 1000.0), + ); + // No columns laid out before column 5, or after column 9. + expect(find.text('R0:C4'), findsNothing); + expect(find.text('R0:C10'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned columns and rows --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowCount: 10, + pinnedRowCount: 1, + pinnedColumnCount: 1, + columnBuilder: (int index) { + // There will only be 10 columns. + if (index == 10) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, 1400.00); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5. + expect(find.text('R0:C6'), findsNothing); + // Change the horizontal scroll offset, validate more columns were + // populated. This exceeds the bounds of the scroll view once the + // columns have been null terminated. + horizontalController.jumpTo(1400.0); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + // Position was corrected. + expect(horizontalController.position.pixels, 1200.0); + // Max scroll extent was updated to reflect reaching the end of the + // columns after returning null. + expect(verticalController.position.maxScrollExtent, 1400.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R0:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C9'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C9')), + const Rect.fromLTRB(600.0, 800.0, 800.0, 1000.0), + ); + // No columns laid out before column 5, or after column 9, except for + // the pinned first column. + expect(find.text('R0:C0'), findsOneWidget); + expect(find.text('R0:C4'), findsNothing); + expect(find.text('R0:C10'), findsNothing); + }); + testWidgets('infinite rows & columns can null terminate', + (WidgetTester tester) async { + // Nothing pinned --- + bool calledRowOutOfBounds = false; + bool calledColumnOutOfBounds = false; + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowBuilder: (int index) { + // There will only be 8 rows. + if (index == 8) { + return null; + } + if (index > 8) { + calledRowOutOfBounds = true; + } + return largeSpan; + }, + columnBuilder: (int index) { + // There will only be 10 columns. + if (index == 10) { + return null; + } + if (index > 10) { + calledColumnOutOfBounds = true; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5. + expect(find.text('R0:C6'), findsNothing); + // No rows laid out beyond row 4. + expect(find.text('R5:C0'), findsNothing); + // Change the scroll offsets, validate more columns and rows were + // populated. + // These exceed the bounds of the scroll view once the column and row + // builders have null terminated. + horizontalController.jumpTo(1400.0); + verticalController.jumpTo(1200.0); + await tester.pumpAndSettle(); + // Position was corrected for both. + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 1200.0); + // After null terminating, the builders were not called further. + expect(calledRowOutOfBounds, isFalse); + expect(calledColumnOutOfBounds, isFalse); + // Max scroll extents were updated to reflect reaching the ends after + // returning null. + expect(verticalController.position.maxScrollExtent, 1000.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R7:C9'), findsOneWidget); + expect( + tester.getRect(find.text('R7:C9')), + const Rect.fromLTRB(600.0, 400.0, 800.0, 600.0), + ); + // No columns laid out before column 5, or after column 9. + expect(find.text('R5:C4'), findsNothing); + expect(find.text('R5:C10'), findsNothing); + // No rows laid out before row 4, or after row 7. + expect(find.text('R3:C6'), findsNothing); + expect(find.text('R8:C6'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned columns --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowBuilder: (int index) { + // There will only be 8 rows. + if (index == 8) { + return null; + } + return largeSpan; + }, + pinnedColumnCount: 1, + columnBuilder: (int index) { + // There will only be 10 columns. + if (index == 10) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5. + expect(find.text('R0:C6'), findsNothing); + // No rows laid out beyond row 4. + expect(find.text('R5:C0'), findsNothing); + // Change the scroll offsets, validate more columns and rows were + // populated. + // These exceed the bounds of the scroll view once the column and row + // builders have null terminated. + horizontalController.jumpTo(1400.0); + verticalController.jumpTo(1200.0); + await tester.pumpAndSettle(); + // Position was corrected for both. + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 1200.0); + // Max scroll extents were updated to reflect reaching the ends after + // returning null. + expect(verticalController.position.maxScrollExtent, 1000.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R7:C9'), findsOneWidget); + expect( + tester.getRect(find.text('R7:C9')), + const Rect.fromLTRB(600.0, 400.0, 800.0, 600.0), + ); + // No columns laid out before column 5, or after column 9, except for + // the pinned first column. + expect(find.text('R5:C0'), findsOneWidget); + expect(find.text('R5:C4'), findsNothing); + expect(find.text('R5:C10'), findsNothing); + // No rows laid out before row 4, or after row 7. + expect(find.text('R3:C6'), findsNothing); + expect(find.text('R8:C6'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned rows --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowBuilder: (int index) { + // There will only be 8 rows. + if (index == 8) { + return null; + } + return largeSpan; + }, + pinnedRowCount: 1, + columnBuilder: (int index) { + // There will only be 10 columns. + if (index == 10) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5. + expect(find.text('R0:C6'), findsNothing); + // No rows laid out beyond row 4. + expect(find.text('R5:C0'), findsNothing); + // Change the scroll offsets, validate more columns and rows were + // populated. + // These exceed the bounds of the scroll view once the column and row + // builders have null terminated. + horizontalController.jumpTo(1400.0); + verticalController.jumpTo(1200.0); + await tester.pumpAndSettle(); + // Position was corrected for both. + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 1200.0); + // Max scroll extents were updated to reflect reaching the ends after + // returning null. + expect(verticalController.position.maxScrollExtent, 1000.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R7:C9'), findsOneWidget); + expect( + tester.getRect(find.text('R7:C9')), + const Rect.fromLTRB(600.0, 400.0, 800.0, 600.0), + ); + // No columns laid out before column 5, or after column 9. + expect(find.text('R5:C4'), findsNothing); + expect(find.text('R5:C10'), findsNothing); + // No rows laid out before row 4, or after row 7, except for first + // pinned row. + expect(find.text('R0:C6'), findsOneWidget); + expect(find.text('R3:C6'), findsNothing); + expect(find.text('R8:C6'), findsNothing); + + await tester.pumpWidget(Container()); + + // Pinned columns and rows --- + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowBuilder: (int index) { + // There will only be 8 rows. + if (index == 8) { + return null; + } + return largeSpan; + }, + pinnedRowCount: 1, + pinnedColumnCount: 1, + columnBuilder: (int index) { + // There will only be 10 columns. + if (index == 10) { + return null; + } + return largeSpan; + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R4:C5'), findsOneWidget); + expect( + tester.getRect(find.text('R4:C5')), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + // No columns laid out beyond column 5. + expect(find.text('R0:C6'), findsNothing); + // No rows laid out beyond row 4. + expect(find.text('R5:C0'), findsNothing); + // Change the scroll offsets, validate more columns and rows were + // populated. + // These exceed the bounds of the scroll view once the column and row + // builders have null terminated. + horizontalController.jumpTo(1400.0); + verticalController.jumpTo(1200.0); + await tester.pumpAndSettle(); + // Position was corrected for both. + expect(verticalController.position.pixels, 1000.0); + expect(horizontalController.position.pixels, 1200.0); + // Max scroll extents were updated to reflect reaching the ends after + // returning null. + expect(verticalController.position.maxScrollExtent, 1000.0); + expect(horizontalController.position.maxScrollExtent, 1200.0); + expect(find.text('R5:C6'), findsOneWidget); + expect( + tester.getRect(find.text('R5:C6')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + expect(find.text('R7:C9'), findsOneWidget); + expect( + tester.getRect(find.text('R7:C9')), + const Rect.fromLTRB(600.0, 400.0, 800.0, 600.0), + ); + // No columns laid out before column 5, or after column 9, except for + //the first pinned column. + expect(find.text('R5:C0'), findsOneWidget); + expect(find.text('R5:C4'), findsNothing); + expect(find.text('R5:C10'), findsNothing); + // No rows laid out before row 4, or after row 7, except for first + // pinned row. + expect(find.text('R0:C6'), findsOneWidget); + expect(find.text('R3:C6'), findsNothing); + expect(find.text('R8:C6'), findsNothing); + }); + testWidgets('merged cells work with lazy layout computation', + (WidgetTester tester) async { + // When columns and rows are finite, the layout is eagerly computed and + // the children are lazily laid out. This makes computing merged cell + // layouts easy. In an infinite world, the layout is also lazily + // computed, so not all of the information may be available for a merged + // cell if it extends into an area we have not computed the layout for + // yet. + const ({int start, int span}) rowConfig = (start: 0, span: 10); + final List mergedRows = List.generate( + 10, + (int index) => index, + ); + const ({int start, int span}) columnConfig = (start: 1, span: 10); + final List mergedColumns = List.generate( + 10, + (int index) => index + 1, + ); + await tester.pumpWidget(MaterialApp( + home: getTableView( + cellBuilder: (_, TableVicinity vicinity) { + // Merged row + if (mergedRows.contains(vicinity.row) && vicinity.column == 0) { + return TableViewCell( + rowMergeStart: rowConfig.start, + rowMergeSpan: rowConfig.span, + child: const Text('R0:C0'), + ); + } + // Merged column + if (mergedColumns.contains(vicinity.column) && + vicinity.row == 0) { + return TableViewCell( + columnMergeStart: columnConfig.start, + columnMergeSpan: columnConfig.span, + child: const Text('R0:C1'), + ); + } + return TableViewCell( + child: Text('R${vicinity.row}:C${vicinity.column}'), + ); + }, + ), + )); + await tester.pumpAndSettle(); + expect(verticalController.position.pixels, 0.0); + expect(horizontalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, double.infinity); + expect(horizontalController.position.maxScrollExtent, double.infinity); + expect(find.text('R0:C0'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C0')), + const Rect.fromLTRB(0.0, 0.0, 200.0, 2000.0), + ); + expect(find.text('R0:C1'), findsOneWidget); + expect( + tester.getRect(find.text('R0:C1')), + const Rect.fromLTRB(200.0, 0.0, 2200.0, 200.0), + ); + expect(find.text('R1:C1'), findsOneWidget); + expect( + tester.getRect(find.text('R1:C1')), + const Rect.fromLTRB(200.0, 200.0, 400.0, 400.0), + ); + }); + + testWidgets('merged column that exceeds metrics will assert', + (WidgetTester tester) async { + final List exceptions = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + const ({int start, int span}) columnConfig = (start: 1, span: 10); + final List mergedColumns = List.generate( + 10, + (int index) => index + 1, + ); + await tester.pumpWidget(MaterialApp( + home: getTableView( + columnBuilder: (int index) { + // There will only be 8 columns, but the merge is set up for 10. + if (index == 8) { + return null; + } + return largeSpan; + }, + cellBuilder: (_, TableVicinity vicinity) { + // Merged column + if (mergedColumns.contains(vicinity.column) && + vicinity.row == 0) { + return TableViewCell( + columnMergeStart: columnConfig.start, + columnMergeSpan: columnConfig.span, + child: const Text('R0:C1'), + ); + } + return TableViewCell( + child: Text('R${vicinity.row}:C${vicinity.column}'), + ); + }, + ), + )); + await tester.pumpWidget(Container()); + FlutterError.onError = oldHandler; + expect(exceptions.length, 3); + expect( + exceptions.first.toString(), + contains( + 'The merged cell containing (row: 0, column: 1) is ' + 'missing TableSpan information necessary for layout. The ' + 'columnBuilder returned null, signifying the end, at column 8 but ' + 'the merged cell is configured to end with column 10.', + ), + ); + }); + + testWidgets('merged row that exceeds metrics will assert', + (WidgetTester tester) async { + final List exceptions = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + const ({int start, int span}) rowConfig = (start: 0, span: 10); + final List mergedRows = List.generate( + 10, + (int index) => index, + ); + await tester.pumpWidget(MaterialApp( + home: getTableView( + rowBuilder: (int index) { + // There will only be 8 rows, but the merge is set up for 9. + if (index == 8) { + return null; + } + return largeSpan; + }, + cellBuilder: (_, TableVicinity vicinity) { + // Merged column + if (mergedRows.contains(vicinity.row) && vicinity.column == 0) { + return TableViewCell( + rowMergeStart: rowConfig.start, + rowMergeSpan: rowConfig.span, + child: const Text('R0:C0'), + ); + } + return TableViewCell( + child: Text('R${vicinity.row}:C${vicinity.column}'), + ); + }, + ), + )); + await tester.pumpWidget(Container()); + FlutterError.onError = oldHandler; + expect(exceptions.length, 3); + expect( + exceptions.first.toString(), + contains( + 'The merged cell containing (row: 0, column: 0) is ' + 'missing TableSpan information necessary for layout. The ' + 'rowBuilder returned null, signifying the end, at row 8 but ' + 'the merged cell is configured to end with row 9.', + ), + ); + }); + }); }); group('TableView.list', () { @@ -580,7 +2436,7 @@ void main() { await tester.pumpAndSettle(); // Even columns and rows are set up for taps, mainAxis is vertical by - // default, mening row major order. Rows should take precedence where they + // default, meaning row major order. Rows should take precedence where they // intersect at even indices. expect(columnTapCounter, 0); expect(rowTapCounter, 0);