paginated_data_table.dart 21.7 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
import 'dart:math' as math;

7
import 'package:flutter/gestures.dart' show DragStartBehavior;
8
import 'package:flutter/widgets.dart';
9 10

import 'card.dart';
11
import 'constants.dart';
12 13
import 'data_table.dart';
import 'data_table_source.dart';
14
import 'debug.dart';
15
import 'dropdown.dart';
16 17
import 'icon_button.dart';
import 'icons.dart';
18
import 'ink_decoration.dart';
19
import 'material_localizations.dart';
20
import 'progress_indicator.dart';
21
import 'theme.dart';
22

23
/// A Material Design data table that shows data using multiple pages.
Adam Barth's avatar
Adam Barth committed
24 25
///
/// A paginated data table shows [rowsPerPage] rows of data per page and
26
/// provides controls for showing other pages.
Adam Barth's avatar
Adam Barth committed
27
///
nt4f04uNd's avatar
nt4f04uNd committed
28
/// Data is read lazily from a [DataTableSource]. The widget is presented
Adam Barth's avatar
Adam Barth committed
29 30 31 32 33
/// as a [Card].
///
/// See also:
///
///  * [DataTable], which is not paginated.
34
///  * <https://material.io/go/design-data-tables#data-tables-tables-within-cards>
35 36 37
class PaginatedDataTable extends StatefulWidget {
  /// Creates a widget describing a paginated [DataTable] on a [Card].
  ///
38
  /// The [header] should give the card's header, typically a [Text] widget.
39
  ///
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
  /// The [columns] argument must be a list of as many [DataColumn] objects as
  /// the table is to have columns, ignoring the leading checkbox column if any.
  /// The [columns] argument must have a length greater than zero and cannot be
  /// null.
  ///
  /// If the table is sorted, the column that provides the current primary key
  /// should be specified by index in [sortColumnIndex], 0 meaning the first
  /// column in [columns], 1 being the next one, and so forth.
  ///
  /// The actual sort order can be specified using [sortAscending]; if the sort
  /// order is ascending, this should be true (the default), otherwise it should
  /// be false.
  ///
  /// The [source] must not be null. The [source] should be a long-lived
  /// [DataTableSource]. The same source should be provided each time a
  /// particular [PaginatedDataTable] widget is created; avoid creating a new
  /// [DataTableSource] with each new instance of the [PaginatedDataTable]
  /// widget unless the data table really is to now show entirely different
  /// data from a new source.
59
  ///
60 61
  /// The [rowsPerPage] and [availableRowsPerPage] must not be null (they
  /// both have defaults, though, so don't have to be specified).
62 63 64 65
  ///
  /// Themed by [DataTableTheme]. [DataTableThemeData.decoration] is ignored.
  /// To modify the border or background color of the [PaginatedDataTable], use
  /// [CardTheme], since a [Card] wraps the inner [DataTable].
66
  PaginatedDataTable({
67
    super.key,
68
    this.header,
69
    this.actions,
70
    required this.columns,
71
    this.sortColumnIndex,
72
    this.sortAscending = true,
73
    this.onSelectAll,
74 75 76 77 78 79 80
    @Deprecated(
      'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. '
      'This feature was deprecated after v3.7.0-5.0.pre.',
    )
    double? dataRowHeight,
    double? dataRowMinHeight,
    double? dataRowMaxHeight,
81
    this.headingRowHeight = 56.0,
82 83
    this.horizontalMargin = 24.0,
    this.columnSpacing = 56.0,
84
    this.showCheckboxColumn = true,
85
    this.showFirstLastButtons = false,
86
    this.initialFirstRowIndex = 0,
87
    this.onPageChanged,
88 89
    this.rowsPerPage = defaultRowsPerPage,
    this.availableRowsPerPage = const <int>[defaultRowsPerPage, defaultRowsPerPage * 2, defaultRowsPerPage * 5, defaultRowsPerPage * 10],
90
    this.onRowsPerPageChanged,
91
    this.dragStartBehavior = DragStartBehavior.start,
92
    this.arrowHeadColor,
93
    required this.source,
94
    this.checkboxHorizontalMargin,
95 96
    this.controller,
    this.primary,
97
  }) : assert(actions == null || (header != null)),
98 99
       assert(columns.isNotEmpty),
       assert(sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length)),
