Skip to content

Commit 2d0f24f

Browse files
authored
[two_dimensional_scrollables] Merged cells for TableView (#5917)
Fixes flutter/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)
1 parent be0124d commit 2d0f24f

File tree

11 files changed

+3470
-226
lines changed

11 files changed

+3470
-226
lines changed

packages/two_dimensional_scrollables/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.1.0
2+
3+
* [Breaking change] Adds support for merged cells in the TableView.
4+
15
## 0.0.6
26

37
* Fixes an error in TableSpanDecoration when one or both axes are reversed.

packages/two_dimensional_scrollables/example/lib/main.dart

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,11 @@ class _TableExampleState extends State<TableExample> {
8888
);
8989
}
9090

91-
Widget _buildCell(BuildContext context, TableVicinity vicinity) {
92-
return Center(
93-
child: Text('Tile c: ${vicinity.column}, r: ${vicinity.row}'),
91+
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
92+
return TableViewCell(
93+
child: Center(
94+
child: Text('Tile c: ${vicinity.column}, r: ${vicinity.row}'),
95+
),
9496
);
9597
}
9698

packages/two_dimensional_scrollables/lib/src/table_view/table.dart

Lines changed: 455 additions & 84 deletions
Large diffs are not rendered by default.

packages/two_dimensional_scrollables/lib/src/table_view/table_cell.dart

Lines changed: 209 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'package:flutter/foundation.dart';
56
import 'package:flutter/widgets.dart';
67

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

35+
/// The origin vicinity of the [TableView], (0,0).
36+
static const TableVicinity zero = TableVicinity(row: 0, column: 0);
37+
38+
/// Returns a new [TableVicinity], copying over the row and column fields with
39+
/// those provided, or maintaining the original values.
40+
TableVicinity copyWith({
41+
int? row,
42+
int? column,
43+
}) {
44+
return TableVicinity(
45+
row: row ?? this.row,
46+
column: column ?? this.column,
47+
);
48+
}
49+
3450
@override
3551
String toString() => '(row: $row, column: $column)';
3652
}
3753

