From c7a043c2cc92da2f2352138c6402cae1571ab8b7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 07:11:50 +0300 Subject: [PATCH] perf(grid): eliminate eager List allocation on every pointer-move during drag selection --- .../grid/grid_pointer_selection.dart | 55 +++++++++++++------ lib/features/workspace/result_grid_view.dart | 2 + .../result_grid_drag_selection_test.dart | 53 ++++++++++++++++++ 3 files changed, 92 insertions(+), 18 deletions(-) diff --git a/lib/features/workspace/grid/grid_pointer_selection.dart b/lib/features/workspace/grid/grid_pointer_selection.dart index d8bf7245..a98b4668 100644 --- a/lib/features/workspace/grid/grid_pointer_selection.dart +++ b/lib/features/workspace/grid/grid_pointer_selection.dart @@ -4,25 +4,22 @@ part of '../result_grid_view.dart'; /// Pointer, drag and tap selection for [VirtualResultGrid]. extension _GridPointerSelection on _VirtualResultGridState { - void _notifySelectionAndFocus() { + void _notifySelectionAndFocus({bool deferValues = false}) { if (widget.onSelectionValuesChanged != null) { if (_selection == null) { + _selectionValuesDebounce?.cancel(); widget.onSelectionValuesChanged!(const []); + } else if (deferValues) { + // During active drag gestures, defer allocating the multi-cell + // String list until the pointer stops moving or the drag ends (#1348). + _selectionValuesDebounce?.cancel(); + _selectionValuesDebounce = Timer(const Duration(milliseconds: 40), () { + if (!mounted) return; + _dispatchSelectionValues(); + }); } else { - final rows = _sortedRows; - final values = []; - for (var r = _selection!.startRow; r <= _selection!.endRow; r++) { - if (r >= 0 && r < rows.length) { - for (var c = _selection!.startColumn; - c <= _selection!.endColumn; - c++) { - if (c >= 0 && c < rows[r].length) { - values.add(_maskedValue(c, rows[r][c])); - } - } - } - } - widget.onSelectionValuesChanged!(values); + _selectionValuesDebounce?.cancel(); + _dispatchSelectionValues(); } } @@ -39,6 +36,24 @@ extension _GridPointerSelection on _VirtualResultGridState { } } + void _dispatchSelectionValues() { + if (widget.onSelectionValuesChanged == null || _selection == null) return; + final rows = _sortedRows; + final values = []; + for (var r = _selection!.startRow; r <= _selection!.endRow; r++) { + if (r >= 0 && r < rows.length) { + for (var c = _selection!.startColumn; + c <= _selection!.endColumn; + c++) { + if (c >= 0 && c < rows[r].length) { + values.add(_maskedValue(c, rows[r][c])); + } + } + } + } + widget.onSelectionValuesChanged!(values); + } + ResultGridCellCoordinate? _cellAtOffset({ required Offset localPosition, required double rowHeight, @@ -127,7 +142,7 @@ extension _GridPointerSelection on _VirtualResultGridState { _focusNode.requestFocus(); final modelRow = _toModelRowIndex(startCell.row); widget.onRowSelected?.call(modelRow); - _notifySelectionAndFocus(); + _notifySelectionAndFocus(deferValues: true); setState(() {}); } _startAutoScroll(rowHeight); @@ -149,7 +164,7 @@ extension _GridPointerSelection on _VirtualResultGridState { }); final modelRow = _toModelRowIndex(currentCell.row); widget.onRowSelected?.call(modelRow); - _notifySelectionAndFocus(); + _notifySelectionAndFocus(deferValues: true); } } } @@ -160,6 +175,7 @@ extension _GridPointerSelection on _VirtualResultGridState { }) { if (_isDragSelecting) { _hasJustDragSelected = true; + _notifySelectionAndFocus(deferValues: false); WidgetsBinding.instance.addPostFrameCallback((_) { _hasJustDragSelected = false; }); @@ -197,6 +213,9 @@ extension _GridPointerSelection on _VirtualResultGridState { } void _onGridPointerCancel(PointerCancelEvent event) { + if (_isDragSelecting) { + _notifySelectionAndFocus(deferValues: false); + } _isPointerDown = false; _isDragSelecting = false; _stopAutoScroll(); @@ -288,7 +307,7 @@ extension _GridPointerSelection on _VirtualResultGridState { }); final modelRow = _toModelRowIndex(currentCell.row); widget.onRowSelected?.call(modelRow); - _notifySelectionAndFocus(); + _notifySelectionAndFocus(deferValues: true); } } diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 5b01ef8a..3a9db0da 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -150,6 +150,7 @@ class _VirtualResultGridState extends material.State { DateTime? _lastTapUpTime; ResultGridCellCoordinate? _lastTapCell; Timer? _autoScrollTimer; + Timer? _selectionValuesDebounce; List _currentDisplayOffsets = const [0]; double _currentAvailableWidth = 0.0; @@ -260,6 +261,7 @@ class _VirtualResultGridState extends material.State { @override void dispose() { + _selectionValuesDebounce?.cancel(); PiiMaskingController.instance.removeListener(_onPiiMaskingChanged); _stopAutoScroll(); _rowWidgets.clear(); diff --git a/test/features/workspace/result_grid_drag_selection_test.dart b/test/features/workspace/result_grid_drag_selection_test.dart index 486798b0..b4466ab3 100644 --- a/test/features/workspace/result_grid_drag_selection_test.dart +++ b/test/features/workspace/result_grid_drag_selection_test.dart @@ -54,6 +54,59 @@ void main() { expect(selectedValues, ['1', 'Alice', '2', 'Bob']); }); + testWidgets('does not eagerly allocate and dispatch selection values on every intermediate drag move', (tester) async { + var notifications = 0; + List? lastValues; + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name', 'role'], + rows: const [ + ['1', 'Alice', 'Admin'], + ['2', 'Bob', 'User'], + ['3', 'Charlie', 'Manager'], + ], + onSelectionValuesChanged: (vals) { + notifications++; + lastValues = vals; + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final startPos = tester.getCenter(find.text('1')); + final cellAlice = tester.getCenter(find.text('Alice')); + final cellBob = tester.getCenter(find.text('Bob')); + + final gesture = await tester.startGesture(startPos, kind: PointerDeviceKind.mouse); + await tester.pump(); + final initialNotifications = notifications; + + // Move across Alice without releasing pointer or waiting for debounce + await gesture.moveTo(cellAlice); + await tester.pump(); + + // Move across Bob without releasing pointer + await gesture.moveTo(cellBob); + await tester.pump(); + + // Intermediate drag-move steps must not trigger synchronous value list extractions (#1348) + expect(notifications, equals(initialNotifications)); + + // Releasing pointer flushes final selection values immediately + await gesture.up(); + await tester.pump(); + + expect(notifications, greaterThan(initialNotifications)); + expect(lastValues, ['1', 'Alice', '2', 'Bob']); + }); + testWidgets('supports reverse drag selection (bottom-right to top-left)', (tester) async { List? selectedValues;