paginated_data_table.dart 17.3 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/widgets.dart';
8
import 'package:flutter/rendering.dart';
9
import 'package:flutter/gestures.dart' show DragStartBehavior;
10

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

Adam Barth's avatar
Adam Barth committed
25 26 27
/// A material design data table that shows data using multiple pages.
///
/// A paginated data table shows [rowsPerPage] rows of data per page and
28
/// provides controls for showing other pages.
Adam Barth's avatar
Adam Barth committed
29 30 31 32 33 34 35
///
/// Data is read lazily from from a [DataTableSource]. The widget is presented
/// as a [Card].
///
/// See also:
///
///  * [DataTable], which is not paginated.
36
///  * <https://material.io/go/design-data-tables#data-tables-tables-within-cards>
37 38 39
class PaginatedDataTable extends StatefulWidget {
  /// Creates a widget describing a paginated [DataTable] on a [Card].
  ///
40 41 42
  /// The [header] should give the card's header, typically a [Text] widget. It
  /// must not be null.
  ///
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
  /// 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.
62
  ///
63 64
  /// The [rowsPerPage] and [availableRowsPerPage] must not be null (they
  /// both have defaults, though, so don't have to be specified).
65 66
  PaginatedDataTable({
    Key key,
67 68
    @required this.header,
    this.actions,
69
    @required this.columns,
70
    this.sortColumnIndex,
71
    this.sortAscending = true,
72
    this.onSelectAll,
73
    this.dataRowHeight = kMinInteractiveDimension,
74
    this.headingRowHeight = 56.0,
75 76
    this.horizontalMargin = 24.0,
    this.columnSpacing = 56.0,
77
    this.initialFirstRowIndex = 0,
78
    this.onPageChanged,
79 80
    this.rowsPerPage = defaultRowsPerPage,
    this.availableRowsPerPage = const <int>[defaultRowsPerPage, defaultRowsPerPage * 2, defaultRowsPerPage * 5, defaultRowsPerPage * 10],
81
    this.onRowsPerPageChanged,
82
    this.dragStartBehavior = DragStartBehavior.start,
83
    @required this.source,
84 85
  }) : assert(header != null),
       assert(columns != null),
86
       assert(dragStartBehavior != null),
87 88 89
       assert(columns.isNotEmpty),
       assert(sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length)),
       assert(sortAscending != null),
90 91
       assert(dataRowHeight != null),
       assert(headingRowHeight != null),
92 93
       assert(horizontalMargin != null),
       assert(columnSpacing != null),
94 95 96 97 98 99
       assert(rowsPerPage != null),
       assert(rowsPerPage > 0),
       assert(() {
         if (onRowsPerPageChanged != null)
           assert(availableRowsPerPage != null && availableRowsPerPage.contains(rowsPerPage));
         return true;
100
       }()),
101 102
       assert(source != null),
       super(key: key);
103

104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121
  /// The table card's header.
  ///
  /// This is typically a [Text] widget, but can also be a [ButtonBar] with
  /// [FlatButton]s. Suitable defaults are automatically provided for the font,
  /// button color, button padding, and so forth.
  ///
  /// If items in the table are selectable, then, when the selection is not
  /// empty, the header is replaced by a count of the selected items.
  final Widget header;

  /// Icon buttons to show at the top right of the table.
  ///
  /// 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).
  final List<Widget> actions;

122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141
  /// The configuration and labels for the columns in the table.
  final List<DataColumn> columns;

  /// The current primary sort key's column.
  ///
  /// See [DataTable.sortColumnIndex].
  final int sortColumnIndex;

  /// 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].
  final ValueSetter<bool> onSelectAll;

142 143
  /// The height of each row (excluding the row that contains column headings).
  ///
144 145
  /// This value is optional and defaults to kMinInteractiveDimension if not
  /// specified.
146 147 148 149 150 151 152
  final double dataRowHeight;

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

153 154 155 156 157 158 159 160 161 162 163 164 165 166
  /// 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.
  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;

167 168 169 170
  /// The index of the first row to display when the widget is first created.
  final int initialFirstRowIndex;

  /// Invoked when the user switches to another page.
171 172
  ///
  /// The value is the index of the first row on the currently displayed page.
173 174 175 176
  final ValueChanged<int> onPageChanged;

  /// The number of rows to show on each page.
  ///
177 178
  /// See also:
  ///
179 180
  ///  * [onRowsPerPageChanged]
  ///  * [defaultRowsPerPage]
181 182
  final int rowsPerPage;

183 184 185 186 187 188 189 190 191 192 193 194 195
  /// 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;

196 197 198 199 200 201 202 203 204 205 206 207 208
  /// 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.
  final ValueChanged<int> onRowsPerPageChanged;

  /// 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;

209 210 211
  /// {@macro flutter.widgets.scrollable.dragStartBehavior}
  final DragStartBehavior dragStartBehavior;

212
  @override
213
  PaginatedDataTableState createState() => PaginatedDataTableState();
214 215 216 217 218 219 220 221 222
}