3854
/// Parent data structure used by [RenderTableViewport].
3955
class TableViewParentData extends TwoDimensionalViewportParentData {
40-
// TODO(Piinks): Add back merged cells here, https://github.com/flutter/flutter/issues/131224
4156
/// Converts the [ChildVicinity] to a [TableVicinity] for ease of use.
4257
TableVicinity get tableVicinity => vicinity as TableVicinity;
58+
59+
/// Represents the row index where a merged cell in the table begins.
60+
///
61+
/// Defaults to null, meaning a non-merged cell. A value must be provided if
62+
/// a value is provided for [rowMergeSpan].
63+
int? rowMergeStart;
64+
65+
/// Represents the number of rows spanned by a merged cell.
66+
///
67+
/// Defaults to null, meaning the cell is not merged. A value must be provided
68+
/// if a value is provided for [rowMergeStart].
69+
int? rowMergeSpan;
70+
71+
/// Represents the column index where a merged cell in the table begins.
72+
///
73+
/// Defaults to null, meaning a non-merged cell. A value must be provided if
74+
/// a value is provided for [columnMergeSpan].
75+
int? columnMergeStart;
76+
77+
/// Represents the number of columns spanned by a merged cell.
78+
///
79+
/// Defaults to null, meaning the cell is not merged. A value must be provided
80+
/// if a value is provided for [columnMergeStart].
81+
int? columnMergeSpan;
82+
83+
@override
84+
String toString() {
85+
String mergeDetails = '';
86+
if (rowMergeStart != null || columnMergeStart != null) {
87+
mergeDetails += ', merged';
88+
}
89+
if (rowMergeStart != null) {
90+
mergeDetails += ', rowMergeStart=$rowMergeStart, '
91+
'rowMergeSpan=$rowMergeSpan';
92+
}
93+
if (columnMergeStart != null) {
94+
mergeDetails += ', columnMergeStart=$columnMergeStart, '
95+
'columnMergeSpan=$columnMergeSpan';
96+
}
97+
return super.toString() + mergeDetails;
98+
}
99+
}
100+
101+
/// Creates a cell of the [TableView], along with information regarding merged
102+
/// cells and [RepaintBoundary]s.
103+
///
104+
/// When merging cells in a [TableView], the same child should be returned from
105+
/// every vicinity the merged cell contains. The `build` method will only be
106+
/// called once for a merged cell, but since the table's children are lazily
107+
/// laid out, returning the same child ensures the merged cell can be built no
108+
/// matter which part of it is visible.
109+
class TableViewCell extends StatelessWidget {
110+
/// Creates a widget that controls how a child of a [TableView] spans across
111+
/// multiple rows or columns.
112+
const TableViewCell({
113+
super.key,
114+
this.rowMergeStart,
115+
this.rowMergeSpan,
116+
this.columnMergeStart,
117+
this.columnMergeSpan,
118+
this.addRepaintBoundaries = true,
119+
required this.child,
120+
}) : assert(
121+
(rowMergeStart == null && rowMergeSpan == null) ||
122+
(rowMergeStart != null && rowMergeSpan != null),
123+
'Row merge start and span must both be set, or both unset.',
124+
),
125+
assert(rowMergeStart == null || rowMergeStart >= 0),
126+
assert(rowMergeSpan == null || rowMergeSpan > 0),
127+
assert(
128+
(columnMergeStart == null && columnMergeSpan == null) ||
129+
(columnMergeStart != null && columnMergeSpan != null),
130+
'Column merge start and span must both be set, or both unset.',
131+
),
132+
assert(columnMergeStart == null || columnMergeStart >= 0),
133+
assert(columnMergeSpan == null || columnMergeSpan > 0);
134+
135+
/// The child contained in this cell of the [TableView].
136+
final Widget child;
137+
138+
/// Represents the row index where a merged cell in the table begins.
139+
///
140+
/// Defaults to null, meaning a non-merged cell. A value must be provided if
141+
/// a value is provided for [rowMergeSpan].
142+
final int? rowMergeStart;
143+
144+
/// Represents the number of rows spanned by a merged cell.
145+
///
146+
/// Defaults to null, meaning the cell is not merged. A value must be provided
147+
/// if a value is provided for [rowMergeStart].
148+
final int? rowMergeSpan;
149+
150+
/// Represents the column index where a merged cell in the table begins.
151+
///
152+
/// Defaults to null, meaning a non-merged cell. A value must be provided if
153+
/// a value is provided for [columnMergeSpan].
154+
final int? columnMergeStart;
155+
156+
/// Represents the number of columns spanned by a merged cell.
157+
///
158+
/// Defaults to null, meaning the cell is not merged. A value must be provided
159+
/// if a value is provided for [columnMergeStart].
160+
final int? columnMergeSpan;
161+
162+
/// Whether to wrap each child in a [RepaintBoundary].
163+
///
164+
/// Typically, children in a scrolling container are wrapped in repaint
165+
/// boundaries so that they do not need to be repainted as the list scrolls.
166+
/// If the children are easy to repaint (e.g., solid color blocks or a short
167+
/// snippet of text), it might be more efficient to not add a repaint boundary
168+
/// and instead always repaint the children during scrolling.
169+
///
170+
/// Defaults to true.
171+
final bool addRepaintBoundaries;
172+
173+
@override
174+
Widget build(BuildContext context) {
175+
Widget child = this.child;
176+
177+
if (addRepaintBoundaries) {
178+
child = RepaintBoundary(child: child);
179+
}
180+
181+
return _TableViewCell(
182+
rowMergeStart: rowMergeStart,
183+
rowMergeSpan: rowMergeSpan,
184+
columnMergeStart: columnMergeStart,
185+
columnMergeSpan: columnMergeSpan,
186+
child: child,
187+
);
188+
}
189+
}
190+
191+
class _TableViewCell extends ParentDataWidget<TableViewParentData> {
192+
const _TableViewCell({
193+
this.rowMergeStart,
194+
this.rowMergeSpan,
195+
this.columnMergeStart,
196+
this.columnMergeSpan,
197+
required super.child,
198+
});
199+
200+
final int? rowMergeStart;
201+
final int? rowMergeSpan;
202+
final int? columnMergeStart;
203+
final int? columnMergeSpan;
204+
205+
@override
206+
void applyParentData(RenderObject renderObject) {
207+
final TableViewParentData parentData =
208+
renderObject.parentData! as TableViewParentData;
209+
bool needsLayout = false;
210+
if (parentData.rowMergeStart != rowMergeStart) {
211+
assert(rowMergeStart == null || rowMergeStart! >= 0);
212+
parentData.rowMergeStart = rowMergeStart;
213+
needsLayout = true;
214+
}
215+
if (parentData.rowMergeSpan != rowMergeSpan) {
216+
assert(rowMergeSpan == null || rowMergeSpan! > 0);
217+
parentData.rowMergeSpan = rowMergeSpan;
218+
needsLayout = true;
219+
}
220+
if (parentData.columnMergeStart != columnMergeStart) {
221+
assert(columnMergeStart == null || columnMergeStart! >= 0);
222+
parentData.columnMergeStart = columnMergeStart;
223+
needsLayout = true;
224+
}
225+
if (parentData.columnMergeSpan != columnMergeSpan) {
226+
assert(columnMergeSpan == null || columnMergeSpan! > 0);
227+
parentData.columnMergeSpan = columnMergeSpan;
228+
needsLayout = true;
229+
}
230+
231+
if (needsLayout) {
232+
renderObject.parent?.markNeedsLayout();
233+
}
234+
}
235+
236+
@override
237+
Type get debugTypicalAncestorWidgetClass => TableViewport;
238+
239+
@override
240+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
241+
super.debugFillProperties(properties);
242+
if (rowMergeStart != null) {
243+
properties.add(IntProperty('rowMergeStart', rowMergeStart));
244+
properties.add(IntProperty('rowMergeSpan', rowMergeSpan));
245+
}
246+
if (columnMergeStart != null) {
247+
properties.add(IntProperty('columnMergeStart', columnMergeStart));
248+
properties.add(IntProperty('columnMergeSpan', columnMergeSpan));
249+
}
250+
}
43251
}

