1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'card_theme.dart';
6/// @docImport 'data_table_theme.dart';
7/// @docImport 'text_button.dart';
8library;
9
10import 'dart:math' as math;
11
12import 'package:flutter/gestures.dart' show DragStartBehavior;
13import 'package:flutter/widgets.dart';
14
15import 'card.dart';
16import 'constants.dart';
17import 'data_table.dart';
18import 'data_table_source.dart';
19import 'debug.dart';
20import 'dropdown.dart';
21import 'icon_button.dart';
22import 'icons.dart';
23import 'ink_decoration.dart';
24import 'material_localizations.dart';
25import 'material_state.dart';
26import 'progress_indicator.dart';
27import 'theme.dart';
28
29/// A table that follows the
30/// [Material 2](https://material.io/go/design-data-tables)
31/// design specification, using multiple pages to display data.
32///
33/// A paginated data table shows [rowsPerPage] rows of data per page and
34/// provides controls for showing other pages.
35///
36/// Data is read lazily from a [DataTableSource]. The widget is presented
37/// as a [Card].
38///
39/// If the [key] is a [PageStorageKey], the [initialFirstRowIndex] is persisted
40/// to [PageStorage].
41///
42/// {@tool dartpad}
43///
44/// This sample shows how to display a [DataTable] with three columns: name,
45/// age, and role. The columns are defined by three [DataColumn] objects. The
46/// table contains three rows of data for three example users, the data for
47/// which is defined by three [DataRow] objects.
48///
49/// ** See code in examples/api/lib/material/paginated_data_table/paginated_data_table.0.dart **
50/// {@end-tool}
51///
52/// {@tool dartpad}
53///
54/// This example shows how paginated data tables can supported sorted data.
55///
56/// ** See code in examples/api/lib/material/paginated_data_table/paginated_data_table.1.dart **
57/// {@end-tool}
58///
59/// See also:
60///
61/// * [DataTable], which is not paginated.
62/// * `TableView` from the
63/// [two_dimensional_scrollables](https://pub.dev/packages/two_dimensional_scrollables)
64/// package, for displaying large amounts of data without pagination.
65/// * <https://material.io/go/design-data-tables>
66class PaginatedDataTable extends StatefulWidget {
67 /// Creates a widget describing a paginated [DataTable] on a [Card].
68 ///
69 /// The [header] should give the card's header, typically a [Text] widget.
70 ///
71 /// The [columns] argument must be a list of as many [DataColumn] objects as
72 /// the table is to have columns, ignoring the leading checkbox column if any.
73 /// The [columns] argument must have a length greater than zero and cannot be
74 /// null.
75 ///
76 /// If the table is sorted, the column that provides the current primary key
77 /// should be specified by index in [sortColumnIndex], 0 meaning the first
78 /// column in [columns], 1 being the next one, and so forth.
79 ///
80 /// The actual sort order can be specified using [sortAscending]; if the sort
81 /// order is ascending, this should be true (the default), otherwise it should
82 /// be false.
83 ///
84 /// The [source] should be a long-lived [DataTableSource]. The same source
85 /// should be provided each time a particular [PaginatedDataTable] widget is
86 /// created; avoid creating a new [DataTableSource] with each new instance of
87 /// the [PaginatedDataTable] widget unless the data table really is to now
88 /// show entirely different data from a new source.
89 ///
90 /// Themed by [DataTableTheme]. [DataTableThemeData.decoration] is ignored.
91 /// To modify the border or background color of the [PaginatedDataTable], use
92 /// [CardTheme], since a [Card] wraps the inner [DataTable].
93 PaginatedDataTable({
94 super.key,
95 this.header,
96 this.actions,
97 required this.columns,
98 this.sortColumnIndex,
99 this.sortAscending = true,
100 this.onSelectAll,
101 @Deprecated(
102 'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. '
103 'This feature was deprecated after v3.7.0-5.0.pre.',
104 )
105 double? dataRowHeight,
106 double? dataRowMinHeight,
107 double? dataRowMaxHeight,
108 this.headingRowHeight = 56.0,
109 this.horizontalMargin = 24.0,
110 this.columnSpacing = 56.0,
111 this.showCheckboxColumn = true,
112 this.showFirstLastButtons = false,
113 this.initialFirstRowIndex = 0,
114 this.onPageChanged,
115 this.rowsPerPage = defaultRowsPerPage,
116 this.availableRowsPerPage = const <int>[
117 defaultRowsPerPage,
118 defaultRowsPerPage * 2,
119 defaultRowsPerPage * 5,
120 defaultRowsPerPage * 10,
121 ],
122 this.onRowsPerPageChanged,
123 this.dragStartBehavior = DragStartBehavior.start,
124 this.arrowHeadColor,
125 required this.source,
126 this.checkboxHorizontalMargin,
127 this.controller,
128 this.primary,
129 this.headingRowColor,
130 this.dividerThickness,
131 this.showEmptyRows = true,
132 }) : assert(actions == null || (header != null)),
133 assert(columns.isNotEmpty),
134 assert(
135 sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length),
136 ),
137 assert(
138 dataRowMinHeight == null ||
139 dataRowMaxHeight == null ||
140 dataRowMaxHeight >= dataRowMinHeight,
141 ),
142 assert(
143 dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null),
144 'dataRowHeight ($dataRowHeight) must not be set if dataRowMinHeight ($dataRowMinHeight) or dataRowMaxHeight ($dataRowMaxHeight) are set.',
145 ),
146 dataRowMinHeight = dataRowHeight ?? dataRowMinHeight,
147 dataRowMaxHeight = dataRowHeight ?? dataRowMaxHeight,
148 assert(rowsPerPage > 0),
149 assert(dividerThickness == null || dividerThickness >= 0),
150 assert(() {
151 if (onRowsPerPageChanged != null) {
152 assert(availableRowsPerPage.contains(rowsPerPage));
153 }
154 return true;
155 }()),
156 assert(
157 !(controller != null && (primary ?? false)),
158 'Primary ScrollViews obtain their ScrollController via inheritance from a PrimaryScrollController widget. '
159 'You cannot both set primary to true and pass an explicit controller.',
160 );
161
162 /// The table card's optional header.
163 ///
164 /// This is typically a [Text] widget, but can also be a [Row] of
165 /// [TextButton]s. To show icon buttons at the top end side of the table with
166 /// a header, set the [actions] property.
167 ///
168 /// If items in the table are selectable, then, when the selection is not
169 /// empty, the header is replaced by a count of the selected items. The
170 /// [actions] are still visible when items are selected.
171 final Widget? header;
172
173 /// Icon buttons to show at the top end side of the table. The [header] must
174 /// not be null to show the actions.
175 ///
176 /// Typically, the exact actions included in this list will vary based on
177 /// whether any rows are selected or not.
178 ///
179 /// These should be size 24.0 with default padding (8.0).
180 final List<Widget>? actions;
181
182 /// The configuration and labels for the columns in the table.
183 final List<DataColumn> columns;
184
185 /// The current primary sort key's column.
186 ///
187 /// See [DataTable.sortColumnIndex] for details.
188 ///
189 /// The direction of the sort is specified using [sortAscending].
190 final int? sortColumnIndex;
191
192 /// Whether the column mentioned in [sortColumnIndex], if any, is sorted
193 /// in ascending order.
194 ///
195 /// See [DataTable.sortAscending] for details.
196 final bool sortAscending;
197
198 /// Invoked when the user selects or unselects every row, using the
199 /// checkbox in the heading row.
200 ///
201 /// See [DataTable.onSelectAll].
202 final ValueSetter<bool?>? onSelectAll;
203
204 /// The height of each row (excluding the row that contains column headings).
205 ///
206 /// This value is optional and defaults to kMinInteractiveDimension if not
207 /// specified.
208 @Deprecated(
209 'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. '
210 'This feature was deprecated after v3.7.0-5.0.pre.',
211 )
212 double? get dataRowHeight => dataRowMinHeight == dataRowMaxHeight ? dataRowMinHeight : null;
213
214 /// The minimum height of each row (excluding the row that contains column headings).
215 ///
216 /// This value is optional and defaults to [kMinInteractiveDimension] if not
217 /// specified.
218 final double? dataRowMinHeight;
219
220 /// The maximum height of each row (excluding the row that contains column headings).
221 ///
222 /// This value is optional and defaults to [kMinInteractiveDimension] if not
223 /// specified.
224 final double? dataRowMaxHeight;
225
226 /// The height of the heading row.
227 ///
228 /// This value is optional and defaults to 56.0 if not specified.
229 final double headingRowHeight;
230
231 /// The horizontal margin between the edges of the table and the content
232 /// in the first and last cells of each row.
233 ///
234 /// When a checkbox is displayed, it is also the margin between the checkbox
235 /// the content in the first data column.
236 ///
237 /// This value defaults to 24.0 to adhere to the Material Design specifications.
238 ///
239 /// If [checkboxHorizontalMargin] is null, then [horizontalMargin] is also the
240 /// margin between the edge of the table and the checkbox, as well as the
241 /// margin between the checkbox and the content in the first data column.
242 final double horizontalMargin;
243
244 /// The horizontal margin between the contents of each data column.
245 ///
246 /// This value defaults to 56.0 to adhere to the Material Design specifications.
247 final double columnSpacing;
248
249 /// {@macro flutter.material.dataTable.showCheckboxColumn}
250 final bool showCheckboxColumn;
251
252 /// Flag to display the pagination buttons to go to the first and last pages.
253 final bool showFirstLastButtons;
254
255 /// The index of the first row to display when the widget is first created.
256 final int? initialFirstRowIndex;
257
258 /// {@macro flutter.material.dataTable.dividerThickness}
259 ///
260 /// If null, [DataTableThemeData.dividerThickness] is used. This value
261 /// defaults to 1.0.
262 final double? dividerThickness;
263
264 /// Invoked when the user switches to another page.
265 ///
266 /// The value is the index of the first row on the currently displayed page.
267 final ValueChanged<int>? onPageChanged;
268
269 /// The number of rows to show on each page.
270 ///
271 /// See also:
272 ///
273 /// * [onRowsPerPageChanged]
274 /// * [defaultRowsPerPage]
275 final int rowsPerPage;
276
277 /// The default value for [rowsPerPage].
278 ///
279 /// Useful when initializing the field that will hold the current
280 /// [rowsPerPage], when implemented [onRowsPerPageChanged].
281 static const int defaultRowsPerPage = 10;
282
283 /// The options to offer for the rowsPerPage.
284 ///
285 /// The current [rowsPerPage] must be a value in this list.
286 ///
287 /// The values in this list should be sorted in ascending order.
288 final List<int> availableRowsPerPage;
289
290 /// Invoked when the user selects a different number of rows per page.
291 ///
292 /// If this is null, then the value given by [rowsPerPage] will be used
293 /// and no affordance will be provided to change the value.
294 final ValueChanged<int?>? onRowsPerPageChanged;
295
296 /// The data source which provides data to show in each row.
297 ///
298 /// This object should generally have a lifetime longer than the
299 /// [PaginatedDataTable] widget itself; it should be reused each time the
300 /// [PaginatedDataTable] constructor is called.
301 final DataTableSource source;
302
303 /// {@macro flutter.widgets.scrollable.dragStartBehavior}
304 final DragStartBehavior dragStartBehavior;
305
306 /// Horizontal margin around the checkbox, if it is displayed.
307 ///
308 /// If null, then [horizontalMargin] is used as the margin between the edge
309 /// of the table and the checkbox, as well as the margin between the checkbox
310 /// and the content in the first data column. This value defaults to 24.0.
311 final double? checkboxHorizontalMargin;
312
313 /// Defines the color of the arrow heads in the footer.
314 final Color? arrowHeadColor;
315
316 /// {@macro flutter.widgets.scroll_view.controller}
317 final ScrollController? controller;
318
319 /// {@macro flutter.widgets.scroll_view.primary}
320 final bool? primary;
321
322 /// {@macro flutter.material.dataTable.headingRowColor}
323 final MaterialStateProperty<Color?>? headingRowColor;
324
325 /// Controls the visibility of empty rows on the last page of a
326 /// [PaginatedDataTable].
327 ///
328 /// Defaults to `true`, which means empty rows will be populated on the
329 /// last page of the table if there is not enough content.
330 /// When set to `false`, empty rows will not be created.
331 final bool showEmptyRows;
332
333 @override
334 PaginatedDataTableState createState() => PaginatedDataTableState();
335}
336
337/// Holds the state of a [PaginatedDataTable].
338///
339/// The table can be programmatically paged using the [pageTo] method.
340class PaginatedDataTableState extends State<PaginatedDataTable> {
341 late int _firstRowIndex;
342 late int _rowCount;
343 late bool _rowCountApproximate;
344 int _selectedRowCount = 0;
345 final Map<int, DataRow?> _rows = <int, DataRow?>{};
346
347 @protected
348 @override
349 void initState() {
350 super.initState();
351 _firstRowIndex =
352 PageStorage.maybeOf(context)?.readState(context) as int? ??
353 widget.initialFirstRowIndex ??
354 0;
355 widget.source.addListener(_handleDataSourceChanged);
356 _handleDataSourceChanged();
357 }
358
359 @protected
360 @override
361 void didUpdateWidget(PaginatedDataTable oldWidget) {
362 super.didUpdateWidget(oldWidget);
363 if (oldWidget.source != widget.source) {
364 oldWidget.source.removeListener(_handleDataSourceChanged);
365 widget.source.addListener(_handleDataSourceChanged);
366 _updateCaches();
367 }
368 }
369
370 @protected
371 @override
372 void reassemble() {
373 super.reassemble();
374 // This function is called during hot reload.
375 //
376 // Normally, if the data source changes, it would notify its listeners and
377 // thus trigger _handleDataSourceChanged(), which clears the row cache and
378 // causes the widget to rebuild.
379 //
380 // During a hot reload, though, a data source can change in ways that will
381 // invalidate the row cache (e.g. adding or removing columns) without ever
382 // triggering a notification, leaving the PaginatedDataTable in an invalid
383 // state. This method handles this case by clearing the cache any time the
384 // widget is involved in a hot reload.
385 _updateCaches();
386 }
387
388 @protected
389 @override
390 void dispose() {
391 widget.source.removeListener(_handleDataSourceChanged);
392 super.dispose();
393 }
394
395 void _handleDataSourceChanged() {
396 setState(_updateCaches);
397 }
398
399 void _updateCaches() {
400 _rowCount = widget.source.rowCount;
401 _rowCountApproximate = widget.source.isRowCountApproximate;
402 _selectedRowCount = widget.source.selectedRowCount;
403 _rows.clear();
404 }
405
406 /// Ensures that the given row is visible.
407 void pageTo(int rowIndex) {
408 final int oldFirstRowIndex = _firstRowIndex;
409 setState(() {
410 final int rowsPerPage = widget.rowsPerPage;
411 _firstRowIndex = (rowIndex ~/ rowsPerPage) * rowsPerPage;
412 });
413 if ((widget.onPageChanged != null) && (oldFirstRowIndex != _firstRowIndex)) {
414 widget.onPageChanged!(_firstRowIndex);
415 }
416 }
417
418 DataRow _getBlankRowFor(int index) {
419 return DataRow.byIndex(
420 index: index,
421 cells: widget.columns.map<DataCell>((DataColumn column) => DataCell.empty).toList(),
422 );
423 }
424
425 DataRow _getProgressIndicatorRowFor(int index) {
426 bool haveProgressIndicator = false;
427 final List<DataCell> cells = widget.columns.map<DataCell>((DataColumn column) {
428 if (!column.numeric) {
429 haveProgressIndicator = true;
430 return const DataCell(CircularProgressIndicator());
431 }
432 return DataCell.empty;
433 }).toList();
434 if (!haveProgressIndicator) {
435 haveProgressIndicator = true;
436 cells[0] = const DataCell(CircularProgressIndicator());
437 }
438 return DataRow.byIndex(index: index, cells: cells);
439 }
440
441 List<DataRow> _getRows(int firstRowIndex, int rowsPerPage) {
442 final List<DataRow> result = <DataRow>[];
443 final int nextPageFirstRowIndex = firstRowIndex + rowsPerPage;
444 bool haveProgressIndicator = false;
445 for (int index = firstRowIndex; index < nextPageFirstRowIndex; index += 1) {
446 DataRow? row;
447 if (index < _rowCount || _rowCountApproximate) {
448 row = _rows.putIfAbsent(index, () => widget.source.getRow(index));
449 if (row == null && !haveProgressIndicator) {
450 row ??= _getProgressIndicatorRowFor(index);
451 haveProgressIndicator = true;
452 }
453 }
454
455 if (widget.showEmptyRows) {
456 row ??= _getBlankRowFor(index);
457 }
458
459 if (row != null) {
460 result.add(row);
461 }
462 }
463 return result;
464 }
465
466 void _handleFirst() {
467 pageTo(0);
468 }
469
470 void _handlePrevious() {
471 pageTo(math.max(_firstRowIndex - widget.rowsPerPage, 0));
472 }
473
474 void _handleNext() {
475 pageTo(_firstRowIndex + widget.rowsPerPage);
476 }
477
478 void _handleLast() {
479 pageTo(((_rowCount - 1) / widget.rowsPerPage).floor() * widget.rowsPerPage);
480 }
481
482 bool _isNextPageUnavailable() =>
483 !_rowCountApproximate && (_firstRowIndex + widget.rowsPerPage >= _rowCount);
484
485 final GlobalKey _tableKey = GlobalKey();
486
487 @protected
488 @override
489 Widget build(BuildContext context) {
490 // TODO(ianh): This whole build function doesn't handle RTL yet.
491 assert(debugCheckHasMaterialLocalizations(context));
492 final ThemeData themeData = Theme.of(context);
493 final MaterialLocalizations localizations = MaterialLocalizations.of(context);
494 // HEADER
495 final List<Widget> headerWidgets = <Widget>[];
496 if (_selectedRowCount == 0 && widget.header != null) {
497 headerWidgets.add(Expanded(child: widget.header!));
498 } else if (widget.header != null) {
499 headerWidgets.add(
500 Expanded(child: Text(localizations.selectedRowCountTitle(_selectedRowCount))),
501 );
502 }
503 if (widget.actions != null) {
504 headerWidgets.addAll(
505 widget.actions!.map<Widget>((Widget action) {
506 return Padding(
507 // 8.0 is the default padding of an icon button
508 padding: const EdgeInsetsDirectional.only(start: 24.0 - 8.0 * 2.0),
509 child: action,
510 );
511 }).toList(),
512 );
513 }
514
515 // FOOTER
516 final TextStyle? footerTextStyle = themeData.textTheme.bodySmall;
517 final List<Widget> footerWidgets = <Widget>[];
518 if (widget.onRowsPerPageChanged != null) {
519 final List<Widget> availableRowsPerPage = widget.availableRowsPerPage
520 .where((int value) => value <= _rowCount || value == widget.rowsPerPage)
521 .map<DropdownMenuItem<int>>((int value) {
522 return DropdownMenuItem<int>(value: value, child: Text('$value'));
523 })
524 .toList();
525 footerWidgets.addAll(<Widget>[
526 // Match trailing padding, in case we overflow and end up scrolling.
527 const SizedBox(width: 14.0),
528 Text(localizations.rowsPerPageTitle),
529 ConstrainedBox(
530 constraints: const BoxConstraints(minWidth: 64.0), // 40.0 for the text, 24.0 for the icon
531 child: Align(
532 alignment: AlignmentDirectional.centerEnd,
533 child: DropdownButtonHideUnderline(
534 child: DropdownButton<int>(
535 items: availableRowsPerPage.cast<DropdownMenuItem<int>>(),
536 value: widget.rowsPerPage,
537 onChanged: widget.onRowsPerPageChanged,
538 style: footerTextStyle,
539 ),
540 ),
541 ),
542 ),
543 ]);
544 }
545 footerWidgets.addAll(<Widget>[
546 const SizedBox(width: 32.0),
547 Text(
548 localizations.pageRowsInfoTitle(
549 _firstRowIndex + 1,
550 math.min(_firstRowIndex + widget.rowsPerPage, _rowCount),
551 _rowCount,
552 _rowCountApproximate,
553 ),
554 ),
555 const SizedBox(width: 32.0),
556 if (widget.showFirstLastButtons)
557 IconButton(
558 icon: const Icon(Icons.skip_previous),
559 padding: EdgeInsets.zero,
560 color: widget.arrowHeadColor,
561 tooltip: localizations.firstPageTooltip,
562 onPressed: _firstRowIndex <= 0 ? null : _handleFirst,
563 ),
564 IconButton(
565 icon: const Icon(Icons.chevron_left),
566 padding: EdgeInsets.zero,
567 color: widget.arrowHeadColor,
568 tooltip: localizations.previousPageTooltip,
569 onPressed: _firstRowIndex <= 0 ? null : _handlePrevious,
570 ),
571 const SizedBox(width: 24.0),
572 IconButton(
573 icon: const Icon(Icons.chevron_right),
574 padding: EdgeInsets.zero,
575 color: widget.arrowHeadColor,
576 tooltip: localizations.nextPageTooltip,
577 onPressed: _isNextPageUnavailable() ? null : _handleNext,
578 ),
579 if (widget.showFirstLastButtons)
580 IconButton(
581 icon: const Icon(Icons.skip_next),
582 padding: EdgeInsets.zero,
583 color: widget.arrowHeadColor,
584 tooltip: localizations.lastPageTooltip,
585 onPressed: _isNextPageUnavailable() ? null : _handleLast,
586 ),
587 const SizedBox(width: 14.0),
588 ]);
589
590 // CARD
591 return Card(
592 semanticContainer: false,
593 child: LayoutBuilder(
594 builder: (BuildContext context, BoxConstraints constraints) {
595 return Column(
596 crossAxisAlignment: CrossAxisAlignment.stretch,
597 children: <Widget>[
598 if (headerWidgets.isNotEmpty)
599 Semantics(
600 container: true,
601 child: DefaultTextStyle(
602 // These typographic styles aren't quite the regular ones. We pick the closest ones from the regular
603 // list and then tweak them appropriately.
604 // See https://material.io/design/components/data-tables.html#tables-within-cards
605 style: _selectedRowCount > 0
606 ? themeData.textTheme.titleMedium!.copyWith(
607 color: themeData.colorScheme.secondary,
608 )
609 : themeData.textTheme.titleLarge!.copyWith(fontWeight: FontWeight.w400),
610 child: IconTheme.merge(
611 data: const IconThemeData(opacity: 0.54),
612 child: Ink(
613 height: 64.0,
614 color: _selectedRowCount > 0 ? themeData.secondaryHeaderColor : null,
615 child: Padding(
616 padding: const EdgeInsetsDirectional.only(start: 24, end: 14.0),
617 child: Row(
618 mainAxisAlignment: MainAxisAlignment.end,
619 children: headerWidgets,
620 ),
621 ),
622 ),
623 ),
624 ),
625 ),
626 SingleChildScrollView(
627 scrollDirection: Axis.horizontal,
628 primary: widget.primary,
629 controller: widget.controller,
630 dragStartBehavior: widget.dragStartBehavior,
631 child: ConstrainedBox(
632 constraints: BoxConstraints(minWidth: constraints.minWidth),
633 child: DataTable(
634 key: _tableKey,
635 columns: widget.columns,
636 sortColumnIndex: widget.sortColumnIndex,
637 sortAscending: widget.sortAscending,
638 onSelectAll: widget.onSelectAll,
639 dividerThickness: widget.dividerThickness,
640 // Make sure no decoration is set on the DataTable
641 // from the theme, as its already wrapped in a Card.
642 decoration: const BoxDecoration(),
643 dataRowMinHeight: widget.dataRowMinHeight,
644 dataRowMaxHeight: widget.dataRowMaxHeight,
645 headingRowHeight: widget.headingRowHeight,
646 horizontalMargin: widget.horizontalMargin,
647 checkboxHorizontalMargin: widget.checkboxHorizontalMargin,
648 columnSpacing: widget.columnSpacing,
649 showCheckboxColumn: widget.showCheckboxColumn,
650 showBottomBorder: true,
651 rows: _getRows(_firstRowIndex, widget.rowsPerPage),
652 headingRowColor: widget.headingRowColor,
653 ),
654 ),
655 ),
656 if (!widget.showEmptyRows)
657 SizedBox(
658 height:
659 (widget.dataRowMaxHeight ?? kMinInteractiveDimension) *
660 (widget.rowsPerPage - _rowCount + _firstRowIndex).clamp(
661 0,
662 widget.rowsPerPage,
663 ),
664 ),
665 DefaultTextStyle(
666 style: footerTextStyle!,
667 child: IconTheme.merge(
668 data: const IconThemeData(opacity: 0.54),
669 child: SizedBox(
670 // TODO(bkonyi): this won't handle text zoom correctly,
671 // https://github.com/flutter/flutter/issues/48522
672 height: 56.0,
673 child: SingleChildScrollView(
674 dragStartBehavior: widget.dragStartBehavior,
675 scrollDirection: Axis.horizontal,
676 reverse: true,
677 child: Row(children: footerWidgets),
678 ),
679 ),
680 ),
681 ),
682 ],
683 );
684 },
685 ),
686 );
687 }
688}
689