/// Holds the state of a [PaginatedDataTable].
///
/// The table can be programmatically paged using the [pageTo] method.
class PaginatedDataTableState extends State<PaginatedDataTable> {
  int _firstRowIndex;
  int _rowCount;
  bool _rowCountApproximate;
223
  int _selectedRowCount;
224 225 226 227 228
  final Map<int, DataRow> _rows = <int, DataRow>{};

  @override
  void initState() {
    super.initState();
229 230
    _firstRowIndex = PageStorage.of(context)?.readState(context) ?? widget.initialFirstRowIndex ?? 0;
    widget.source.addListener(_handleDataSourceChanged);
231 232 233 234
    _handleDataSourceChanged();
  }

  @override
235 236 237 238 239
  void didUpdateWidget(PaginatedDataTable oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.source != widget.source) {
      oldWidget.source.removeListener(_handleDataSourceChanged);
      widget.source.addListener(_handleDataSourceChanged);
240 241 242 243 244 245
      _handleDataSourceChanged();
    }
  }

  @override
  void dispose() {
246
    widget.source.removeListener(_handleDataSourceChanged);
247 248 249 250 251
    super.dispose();
  }

  void _handleDataSourceChanged() {
    setState(() {
252 253 254
      _rowCount = widget.source.rowCount;
      _rowCountApproximate = widget.source.isRowCountApproximate;
      _selectedRowCount = widget.source.selectedRowCount;
255 256 257 258 259
      _rows.clear();
    });
  }

  /// Ensures that the given row is visible.
260 261
  void pageTo(int rowIndex) {
    final int oldFirstRowIndex = _firstRowIndex;
262
    setState(() {
263
      final int rowsPerPage = widget.rowsPerPage;
264 265
      _firstRowIndex = (rowIndex ~/ rowsPerPage) * rowsPerPage;
    });
266
    if ((widget.onPageChanged != null) &&
267
        (oldFirstRowIndex != _firstRowIndex))
268
      widget.onPageChanged(_firstRowIndex);
269 270 271
  }

  DataRow _getBlankRowFor(int index) {
272
    return DataRow.byIndex(
273
      index: index,
274
      cells: widget.columns.map<DataCell>((DataColumn column) => DataCell.empty).toList(),
275 276 277 278 279
    );
  }

  DataRow _getProgressIndicatorRowFor(int index) {
    bool haveProgressIndicator = false;
280
    final List<DataCell> cells = widget.columns.map<DataCell>((DataColumn column) {
281 282
      if (!column.numeric) {
        haveProgressIndicator = true;
283
        return const DataCell(CircularProgressIndicator());
284 285 286 287 288
      }
      return DataCell.empty;
    }).toList();
    if (!haveProgressIndicator) {
      haveProgressIndicator = true;
289
      cells[0] = const DataCell(CircularProgressIndicator());
290
    }
291
    return DataRow.byIndex(
292
      index: index,
293
      cells: cells,
294 295 296 297 298 299 300 301 302 303
    );
  }

  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) {
      DataRow row;
      if (index < _rowCount || _rowCountApproximate) {
304
        row = _rows.putIfAbsent(index, () => widget.source.getRow(index));
305 306 307 308 309 310 311 312 313 314 315
        if (row == null && !haveProgressIndicator) {
          row ??= _getProgressIndicatorRowFor(index);
          haveProgressIndicator = true;
        }
      }
      row ??= _getBlankRowFor(index);
      result.add(row);
    }
    return result;
  }

316 317 318 319 320 321 322 323
  void _handlePrevious() {
    pageTo(math.max(_firstRowIndex - widget.rowsPerPage, 0));
  }

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

324
  final GlobalKey _tableKey = GlobalKey();
