-
Notifications
You must be signed in to change notification settings - Fork 29.7k
feat(table): add support for colSpan and rowSpan in TableCell #177102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
This pull request introduces a significant and well-implemented feature, adding colSpan and rowSpan support to the Table widget. The changes are comprehensive, covering rendering logic, widget API, border painting, and extensive testing. The implementation includes thoughtful performance optimizations, such as caching span information and using typed data lists. Additionally, the new assertions and detailed error messages will greatly improve the developer experience when working with complex table layouts. Overall, this is a high-quality contribution.
- Enhanced TableCell to include colSpan and rowSpan properties, allowing cells to span multiple columns and rows. - Updated Table widget to validate colSpan and rowSpan values, ensuring they do not exceed table dimensions. - Improved error handling for irregular row lengths and empty TableRow scenarios. - Added tests for colSpan and rowSpan functionality, including edge cases and layout validation. - Updated documentation to reflect new features and usage guidelines for TableCell. refactor(RenderTable): improve comments for column and row span logic
Piinks
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey @hm21 thanks for contributing! This is a big change, to help facilitate review, would you be willing to draft an outline of your changes and the motivations behind the decisions you made here? We usually do these using this template: https://github.com/flutter/flutter/blob/master/docs/contributing/Design-Documents.md
|
I am not sure what the implications might be on performance here, have you see the TableView widget that supports cell merging with lazy loading for children? |
|
Hey @Piinks, thanks for the fast response to that PR, really appreciate it cuz I thought it would take way longer for someone to take a look, as my other older PR #176393 is still waiting, but maybe that one’s not very interesting for you guys😅🙈
Sure, I can fill it out but not sure I’ll have time this weekend, but definitely next week I should find a bit of time.
I'm not sure, but I guess you're referring to the Reasons for that PR
Compare-PerformanceTo measure the average render duration, I repeatedly called Test 1: Very Large (10,000 cells: 500 rows × 20 columns)
Test 2: Large (500 cells: 50 rows × 10 columns)
Btw, I hope you didn’t get me wrong Thanks again for the fast response to that PR and I'll fill out the document you mentioned as soon as possible. |
Actually, we use labels to route PRs to the right sub-team for review. I noticed that PR did not have a team label on it, so I applied one and reached out to the team to confirm. I hope you will hear back soon on it, and thanks again for contributing!
Thanks as well for your detailed response. If you are able to fill out the design dic template (the various sections are suggestive, not all required) it would be helpful for review. Otherwise I can start digging into this, it just might take a minute to get through it all. :) |
|
Hey @Piinks, thanks for sharing those two links and my bad for not reading carefully enough that I was supposed to mention the open PR in the Discord channel after two weeks🙈 Still, really appreciate your help with tagging the team on the other PR. The link to the design-document is https://flutter.dev/go/table-cell-span. Also, sorry again, I missed that the usual process is to first create an issue referencing the design doc before opening a PR. If you'd still prefer I create a new issue for the design doc but I just thought it might be a bit pointless now that the PR is already there😅 |
## Description of what this PR is changing or adding, and why: Adds a new shortlink redirect for the Flutter design document describing TableCell span support. ## Issues fixed by this PR (if any): ## PRs or commits this PR depends on (if any): [flutter/flutter#177102](flutter/flutter#177102)
Piinks
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Still working through, but a couple questions to start. Thanks for writing a doc! Very helpful!
| } | ||
|
|
||
| // Convert logical span information to visual span information for RTL | ||
| if (textDirection == TextDirection.rtl) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I am really glad you included this. Does this mean that where developers put the TableCell.none must correlate with the text direction?
As in... if TextDirection.rtl:
[TableCell(colSpan: 2), TableCell.none]
But if TextDirection.ltr developers should do:
[TableCell.none, TableCell(colSpan: 2)]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, just a nit, but best to use a switch statement here on the enum for clarity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, it keeps the same logic in both RTL and LTR. In short [TableCell(colSpan: 2), TableCell.none] works the same way in both directions but will be reversed. I know it’s a bit confusing at first when reading it like you showed, but it actually makes sense cuz in LTR, the first cell starts at the top left, and in RTL, at the top right, so the first cell in the array should be the same. In that example, TableCell.none has to stay at the end.
It maybe feels confusing cuz in your example it's written in a single line, and since we both read from left to right, it looks for us reversed in the table. But if we were using an RTL IDE, the code itself would also appear reversed, and then it would look correct again.
For example, the code above in "Example 1" will look like this in RTL:

Piinks
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the updates and your patience here. :)
Couple tests I want to make sure we've covered..
From your doc (thanks again for providing!):
If a developer forgets to include the required TableCell.none entries, assertions during layout report the conflict and specify which row and column overlap occurred.
Is that covered here? I might have missed it.
Also, what happens if the configuration of spans causes overlapping merged cells? How is that handled?
| pendingRowSpanHeights[futureY] -= rowHeight; | ||
| // Ensure it doesn't go negative | ||
| if (pendingRowSpanHeights[futureY] < 0) { | ||
| pendingRowSpanHeights[futureY] = 0.0; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here and where this logic is repeated, instead of the additional case to ensure it does not go negative, can you use clampDouble?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I also struggled to follow the logic here a bit, can you explain a bit more way we set the height, then subtract? Up above on 1475, it only sets the trailing row of a span's height for row spans, with the potential in-betweens set to zero, and we are still in the row loop, so for every row, we iterate through every following row to subtract the height for what purpose?
(Forgive me, when I did merged cell support in TableView is melted my brain 😆 )
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, let me try to explain it:
The key idea is that we don’t know the final row heights upfront, but we do know the full height of a spanning cell when it’s first laid out.
So instead of trying to split that height immediately:
We store the full required height only in the last row of the span.
All intermediate rows stay at 0 for now.
Each time a row gets its final height, we subtract that height from all future pending spans.
This keeps track of how much height is still “unresolved” for those row-spanning cells.
When we finally reach the last row of the span, whatever height is left is applied to that row.
That guarantees the total height across all spanned rows matches the cell’s height exactly.
In short: We set once, subtract as we go, and apply the remainder at the end.
This avoids needing to precalculate or retroactively resize earlier rows.
FYI if I didn’t explain it in enough detail, I'm happy to write a more detailed explanation of each step and also point out where it’s important for TableCellVerticalAlignment to work correctly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gotcha. Acking this, appreciated the explanation!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the clamping non-negative is not addressed, should use math.max(0.0, ) instead
also would be good to put the explanation as code comment since this is non-trivial
| }()), | ||
| assert(() { | ||
| if (children.isNotEmpty) { | ||
| final int cellCount = children.first.children.length; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since TableCell.non is used in place of hidden cells, shouldn't this assertion logic still work? A TableCell for every cell?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, the old assert would still work with TableCell.none. I changed it intentionally to scan every row and report the exact row index and expected column count when there’s a mismatch.
I think for small tables the old message is fine, but for large tables (e.g. 20×40) this makes debugging much faster, cuz you can immediately see which row is wrong and what was expected, instead of just getting a generic error.
So I’d prefer to keep the new logic for better diagnostics, but I’m fine reverting if you feel the simpler assert is more appropriate here.
| expect(find.text('Bottom Middle'), findsOneWidget); | ||
| }); | ||
|
|
||
| testWidgets('Table with all cells using TableCell.none in spanning area', ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if the user puts another widget in a hidden cell instead of TableCell.none?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently, nothing will happen and it will just use it as a placeholder same as TableCell.none. I did consider adding an assert, but decided to leave it optional and allow the use of TableCell.none for a couple of reasons:
- Some users might prefer to use
SizedBox.shrink()or another widget, so this gives them the flexibility to choose. - Imagine a scenario where cells can be dynamically expanded or collapsed. In such cases, developers can simply update the
colSpanorrowSpanvalue without having to switch betweenTableCell.noneand an actual widget.
Anyway if you’d prefer to enforce the use of TableCell.none, I can add an assert, I'm fine with that too :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Connecting the other thread - we checked against TableView, and overlap like this is allowed. It is in the realm of possibility folks may want to be able to do this for their UI.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am slightly confused about the reasoning for allowing use of other widget in placed for the hidden cell? If I understand correctly
TableRow(
children: <Widget>[
TableCell(colSpan: 2, child: Text('Spanning Cell')),
Text('hidden'), // This is ignored?
],
),This feels like code smell that they should use TableCell.none to be more descriptive.
|
Also, FYI it looks like there are some failing tests here. |
|
Hey @Piinks, so sorry for my late response, I was on vacation in Iceland, and afterwards got a bit caught up with some personal work projects🙈
Currently it detects two different behaviors:
At the moment, there's no assert that throws an error in that case, so if it happens, the merged cells will overlap the other cells in reverse order (the last widget ends up on top of the others). I guess it would make sense to implement an assert for that too, right? Or how do you handle that case in
Oh, I thought it was cuz of the infrastructure issue with the Linux tests that were failing around the time I created that PR. In another PR, I had the same problem and was told here that it was related to that. But my bad for not double-checking also here more carefully.
EDIT: So I’m guessing that part is only available to Flutter team members, or in this case, to “you,” right? Or is there something else I need to do first? |
|
Golden file changes have been found for this pull request. Click here to view and triage (e.g. because this is an intentional change). If you are still iterating on this change and are not ready to resolve the images on the Flutter Gold dashboard, consider marking this PR as a draft pull request above. You will still be able to view image results on the dashboard, commenting will be silenced, and the check will not try to resolve itself until marked ready for review. For more guidance, visit Writing a golden file test for Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. |
| baselines[y * columns + x] = childBaseline; | ||
| haveBaseline = true; | ||
| } else { | ||
| if (!isHiddenCell) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this assert !isHiddenCell instead? Looking at the continue based on isHiddenCell on l#1619, we should not end up here if isHiddenCell is true.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, I removed it directly in 0a3684d since, as you mentioned, we should never reach this code path.
I can still add an assert if you prefer, but the golden tests will fail in the case this path is reached anyway. I’m also fairly sure the widget tests would fail in that case as well. But if you prefer, I can also add an assert here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we should never reach this code path.
How do we end up reaching it in widget and golden file tests?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is I think the last outstanding question for me. I am about to go OOO for the holidays, but I'll be lurking around Github tinkering with personal projects. I'd love to help get this in without having to wait for the new year. :)
That being said, we do need a second review. I have sent off or a second reviewer, with the hopes that code review is the perfect pastime for the quiet holiday period. 🙏
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How do we end up reaching it in widget and golden file tests?
Oops, my bad I just tested it and you're right. The tests won't fail cuz it seems the logic still keeps the "hidden" cell with a zero size, so it's more of a performance improvement and not something that would cause the logic to crash.
The point is, before, there was an if (!isHiddenCell) directly inside the switch for just some cases. But with the current logic, none of the cases should run if isHiddenCell is missing. So if we add an assert here, it needs to go before the switch, and it would look like this:
final bool isHiddenCell = currentRowHiddenCells.contains(x);
if (isHiddenCell) {
continue;
}
assert(!isHiddenCell, '...');I mean, we can do that, but it feels wrong because we'd be directly asserting the if right after it runs. Alternatively, we could add a doc comment like "Important for performance improvement", but on the other hand, I feel like if someone sees the code skipping "hidden" cells, it should already be clear that it's for performance reasons. But I’m open to it, I can add the assert, the doc comment, or both, whatever you prefer :)
This is I think the last outstanding question for me. I am about to go OOO...
Sounds good to me, and thanks a lot for pushing this forward 🙌
Really appreciate you lining up a second review as well.
Enjoy the holidays and OOO time! I’ll keep an eye on GitHub too, so if anything comes up during review I’m happy to jump in and help😊
| case TableCellVerticalAlignment.middle: | ||
| case TableCellVerticalAlignment.bottom: | ||
| case TableCellVerticalAlignment.intrinsicHeight: | ||
| if (!isHiddenCell) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here as well?
|
Thanks for the updates! I hope you had a nice time in Iceland. Let me check how TableView handles overlapping merged cells. I do not recall, and there are probably some fundamental differences here since it lays out lazily. |
Looks like the TableView will not complain about overlapping merged cells. So maybe that's alright here too. :) |
Yes, we have an allow list to manage write access to images. I can approve them, looking now. |
|
Golden file changes are available for triage from new commit, Click here to view. For more guidance, visit Writing a golden file test for Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. |
|
Hey @Piinks, thanks again for your review :)
Okay, that's good to know but if you still prefer me to add it here, just let me know :) |
| for (final RenderBox? child in _children) { | ||
| if (child != null && child.hasSize) { | ||
| final cellParentData = child.parentData! as TableCellParentData; | ||
| if (cellParentData.rowSpan != 0 && cellParentData.colSpan != 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
convert this to be a private getter in TableCellParentData
| final int rowSpan = parentData.rowSpan; | ||
|
|
||
| // Only process if there are actual spans to avoid unnecessary work | ||
| if (colSpan <= 1 && rowSpan <= 1) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same this can use a private getter for readability
| late double _tableWidth; | ||
|
|
||
| // Cached layout data used during painting to avoid recomputation. | ||
| List<Set<int>> _cachedSpannedColumnsPerRow = const <Set<int>>[]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| List<Set<int>> _cachedSpannedColumnsPerRow = const <Set<int>>[]; | |
| List<Set<int>> _cachedSpannedColumnsForRows = const <Set<int>>[]; |
|
|
||
| // Cached layout data used during painting to avoid recomputation. | ||
| List<Set<int>> _cachedSpannedColumnsPerRow = const <Set<int>>[]; | ||
| List<Set<int>> _cachedSpannedRowsPerColumn = const <Set<int>>[]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| List<Set<int>> _cachedSpannedRowsPerColumn = const <Set<int>>[]; | |
| List<Set<int>> _cachedSpannedRowsForColumns = const <Set<int>>[]; |
| expect(find.text('Bottom Middle'), findsOneWidget); | ||
| }); | ||
|
|
||
| testWidgets('Table with all cells using TableCell.none in spanning area', ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am slightly confused about the reasoning for allowing use of other widget in placed for the hidden cell? If I understand correctly
TableRow(
children: <Widget>[
TableCell(colSpan: 2, child: Text('Spanning Cell')),
Text('hidden'), // This is ignored?
],
),This feels like code smell that they should use TableCell.none to be more descriptive.
| case TableCellVerticalAlignment.middle: | ||
| case TableCellVerticalAlignment.bottom: | ||
| case TableCellVerticalAlignment.intrinsicHeight: | ||
| final Size childSize = child.getDryLayout(BoxConstraints.tightFor(width: widths[x])); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we also consider colspan? for example the for colspan == 2 the width will be the sum of widths[x] + widths[x + 1]
| pendingRowSpanHeights[futureY] -= rowHeight; | ||
| // Ensure it doesn't go negative | ||
| if (pendingRowSpanHeights[futureY] < 0) { | ||
| pendingRowSpanHeights[futureY] = 0.0; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the clamping non-negative is not addressed, should use math.max(0.0, ) instead
also would be good to put the explanation as code comment since this is non-trivial
| final Float64List rowHeights = Float64List(rows); | ||
| final Float64List beforeBaselineDistances = Float64List(rows); | ||
| // Use flat arrays instead of nested lists for better cache locality. | ||
| final Float64List spanWidths = Float64List(rows * columns); // [row][col] = row * columns + col |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can understand the code from comment in line 1532, but I am not sure what the code comment in this line mean.
| final Float64List spanWidths = Float64List(rows * columns); // [row][col] = row * columns + col | ||
| final Float64List baselines = Float64List(rows * columns); // [row][col] = row * columns + col | ||
|
|
||
| final bool isTextDirectionRtl = textDirection == TextDirection.rtl; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use line 1539 to set this value
final bool isTextDirectionRtl;
switch (...) {
case ...
isTextDirectionRtl = true;
}
Description
This PR adds support for
colSpanandrowSpanto theTableCellwidget.With this change, each
TableCellcan now span multiple columns and/or rows, similar to how HTML tables work.This allows developers to create more flexible and complex table layouts without manually merging cells or resorting to nested tables.
Examples
Example 1
Example 2
Issues Fixed
Fixes #21594
Questions for Reviewers
TableCell.noneto represent a skipped cell (for when another cell usesrowSpanorcolSpan). I’m not sure ifnoneis the best name, alternatives likeplaceholderorskippedmight also fit. I avoidedemptysinceDataCell.emptyalready exists with different semantics.DataTable? I initially didn’t include it cuz the Material Design table doesn’t normally supportrowSpanorcolSpan.Pre-launch Checklist
///).