@@ -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).
42153class 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 ),
0 commit comments