From 919f3d1e3f29fb250fe9f927d9646f92f1c29a23 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 25 Sep 2026 13:59:19 +0300 Subject: [PATCH] perf(grid): rebuild on horizontal scroll only when the visible column window changes (Closes #879) _onHorizontalScroll called setState for every 0.5px of movement, rebuilding the whole virtualized body on each pan frame of a wide grid. The horizontal scroll view already translates header and body together; the offset only decides which columns are built. Recompute the visible column window from the last build's widths and rebuild only when it differs, otherwise just remember the offset. --- lib/features/workspace/result_grid_view.dart | 24 ++++++- .../result_grid_sorting_model_test.dart | 70 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 0baf7af..9d5ac08 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -716,6 +716,11 @@ class _VirtualResultGridState extends material.State { Timer? _autoScrollTimer; List _currentDisplayOffsets = const [0]; double _currentAvailableWidth = 0.0; + + /// Widths and window the last build used, so a scroll tick can tell whether + /// the set of built columns actually changes before it asks for a rebuild. + List _currentDisplayWidths = const []; + ResultGridColumnWindow? _currentWindow; double _currentRowsViewportHeight = 0.0; @override @@ -976,11 +981,26 @@ class _VirtualResultGridState extends material.State { } } + /// The scroll view already translates header and body together; the offset + /// only decides which columns are built (virtualized window). So rebuild only + /// when that window changes, not on every pan frame. void _onHorizontalScroll() { if (!_horizontalController.hasClients) return; final offset = _horizontalController.offset; if ((offset - _scrollOffset).abs() < 0.5) return; - setState(() => _scrollOffset = offset); + _scrollOffset = offset; + + final current = _currentWindow; + if (current != null && _currentDisplayWidths.isNotEmpty) { + final next = computeVisibleColumnWindow( + columnWidths: _currentDisplayWidths, + columnOffsets: _currentDisplayOffsets, + scrollOffset: offset, + viewportWidth: _currentAvailableWidth, + ); + if (next == current) return; + } + setState(() {}); } void _onColumnResize(int index, double delta) { @@ -2076,6 +2096,8 @@ class _VirtualResultGridState extends material.State { : computeResultGridColumnOffsets(displayWidths); final window = _columnWindow(displayWidths, availableWidth); + _currentDisplayWidths = displayWidths; + _currentWindow = window; return material.Scrollbar( controller: _horizontalController, diff --git a/test/features/workspace/result_grid_sorting_model_test.dart b/test/features/workspace/result_grid_sorting_model_test.dart index b55619a..8dfaa1f 100644 --- a/test/features/workspace/result_grid_sorting_model_test.dart +++ b/test/features/workspace/result_grid_sorting_model_test.dart @@ -319,4 +319,74 @@ void main() { expect(y(tester, 'Aaron') < y(tester, 'Alice'), isTrue); }); }); + + group('VirtualResultGrid horizontal scroll rebuilds (#879)', () { + const colCount = 40; + final columns = [for (var c = 0; c < colCount; c++) 'column_$c']; + final rows = [ + for (var r = 0; r < 5; r++) [for (var c = 0; c < colCount; c++) 'v${r}_$c'], + ]; + + Future pumpWide(WidgetTester tester) async { + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 600, + height: 400, + child: VirtualResultGrid(columns: columns, rows: rows), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Finder firstRow() => find.byKey(const material.ValueKey('result-row-0')); + + Finder hScrollable() => find.byWidgetPredicate( + (w) => w is Scrollable && w.axis == Axis.horizontal, + ); + + testWidgets('a small pan inside the same column window does not rebuild rows', + (tester) async { + await pumpWide(tester); + final before = tester.widget(firstRow()); + expect(find.text('column_0'), findsOneWidget); + + // A few pixels stays inside the current (overscanned) window. + final position = tester.state(hScrollable()).position; + position.jumpTo(3); + await tester.pump(); + expect(position.pixels, 3); + + expect(identical(tester.widget(firstRow()), before), isTrue, + reason: 'rows must not be rebuilt for a pan that keeps the window'); + await tester.pumpAndSettle(); // let the scrollbar fade timer finish + }); + + testWidgets('panning far enough builds the newly visible columns', + (tester) async { + await pumpWide(tester); + expect(find.text('column_39'), findsNothing); + + final position = tester.state(hScrollable()).position; + position.jumpTo(position.maxScrollExtent); + await tester.pumpAndSettle(); + + expect(find.text('column_39'), findsOneWidget); + expect(find.text('v0_39'), findsOneWidget); + expect(find.text('column_0'), findsNothing); + }); + + testWidgets('scrolling back restores the first columns', (tester) async { + await pumpWide(tester); + final position = tester.state(hScrollable()).position; + position.jumpTo(position.maxScrollExtent); + await tester.pumpAndSettle(); + position.jumpTo(0); + await tester.pumpAndSettle(); + + expect(find.text('column_0'), findsOneWidget); + expect(find.text('column_39'), findsNothing); + }); + }); }