325 326 327

  @override
  Widget build(BuildContext context) {
328
    // TODO(ianh): This whole build function doesn't handle RTL yet.
329
    assert(debugCheckHasMaterialLocalizations(context));
330
    final ThemeData themeData = Theme.of(context);
331
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
332 333
    // HEADER
    final List<Widget> headerWidgets = <Widget>[];
334
    double startPadding = 24.0;
335
    if (_selectedRowCount == 0) {
336
      headerWidgets.add(Expanded(child: widget.header));
337
      if (widget.header is ButtonBar) {
338 339 340 341 342 343
        // We adjust the padding when a button bar is present, because the
        // ButtonBar introduces 2 pixels of outside padding, plus 2 pixels
        // around each button on each side, and the button itself will have 8
        // pixels internally on each side, yet we want the left edge of the
        // inside of the button to line up with the 24.0 left inset.
        // TODO(ianh): Better magic. See https://github.com/flutter/flutter/issues/4460
344
        startPadding = 12.0;
345 346
      }
    } else {
347 348
      headerWidgets.add(Expanded(
        child: Text(localizations.selectedRowCountTitle(_selectedRowCount)),
349
      ));
350
    }
351
    if (widget.actions != null) {
352
      headerWidgets.addAll(
353
        widget.actions.map<Widget>((Widget action) {
354
          return Padding(
355
            // 8.0 is the default padding of an icon button
356
            padding: const EdgeInsetsDirectional.only(start: 24.0 - 8.0 * 2.0),
357
            child: action,
358 359 360 361 362 363 364
          );
        }).toList()
      );
    }

    // FOOTER
    final TextStyle footerTextStyle = themeData.textTheme.caption;
365
    final List<Widget> footerWidgets = <Widget>[];
366 367
    if (widget.onRowsPerPageChanged != null) {
      final List<Widget> availableRowsPerPage = widget.availableRowsPerPage
368
        .where((int value) => value <= _rowCount || value == widget.rowsPerPage)
369
        .map<DropdownMenuItem<int>>((int value) {
370
          return DropdownMenuItem<int>(
371
            value: value,
372
            child: Text('$value'),
373 374 375 376
          );
        })
        .toList();
      footerWidgets.addAll(<Widget>[
377 378 379
        Container(width: 14.0), // to match trailing padding in case we overflow and end up scrolling
        Text(localizations.rowsPerPageTitle),
        ConstrainedBox(
380
          constraints: const BoxConstraints(minWidth: 64.0), // 40.0 for the text, 24.0 for the icon
381
          child: Align(
382
            alignment: AlignmentDirectional.centerEnd,
383 384
            child: DropdownButtonHideUnderline(
              child: DropdownButton<int>(
385 386 387 388 389 390 391 392
                items: availableRowsPerPage,
                value: widget.rowsPerPage,
                onChanged: widget.onRowsPerPageChanged,
                style: footerTextStyle,
                iconSize: 24.0,
              ),
            ),
          ),
393 394 395 396
        ),
      ]);
    }
    footerWidgets.addAll(<Widget>[
397 398
      Container(width: 32.0),
      Text(
399 400 401 402
        localizations.pageRowsInfoTitle(
          _firstRowIndex + 1,
          _firstRowIndex + widget.rowsPerPage,
          _rowCount,
403
          _rowCountApproximate,
404
        ),
405
      ),
406 407
      Container(width: 32.0),
      IconButton(
408
        icon: const Icon(Icons.chevron_left),
409
        padding: EdgeInsets.zero,
410
        tooltip: localizations.previousPageTooltip,
411
        onPressed: _firstRowIndex <= 0 ? null : _handlePrevious,
412
      ),
413 414
      Container(width: 24.0),
      IconButton(
415
        icon: const Icon(Icons.chevron_right),
416
        padding: EdgeInsets.zero,
417
        tooltip: localizations.nextPageTooltip,
418
        onPressed: (!_rowCountApproximate && (_firstRowIndex + widget.rowsPerPage >= _rowCount)) ? null : _handleNext,
419
      ),
420
      Container(width: 14.0),
421
    ]);
422 423

    // CARD
424
    return Card(
425
      semanticContainer: false,
426
      child: Column(
427
        crossAxisAlignment: CrossAxisAlignment.stretch,
428
        children: <Widget>[
429
          Semantics(
430
            container: true,
431
            child: DefaultTextStyle(
432 433
              // These typographic styles aren't quite the regular ones. We pick the closest ones from the regular
              // list and then tweak them appropriately.
434
              // See https://material.io/design/components/data-tables.html#tables-within-cards
435 436 437 438 439 440
              style: _selectedRowCount > 0 ? themeData.textTheme.subhead.copyWith(color: themeData.accentColor)
                                           : themeData.textTheme.title.copyWith(fontWeight: FontWeight.w400),
              child: IconTheme.merge(
                data: const IconThemeData(
                  opacity: 0.54
                ),
441 442 443 444 445 446 447 448
                child: Ink(
                  height: 64.0,
                  color: _selectedRowCount > 0 ? themeData.secondaryHeaderColor : null,
                  child: Padding(
                    padding: EdgeInsetsDirectional.only(start: startPadding, end: 14.0),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.end,
                      children: headerWidgets,
449 450 451 452
                    ),
                  ),
                ),
              ),
453
            ),
454
          ),
455
          SingleChildScrollView(
456
            scrollDirection: Axis.horizontal,
457
            dragStartBehavior: widget.dragStartBehavior,
458
            child: DataTable(
459
              key: _tableKey,
460 461 462 463
              columns: widget.columns,
              sortColumnIndex: widget.sortColumnIndex,
              sortAscending: widget.sortAscending,
              onSelectAll: widget.onSelectAll,
464 465
              dataRowHeight: widget.dataRowHeight,
              headingRowHeight: widget.headingRowHeight,
466 467
              horizontalMargin: widget.horizontalMargin,
              columnSpacing: widget.columnSpacing,
468 469
              rows: _getRows(_firstRowIndex, widget.rowsPerPage),
            ),
470
          ),
471
          DefaultTextStyle(
472
            style: footerTextStyle,
473
            child: IconTheme.merge(
474
              data: const IconThemeData(
475 476
                opacity: 0.54
              ),
477
              child: Container(
478
                height: 56.0,
479
                child: SingleChildScrollView(
480
                  dragStartBehavior: widget.dragStartBehavior,
481 482
                  scrollDirection: Axis.horizontal,
                  reverse: true,
483
                  child: Row(
484 485 486 487 488 489 490 491
                    children: footerWidgets,
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
492 493 494
    );
  }
}