Skip to content

Commit e6955be

Browse files
authored
[two_dimensional_scrollables] optimizes tableview janks with >250k rows (#10738)
### Description: This PR optimizes the scrolling jank issue of the TableView component when the number of rows exceeds 250,000. ### Root Cause: The _updateFirstAndLastVisibleCell method in RenderTableViewport uses linear for-loop traversal on _columnMetrics and _rowMetrics to locate the visible boundary cells: _firstNonPinnedRow, _lastNonPinnedRow, _firstNonPinnedColumn, and _lastNonPinnedColumn. When the number of rows/columns is extremely large (e.g., >250k rows), this linear traversal causes significant main-thread blocking and scrolling jank. ### Solution: Replace the linear for-loop with binary search algorithm to find the visible boundary cells (_firstNonPinnedRow, _lastNonPinnedRow, _firstNonPinnedColumn, _lastNonPinnedColumn). Binary search reduces the time complexity from O(n) to O(log n), effectively optimizing the scrolling jank issue under large data volumes. ### Fixes: [#138271](flutter/flutter#138271) ### Video performance comparison before: https://github.com/user-attachments/assets/ca5b8821-4bdb-411f-bb2c-63998ac7c0d9 after: https://github.com/user-attachments/assets/ebbf96d9-9e04-4ede-ae79-cd433885d3ab ## Pre-Review Checklist
1 parent f709ea1 commit e6955be

4 files changed

Lines changed: 79 additions & 30 deletions

File tree

packages/two_dimensional_scrollables/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
## NEXT
1+
## 0.3.8
22

33
* Updates minimum supported SDK version to Flutter 3.35/Dart 3.9.
44
* Updates examples to use the new RadioGroup API instead of deprecated Radio parameters.
5+
* Optimizes tableview janks with >250k rows.
56

67
## 0.3.7
78

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

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,32 @@ class RenderTableViewport extends RenderTwoDimensionalViewport {
728728
return verticalOffset.applyContentDimensions(0.0, maxVerticalScrollExtent);
729729
}
730730

731+
/// Binary search to find the first index with [_Span] matching the condition.
732+
/// [map]: Index-[_Span] map, [condition]: Match rule
733+
/// Returns the first matched index or null if not found.
734+
int? _binarySearchFirstFromMap(
735+
Map<int, _Span> map,
736+
bool Function(_Span) condition,
737+
) {
738+
if (map.isEmpty) {
739+
return null;
740+
}
741+
var low = 0;
742+
int high = map.length - 1;
743+
int? result;
744+
while (low <= high) {
745+
final int mid = low + ((high - low) >> 1);
746+
final _Span span = map[mid]!;
747+
if (condition(span)) {
748+
result = mid;
749+
high = mid - 1;
750+
} else {
751+
low = mid + 1;
752+
}
753+
}
754+
return result;
755+
}
756+
731757
// Uses the cached metrics to update the currently visible cells. If the
732758
// number of rows or columns are infinite, the layout is computed lazily, so
733759
// this will call for an update to the metrics if we have scrolled beyond the
@@ -750,21 +776,17 @@ class RenderTableViewport extends RenderTwoDimensionalViewport {
750776
}
751777
_firstNonPinnedColumn = null;
752778
_lastNonPinnedColumn = null;
753-
for (var column = 0; column < _columnMetrics.length; column++) {
754-
if (_columnMetrics[column]!.isPinned) {
755-
continue;
756-
}
757-
final double endOfColumn = _columnMetrics[column]!.trailingOffset;
758-
if (endOfColumn >= _targetLeadingColumnPixel &&
759-
_firstNonPinnedColumn == null) {
760-
_firstNonPinnedColumn = column;
761-
}
762-
if (endOfColumn >= _targetTrailingColumnPixel &&
763-
_lastNonPinnedColumn == null) {
764-
_lastNonPinnedColumn = column;
765-
break;
766-
}
767-
}
779+
// Binary search replaces for-loop to reduce computation.
780+
_firstNonPinnedColumn = _binarySearchFirstFromMap(
781+
_columnMetrics,
782+
(span) =>
783+
!span.isPinned && span.trailingOffset >= _targetLeadingColumnPixel,
784+
);
785+
_lastNonPinnedColumn = _binarySearchFirstFromMap(
786+
_columnMetrics,
787+
(span) =>
788+
!span.isPinned && span.trailingOffset >= _targetTrailingColumnPixel,
789+
);
768790
if (_firstNonPinnedColumn != null) {
769791
_lastNonPinnedColumn ??= _columnMetrics.length - 1;
770792
}
@@ -786,19 +808,16 @@ class RenderTableViewport extends RenderTwoDimensionalViewport {
786808
}
787809
_firstNonPinnedRow = null;
788810
_lastNonPinnedRow = null;
789-
for (var row = 0; row < _rowMetrics.length; row++) {
790-
if (_rowMetrics[row]!.isPinned) {
791-
continue;
792-
}
793-
final double endOfRow = _rowMetrics[row]!.trailingOffset;
794-
if (endOfRow >= _targetLeadingRowPixel && _firstNonPinnedRow == null) {
795-
_firstNonPinnedRow = row;
796-
}
797-
if (endOfRow >= _targetTrailingRowPixel && _lastNonPinnedRow == null) {
798-
_lastNonPinnedRow = row;
799-
break;
800-
}
801-
}
811+
// Binary search replaces for-loop to reduce computation.
812+
_firstNonPinnedRow = _binarySearchFirstFromMap(
813+
_rowMetrics,
814+
(span) => !span.isPinned && span.trailingOffset >= _targetLeadingRowPixel,
815+
);
816+
_lastNonPinnedRow = _binarySearchFirstFromMap(
817+
_rowMetrics,
818+
(span) =>
819+
!span.isPinned && span.trailingOffset >= _targetTrailingRowPixel,
820+
);
802821
if (_firstNonPinnedRow != null) {
803822
_lastNonPinnedRow ??= _rowMetrics.length - 1;
804823
}

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.3.7
3+
version: 0.3.8
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

packages/two_dimensional_scrollables/test/table_view/table_test.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2130,6 +2130,35 @@ void main() {
21302130
),
21312131
);
21322132
});
2133+
2134+
testWidgets('Binary search correctly finds first/last non-pinned cells', (
2135+
WidgetTester tester,
2136+
) async {
2137+
Future<void> runScrollTest(Widget tableView) async {
2138+
await tester.pumpWidget(MaterialApp(home: tableView));
2139+
await tester.pumpAndSettle();
2140+
expect(verticalController.position.pixels, 0.0);
2141+
expect(horizontalController.position.pixels, 0.0);
2142+
expect(find.text('R0:C0'), findsOneWidget);
2143+
expect(find.text('R4:C5'), findsOneWidget);
2144+
// No columns laid out beyond column 5.
2145+
expect(find.text('R0:C6'), findsNothing);
2146+
// Change the vertical scroll offset, validate more rows were
2147+
verticalController.jumpTo(1000000.0);
2148+
await tester.pump();
2149+
expect(find.text('R5000:C0'), findsOneWidget);
2150+
expect(find.text('R5004:C0'), findsOneWidget);
2151+
expect(find.text('R4990:C0'), findsNothing); // Not laid out
2152+
expect(find.text('R5007:C0'), findsNothing); // Not laid out
2153+
await tester.pumpWidget(Container());
2154+
}
2155+
2156+
// infinite rows & columns
2157+
await runScrollTest(getTableView());
2158+
2159+
// finite rows & columns
2160+
await runScrollTest(getTableView(rowCount: 10000, columnCount: 200));
2161+
});
21332162
});
21342163
});
21352164

0 commit comments

Comments
 (0)