100 101 102 103 104
       assert(dataRowMinHeight == null || dataRowMaxHeight == null || dataRowMaxHeight >= dataRowMinHeight),
       assert(dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null),
         'dataRowHeight ($dataRowHeight) must not be set if dataRowMinHeight ($dataRowMinHeight) or dataRowMaxHeight ($dataRowMaxHeight) are set.'),
       dataRowMinHeight = dataRowHeight ?? dataRowMinHeight ?? kMinInteractiveDimension,
       dataRowMaxHeight = dataRowHeight ?? dataRowMaxHeight ?? kMinInteractiveDimension,
105 106
       assert(rowsPerPage > 0),
       assert(() {
107
         if (onRowsPerPageChanged != null) {
108
           assert(availableRowsPerPage.contains(rowsPerPage));
109
         }
110
         return true;
111
       }()),
112 113 114 115
       assert(!(controller != null && (primary ?? false)),
          'Primary ScrollViews obtain their ScrollController via inheritance from a PrimaryScrollController widget. '
          'You cannot both set primary to true and pass an explicit controller.',
       );
116

117
  /// The table card's optional header.
118
  ///
119 120 121
  /// This is typically a [Text] widget, but can also be a [Row] of
  /// [TextButton]s. To show icon buttons at the top end side of the table with
  /// a header, set the [actions] property.
122 123
  ///
  /// If items in the table are selectable, then, when the selection is not
124 125 126
  /// empty, the header is replaced by a count of the selected items. The
  /// [actions] are still visible when items are selected.
  final Widget? header;
127

128 129
  /// Icon buttons to show at the top end side of the table. The [header] must
  /// not be null to show the actions.
130 131 132 133 134
  ///
  /// Typically, the exact actions included in this list will vary based on
  /// whether any rows are selected or not.
  ///
  /// These should be size 24.0 with default padding (8.0).
135
  final List<Widget>? actions;
136

137 138 139 140 141 142
  /// The configuration and labels for the columns in the table.
  final List<DataColumn> columns;

  /// The current primary sort key's column.
  ///
  /// See [DataTable.sortColumnIndex].
143
  final int? sortColumnIndex;
144 145 146 147 148 149 150 151 152 153 154

  /// Whether the column mentioned in [sortColumnIndex], if any, is sorted
  /// in ascending order.
  ///
  /// See [DataTable.sortAscending].
  final bool sortAscending;

  /// Invoked when the user selects or unselects every row, using the
  /// checkbox in the heading row.
  ///
  /// See [DataTable.onSelectAll].
155
  final ValueSetter<bool?>? onSelectAll;
156

157 158
  /// The height of each row (excluding the row that contains column headings).
  ///
159 160
  /// This value is optional and defaults to kMinInteractiveDimension if not
  /// specified.
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
  @Deprecated(
    'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. '
    'This feature was deprecated after v3.7.0-5.0.pre.',
  )
  double? get dataRowHeight => dataRowMinHeight == dataRowMaxHeight ? dataRowMinHeight : null;

  /// The minimum height of each row (excluding the row that contains column headings).
  ///
  /// This value is optional and defaults to [kMinInteractiveDimension] if not
  /// specified.
  final double dataRowMinHeight;

  /// The maximum height of each row (excluding the row that contains column headings).
  ///
  /// This value is optional and defaults to kMinInteractiveDimension if not
  /// specified.
  final double dataRowMaxHeight;
178 179 180 181 182 183

  /// The height of the heading row.
  ///
  /// This value is optional and defaults to 56.0 if not specified.
  final double headingRowHeight;

