Skip to content

Commit e46d527

Browse files
Merge pull request #444 from QueryaHub/issue/423-result-grid-2d-virtualize
perf(ui): 2D virtualization for VirtualResultGrid (#423)
2 parents 16d9298 + 4d77ecf commit e46d527

2 files changed

Lines changed: 243 additions & 8 deletions

File tree

lib/features/main_screen/result_grid_view.dart

Lines changed: 164 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,56 @@ abstract final class ResultGridMetrics {
1111
static const double maxColumnWidth = 280;
1212
static const int columnWidthSampleRows = 40;
1313
static const int tooltipMinLength = 48;
14+
15+
/// Extra columns built beyond the viewport to reduce scroll flicker.
16+
static const int columnOverscan = 2;
17+
}
18+
19+
/// Inclusive visible column window with spacer widths for off-screen columns.
20+
@immutable
21+
class ResultGridColumnWindow {
22+
const ResultGridColumnWindow({
23+
required this.first,
24+
required this.last,
25+
required this.leadingWidth,
26+
required this.trailingWidth,
27+
});
28+
29+
/// Empty window (no columns).
30+
static const empty = ResultGridColumnWindow(
31+
first: 0,
32+
last: -1,
33+
leadingWidth: 0,
34+
trailingWidth: 0,
35+
);
36+
37+
/// Inclusive first visible (or overscanned) column index.
38+
final int first;
39+
40+
/// Inclusive last visible (or overscanned) column index.
41+
final int last;
42+
43+
/// Width of columns strictly before [first] (left spacer).
44+
final double leadingWidth;
45+
46+
/// Width of columns strictly after [last] (right spacer).
47+
final double trailingWidth;
48+
49+
bool get isEmpty => last < first;
50+
51+
int get columnCount => isEmpty ? 0 : last - first + 1;
52+
53+
@override
54+
bool operator ==(Object other) =>
55+
identical(this, other) ||
56+
other is ResultGridColumnWindow &&
57+
first == other.first &&
58+
last == other.last &&
59+
leadingWidth == other.leadingWidth &&
60+
trailingWidth == other.trailingWidth;
61+
62+
@override
63+
int get hashCode => Object.hash(first, last, leadingWidth, trailingWidth);
1464
}
1565

1666
/// Computes fixed column widths from headers and a sample of [rows].
@@ -38,7 +88,68 @@ List<double> computeResultGridColumnWidths({
3888
return widths;
3989
}
4090

41-
/// Virtualized read-only grid for SQL query results.
91+
/// Prefix sums: `offsets[i]` = sum of widths `[0, i)`.
92+
@visibleForTesting
93+
List<double> computeResultGridColumnOffsets(List<double> columnWidths) {
94+
final offsets = List<double>.filled(columnWidths.length + 1, 0);
95+
for (var i = 0; i < columnWidths.length; i++) {
96+
offsets[i + 1] = offsets[i] + columnWidths[i];
97+
}
98+
return offsets;
99+
}
100+
101+
/// Visible column range for a horizontal viewport (with overscan).
102+
@visibleForTesting
103+
ResultGridColumnWindow computeVisibleColumnWindow({
104+
required List<double> columnWidths,
105+
required List<double> columnOffsets,
106+
required double scrollOffset,
107+
required double viewportWidth,
108+
int overscanColumns = ResultGridMetrics.columnOverscan,
109+
}) {
110+
final n = columnWidths.length;
111+
if (n == 0) return ResultGridColumnWindow.empty;
112+
assert(columnOffsets.length == n + 1);
113+
114+
final total = columnOffsets[n];
115+
if (viewportWidth <= 0) {
116+
return ResultGridColumnWindow(
117+
first: 0,
118+
last: n - 1,
119+
leadingWidth: 0,
120+
trailingWidth: 0,
121+
);
122+
}
123+
124+
final start = scrollOffset.clamp(0.0, total);
125+
final end = (scrollOffset + viewportWidth).clamp(0.0, total);
126+
127+
// First column with any pixel past [start].
128+
var first = 0;
129+
while (first < n && columnOffsets[first + 1] <= start) {
130+
first++;
131+
}
132+
// Last column with any pixel before [end].
133+
var last = n - 1;
134+
while (last > 0 && columnOffsets[last] >= end) {
135+
last--;
136+
}
137+
if (first > last) {
138+
first = last.clamp(0, n - 1);
139+
}
140+
141+
first = (first - overscanColumns).clamp(0, n - 1);
142+
last = (last + overscanColumns).clamp(0, n - 1);
143+
144+
return ResultGridColumnWindow(
145+
first: first,
146+
last: last,
147+
leadingWidth: columnOffsets[first],
148+
trailingWidth: total - columnOffsets[last + 1],
149+
);
150+
}
151+
152+
/// Virtualized read-only grid for SQL query results (rows + columns).
42153
class VirtualResultGrid extends material.StatefulWidget {
43154
const VirtualResultGrid({
44155
super.key,
@@ -58,7 +169,15 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
58169
final _verticalController = material.ScrollController();
59170

60171
List<double> _columnWidths = const [];
172+
List<double> _columnOffsets = const [0];
61173
bool _widthsNeedUpdate = true;
174+
double _scrollOffset = 0;
175+
176+
@override
177+
void initState() {
178+
super.initState();
179+
_horizontalController.addListener(_onHorizontalScroll);
180+
}
62181

63182
@override
64183
void didChangeDependencies() {
@@ -76,11 +195,19 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
76195

77196
@override
78197
void dispose() {
198+
_horizontalController.removeListener(_onHorizontalScroll);
79199
_horizontalController.dispose();
80200
_verticalController.dispose();
81201
super.dispose();
82202
}
83203

204+
void _onHorizontalScroll() {
205+
if (!_horizontalController.hasClients) return;
206+
final offset = _horizontalController.offset;
207+
if ((offset - _scrollOffset).abs() < 0.5) return;
208+
setState(() => _scrollOffset = offset);
209+
}
210+
84211
List<double> _computeColumnWidths() {
85212
return computeResultGridColumnWidths(
86213
columns: widget.columns,
@@ -92,7 +219,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
92219

93220
double get _tableWidth {
94221
if (_columnWidths.isEmpty) return 0;
95-
return _columnWidths.reduce((a, b) => a + b);
222+
return _columnOffsets[_columnWidths.length];
96223
}
97224

98225
double _scaledRowHeight(material.BuildContext context) =>
@@ -101,21 +228,37 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
101228
double _scaledHeaderHeight(material.BuildContext context) =>
102229
context.scaled(ResultGridMetrics.headerHeight);
103230

231+
ResultGridColumnWindow _columnWindow(
232+
List<double> displayWidths,
233+
double viewportWidth,
234+
) {
235+
final offsets = identical(displayWidths, _columnWidths)
236+
? _columnOffsets
237+
: computeResultGridColumnOffsets(displayWidths);
238+
return computeVisibleColumnWindow(
239+
columnWidths: displayWidths,
240+
columnOffsets: offsets,
241+
scrollOffset: _scrollOffset,
242+
viewportWidth: viewportWidth,
243+
);
244+
}
245+
104246
@override
105247
material.Widget build(material.BuildContext context) {
106248
if (_widthsNeedUpdate) {
107249
_columnWidths = _computeColumnWidths();
250+
_columnOffsets = computeResultGridColumnOffsets(_columnWidths);
108251
_widthsNeedUpdate = false;
109252
}
110253
final cs = Theme.of(context).colorScheme;
111-
final colCount = widget.columns.length;
112254
final rowHeight = _scaledRowHeight(context);
113255
final headerHeight = _scaledHeaderHeight(context);
114256

115257
return material.RepaintBoundary(
116258
child: material.LayoutBuilder(
117259
builder: (context, constraints) {
118260
final availableWidth = constraints.maxWidth;
261+
119262
var displayWidths = _columnWidths;
120263
var tableWidth = _tableWidth;
121264
if (tableWidth < availableWidth && _columnWidths.isNotEmpty) {
@@ -129,6 +272,8 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
129272
tableWidth = availableWidth;
130273
}
131274

275+
final window = _columnWindow(displayWidths, availableWidth);
276+
132277
return material.Scrollbar(
133278
controller: _horizontalController,
134279
thumbVisibility: true,
@@ -144,6 +289,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
144289
_HeaderRow(
145290
columns: widget.columns,
146291
columnWidths: displayWidths,
292+
window: window,
147293
height: headerHeight,
148294
colorScheme: cs,
149295
),
@@ -162,7 +308,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
162308
key: ValueKey('result-row-$rowIndex'),
163309
row: row,
164310
columnWidths: displayWidths,
165-
columnCount: colCount,
311+
window: window,
166312
height: rowHeight,
167313
colorScheme: cs,
168314
striped: !isEven,
@@ -186,12 +332,14 @@ class _HeaderRow extends material.StatelessWidget {
186332
const _HeaderRow({
187333
required this.columns,
188334
required this.columnWidths,
335+
required this.window,
189336
required this.height,
190337
required this.colorScheme,
191338
});
192339

193340
final List<String> columns;
194341
final List<double> columnWidths;
342+
final ResultGridColumnWindow window;
195343
final double height;
196344
final ColorScheme colorScheme;
197345

@@ -209,13 +357,17 @@ class _HeaderRow extends material.StatelessWidget {
209357
),
210358
child: material.Row(
211359
children: [
212-
for (var i = 0; i < columns.length; i++)
360+
if (window.leadingWidth > 0)
361+
material.SizedBox(width: window.leadingWidth),
362+
for (var i = window.first; i <= window.last; i++)
213363
_GridCell(
214364
text: columns[i],
215365
width: columnWidths[i],
216366
isHeader: true,
217367
colorScheme: colorScheme,
218368
),
369+
if (window.trailingWidth > 0)
370+
material.SizedBox(width: window.trailingWidth),
219371
],
220372
),
221373
);
@@ -227,15 +379,15 @@ class _DataRow extends material.StatelessWidget {
227379
super.key,
228380
required this.row,
229381
required this.columnWidths,
230-
required this.columnCount,
382+
required this.window,
231383
required this.height,
232384
required this.colorScheme,
233385
required this.striped,
234386
});
235387

236388
final List<String> row;
237389
final List<double> columnWidths;
238-
final int columnCount;
390+
final ResultGridColumnWindow window;
239391
final double height;
240392
final ColorScheme colorScheme;
241393
final bool striped;
@@ -257,12 +409,16 @@ class _DataRow extends material.StatelessWidget {
257409
),
258410
child: material.Row(
259411
children: [
260-
for (var c = 0; c < columnCount; c++)
412+
if (window.leadingWidth > 0)
413+
material.SizedBox(width: window.leadingWidth),
414+
for (var c = window.first; c <= window.last; c++)
261415
_GridCell(
262416
text: c < row.length ? row[c] : '',
263417
width: columnWidths[c],
264418
colorScheme: colorScheme,
265419
),
420+
if (window.trailingWidth > 0)
421+
material.SizedBox(width: window.trailingWidth),
266422
],
267423
),
268424
),

test/features/main_screen/results_tab_test.dart

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,53 @@ void main() {
6161
});
6262
});
6363

64+
group('computeVisibleColumnWindow', () {
65+
test('returns empty for no columns', () {
66+
expect(
67+
computeVisibleColumnWindow(
68+
columnWidths: const [],
69+
columnOffsets: const [0],
70+
scrollOffset: 0,
71+
viewportWidth: 400,
72+
),
73+
ResultGridColumnWindow.empty,
74+
);
75+
});
76+
77+
test('keeps far columns out of a narrow viewport', () {
78+
final widths = List<double>.filled(80, 120);
79+
final offsets = computeResultGridColumnOffsets(widths);
80+
final window = computeVisibleColumnWindow(
81+
columnWidths: widths,
82+
columnOffsets: offsets,
83+
scrollOffset: 0,
84+
viewportWidth: 400,
85+
overscanColumns: 1,
86+
);
87+
// ~4 visible + 1 overscan on the right → last around 4.
88+
expect(window.first, 0);
89+
expect(window.last, lessThan(10));
90+
expect(window.columnCount, lessThan(12));
91+
expect(window.leadingWidth, 0);
92+
expect(window.trailingWidth, greaterThan(0));
93+
});
94+
95+
test('shifts window when scrolled horizontally', () {
96+
final widths = List<double>.filled(50, 100);
97+
final offsets = computeResultGridColumnOffsets(widths);
98+
final window = computeVisibleColumnWindow(
99+
columnWidths: widths,
100+
columnOffsets: offsets,
101+
scrollOffset: 2000,
102+
viewportWidth: 300,
103+
overscanColumns: 0,
104+
);
105+
expect(window.first, greaterThan(15));
106+
expect(window.last, lessThan(30));
107+
expect(window.leadingWidth, greaterThan(0));
108+
});
109+
});
110+
64111
group('ResultsTab', () {
65112
testWidgets('uses virtualized grid instead of Table', (tester) async {
66113
final rows = List.generate(
@@ -117,6 +164,38 @@ void main() {
117164
expect(dataRowWidgets, lessThan(80));
118165
});
119166

167+
testWidgets('does not build off-screen columns in a wide grid',
168+
(tester) async {
169+
final columns = List.generate(80, (i) => 'col_$i');
170+
final rows = List.generate(
171+
40,
172+
(r) => List.generate(80, (c) => 'r${r}_c$c'),
173+
);
174+
175+
await tester.pumpWidget(
176+
resultsShell(
177+
child: material.SizedBox(
178+
height: 400,
179+
width: 360,
180+
child: VirtualResultGrid(
181+
columns: columns,
182+
rows: rows,
183+
),
184+
),
185+
),
186+
);
187+
await tester.pumpAndSettle();
188+
189+
expect(find.text('col_0'), findsOneWidget);
190+
expect(find.text('col_79'), findsNothing);
191+
expect(find.text('r0_c0'), findsOneWidget);
192+
expect(find.text('r0_c79'), findsNothing);
193+
194+
// Far fewer Text widgets than rows×cols (40×80=3200).
195+
final texts = tester.widgetList(find.byType(material.Text)).length;
196+
expect(texts, lessThan(400));
197+
});
198+
120199
testWidgets(
121200
'recalculates column widths when updated with different columns without throwing RangeError',
122201
(tester) async {

0 commit comments

Comments
 (0)