diff --git a/packages/two_dimensional_scrollables/CHANGELOG.md b/packages/two_dimensional_scrollables/CHANGELOG.md index 9f36f35ba095..ac398ba61b3b 100644 --- a/packages/two_dimensional_scrollables/CHANGELOG.md +++ b/packages/two_dimensional_scrollables/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.5.0 + +* Adds support for trailing pinned columns and rows in TableView. + ## 0.4.2 * Fixes an issue where merged cells would unmerge when the first cell was overlaid by a pinned row or column. 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 f675a6a9a84c..54b01a4f74d3 100644 --- a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart +++ b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart @@ -159,6 +159,8 @@ class TableView extends TwoDimensionalScrollView { super.clipBehavior, int pinnedRowCount = 0, int pinnedColumnCount = 0, + int trailingPinnedRowCount = 0, + int trailingPinnedColumnCount = 0, int? columnCount, int? rowCount, required TableSpanBuilder columnBuilder, @@ -166,17 +168,27 @@ class TableView extends TwoDimensionalScrollView { required TableViewCellBuilder cellBuilder, this.alignment = Alignment.topLeft, }) : assert(pinnedRowCount >= 0), + assert(trailingPinnedRowCount >= 0), assert(rowCount == null || rowCount >= 0), - assert(rowCount == null || rowCount >= pinnedRowCount), + assert( + rowCount == null || + rowCount >= pinnedRowCount + trailingPinnedRowCount, + ), assert(columnCount == null || columnCount >= 0), assert(pinnedColumnCount >= 0), - assert(columnCount == null || columnCount >= pinnedColumnCount), + assert(trailingPinnedColumnCount >= 0), + assert( + columnCount == null || + columnCount >= pinnedColumnCount + trailingPinnedColumnCount, + ), super( delegate: TableCellBuilderDelegate( columnCount: columnCount, rowCount: rowCount, pinnedColumnCount: pinnedColumnCount, pinnedRowCount: pinnedRowCount, + trailingPinnedColumnCount: trailingPinnedColumnCount, + trailingPinnedRowCount: trailingPinnedRowCount, cellBuilder: cellBuilder, columnBuilder: columnBuilder, rowBuilder: rowBuilder, @@ -206,16 +218,22 @@ class TableView extends TwoDimensionalScrollView { super.clipBehavior, int pinnedRowCount = 0, int pinnedColumnCount = 0, + int trailingPinnedRowCount = 0, + int trailingPinnedColumnCount = 0, required TableSpanBuilder columnBuilder, required TableSpanBuilder rowBuilder, List> cells = const >[], this.alignment = Alignment.topLeft, }) : assert(pinnedRowCount >= 0), assert(pinnedColumnCount >= 0), + assert(trailingPinnedRowCount >= 0), + assert(trailingPinnedColumnCount >= 0), super( delegate: TableCellListDelegate( pinnedColumnCount: pinnedColumnCount, pinnedRowCount: pinnedRowCount, + trailingPinnedColumnCount: trailingPinnedColumnCount, + trailingPinnedRowCount: trailingPinnedRowCount, cells: cells, columnBuilder: columnBuilder, rowBuilder: rowBuilder, @@ -388,7 +406,8 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { // Where column layout begins, potentially outside of the visible area. double get _targetLeadingColumnPixel { return clampDouble( - horizontalOffset.pixels - math.max(_pinnedColumnsExtent, cacheExtent), + horizontalOffset.pixels - + math.max(_leadingPinnedColumnsExtent, cacheExtent), 0, double.infinity, ); @@ -407,7 +426,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { // Where row layout begins, potentially outside of the visible area. double get _targetLeadingRowPixel { return clampDouble( - verticalOffset.pixels - math.max(_pinnedRowsExtent, cacheExtent), + verticalOffset.pixels - math.max(_leadingPinnedRowsExtent, cacheExtent), 0, double.infinity, ); @@ -446,13 +465,55 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { int? get _lastPinnedColumn => delegate.pinnedColumnCount > 0 ? delegate.pinnedColumnCount - 1 : null; - double get _pinnedRowsExtent => _lastPinnedRow != null - ? _rowMetrics[_lastPinnedRow]!.trailingOffset + int? get _firstTrailingPinnedRow => + delegate.trailingPinnedRowCount > 0 && delegate.rowCount != null + ? delegate.rowCount! - delegate.trailingPinnedRowCount + : null; + int? get _firstTrailingPinnedColumn => + delegate.trailingPinnedColumnCount > 0 && delegate.columnCount != null + ? delegate.columnCount! - delegate.trailingPinnedColumnCount + : null; + + double get _leadingPinnedRowsExtent => delegate.pinnedRowCount > 0 + ? _rowMetrics[delegate.pinnedRowCount - 1]!.trailingOffset : 0.0; - double get _pinnedColumnsExtent => _lastPinnedColumn != null - ? _columnMetrics[_lastPinnedColumn]!.trailingOffset + + double get _leadingPinnedColumnsExtent => delegate.pinnedColumnCount > 0 + ? _columnMetrics[delegate.pinnedColumnCount - 1]!.trailingOffset : 0.0; + double get _trailingPinnedRowsExtent { + if (_firstTrailingPinnedRow == null) { + return 0.0; + } + final int lastRow = delegate.rowCount! - 1; + final _Span? firstSpan = _rowMetrics[_firstTrailingPinnedRow!]; + final _Span? lastSpan = _rowMetrics[lastRow]; + if (firstSpan == null || lastSpan == null) { + return 0.0; + } + return lastSpan.trailingOffset - firstSpan.leadingOffset; + } + + double get _trailingPinnedColumnsExtent { + if (_firstTrailingPinnedColumn == null) { + return 0.0; + } + final int lastColumn = delegate.columnCount! - 1; + final _Span? firstSpan = _columnMetrics[_firstTrailingPinnedColumn!]; + final _Span? lastSpan = _columnMetrics[lastColumn]; + if (firstSpan == null || lastSpan == null) { + return 0.0; + } + return lastSpan.trailingOffset - firstSpan.leadingOffset; + } + + double get _pinnedRowsExtent => + _leadingPinnedRowsExtent + _trailingPinnedRowsExtent; + + double get _pinnedColumnsExtent => + _leadingPinnedColumnsExtent + _trailingPinnedColumnsExtent; + void _debugCheckPinnedExtent() { assert(() { if (_pinnedColumnsExtent > viewportDimension.width) { @@ -465,7 +526,8 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { } else if (_pinnedColumnsExtent == viewportDimension.width) { final bool hasUnpinnedColumns = delegate.columnCount == null || - delegate.columnCount! > delegate.pinnedColumnCount; + delegate.columnCount! > + delegate.pinnedColumnCount + delegate.trailingPinnedColumnCount; if (hasUnpinnedColumns) { debugPrint( 'TableView has pinned columns that fully consume the viewport width. ' @@ -484,7 +546,8 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { } else if (_pinnedRowsExtent == viewportDimension.height) { final bool hasUnpinnedRows = delegate.rowCount == null || - delegate.rowCount! > delegate.pinnedRowCount; + delegate.rowCount! > + delegate.pinnedRowCount + delegate.trailingPinnedRowCount; if (hasUnpinnedRows) { debugPrint( 'TableView has pinned rows that fully consume the viewport height. ' @@ -573,6 +636,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { }()); var startOfRegularColumn = 0.0; var startOfPinnedColumn = 0.0; + var startOfTrailingPinnedColumn = 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 @@ -612,9 +676,15 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { } while (!reachedColumnEnd()) { - final bool isPinned = column < delegate.pinnedColumnCount; + final bool isPinned = + column < delegate.pinnedColumnCount || + (delegate.columnCount != null && + column >= + delegate.columnCount! - delegate.trailingPinnedColumnCount); final leadingOffset = isPinned - ? startOfPinnedColumn + ? (column < delegate.pinnedColumnCount + ? startOfPinnedColumn + : startOfTrailingPinnedColumn) : startOfRegularColumn; _Span? span = _columnMetrics.remove(column); final TableSpan? configuration = @@ -654,13 +724,22 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _lastNonPinnedColumn = column; } startOfRegularColumn = span.trailingOffset; - } else { + } else if (column < delegate.pinnedColumnCount) { startOfPinnedColumn = span.trailingOffset; + } else { + startOfTrailingPinnedColumn = span.trailingOffset; } column++; } assert(_columnMetrics.length >= delegate.pinnedColumnCount); + if (_firstNonPinnedColumn != null) { + _lastNonPinnedColumn ??= _columnNullTerminatedIndex != null + ? _columnNullTerminatedIndex! - 1 + : (delegate.columnCount != null + ? delegate.columnCount! - delegate.trailingPinnedColumnCount - 1 + : null); + } } // Updates the cached row metrics for the table. @@ -680,6 +759,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { }()); var startOfRegularRow = 0.0; var startOfPinnedRow = 0.0; + var startOfTrailingPinnedRow = 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 @@ -714,8 +794,15 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { } while (!reachedRowEnd()) { - final bool isPinned = row < delegate.pinnedRowCount; - final leadingOffset = isPinned ? startOfPinnedRow : startOfRegularRow; + final bool isPinned = + row < delegate.pinnedRowCount || + (delegate.rowCount != null && + row >= delegate.rowCount! - delegate.trailingPinnedRowCount); + final leadingOffset = isPinned + ? (row < delegate.pinnedRowCount + ? startOfPinnedRow + : startOfTrailingPinnedRow) + : startOfRegularRow; _Span? span = _rowMetrics.remove(row); final TableSpan? configuration = span?.configuration ?? delegate.buildRow(row); @@ -755,15 +842,36 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _lastNonPinnedRow = row; } startOfRegularRow = span.trailingOffset; - } else { + } else if (row < delegate.pinnedRowCount) { startOfPinnedRow = span.trailingOffset; + } else { + startOfTrailingPinnedRow = span.trailingOffset; } row++; } assert(_rowMetrics.length >= delegate.pinnedRowCount); + if (_firstNonPinnedRow != null) { + _lastNonPinnedRow ??= _rowNullTerminatedIndex != null + ? _rowNullTerminatedIndex! - 1 + : (delegate.rowCount != null + ? delegate.rowCount! - delegate.trailingPinnedRowCount - 1 + : null); + } } + int? get _lastRegularColumnIndex => delegate.columnCount == null + ? _columnNullTerminatedIndex != null + ? _columnNullTerminatedIndex! - 1 + : null + : delegate.columnCount! - delegate.trailingPinnedColumnCount - 1; + + int? get _lastRegularRowIndex => delegate.rowCount == null + ? _rowNullTerminatedIndex != null + ? _rowNullTerminatedIndex! - 1 + : null + : delegate.rowCount! - delegate.trailingPinnedRowCount - 1; + void _updateScrollBounds() { final bool acceptedDimension = _updateHorizontalScrollBounds() && _updateVerticalScrollBounds(); @@ -777,20 +885,22 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { if (_columnsAreInfinite && _columnNullTerminatedIndex == null) { maxHorizontalScrollExtent = double.infinity; } else if (!_columnsAreInfinite && - _columnMetrics.length <= delegate.pinnedColumnCount) { + _columnMetrics.length <= + delegate.pinnedColumnCount + delegate.trailingPinnedColumnCount) { assert(_firstNonPinnedColumn == null && _lastNonPinnedColumn == null); maxHorizontalScrollExtent = 0.0; } else { - final int lastColumn = _columnMetrics.length - 1; - if (_firstNonPinnedColumn != null) { - _lastNonPinnedColumn ??= lastColumn; + final int? lastColumn = _lastRegularColumnIndex; + if (lastColumn == null || _columnMetrics[lastColumn] == null) { + maxHorizontalScrollExtent = 0.0; + } else { + maxHorizontalScrollExtent = math.max( + 0.0, + _columnMetrics[lastColumn]!.trailingOffset - + viewportDimension.width + + _pinnedColumnsExtent, + ); } - maxHorizontalScrollExtent = math.max( - 0.0, - _columnMetrics[lastColumn]!.trailingOffset - - viewportDimension.width + - _pinnedColumnsExtent, - ); } return horizontalOffset.applyContentDimensions( 0.0, @@ -803,20 +913,22 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { if (_rowsAreInfinite && _rowNullTerminatedIndex == null) { maxVerticalScrollExtent = double.infinity; } else if (!_rowsAreInfinite && - _rowMetrics.length <= delegate.pinnedRowCount) { + _rowMetrics.length <= + delegate.pinnedRowCount + delegate.trailingPinnedRowCount) { assert(_firstNonPinnedRow == null && _lastNonPinnedRow == null); maxVerticalScrollExtent = 0.0; } else { - final int lastRow = _rowMetrics.length - 1; - if (_firstNonPinnedRow != null) { - _lastNonPinnedRow ??= lastRow; + final int? lastRow = _lastRegularRowIndex; + if (lastRow == null || _rowMetrics[lastRow] == null) { + maxVerticalScrollExtent = 0.0; + } else { + maxVerticalScrollExtent = math.max( + 0.0, + _rowMetrics[lastRow]!.trailingOffset - + viewportDimension.height + + _pinnedRowsExtent, + ); } - maxVerticalScrollExtent = math.max( - 0.0, - _rowMetrics[lastRow]!.trailingOffset - - viewportDimension.height + - _pinnedRowsExtent, - ); } return verticalOffset.applyContentDimensions(0.0, maxVerticalScrollExtent); } @@ -975,64 +1087,159 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { if (_firstNonPinnedCell == null && _lastPinnedRow == null && - _lastPinnedColumn == null) { + _lastPinnedColumn == null && + _firstTrailingPinnedRow == null && + _firstTrailingPinnedColumn == null) { assert(_lastNonPinnedCell == null); return; } + final double trailingPinnedColumnOffset = _firstTrailingPinnedColumn != null + ? -(viewportDimension.width - _trailingPinnedColumnsExtent) + : 0.0; + final double trailingPinnedRowOffset = _firstTrailingPinnedRow != null + ? -(viewportDimension.height - _trailingPinnedRowsExtent) + : 0.0; + final double? offsetIntoColumn = _firstNonPinnedColumn != null ? horizontalOffset.pixels - _columnMetrics[_firstNonPinnedColumn]!.leadingOffset - - _pinnedColumnsExtent - + _leadingPinnedColumnsExtent - _hAlignmentOffset : null; final double? offsetIntoRow = _firstNonPinnedRow != null ? verticalOffset.pixels - _rowMetrics[_firstNonPinnedRow]!.leadingOffset - - _pinnedRowsExtent - + _leadingPinnedRowsExtent - _vAlignmentOffset : null; - if (_lastPinnedRow != null && _lastPinnedColumn != null) { - // Layout cells that are contained in both pinned rows and columns - _layoutCells( - start: TableVicinity.zero, - end: TableVicinity(column: _lastPinnedColumn!, row: _lastPinnedRow!), - offset: Offset(-_hAlignmentOffset, -_vAlignmentOffset), - ); - } - if (_lastPinnedRow != null && _firstNonPinnedColumn != null) { - // Layout cells of pinned rows - those that do not intersect with pinned - // columns above - assert(_lastNonPinnedColumn != null); - assert(offsetIntoColumn != null); - _layoutCells( - start: TableVicinity(column: _firstNonPinnedColumn!, row: 0), - end: TableVicinity(column: _lastNonPinnedColumn!, row: _lastPinnedRow!), - offset: Offset(offsetIntoColumn!, -_vAlignmentOffset), - ); + // Row Category L (Leading Pinned) + if (_lastPinnedRow != null) { + // (L, L) + if (_lastPinnedColumn != null) { + _layoutCells( + start: TableVicinity.zero, + end: TableVicinity(column: _lastPinnedColumn!, row: _lastPinnedRow!), + offset: Offset(-_hAlignmentOffset, -_vAlignmentOffset), + ); + } + // (L, N) + if (_firstNonPinnedColumn != null) { + assert(_lastNonPinnedColumn != null); + assert(offsetIntoColumn != null); + _layoutCells( + start: TableVicinity(column: _firstNonPinnedColumn!, row: 0), + end: TableVicinity( + column: _lastNonPinnedColumn!, + row: _lastPinnedRow!, + ), + offset: Offset(offsetIntoColumn!, -_vAlignmentOffset), + ); + } + // (L, T) + if (_firstTrailingPinnedColumn != null) { + _layoutCells( + start: TableVicinity(column: _firstTrailingPinnedColumn!, row: 0), + end: TableVicinity( + column: delegate.columnCount! - 1, + row: _lastPinnedRow!, + ), + offset: Offset(trailingPinnedColumnOffset, -_vAlignmentOffset), + ); + } } - if (_lastPinnedColumn != null && _firstNonPinnedRow != null) { - // Layout cells of pinned columns - those that do not intersect with - // pinned rows above + + // Row Category N (Non-Pinned) + if (_firstNonPinnedRow != null) { assert(_lastNonPinnedRow != null); assert(offsetIntoRow != null); - _layoutCells( - start: TableVicinity(column: 0, row: _firstNonPinnedRow!), - end: TableVicinity(column: _lastPinnedColumn!, row: _lastNonPinnedRow!), - offset: Offset(-_hAlignmentOffset, offsetIntoRow!), - ); + // (N, L) + if (_lastPinnedColumn != null) { + _layoutCells( + start: TableVicinity(column: 0, row: _firstNonPinnedRow!), + end: TableVicinity( + column: _lastPinnedColumn!, + row: _lastNonPinnedRow!, + ), + offset: Offset(-_hAlignmentOffset, offsetIntoRow!), + ); + } + // (N, N) + if (_firstNonPinnedColumn != null) { + assert(_lastNonPinnedColumn != null); + assert(offsetIntoColumn != null); + _layoutCells( + start: TableVicinity( + column: _firstNonPinnedColumn!, + row: _firstNonPinnedRow!, + ), + end: TableVicinity( + column: _lastNonPinnedColumn!, + row: _lastNonPinnedRow!, + ), + offset: Offset(offsetIntoColumn!, offsetIntoRow!), + ); + } + // (N, T) + if (_firstTrailingPinnedColumn != null) { + _layoutCells( + start: TableVicinity( + column: _firstTrailingPinnedColumn!, + row: _firstNonPinnedRow!, + ), + end: TableVicinity( + column: delegate.columnCount! - 1, + row: _lastNonPinnedRow!, + ), + offset: Offset(trailingPinnedColumnOffset, offsetIntoRow!), + ); + } } - if (_firstNonPinnedCell != null) { - // Layout all other cells. - assert(_lastNonPinnedCell != null); - assert(offsetIntoColumn != null); - assert(offsetIntoRow != null); - _layoutCells( - start: _firstNonPinnedCell!, - end: _lastNonPinnedCell!, - offset: Offset(offsetIntoColumn!, offsetIntoRow!), - ); + + // Row Category T (Trailing Pinned) + if (_firstTrailingPinnedRow != null) { + // (T, L) + if (_lastPinnedColumn != null) { + _layoutCells( + start: TableVicinity(column: 0, row: _firstTrailingPinnedRow!), + end: TableVicinity( + column: _lastPinnedColumn!, + row: delegate.rowCount! - 1, + ), + offset: Offset(-_hAlignmentOffset, trailingPinnedRowOffset), + ); + } + // (T, N) + if (_firstNonPinnedColumn != null) { + assert(_lastNonPinnedColumn != null); + assert(offsetIntoColumn != null); + _layoutCells( + start: TableVicinity( + column: _firstNonPinnedColumn!, + row: _firstTrailingPinnedRow!, + ), + end: TableVicinity( + column: _lastNonPinnedColumn!, + row: delegate.rowCount! - 1, + ), + offset: Offset(offsetIntoColumn!, trailingPinnedRowOffset), + ); + } + // (T, T) + if (_firstTrailingPinnedColumn != null) { + _layoutCells( + start: TableVicinity( + column: _firstTrailingPinnedColumn!, + row: _firstTrailingPinnedRow!, + ), + end: TableVicinity( + column: delegate.columnCount! - 1, + row: delegate.rowCount! - 1, + ), + offset: Offset(trailingPinnedColumnOffset, trailingPinnedRowOffset), + ); + } } } @@ -1043,6 +1250,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { required int spanMergeEnd, required int? spanCount, required int pinnedSpanCount, + required int trailingPinnedSpanCount, required TableVicinity currentVicinity, }) { if (spanMergeStart == spanMergeEnd) { @@ -1064,7 +1272,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { '$spanMergeEnd. The TableView contains $spanCount.', ); if (spanMergeStart < pinnedSpanCount) { - // Merged cells cannot span pinned and unpinned cells. + // Merged cells cannot span leading pinned and unpinned cells. assert( spanMergeEnd < pinnedSpanCount, 'Merged cells cannot span pinned and unpinned cells. $spanOrientation ' @@ -1073,6 +1281,17 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { '$lowerSpanOrientation ${pinnedSpanCount - 1}.', ); } + if (spanCount != null && + spanMergeEnd >= spanCount - trailingPinnedSpanCount) { + // Merged cells cannot span trailing pinned and unpinned cells. + assert( + spanMergeStart >= spanCount - trailingPinnedSpanCount, + 'Merged cells cannot span pinned and unpinned cells. $spanOrientation ' + 'merge containing $currentVicinity starts at $spanMergeStart, and ends ' + 'at $spanMergeEnd. ${spanOrientation}s are currently pinned from ' + '$lowerSpanOrientation ${spanCount - trailingPinnedSpanCount} to ${spanCount - 1}.', + ); + } return true; } @@ -1123,6 +1342,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { spanMergeEnd: lastRow, spanCount: delegate.rowCount, pinnedSpanCount: delegate.pinnedRowCount, + trailingPinnedSpanCount: delegate.trailingPinnedRowCount, currentVicinity: vicinity, ), ); @@ -1139,6 +1359,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { spanMergeEnd: lastColumn, spanCount: delegate.columnCount, pinnedSpanCount: delegate.pinnedColumnCount, + trailingPinnedSpanCount: delegate.trailingPinnedColumnCount, currentVicinity: vicinity, ), ); @@ -1155,19 +1376,27 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { // Compute height and layout offset for merged rows. final bool rowIsInPinnedColumn = - _lastPinnedColumn != null && - vicinity.column <= _lastPinnedColumn!; + (_lastPinnedColumn != null && + vicinity.column <= _lastPinnedColumn!) || + (_firstTrailingPinnedColumn != null && + vicinity.column >= _firstTrailingPinnedColumn!); final bool rowIsPinned = - _lastPinnedRow != null && firstRow <= _lastPinnedRow!; + (_lastPinnedRow != null && firstRow <= _lastPinnedRow!) || + (_firstTrailingPinnedRow != null && + firstRow >= _firstTrailingPinnedRow!); final double baseRowOffset = switch (( rowIsInPinnedColumn, rowIsPinned, )) { // Both row and column are pinned at this cell, or just pinned row. - (true, true) || (false, true) => 0.0, + (true, true) || (false, true) => + _firstTrailingPinnedRow != null && + firstRow >= _firstTrailingPinnedRow! + ? viewportDimension.height - _trailingPinnedRowsExtent + : 0.0, // Cell is within a pinned column, or no pinned area at all. - (true, false) || - (false, false) => _pinnedRowsExtent - verticalOffset.pixels, + (true, false) || (false, false) => + _leadingPinnedRowsExtent - verticalOffset.pixels, }; mergedRowOffset = baseRowOffset + @@ -1193,18 +1422,27 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _rowMetrics[firstRow]!.configuration.padding.leading; // Compute width and layout offset for merged columns. final bool columnIsInPinnedRow = - _lastPinnedRow != null && vicinity.row <= _lastPinnedRow!; + (_lastPinnedRow != null && vicinity.row <= _lastPinnedRow!) || + (_firstTrailingPinnedRow != null && + vicinity.row >= _firstTrailingPinnedRow!); final bool columnIsPinned = - _lastPinnedColumn != null && firstColumn <= _lastPinnedColumn!; + (_lastPinnedColumn != null && + firstColumn <= _lastPinnedColumn!) || + (_firstTrailingPinnedColumn != null && + firstColumn >= _firstTrailingPinnedColumn!); final double baseColumnOffset = switch (( columnIsInPinnedRow, columnIsPinned, )) { // Both row and column are pinned at this cell, or just pinned column. - (true, true) || (false, true) => 0.0, + (true, true) || (false, true) => + _firstTrailingPinnedColumn != null && + firstColumn >= _firstTrailingPinnedColumn! + ? viewportDimension.width - _trailingPinnedColumnsExtent + : 0.0, // Cell is within a pinned row, or no pinned area at all. - (true, false) || - (false, false) => _pinnedColumnsExtent - horizontalOffset.pixels, + (true, false) || (false, false) => + _leadingPinnedColumnsExtent - horizontalOffset.pixels, }; mergedColumnOffset = baseColumnOffset + @@ -1282,6 +1520,10 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { LayerHandle(); final LayerHandle _clipPinnedColumnsHandle = LayerHandle(); + final LayerHandle _clipTrailingPinnedRowsHandle = + LayerHandle(); + final LayerHandle _clipTrailingPinnedColumnsHandle = + LayerHandle(); final LayerHandle _clipCellsHandle = LayerHandle(); @@ -1289,16 +1531,13 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { void paint(PaintingContext context, Offset offset) { if (_firstNonPinnedCell == null && _lastPinnedRow == null && - _lastPinnedColumn == null) { + _lastPinnedColumn == null && + _firstTrailingPinnedRow == null && + _firstTrailingPinnedColumn == null) { assert(_lastNonPinnedCell == null); return; } - // Subclasses of RenderTwoDimensionalViewport will typically use - // firstChild to traverse children in a standard paint order that - // follows row or column major ordering. Here is slightly different - // as we break the cells up into 4 main paint passes to clip for overlap. - final bool reversedH = axisDirectionIsReversed(horizontalAxisDirection); final bool reversedV = axisDirectionIsReversed(verticalAxisDirection); @@ -1309,9 +1548,11 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { needsCompositing, offset, Rect.fromLTWH( - (reversedH ? 0.0 : _pinnedColumnsExtent) + + (reversedH + ? _trailingPinnedColumnsExtent + : _leadingPinnedColumnsExtent) + (reversedH ? -_hAlignmentOffset : _hAlignmentOffset), - (reversedV ? 0.0 : _pinnedRowsExtent) + + (reversedV ? _trailingPinnedRowsExtent : _leadingPinnedRowsExtent) + (reversedV ? -_vAlignmentOffset : _vAlignmentOffset), viewportDimension.width - _pinnedColumnsExtent, viewportDimension.height - _pinnedRowsExtent, @@ -1332,20 +1573,20 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { } if (_lastPinnedColumn != null && _firstNonPinnedRow != null) { - // Paint all visible pinned column cells that do not intersect with pinned - // row cells. + // Paint all visible leading pinned column cells that do not intersect with + // pinned row cells. _clipPinnedColumnsHandle.layer = context.pushClipRect( needsCompositing, offset, Rect.fromLTWH( reversedH ? viewportDimension.width - - _pinnedColumnsExtent - + _leadingPinnedColumnsExtent - _hAlignmentOffset : _hAlignmentOffset, - (reversedV ? 0.0 : _pinnedRowsExtent) + + (reversedV ? _trailingPinnedRowsExtent : _leadingPinnedRowsExtent) + (reversedV ? -_vAlignmentOffset : _vAlignmentOffset), - _pinnedColumnsExtent, + _leadingPinnedColumnsExtent, viewportDimension.height - _pinnedRowsExtent, ), (PaintingContext context, Offset offset) { @@ -1366,20 +1607,62 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _clipPinnedColumnsHandle.layer = null; } + if (_firstTrailingPinnedColumn != null && _firstNonPinnedRow != null) { + // Paint all visible trailing pinned column cells that do not intersect + // with pinned row cells. + _clipTrailingPinnedColumnsHandle.layer = context.pushClipRect( + needsCompositing, + offset, + Rect.fromLTWH( + reversedH + ? _hAlignmentOffset + : viewportDimension.width - + _trailingPinnedColumnsExtent - + _hAlignmentOffset, + (reversedV ? _trailingPinnedRowsExtent : _leadingPinnedRowsExtent) + + (reversedV ? -_vAlignmentOffset : _vAlignmentOffset), + _trailingPinnedColumnsExtent, + viewportDimension.height - _pinnedRowsExtent, + ), + (PaintingContext context, Offset offset) { + _paintCells( + context: context, + offset: offset, + leadingVicinity: TableVicinity( + column: _firstTrailingPinnedColumn!, + row: _firstNonPinnedRow!, + ), + trailingVicinity: TableVicinity( + column: delegate.columnCount! - 1, + row: _lastNonPinnedRow!, + ), + ); + }, + clipBehavior: clipBehavior, + oldLayer: _clipTrailingPinnedColumnsHandle.layer, + ); + } else { + _clipTrailingPinnedColumnsHandle.layer = null; + } + if (_lastPinnedRow != null && _firstNonPinnedColumn != null) { - // Paint all visible pinned row cells that do not intersect with pinned - // column cells. + // Paint all visible leading pinned row cells that do not intersect with + // pinned column cells. _clipPinnedRowsHandle.layer = context.pushClipRect( needsCompositing, offset, Rect.fromLTWH( - (reversedH ? 0.0 : _pinnedColumnsExtent) + + (reversedH + ? _trailingPinnedColumnsExtent + : _leadingPinnedColumnsExtent) + (reversedH ? -_hAlignmentOffset : _hAlignmentOffset), reversedV - ? viewportDimension.height - _pinnedRowsExtent - _vAlignmentOffset + ? viewportDimension.height - + _leadingPinnedRowsExtent - + _vAlignmentOffset : _vAlignmentOffset, viewportDimension.width - _pinnedColumnsExtent, - _pinnedRowsExtent, + _leadingPinnedRowsExtent, ), (PaintingContext context, Offset offset) { _paintCells( @@ -1402,18 +1685,103 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _clipPinnedRowsHandle.layer = null; } - if (_lastPinnedRow != null && _lastPinnedColumn != null) { - // Paint remaining visible pinned cells that represent the intersection of - // both pinned rows and columns. - _paintCells( - context: context, - offset: offset, - leadingVicinity: TableVicinity.zero, - trailingVicinity: TableVicinity( - column: _lastPinnedColumn!, - row: _lastPinnedRow!, + if (_firstTrailingPinnedRow != null && _firstNonPinnedColumn != null) { + // Paint all visible trailing pinned row cells that do not intersect with + // pinned column cells. + _clipTrailingPinnedRowsHandle.layer = context.pushClipRect( + needsCompositing, + offset, + Rect.fromLTWH( + (reversedH + ? _trailingPinnedColumnsExtent + : _leadingPinnedColumnsExtent) + + (reversedH ? -_hAlignmentOffset : _hAlignmentOffset), + reversedV + ? _vAlignmentOffset + : viewportDimension.height - + _trailingPinnedRowsExtent - + _vAlignmentOffset, + viewportDimension.width - _pinnedColumnsExtent, + _trailingPinnedRowsExtent, ), + (PaintingContext context, Offset offset) { + _paintCells( + context: context, + offset: offset, + leadingVicinity: TableVicinity( + column: _firstNonPinnedColumn!, + row: _firstTrailingPinnedRow!, + ), + trailingVicinity: TableVicinity( + column: _lastNonPinnedColumn!, + row: delegate.rowCount! - 1, + ), + ); + }, + clipBehavior: clipBehavior, + oldLayer: _clipTrailingPinnedRowsHandle.layer, ); + } else { + _clipTrailingPinnedRowsHandle.layer = null; + } + + // Paint all intersections + if (_lastPinnedRow != null) { + if (_lastPinnedColumn != null) { + _paintCells( + context: context, + offset: offset, + leadingVicinity: TableVicinity.zero, + trailingVicinity: TableVicinity( + column: _lastPinnedColumn!, + row: _lastPinnedRow!, + ), + ); + } + if (_firstTrailingPinnedColumn != null) { + _paintCells( + context: context, + offset: offset, + leadingVicinity: TableVicinity( + column: _firstTrailingPinnedColumn!, + row: 0, + ), + trailingVicinity: TableVicinity( + column: delegate.columnCount! - 1, + row: _lastPinnedRow!, + ), + ); + } + } + if (_firstTrailingPinnedRow != null) { + if (_lastPinnedColumn != null) { + _paintCells( + context: context, + offset: offset, + leadingVicinity: TableVicinity( + column: 0, + row: _firstTrailingPinnedRow!, + ), + trailingVicinity: TableVicinity( + column: _lastPinnedColumn!, + row: delegate.rowCount! - 1, + ), + ); + } + if (_firstTrailingPinnedColumn != null) { + _paintCells( + context: context, + offset: offset, + leadingVicinity: TableVicinity( + column: _firstTrailingPinnedColumn!, + row: _firstTrailingPinnedRow!, + ), + trailingVicinity: TableVicinity( + column: delegate.columnCount! - 1, + row: delegate.rowCount! - 1, + ), + ); + } } } @@ -1879,6 +2247,8 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { void dispose() { _clipPinnedRowsHandle.layer = null; _clipPinnedColumnsHandle.layer = null; + _clipTrailingPinnedRowsHandle.layer = null; + _clipTrailingPinnedColumnsHandle.layer = null; _clipCellsHandle.layer = null; for (final _Span span in _rowMetrics.values) { span.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 152006eae3fb..b99cccf62183 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 @@ -91,6 +91,27 @@ mixin TableCellDelegateMixin on TwoDimensionalChildDelegate { /// the delegate object, [notifyListeners] must be called. int get pinnedColumnCount => 0; + /// The number of columns that are permanently shown on the trailing vertical + /// edge of the viewport. + /// + /// If scrolling is enabled, other columns will scroll underneath the pinned + /// columns. + /// + /// Just like for regular columns, [buildColumn] method will be consulted for + /// additional information about the pinned column. The indices of trailing + /// pinned columns start at `columnCount - trailingPinnedColumnCount` and go + /// to `columnCount - 1`. + /// + /// [columnCount] must not be null if [trailingPinnedColumnCount] is greater + /// than zero. + /// + /// The integer returned by this getter must be smaller than (or equal to) the + /// integer returned by [columnCount]. + /// + /// If the value returned by this getter changes throughout the lifetime of + /// the delegate object, [notifyListeners] must be called. + int get trailingPinnedColumnCount => 0; + /// The number of rows that are permanently shown on the leading horizontal /// edge of the viewport. /// @@ -108,6 +129,27 @@ mixin TableCellDelegateMixin on TwoDimensionalChildDelegate { /// the delegate object, [notifyListeners] must be called. int get pinnedRowCount => 0; + /// The number of rows that are permanently shown on the trailing horizontal + /// edge of the viewport. + /// + /// If scrolling is enabled, other rows will scroll underneath the pinned + /// rows. + /// + /// Just like for regular rows, [buildRow] will be consulted for + /// additional information about the pinned row. The indices of trailing + /// pinned rows start at `rowCount - trailingPinnedRowCount` and go to + /// `rowCount - 1`. + /// + /// [rowCount] must not be null if [trailingPinnedRowCount] is greater than + /// zero. + /// + /// The integer returned by this getter must be smaller than (or equal to) the + /// integer returned by [rowCount]. + /// + /// If the value returned by this getter changes throughout the lifetime of + /// the delegate object, [notifyListeners] must be called. + int get trailingPinnedRowCount => 0; + /// Builds the [TableSpan] that describes the column at the provided index. /// /// The builder must return a valid [TableSpan] for all indices smaller than @@ -144,20 +186,32 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate int? rowCount, int pinnedColumnCount = 0, int pinnedRowCount = 0, + int trailingPinnedColumnCount = 0, + int trailingPinnedRowCount = 0, super.addAutomaticKeepAlives, required TableViewCellBuilder cellBuilder, required TableSpanBuilder columnBuilder, required TableSpanBuilder rowBuilder, }) : assert(pinnedColumnCount >= 0), assert(pinnedRowCount >= 0), + assert(trailingPinnedColumnCount >= 0), + assert(trailingPinnedRowCount >= 0), assert(rowCount == null || rowCount >= 0), assert(columnCount == null || columnCount >= 0), - assert(columnCount == null || pinnedColumnCount <= columnCount), - assert(rowCount == null || pinnedRowCount <= rowCount), + assert( + columnCount == null || + pinnedColumnCount + trailingPinnedColumnCount <= columnCount, + ), + assert( + rowCount == null || + pinnedRowCount + trailingPinnedRowCount <= rowCount, + ), _rowBuilder = rowBuilder, _columnBuilder = columnBuilder, _pinnedColumnCount = pinnedColumnCount, _pinnedRowCount = pinnedRowCount, + _trailingPinnedColumnCount = trailingPinnedColumnCount, + _trailingPinnedRowCount = trailingPinnedRowCount, super( builder: (BuildContext context, ChildVicinity vicinity) => cellBuilder(context, vicinity as TableVicinity), @@ -171,7 +225,9 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate int? get columnCount => maxXIndex == null ? null : maxXIndex! + 1; set columnCount(int? value) { - assert(value == null || pinnedColumnCount <= value); + assert( + value == null || pinnedColumnCount + trailingPinnedColumnCount <= value, + ); maxXIndex = value == null ? null : value - 1; } @@ -190,7 +246,9 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate int _pinnedColumnCount; set pinnedColumnCount(int value) { assert(value >= 0); - assert(columnCount == null || value <= columnCount!); + assert( + columnCount == null || value + trailingPinnedColumnCount <= columnCount!, + ); if (pinnedColumnCount == value) { return; } @@ -198,11 +256,24 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate notifyListeners(); } + @override + int get trailingPinnedColumnCount => _trailingPinnedColumnCount; + int _trailingPinnedColumnCount; + set trailingPinnedColumnCount(int value) { + assert(value >= 0); + assert(columnCount == null || pinnedColumnCount + value <= columnCount!); + if (trailingPinnedColumnCount == value) { + return; + } + _trailingPinnedColumnCount = value; + notifyListeners(); + } + @override int? get rowCount => maxYIndex == null ? null : maxYIndex! + 1; set rowCount(int? value) { - assert(value == null || pinnedRowCount <= value); + assert(value == null || pinnedRowCount + trailingPinnedRowCount <= value); maxYIndex = value == null ? null : value - 1; } @@ -221,13 +292,26 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate int _pinnedRowCount; set pinnedRowCount(int value) { assert(value >= 0); - assert(rowCount == null || value <= rowCount!); + assert(rowCount == null || value + trailingPinnedRowCount <= rowCount!); if (pinnedRowCount == value) { return; } _pinnedRowCount = value; notifyListeners(); } + + @override + int get trailingPinnedRowCount => _trailingPinnedRowCount; + int _trailingPinnedRowCount; + set trailingPinnedRowCount(int value) { + assert(value >= 0); + assert(rowCount == null || pinnedRowCount + value <= rowCount!); + if (trailingPinnedRowCount == value) { + return; + } + _trailingPinnedRowCount = value; + notifyListeners(); + } } /// A delegate that supplies children for a [TableViewport] using an @@ -246,16 +330,22 @@ class TableCellListDelegate extends TwoDimensionalChildListDelegate TableCellListDelegate({ int pinnedColumnCount = 0, int pinnedRowCount = 0, + int trailingPinnedColumnCount = 0, + int trailingPinnedRowCount = 0, super.addAutomaticKeepAlives, required List> cells, required TableSpanBuilder columnBuilder, required TableSpanBuilder rowBuilder, }) : assert(pinnedColumnCount >= 0), assert(pinnedRowCount >= 0), + assert(trailingPinnedColumnCount >= 0), + assert(trailingPinnedRowCount >= 0), _columnBuilder = columnBuilder, _rowBuilder = rowBuilder, _pinnedColumnCount = pinnedColumnCount, _pinnedRowCount = pinnedRowCount, + _trailingPinnedColumnCount = trailingPinnedColumnCount, + _trailingPinnedRowCount = trailingPinnedRowCount, super( children: cells, // repaintBoundaries handled by TableViewCell @@ -270,8 +360,8 @@ class TableCellListDelegate extends TwoDimensionalChildListDelegate children.map((List array) => array.length).toSet().length == 1, 'Each list of Widgets within cells must be of the same length.', ); - assert(rowCount >= pinnedRowCount); - assert(columnCount >= pinnedColumnCount); + assert(columnCount >= pinnedColumnCount + trailingPinnedColumnCount); + assert(rowCount >= pinnedRowCount + trailingPinnedRowCount); } @override @@ -296,7 +386,7 @@ class TableCellListDelegate extends TwoDimensionalChildListDelegate int _pinnedColumnCount; set pinnedColumnCount(int value) { assert(value >= 0); - assert(value <= columnCount); + assert(value + trailingPinnedColumnCount <= columnCount); if (pinnedColumnCount == value) { return; } @@ -304,6 +394,19 @@ class TableCellListDelegate extends TwoDimensionalChildListDelegate notifyListeners(); } + @override + int get trailingPinnedColumnCount => _trailingPinnedColumnCount; + int _trailingPinnedColumnCount; + set trailingPinnedColumnCount(int value) { + assert(value >= 0); + assert(pinnedColumnCount + value <= columnCount); + if (trailingPinnedColumnCount == value) { + return; + } + _trailingPinnedColumnCount = value; + notifyListeners(); + } + @override int get rowCount => children.length; @@ -326,7 +429,7 @@ class TableCellListDelegate extends TwoDimensionalChildListDelegate int _pinnedRowCount; set pinnedRowCount(int value) { assert(value >= 0); - assert(value <= rowCount); + assert(value + trailingPinnedRowCount <= rowCount); if (pinnedRowCount == value) { return; } @@ -334,14 +437,29 @@ class TableCellListDelegate extends TwoDimensionalChildListDelegate notifyListeners(); } + @override + int get trailingPinnedRowCount => _trailingPinnedRowCount; + int _trailingPinnedRowCount; + set trailingPinnedRowCount(int value) { + assert(value >= 0); + assert(pinnedRowCount + value <= rowCount); + if (trailingPinnedRowCount == value) { + return; + } + _trailingPinnedRowCount = value; + notifyListeners(); + } + @override bool shouldRebuild(covariant TableCellListDelegate oldDelegate) { return columnCount != oldDelegate.columnCount || _columnBuilder != oldDelegate._columnBuilder || pinnedColumnCount != oldDelegate.pinnedColumnCount || + trailingPinnedColumnCount != oldDelegate.trailingPinnedColumnCount || rowCount != oldDelegate.rowCount || _rowBuilder != oldDelegate._rowBuilder || pinnedRowCount != oldDelegate.pinnedRowCount || + trailingPinnedRowCount != oldDelegate.trailingPinnedRowCount || super.shouldRebuild(oldDelegate); } } diff --git a/packages/two_dimensional_scrollables/pubspec.yaml b/packages/two_dimensional_scrollables/pubspec.yaml index 2e25c7198dd8..8179a1cc5563 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.4.2 +version: 0.5.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/alignment_test.dart b/packages/two_dimensional_scrollables/test/table_view/alignment_test.dart index cba1e1fbc872..2f87360f6278 100644 --- a/packages/two_dimensional_scrollables/test/table_view/alignment_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/alignment_test.dart @@ -604,5 +604,134 @@ void main() { const Offset(0.0, 150.0), ); }); + + testWidgets('Alignment with trailing pinned columns', ( + WidgetTester tester, + ) async { + const viewportWidth = 600.0; + + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xFFFFFFFF), + debugShowCheckedModeBanner: false, + builder: (context, child) => Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: viewportWidth, + height: 400, + child: TableView.builder( + columnCount: 3, + rowCount: 1, + trailingPinnedColumnCount: 1, + alignment: Alignment.topCenter, + columnBuilder: (index) => + const TableSpan(extent: FixedTableSpanExtent(100)), + rowBuilder: (index) => + const TableSpan(extent: FixedTableSpanExtent(100)), + cellBuilder: (context, vicinity) { + return TableViewCell( + child: SizedBox( + key: ValueKey( + 'cell ${vicinity.column}:${vicinity.row}', + ), + ), + ); + }, + ), + ), + ), + ), + ), + ); + + final Offset tableTopLeft = tester.getTopLeft(find.byType(TableView)); + // Total width 300. Viewport 600. Offset 150. + // Columns 0 and 1 are unpinned. Column 2 is trailing pinned. + // If it sticks to table flow, it should be at 350. + // If it sticks to viewport edge, it should be at 500. + final Finder cell00 = find.byKey(const ValueKey('cell 0:0')); + expect( + tester.getTopLeft(cell00) - tableTopLeft, + const Offset(200.0, 0.0), + ); + + final Finder cell10 = find.byKey(const ValueKey('cell 1:0')); + expect( + tester.getTopLeft(cell10) - tableTopLeft, + const Offset(300.0, 0.0), + ); + + final Finder cell20 = find.byKey(const ValueKey('cell 2:0')); + expect( + tester.getTopLeft(cell20) - tableTopLeft, + const Offset(500.0, 0.0), + ); + }); + + testWidgets('Alignment with trailing pinned rows', ( + WidgetTester tester, + ) async { + const viewportHeight = 600.0; + + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xFFFFFFFF), + debugShowCheckedModeBanner: false, + builder: (context, child) => Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: viewportHeight, + child: TableView.builder( + columnCount: 1, + rowCount: 3, + trailingPinnedRowCount: 1, + alignment: Alignment.centerLeft, + columnBuilder: (index) => + const TableSpan(extent: FixedTableSpanExtent(100)), + rowBuilder: (index) => + const TableSpan(extent: FixedTableSpanExtent(100)), + cellBuilder: (context, vicinity) { + return TableViewCell( + child: SizedBox( + key: ValueKey( + 'cell ${vicinity.column}:${vicinity.row}', + ), + ), + ); + }, + ), + ), + ), + ), + ), + ); + + final Offset tableTopLeft = tester.getTopLeft(find.byType(TableView)); + // Total height 300. Viewport 600. Offset 150. + // Rows 0 and 1 are unpinned. Row 2 is trailing pinned. + // If it sticks to table flow, it should be at 350. + final Finder cell00 = find.byKey(const ValueKey('cell 0:0')); + expect( + tester.getTopLeft(cell00) - tableTopLeft, + const Offset(0.0, 200.0), + ); + + final Finder cell01 = find.byKey(const ValueKey('cell 0:1')); + expect( + tester.getTopLeft(cell01) - tableTopLeft, + const Offset(0.0, 300.0), + ); + + final Finder cell02 = find.byKey(const ValueKey('cell 0:2')); + expect( + tester.getTopLeft(cell02) - tableTopLeft, + const Offset(0.0, 500.0), + ); + }); }); } diff --git a/packages/two_dimensional_scrollables/test/table_view/table_delegate_test.dart b/packages/two_dimensional_scrollables/test/table_view/table_delegate_test.dart index d2ccb460c8a7..e94918238df0 100644 --- a/packages/two_dimensional_scrollables/test/table_view/table_delegate_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/table_delegate_test.dart @@ -118,7 +118,9 @@ void main() { isA().having( (AssertionError error) => error.toString(), 'description', - contains('pinnedColumnCount <= columnCount'), + contains( + 'pinnedColumnCount + trailingPinnedColumnCount <= columnCount', + ), ), ), ); @@ -138,7 +140,7 @@ void main() { isA().having( (AssertionError error) => error.toString(), 'description', - contains('pinnedRowCount <= rowCount'), + contains('pinnedRowCount + trailingPinnedRowCount <= rowCount'), ), ), ); @@ -499,26 +501,26 @@ void main() { expect( () { - delegate.pinnedColumnCount = 4; + delegate.pinnedColumnCount = 5; }, throwsA( isA().having( (AssertionError error) => error.toString(), 'description', - contains('value <= columnCount'), + contains('value + trailingPinnedColumnCount <= columnCount'), ), ), ); expect( () { - delegate.pinnedRowCount = 4; + delegate.pinnedRowCount = 5; }, throwsA( isA().having( (AssertionError error) => error.toString(), 'description', - contains('value <= rowCount'), + contains('value + trailingPinnedRowCount <= rowCount'), ), ), ); 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 eaa3784e2ae7..743e862777c7 100644 --- a/packages/two_dimensional_scrollables/test/table_view/table_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/table_test.dart @@ -4400,6 +4400,171 @@ void main() { expect(cellTextField, findsOneWidget); }, ); + + testWidgets('Trailing pinned columns and rows - smoke test', ( + WidgetTester tester, + ) async { + final horizontalController = ScrollController(); + final verticalController = ScrollController(); + + Widget getTableView({ + int? columnCount = 10, + int? rowCount = 10, + int pinnedColumnCount = 0, + int pinnedRowCount = 0, + int trailingPinnedColumnCount = 0, + int trailingPinnedRowCount = 0, + }) { + return TableView.builder( + cacheExtent: 0.0, + columnCount: columnCount, + rowCount: rowCount, + pinnedColumnCount: pinnedColumnCount, + pinnedRowCount: pinnedRowCount, + trailingPinnedColumnCount: trailingPinnedColumnCount, + trailingPinnedRowCount: trailingPinnedRowCount, + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + ), + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + ), + columnBuilder: (int index) => + const TableSpan(extent: FixedTableSpanExtent(100)), + rowBuilder: (int index) => + const TableSpan(extent: FixedTableSpanExtent(100)), + cellBuilder: (BuildContext context, TableVicinity vicinity) { + return TableViewCell( + child: Text('R${vicinity.row} C${vicinity.column}'), + ); + }, + ); + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 400, + width: 400, + child: getTableView( + trailingPinnedColumnCount: 1, + trailingPinnedRowCount: 1, + ), + ), + ), + ), + ); + + // Initial view: 0-2 rows/cols are visible (100 each), plus trailing pinned (Row 9, Column 9) + expect(find.text('R0 C0'), findsOneWidget); + expect(find.text('R9 C9'), findsOneWidget); + expect(find.text('R0 C9'), findsOneWidget); + expect(find.text('R9 C0'), findsOneWidget); + + expect(tester.getRect(find.text('R0 C9')).left, 300); + expect(tester.getRect(find.text('R9 C0')).top, 300); + + // Scroll + horizontalController.jumpTo(50); + verticalController.jumpTo(50); + await tester.pump(); + + expect(tester.getRect(find.text('R0 C0')).left, -50); + expect(tester.getRect(find.text('R0 C0')).top, -50); + expect(tester.getRect(find.text('R0 C9')).left, 300); + expect(tester.getRect(find.text('R9 C0')).top, 300); + expect(tester.getRect(find.text('R9 C9')).left, 300); + expect(tester.getRect(find.text('R9 C9')).top, 300); + }); + + testWidgets('Intersections of leading and trailing pinned', ( + WidgetTester tester, + ) async { + const span = TableSpan(extent: FixedTableSpanExtent(100)); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 400, + width: 400, + child: TableView.builder( + columnCount: 10, + rowCount: 10, + pinnedColumnCount: 1, + pinnedRowCount: 1, + trailingPinnedColumnCount: 1, + trailingPinnedRowCount: 1, + columnBuilder: (int index) => span, + rowBuilder: (int index) => span, + cellBuilder: (BuildContext context, TableVicinity vicinity) { + return TableViewCell( + child: Text('R${vicinity.row} C${vicinity.column}'), + ); + }, + ), + ), + ), + ), + ); + + // Leading-Leading intersection + expect(tester.getRect(find.text('R0 C0')).topLeft, Offset.zero); + // Leading-Trailing intersection (Row 0, Col 9) + expect(tester.getRect(find.text('R0 C9')).topLeft, const Offset(300, 0)); + // Trailing-Leading intersection (Row 9, Col 0) + expect(tester.getRect(find.text('R9 C0')).topLeft, const Offset(0, 300)); + // Trailing-Trailing intersection (Row 9, Col 9) + expect(tester.getRect(find.text('R9 C9')).topLeft, const Offset(300, 300)); + + // Non-pinned middle + expect(tester.getRect(find.text('R1 C1')).topLeft, const Offset(100, 100)); + }); + + testWidgets('Trailing pinned - merged cells validation', ( + WidgetTester tester, + ) async { + // Merged cell in trailing pinned row + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 400, + width: 400, + child: TableView.builder( + cacheExtent: 0.0, + columnCount: 10, + rowCount: 10, + trailingPinnedRowCount: 2, + columnBuilder: (int index) => + const TableSpan(extent: FixedTableSpanExtent(100)), + rowBuilder: (int index) => + const TableSpan(extent: FixedTableSpanExtent(100)), + cellBuilder: (BuildContext context, TableVicinity vicinity) { + if (vicinity.row >= 8 && vicinity.column == 0) { + return const TableViewCell( + rowMergeStart: 8, + rowMergeSpan: 2, + child: Text('Merged R8-9 C0'), + ); + } + return TableViewCell( + child: Text('R${vicinity.row} C${vicinity.column}'), + ); + }, + ), + ), + ), + ), + ); + + // Merged cell should be at [0, 100] horizontally, and [200, 400] vertically + // because Row 8 & 9 are trailing pinned (bottom 200 pixels). + expect(find.text('Merged R8-9 C0'), findsOneWidget); + final Rect mergedRect = tester.getRect(find.text('Merged R8-9 C0')); + expect(mergedRect.top, 200); + expect(mergedRect.bottom, 400); + }); } class _NullBuildContext implements BuildContext, TwoDimensionalChildManager {