184 185 186 187 188 189 190
  /// The horizontal margin between the edges of the table and the content
  /// in the first and last cells of each row.
  ///
  /// When a checkbox is displayed, it is also the margin between the checkbox
  /// the content in the first data column.
  ///
  /// This value defaults to 24.0 to adhere to the Material Design specifications.
191 192 193 194
  ///
  /// If [checkboxHorizontalMargin] is null, then [horizontalMargin] is also the
  /// margin between the edge of the table and the checkbox, as well as the
  /// margin between the checkbox and the content in the first data column.
195 196 197 198 199 200 201
  final double horizontalMargin;

  /// The horizontal margin between the contents of each data column.
  ///
  /// This value defaults to 56.0 to adhere to the Material Design specifications.
  final double columnSpacing;

202 203 204
  /// {@macro flutter.material.dataTable.showCheckboxColumn}
  final bool showCheckboxColumn;

205 206 207
  /// Flag to display the pagination buttons to go to the first and last pages.
  final bool showFirstLastButtons;

208
  /// The index of the first row to display when the widget is first created.
209
  final int? initialFirstRowIndex;
210 211

  /// Invoked when the user switches to another page.
212 213
  ///
  /// The value is the index of the first row on the currently displayed page.
214
  final ValueChanged<int>? onPageChanged;
215 216 217

  /// The number of rows to show on each page.
  ///
218 219
  /// See also:
  ///
220 221
  ///  * [onRowsPerPageChanged]
  ///  * [defaultRowsPerPage]
222 223
  final int rowsPerPage;

224 225 226 227 228 229 230 231 232 233 234 235 236
  /// The default value for [rowsPerPage].
  ///
  /// Useful when initializing the field that will hold the current
  /// [rowsPerPage], when implemented [onRowsPerPageChanged].
  static const int defaultRowsPerPage = 10;

  /// The options to offer for the rowsPerPage.
  ///
  /// The current [rowsPerPage] must be a value in this list.
  ///
  /// The values in this list should be sorted in ascending order.
  final List<int> availableRowsPerPage;

237 238 239 240
  /// Invoked when the user selects a different number of rows per page.
  ///
  /// If this is null, then the value given by [rowsPerPage] will be used
  /// and no affordance will be provided to change the value.
241
  final ValueChanged<int?>? onRowsPerPageChanged;
242 243 244 245 246 247 248 249

  /// The data source which provides data to show in each row. Must be non-null.
  ///
  /// This object should generally have a lifetime longer than the
  /// [PaginatedDataTable] widget itself; it should be reused each time the
  /// [PaginatedDataTable] constructor is called.
  final DataTableSource source;

250 251 252
  /// {@macro flutter.widgets.scrollable.dragStartBehavior}
  final DragStartBehavior dragStartBehavior;

253 254 255 256 257 258 259
  /// Horizontal margin around the checkbox, if it is displayed.
  ///
  /// If null, then [horizontalMargin] is used as the margin between the edge
  /// of the table and the checkbox, as well as the margin between the checkbox
  /// and the content in the first data column. This value defaults to 24.0.
  final double? checkboxHorizontalMargin;

260 261 262
  /// Defines the color of the arrow heads in the footer.
  final Color? arrowHeadColor;

263 264 265 266 267 268
  /// {@macro flutter.widgets.scroll_view.controller}
  final ScrollController? controller;

  /// {@macro flutter.widgets.scroll_view.primary}
  final bool? primary;

269
  @override
270
  PaginatedDataTableState createState() => PaginatedDataTableState();
271 272 273 274 275 276
}

/// Holds the state of a [PaginatedDataTable].
///
/// The table can be programmatically paged using the [pageTo] method.
class PaginatedDataTableState extends State<PaginatedDataTable> {
277 278 279 280 281
  late int _firstRowIndex;
  late int _rowCount;
  late bool _rowCountApproximate;
  int _selectedRowCount = 0;
  final Map<int, DataRow?> _rows = <int, DataRow?>{};
282 283 284 285

