From d3e05010f60de592670887dbb09f028784b01afa Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Thu, 30 Apr 2026 14:05:26 -0500 Subject: [PATCH] Fix repainted trailing pinned cells --- .../two_dimensional_scrollables/CHANGELOG.md | 4 + .../lib/src/table_view/table.dart | 4 +- .../two_dimensional_scrollables/pubspec.yaml | 2 +- .../test/table_view/table_test.dart | 78 +++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/two_dimensional_scrollables/CHANGELOG.md b/packages/two_dimensional_scrollables/CHANGELOG.md index 813b9bbc00e..a7b76cdb2a5 100644 --- a/packages/two_dimensional_scrollables/CHANGELOG.md +++ b/packages/two_dimensional_scrollables/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.5.2 + +* Fixes an issue where trailing pinned spans were included in the regular layout pass, leading to unnecessary iterations. + ## 0.5.1 * Fixes an infinite loop of onExit/onEnter events when setState is called within onEnter in a TableSpan. 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 dc46613574f..c2024d6e556 100644 --- a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart +++ b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart @@ -1025,7 +1025,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { !span.isPinned && span.trailingOffset >= _targetTrailingColumnPixel, ); if (_firstNonPinnedColumn != null) { - _lastNonPinnedColumn ??= _columnMetrics.length - 1; + _lastNonPinnedColumn ??= _lastRegularColumnIndex; } if (_rowMetrics.isNotEmpty) { @@ -1056,7 +1056,7 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { !span.isPinned && span.trailingOffset >= _targetTrailingRowPixel, ); if (_firstNonPinnedRow != null) { - _lastNonPinnedRow ??= _rowMetrics.length - 1; + _lastNonPinnedRow ??= _lastRegularRowIndex; } } diff --git a/packages/two_dimensional_scrollables/pubspec.yaml b/packages/two_dimensional_scrollables/pubspec.yaml index 3942be48404..df4051284ee 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.5.1 +version: 0.5.2 repository: https://github.com/flutter/packages/tree/main/packages/two_dimensional_scrollables issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+two_dimensional_scrollables%22+ diff --git a/packages/two_dimensional_scrollables/test/table_view/table_test.dart b/packages/two_dimensional_scrollables/test/table_view/table_test.dart index a89029c843b..875f1282e1a 100644 --- a/packages/two_dimensional_scrollables/test/table_view/table_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/table_test.dart @@ -4631,6 +4631,84 @@ void main() { expect(mergedRect.top, 200); expect(mergedRect.bottom, 400); }); + + testWidgets('Trailing pinned spans are not included in regular layout pass', ( + WidgetTester tester, + ) async { + // This test ensures that trailing pinned spans are not built or painted + // as part of the regular (non-pinned) range. + const spanExtent = 100.0; + final paintCounts = {}; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 400, + width: 400, + child: TableView.builder( + cacheExtent: 0.0, + columnCount: 6, // 2 regular (0,1), 4 trailing pinned (2,3,4,5) + rowCount: 6, // 2 regular, 4 trailing pinned + trailingPinnedColumnCount: 4, + trailingPinnedRowCount: 4, + columnBuilder: (int index) => + const TableSpan(extent: FixedTableSpanExtent(spanExtent)), + rowBuilder: (int index) => + const TableSpan(extent: FixedTableSpanExtent(spanExtent)), + cellBuilder: (context, vicinity) { + return TableViewCell( + child: _PaintCounter( + onPaint: () { + paintCounts[vicinity] = (paintCounts[vicinity] ?? 0) + 1; + }, + child: Text('x: ${vicinity.column} y: ${vicinity.row}'), + ), + ); + }, + ), + ), + ), + ), + ); + + await tester.pump(); + + // Pinned cells should be painted exactly once. + // Before the fix, they would be painted once in the regular pass + // (if incorrectly included) and once in the pinned pass. + // Since they are visually clipped in the regular pass by pushClipRect, + // they would only be painted once anyway, BUT the fix ensures + // we don't even iterate over them in the regular pass. + expect(paintCounts[const TableVicinity(column: 5, row: 5)], 1); + expect(paintCounts[TableVicinity.zero], 1); + expect(paintCounts.length, 36); + }); +} + +class _PaintCounter extends SingleChildRenderObjectWidget { + const _PaintCounter({required this.onPaint, required super.child}); + final VoidCallback onPaint; + @override + _RenderPaintCounter createRenderObject(BuildContext context) => + _RenderPaintCounter(onPaint); + @override + void updateRenderObject( + BuildContext context, + _RenderPaintCounter renderObject, + ) { + renderObject.onPaint = onPaint; + } +} + +class _RenderPaintCounter extends RenderProxyBox { + _RenderPaintCounter(this.onPaint); + VoidCallback onPaint; + @override + void paint(PaintingContext context, Offset offset) { + onPaint(); + super.paint(context, offset); + } } class _NullBuildContext implements BuildContext, TwoDimensionalChildManager {