Skip to content

Commit

Permalink
[two_dimensional_scrollables] Merged cells for TableView (flutter#5917)
Browse files Browse the repository at this point in the history
Fixes flutter#131224
 
��  [Design Doc](https://docs.google.com/document/d/1UekXjG_VKmWYbsxDEzMqTb7F-6oUr05v998n5IqtVWs/edit?usp=sharing) �� 

This adds support for merged cells in TableView.
This contains a breaking change that will require all children of the TableView to be a TableViewCell.

![Screenshot 2024-01-25 at 4 38 49�PM](https://github.com/flutter/packages/assets/16964204/02f4c158-23e9-401e-ac84-b6303d999095)
  • Loading branch information
Piinks authored Jan 31, 2024
1 parent be0124d commit 2d0f24f
Show file tree
Hide file tree
Showing 11 changed files with 3,470 additions and 226 deletions.
4 changes: 4 additions & 0 deletions packages/two_dimensional_scrollables/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.1.0

* [Breaking change] Adds support for merged cells in the TableView.

## 0.0.6

* Fixes an error in TableSpanDecoration when one or both axes are reversed.
Expand Down
8 changes: 5 additions & 3 deletions packages/two_dimensional_scrollables/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,11 @@ class _TableExampleState extends State<TableExample> {
);
}

Widget _buildCell(BuildContext context, TableVicinity vicinity) {
return Center(
child: Text('Tile c: ${vicinity.column}, r: ${vicinity.row}'),
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
return TableViewCell(
child: Center(
child: Text('Tile c: ${vicinity.column}, r: ${vicinity.row}'),
),
);
}

Expand Down
539 changes: 455 additions & 84 deletions packages/two_dimensional_scrollables/lib/src/table_view/table.dart

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

import 'table.dart';
Expand Down Expand Up @@ -31,13 +32,220 @@ class TableVicinity extends ChildVicinity {
/// Equivalent to the [xIndex].
int get column => xIndex;

/// The origin vicinity of the [TableView], (0,0).
static const TableVicinity zero = TableVicinity(row: 0, column: 0);

/// Returns a new [TableVicinity], copying over the row and column fields with
/// those provided, or maintaining the original values.
TableVicinity copyWith({
int? row,
int? column,
}) {
return TableVicinity(
row: row ?? this.row,
column: column ?? this.column,
);
}

@override
String toString() => '(row: $row, column: $column)';
}

/// Parent data structure used by [RenderTableViewport].
class TableViewParentData extends TwoDimensionalViewportParentData {
// TODO(Piinks): Add back merged cells here, https://github.com/flutter/flutter/issues/131224
/// Converts the [ChildVicinity] to a [TableVicinity] for ease of use.
TableVicinity get tableVicinity => vicinity as TableVicinity;

/// Represents the row index where a merged cell in the table begins.
///
/// Defaults to null, meaning a non-merged cell. A value must be provided if
/// a value is provided for [rowMergeSpan].
int? rowMergeStart;

/// Represents the number of rows spanned by a merged cell.
///
/// Defaults to null, meaning the cell is not merged. A value must be provided
/// if a value is provided for [rowMergeStart].
int? rowMergeSpan;

/// Represents the column index where a merged cell in the table begins.
///
/// Defaults to null, meaning a non-merged cell. A value must be provided if
/// a value is provided for [columnMergeSpan].
int? columnMergeStart;

/// Represents the number of columns spanned by a merged cell.
///
/// Defaults to null, meaning the cell is not merged. A value must be provided
/// if a value is provided for [columnMergeStart].
int? columnMergeSpan;

@override
String toString() {
String mergeDetails = '';
if (rowMergeStart != null || columnMergeStart != null) {
mergeDetails += ', merged';
}
if (rowMergeStart != null) {
mergeDetails += ', rowMergeStart=$rowMergeStart, '
'rowMergeSpan=$rowMergeSpan';
}
if (columnMergeStart != null) {
mergeDetails += ', columnMergeStart=$columnMergeStart, '
'columnMergeSpan=$columnMergeSpan';
}
return super.toString() + mergeDetails;
}
}

/// Creates a cell of the [TableView], along with information regarding merged
/// cells and [RepaintBoundary]s.
///
/// When merging cells in a [TableView], the same child should be returned from
/// every vicinity the merged cell contains. The `build` method will only be
/// called once for a merged cell, but since the table's children are lazily
/// laid out, returning the same child ensures the merged cell can be built no
/// matter which part of it is visible.
class TableViewCell extends StatelessWidget {
/// Creates a widget that controls how a child of a [TableView] spans across
/// multiple rows or columns.
const TableViewCell({
super.key,
this.rowMergeStart,
this.rowMergeSpan,
this.columnMergeStart,
this.columnMergeSpan,
this.addRepaintBoundaries = true,
required this.child,
}) : assert(
(rowMergeStart == null && rowMergeSpan == null) ||
(rowMergeStart != null && rowMergeSpan != null),
'Row merge start and span must both be set, or both unset.',
),
assert(rowMergeStart == null || rowMergeStart >= 0),
assert(rowMergeSpan == null || rowMergeSpan > 0),
assert(
(columnMergeStart == null && columnMergeSpan == null) ||
(columnMergeStart != null && columnMergeSpan != null),
'Column merge start and span must both be set, or both unset.',
),
assert(columnMergeStart == null || columnMergeStart >= 0),
assert(columnMergeSpan == null || columnMergeSpan > 0);

/// The child contained in this cell of the [TableView].
final Widget child;

/// Represents the row index where a merged cell in the table begins.
///
/// Defaults to null, meaning a non-merged cell. A value must be provided if
/// a value is provided for [rowMergeSpan].
final int? rowMergeStart;

/// Represents the number of rows spanned by a merged cell.
///
/// Defaults to null, meaning the cell is not merged. A value must be provided
/// if a value is provided for [rowMergeStart].
final int? rowMergeSpan;

/// Represents the column index where a merged cell in the table begins.
///
/// Defaults to null, meaning a non-merged cell. A value must be provided if
/// a value is provided for [columnMergeSpan].
final int? columnMergeStart;

/// Represents the number of columns spanned by a merged cell.
///
/// Defaults to null, meaning the cell is not merged. A value must be provided
/// if a value is provided for [columnMergeStart].
final int? columnMergeSpan;

/// Whether to wrap each child in a [RepaintBoundary].
///
/// Typically, children in a scrolling container are wrapped in repaint
/// boundaries so that they do not need to be repainted as the list scrolls.
/// If the children are easy to repaint (e.g., solid color blocks or a short
/// snippet of text), it might be more efficient to not add a repaint boundary
/// and instead always repaint the children during scrolling.
///
/// Defaults to true.
final bool addRepaintBoundaries;

@override
Widget build(BuildContext context) {
Widget child = this.child;

if (addRepaintBoundaries) {
child = RepaintBoundary(child: child);
}

return _TableViewCell(
rowMergeStart: rowMergeStart,
rowMergeSpan: rowMergeSpan,
columnMergeStart: columnMergeStart,
columnMergeSpan: columnMergeSpan,
child: child,
);
}
}

class _TableViewCell extends ParentDataWidget<TableViewParentData> {
const _TableViewCell({
this.rowMergeStart,
this.rowMergeSpan,
this.columnMergeStart,
this.columnMergeSpan,
required super.child,
});

final int? rowMergeStart;
final int? rowMergeSpan;
final int? columnMergeStart;
final int? columnMergeSpan;

@override
void applyParentData(RenderObject renderObject) {
final TableViewParentData parentData =
renderObject.parentData! as TableViewParentData;
bool needsLayout = false;
if (parentData.rowMergeStart != rowMergeStart) {
assert(rowMergeStart == null || rowMergeStart! >= 0);
parentData.rowMergeStart = rowMergeStart;
needsLayout = true;
}
if (parentData.rowMergeSpan != rowMergeSpan) {
assert(rowMergeSpan == null || rowMergeSpan! > 0);
parentData.rowMergeSpan = rowMergeSpan;
needsLayout = true;
}
if (parentData.columnMergeStart != columnMergeStart) {
assert(columnMergeStart == null || columnMergeStart! >= 0);
parentData.columnMergeStart = columnMergeStart;
needsLayout = true;
}
if (parentData.columnMergeSpan != columnMergeSpan) {
assert(columnMergeSpan == null || columnMergeSpan! > 0);
parentData.columnMergeSpan = columnMergeSpan;
needsLayout = true;
}

if (needsLayout) {
renderObject.parent?.markNeedsLayout();
}
}

@override
Type get debugTypicalAncestorWidgetClass => TableViewport;

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
if (rowMergeStart != null) {
properties.add(IntProperty('rowMergeStart', rowMergeStart));
properties.add(IntProperty('rowMergeSpan', rowMergeSpan));
}
if (columnMergeStart != null) {
properties.add(IntProperty('columnMergeStart', columnMergeStart));
properties.add(IntProperty('columnMergeSpan', columnMergeSpan));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ import 'table_span.dart';
/// [TableView].
typedef TableSpanBuilder = TableSpan Function(int index);

/// Signature for a function that creates a child [Widget] for a given
/// Signature for a function that creates a child [TableViewCell] for a given
/// [TableVicinity] in a [TableView], but may return null.
///
/// Used by [TableCellBuilderDelegate.builder] to build cells on demand for the
/// table.
typedef TableViewCellBuilder = Widget? Function(
typedef TableViewCellBuilder = TableViewCell Function(
BuildContext context,
TableVicinity vicinity,
);
Expand Down Expand Up @@ -116,6 +116,10 @@ mixin TableCellDelegateMixin on TwoDimensionalChildDelegate {

/// A delegate that supplies children for a [TableViewport] on demand using a
/// builder callback.
///
/// Unlike the base [TwoDimensionalChildBuilderDelegate] this delegate does not
/// automatically insert repaint boundaries. Instead, repaint boundaries are
/// controlled by [TableViewCell.addRepaintBoundaries].
class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate
with TableCellDelegateMixin {
/// Creates a lazy building delegate to use with a [TableView].
Expand All @@ -124,7 +128,6 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate
required int rowCount,
int pinnedColumnCount = 0,
int pinnedRowCount = 0,
super.addRepaintBoundaries,
super.addAutomaticKeepAlives,
required TableViewCellBuilder cellBuilder,
required TableSpanBuilder columnBuilder,
Expand All @@ -144,6 +147,8 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate
cellBuilder(context, vicinity as TableVicinity),
maxXIndex: columnCount - 1,
maxYIndex: rowCount - 1,
// repaintBoundaries handled by TableViewCell
addRepaintBoundaries: false,
);

@override
Expand Down Expand Up @@ -209,15 +214,18 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate
/// The [children] are accessed for each [TableVicinity.row] and
/// [TableVicinity.column] of the [TwoDimensionalViewport] as
/// `children[vicinity.row][vicinity.column]`.
///
/// Unlike the base [TwoDimensionalChildBuilderDelegate] this delegate does not
/// automatically insert repaint boundaries. Instead, repaint boundaries are
/// controlled by [TableViewCell.addRepaintBoundaries].
class TableCellListDelegate extends TwoDimensionalChildListDelegate
with TableCellDelegateMixin {
/// Creates a delegate that supplies children for a [TableView].
TableCellListDelegate({
int pinnedColumnCount = 0,
int pinnedRowCount = 0,
super.addRepaintBoundaries,
super.addAutomaticKeepAlives,
required List<List<Widget>> cells,
required List<List<TableViewCell>> cells,
required TableSpanBuilder columnBuilder,
required TableSpanBuilder rowBuilder,
}) : assert(pinnedColumnCount >= 0),
Expand All @@ -226,10 +234,16 @@ class TableCellListDelegate extends TwoDimensionalChildListDelegate
_rowBuilder = rowBuilder,
_pinnedColumnCount = pinnedColumnCount,
_pinnedRowCount = pinnedRowCount,
super(children: cells) {
super(
children: cells,
// repaintBoundaries handled by TableViewCell
addRepaintBoundaries: false,
) {
// Even if there are merged cells, they should be represented by the same
// child in each cell location. So all arrays of cells should have the same
// length.
// child in each cell location. This ensures that no matter which direction
// the merged cell scrolls into view from, we can build the correct child
// without having to explore all possible vicinities of the merged cell
// area. So all arrays of cells should have the same length.
assert(
children.map((List<Widget> array) => array.length).toSet().length == 1,
'Each list of Widgets within cells must be of the same length.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,9 @@ class MinTableSpanExtent extends CombiningTableSpanExtent {
}

/// A decoration for a [TableSpan].
///
/// When decorating merged cells in the [TableView], a merged cell will take its
/// decoration from the leading cell of the merged span.
class TableSpanDecoration {
/// Creates a [TableSpanDecoration].
const TableSpanDecoration({
Expand Down
2 changes: 1 addition & 1 deletion packages/two_dimensional_scrollables/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: two_dimensional_scrollables
description: Widgets that scroll using the two dimensional scrolling foundation.
version: 0.0.6
version: 0.1.0
repository: https://github.com/flutter/packages/tree/main/packages/two_dimensional_scrollables
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+two_dimensional_scrollables%22+

Expand Down
Loading

0 comments on commit 2d0f24f

Please sign in to comment.