  @override
  void initState() {
    super.initState();
286
    _firstRowIndex = PageStorage.maybeOf(context)?.readState(context) as int? ?? widget.initialFirstRowIndex ?? 0;
287
    widget.source.addListener(_handleDataSourceChanged);
288 289 290 291
    _handleDataSourceChanged();
  }

  @override
292 293 294 295 296
  void didUpdateWidget(PaginatedDataTable oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.source != widget.source) {
      oldWidget.source.removeListener(_handleDataSourceChanged);
      widget.source.addListener(_handleDataSourceChanged);
297 298 299 300 301 302
      _handleDataSourceChanged();
    }
  }

  @override
  void dispose() {
303
    widget.source.removeListener(_handleDataSourceChanged);
304 305 306 307 308
    super.dispose();
  }

  void _handleDataSourceChanged() {
    setState(() {
309 310 311
      _rowCount = widget.source.rowCount;
      _rowCountApproximate = widget.source.isRowCountApproximate;
      _selectedRowCount = widget.source.selectedRowCount;
312 313 314 315 316
      _rows.clear();
    });
  }

  /// Ensures that the given row is visible.
317 318
  void pageTo(int rowIndex) {
    final int oldFirstRowIndex = _firstRowIndex;
319
    setState(() {
320
      final int rowsPerPage = widget.rowsPerPage;
321 322
      _firstRowIndex = (rowIndex ~/ rowsPerPage) * rowsPerPage;
    });
323
    if ((widget.onPageChanged != null) &&
324
        (oldFirstRowIndex != _firstRowIndex)) {
325
      widget.onPageChanged!(_firstRowIndex);
326
    }
327 328 329
  }

  DataRow _getBlankRowFor(int index) {
330
    return DataRow.byIndex(
331
      index: index,
332
      cells: widget.columns.map<DataCell>((DataColumn column) => DataCell.empty).toList(),
333 334 335 336 337
    );
  }

  DataRow _getProgressIndicatorRowFor(int index) {
    bool haveProgressIndicator = false;
338
    final List<DataCell> cells = widget.columns.map<DataCell>((DataColumn column) {
339 340
      if (!column.numeric) {
        haveProgressIndicator = true;
341
        return const DataCell(CircularProgressIndicator());
342 343 344 345 346
      }
      return DataCell.empty;
    }).toList();
    if (!haveProgressIndicator) {
      haveProgressIndicator = true;
347
      cells[0] = const DataCell(CircularProgressIndicator());
348
    }
349
    return DataRow.byIndex(
350
      index: index,
351
      cells: cells,
352 353 354 355 356 357 358 359
    );
  }

  List<DataRow> _getRows(int firstRowIndex, int rowsPerPage) {
    final List<DataRow> result = <DataRow>[];
    final int nextPageFirstRowIndex = firstRowIndex + rowsPerPage;
    bool haveProgressIndicator = false;
    for (int index = firstRowIndex; index < nextPageFirstRowIndex; index += 1) {
360
      DataRow? row;
361
      if (index < _rowCount || _rowCountApproximate) {
362
        row = _rows.putIfAbsent(index, () => widget.source.getRow(index));
363 364 365 366 367 368 369 370 371 372 373
        if (row == null && !haveProgressIndicator) {
          row ??= _getProgressIndicatorRowFor(index);
          haveProgressIndicator = true;
        }
      }
      row ??= _getBlankRowFor(index);
      result.add(row);
    }
    return result;
  }

374 375 376 377
  void _handleFirst() {
    pageTo(0);
  }

378 379 380 381 382 383 384 385
  void _handlePrevious() {
    pageTo(math.max(_firstRowIndex - widget.rowsPerPage, 0));
  }

  void _handleNext() {
    pageTo(_firstRowIndex + widget.rowsPerPage);
  }

386 387 388 389 390 391 392
  void _handleLast() {
    pageTo(((_rowCount - 1) / widget.rowsPerPage).floor() * widget.rowsPerPage);
  }

  bool _isNextPageUnavailable() => !_rowCountApproximate &&
      (_firstRowIndex + widget.rowsPerPage >= _rowCount);