packages/two_dimensional_scrollables/lib/src/table_view/table_delegate.dart

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ import 'table_span.dart';
1616
/// [TableView].
1717
typedef TableSpanBuilder = TableSpan Function(int index);
1818

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

117117
/// A delegate that supplies children for a [TableViewport] on demand using a
118118
/// builder callback.
119+
///
120+
/// Unlike the base [TwoDimensionalChildBuilderDelegate] this delegate does not
121+
/// automatically insert repaint boundaries. Instead, repaint boundaries are
122+
/// controlled by [TableViewCell.addRepaintBoundaries].
119123
class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate
120124
with TableCellDelegateMixin {
121125
/// Creates a lazy building delegate to use with a [TableView].
@@ -124,7 +128,6 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate
124128
required int rowCount,
125129
int pinnedColumnCount = 0,
126130
int pinnedRowCount = 0,
127-
super.addRepaintBoundaries,
128131
super.addAutomaticKeepAlives,
129132
required TableViewCellBuilder cellBuilder,
130133
required TableSpanBuilder columnBuilder,
@@ -144,6 +147,8 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate
144147
cellBuilder(context, vicinity as TableVicinity),
145148
maxXIndex: columnCount - 1,
146149
maxYIndex: rowCount - 1,
150+
// repaintBoundaries handled by TableViewCell
151+
addRepaintBoundaries: false,
147152
);
148153

149154
@override
@@ -209,15 +214,18 @@ class TableCellBuilderDelegate extends TwoDimensionalChildBuilderDelegate
209214
/// The [children] are accessed for each [TableVicinity.row] and
210215
/// [TableVicinity.column] of the [TwoDimensionalViewport] as
211216
/// `children[vicinity.row][vicinity.column]`.
217+
///
218+
/// Unlike the base [TwoDimensionalChildBuilderDelegate] this delegate does not
219+
/// automatically insert repaint boundaries. Instead, repaint boundaries are
220+
/// controlled by [TableViewCell.addRepaintBoundaries].
212221
class TableCellListDelegate extends TwoDimensionalChildListDelegate
213222
with TableCellDelegateMixin {
214223
/// Creates a delegate that supplies children for a [TableView].
215224
TableCellListDelegate({
216225
int pinnedColumnCount = 0,
217226
int pinnedRowCount = 0,
218-
super.addRepaintBoundaries,
219227
super.addAutomaticKeepAlives,
220-
required List<List<Widget>> cells,
228+
required List<List<TableViewCell>> cells,
221229
required TableSpanBuilder columnBuilder,
222230
required TableSpanBuilder rowBuilder,
223231
}) : assert(pinnedColumnCount >= 0),
@@ -226,10 +234,16 @@ class TableCellListDelegate extends TwoDimensionalChildListDelegate
226234
_rowBuilder = rowBuilder,
227235
_pinnedColumnCount = pinnedColumnCount,
228236
_pinnedRowCount = pinnedRowCount,
229-
super(children: cells) {
237+
super(
238+
children: cells,
239+
// repaintBoundaries handled by TableViewCell
240+
addRepaintBoundaries: false,
241+
) {
230242
// Even if there are merged cells, they should be represented by the same
231-
// child in each cell location. So all arrays of cells should have the same
232-
// length.
243+
// child in each cell location. This ensures that no matter which direction
244+
// the merged cell scrolls into view from, we can build the correct child
245+
// without having to explore all possible vicinities of the merged cell
246+
// area. So all arrays of cells should have the same length.
233247
assert(
234248
children.map((List<Widget> array) => array.length).toSet().length == 1,
235249
'Each list of Widgets within cells must be of the same length.',

packages/two_dimensional_scrollables/lib/src/table_view/table_span.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,9 @@ class MinTableSpanExtent extends CombiningTableSpanExtent {
291291
}
292292

293293
/// A decoration for a [TableSpan].
294+
///
295+
/// When decorating merged cells in the [TableView], a merged cell will take its
296+
/// decoration from the leading cell of the merged span.
294297
class TableSpanDecoration {
295298
/// Creates a [TableSpanDecoration].
296299
const TableSpanDecoration({

packages/two_dimensional_scrollables/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: two_dimensional_scrollables
22
description: Widgets that scroll using the two dimensional scrolling foundation.
3-
version: 0.0.6
3+
version: 0.1.0
44
repository: https://github.com/flutter/packages/tree/main/packages/two_dimensional_scrollables
55
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+two_dimensional_scrollables%22+
66

0 commit comments

Comments
 (0)