393
  final GlobalKey _tableKey = GlobalKey();
394 395 396

  @override
  Widget build(BuildContext context) {
397
    // TODO(ianh): This whole build function doesn't handle RTL yet.
398
    assert(debugCheckHasMaterialLocalizations(context));
399
    final ThemeData themeData = Theme.of(context);
400
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
401 402
    // HEADER
    final List<Widget> headerWidgets = <Widget>[];
403 404 405
    if (_selectedRowCount == 0 && widget.header != null) {
      headerWidgets.add(Expanded(child: widget.header!));
    } else if (widget.header != null) {
406 407
      headerWidgets.add(Expanded(
        child: Text(localizations.selectedRowCountTitle(_selectedRowCount)),
408
      ));
409
    }
410
    if (widget.actions != null) {
411
      headerWidgets.addAll(
412
        widget.actions!.map<Widget>((Widget action) {
413
          return Padding(
414
            // 8.0 is the default padding of an icon button
415
            padding: const EdgeInsetsDirectional.only(start: 24.0 - 8.0 * 2.0),
416
            child: action,
417
          );
418
        }).toList(),
419 420 421 422
      );
    }

    // FOOTER
423
    final TextStyle? footerTextStyle = themeData.textTheme.bodySmall;
424
    final List<Widget> footerWidgets = <Widget>[];
425 426
    if (widget.onRowsPerPageChanged != null) {
      final List<Widget> availableRowsPerPage = widget.availableRowsPerPage
427
        .where((int value) => value <= _rowCount || value == widget.rowsPerPage)
428
        .map<DropdownMenuItem<int>>((int value) {
429
          return DropdownMenuItem<int>(
430
            value: value,
431
            child: Text('$value'),
432 433 434 435
          );
        })
        .toList();
      footerWidgets.addAll(<Widget>[
436 437 438
        Container(width: 14.0), // to match trailing padding in case we overflow and end up scrolling
        Text(localizations.rowsPerPageTitle),
        ConstrainedBox(
439
          constraints: const BoxConstraints(minWidth: 64.0), // 40.0 for the text, 24.0 for the icon
440
          child: Align(
441
            alignment: AlignmentDirectional.centerEnd,
442 443
            child: DropdownButtonHideUnderline(
              child: DropdownButton<int>(
444
                items: availableRowsPerPage.cast<DropdownMenuItem<int>>(),
445 446 447 448 449 450
                value: widget.rowsPerPage,
                onChanged: widget.onRowsPerPageChanged,
                style: footerTextStyle,
              ),
            ),
          ),
451 452 453 454
        ),
      ]);
    }
    footerWidgets.addAll(<Widget>[
455 456
      Container(width: 32.0),
      Text(
457 458 459 460
        localizations.pageRowsInfoTitle(
          _firstRowIndex + 1,
          _firstRowIndex + widget.rowsPerPage,
          _rowCount,
461
          _rowCountApproximate,
462
        ),
463
      ),
464
      Container(width: 32.0),
465 466
      if (widget.showFirstLastButtons)
        IconButton(
467
          icon: Icon(Icons.skip_previous, color: widget.arrowHeadColor),
468 469 470 471
          padding: EdgeInsets.zero,
          tooltip: localizations.firstPageTooltip,
          onPressed: _firstRowIndex <= 0 ? null : _handleFirst,
        ),
472
      IconButton(
473
        icon: Icon(Icons.chevron_left, color: widget.arrowHeadColor),
474
        padding: EdgeInsets.zero,
475
        tooltip: localizations.previousPageTooltip,
476
        onPressed: _firstRowIndex <= 0 ? null : _handlePrevious,
477
      ),
478 479
      Container(width: 24.0),
      IconButton(
480
        icon: Icon(Icons.chevron_right, color: widget.arrowHeadColor),
481
        padding: EdgeInsets.zero,
482
        tooltip: localizations.nextPageTooltip,
483
        onPressed: _isNextPageUnavailable() ? null : _handleNext,
484
      ),
485 486
      if (widget.showFirstLastButtons)
        IconButton(
487
          icon: Icon(Icons.skip_next, color: widget.arrowHeadColor),
488 489 490 491 492 493
          padding: EdgeInsets.zero,
          tooltip: localizations.lastPageTooltip,
          onPressed: _isNextPageUnavailable()
              ? null
              : _handleLast,
        ),
494
      Container(width: 14.0),
495
    ]);
496 497

    // CARD
498 499 500 501 502
    return Card(
      semanticContainer: false,
      child: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
          return Column(
503 504
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
505 506 507 508 509 510 511
              if (headerWidgets.isNotEmpty)
                Semantics(
                  container: true,
                  child: DefaultTextStyle(
                    // These typographic styles aren't quite the regular ones. We pick the closest ones from the regular
                    // list and then tweak them appropriately.
                    // See https://material.io/design/components/data-tables.html#tables-within-cards
512 513
                    style: _selectedRowCount > 0 ? themeData.textTheme.titleMedium!.copyWith(color: themeData.colorScheme.secondary)
                                                 : themeData.textTheme.titleLarge!.copyWith(fontWeight: FontWeight.w400),
514 515
                    child: IconTheme.merge(
                      data: const IconThemeData(
516
                        opacity: 0.54,
517 518 519 520 521
                      ),
                      child: Ink(
                        height: 64.0,
                        color: _selectedRowCount > 0 ? themeData.secondaryHeaderColor : null,
                        child: Padding(
522
                          padding: const EdgeInsetsDirectional.only(start: 24, end: 14.0),
523 524 525 526
                          child: Row(
                            mainAxisAlignment: MainAxisAlignment.end,
                            children: headerWidgets,
                          ),
527 528
                        ),
                      ),
529 530 531
                    ),
                  ),
                ),
532 533
              SingleChildScrollView(
                scrollDirection: Axis.horizontal,
534 535
                primary: widget.primary,
                controller: widget.controller,
536 537 538 539 540 541 542 543 544
                dragStartBehavior: widget.dragStartBehavior,
                child: ConstrainedBox(
                  constraints: BoxConstraints(minWidth: constraints.minWidth),
                  child: DataTable(
                    key: _tableKey,
                    columns: widget.columns,
                    sortColumnIndex: widget.sortColumnIndex,
                    sortAscending: widget.sortAscending,
                    onSelectAll: widget.onSelectAll,
545 546 547
                    // Make sure no decoration is set on the DataTable
                    // from the theme, as its already wrapped in a Card.
                    decoration: const BoxDecoration(),
548 549
                    dataRowMinHeight: widget.dataRowMinHeight,
                    dataRowMaxHeight: widget.dataRowMaxHeight,
550 551
                    headingRowHeight: widget.headingRowHeight,
                    horizontalMargin: widget.horizontalMargin,
552
                    checkboxHorizontalMargin: widget.checkboxHorizontalMargin,
553
                    columnSpacing: widget.columnSpacing,
554
                    showCheckboxColumn: widget.showCheckboxColumn,
555
                    showBottomBorder: true,
556 557 558
                    rows: _getRows(_firstRowIndex, widget.rowsPerPage),
                  ),
                ),
559
              ),
560
              DefaultTextStyle(
561
                style: footerTextStyle!,
562 563
                child: IconTheme.merge(
                  data: const IconThemeData(
564
                    opacity: 0.54,
565
                  ),
566
                  child: SizedBox(
567 568
                    // TODO(bkonyi): this won't handle text zoom correctly,
                    //  https://github.com/flutter/flutter/issues/48522
569 570 571 572 573 574 575 576 577
                    height: 56.0,
                    child: SingleChildScrollView(
                      dragStartBehavior: widget.dragStartBehavior,
                      scrollDirection: Axis.horizontal,
                      reverse: true,
                      child: Row(
                        children: footerWidgets,
                      ),
                    ),
578 579 580
                  ),
                ),
              ),
581
            ],
582 583 584
          );
        },
      ),
585 586 587
    );
  }
}