data_table.dart 34.6 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
// @dart = 2.8

7 8
import 'dart:math' as math;

9
import 'package:flutter/foundation.dart';
10 11 12 13 14
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

import 'checkbox.dart';
import 'colors.dart';
15
import 'constants.dart';
16
import 'debug.dart';
17
import 'divider.dart';
18
import 'dropdown.dart';
19 20 21
import 'icons.dart';
import 'ink_well.dart';
import 'material.dart';
22
import 'material_state.dart';
23 24 25 26
import 'theme.dart';
import 'theme_data.dart';
import 'tooltip.dart';

27
/// Signature for [DataColumn.onSort] callback.
28
typedef DataColumnSortCallback = void Function(int columnIndex, bool ascending);
29 30 31 32 33 34

/// Column configuration for a [DataTable].
///
/// One column configuration must be provided for each column to
/// display in the table. The list of [DataColumn] objects is passed
/// as the `columns` argument to the [new DataTable] constructor.
35
@immutable
36 37 38 39 40
class DataColumn {
  /// Creates the configuration for a column of a [DataTable].
  ///
  /// The [label] argument must not be null.
  const DataColumn({
41
    @required this.label,
42
    this.tooltip,
43
    this.numeric = false,
44
    this.onSort,
45
  }) : assert(label != null);
46 47 48 49 50 51 52

  /// The column heading.
  ///
  /// Typically, this will be a [Text] widget. It could also be an
  /// [Icon] (typically using size 18), or a [Row] with an icon and
  /// some text.
  ///
53 54 55 56
  /// By default, this widget will only occupy the minimal space. If you want
  /// it to take the entire remaining space, e.g. when you want to use [Center],
  /// you can wrap it with an [Expanded].
  ///
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
  /// The label should not include the sort indicator.
  final Widget label;

  /// The column heading's tooltip.
  ///
  /// This is a longer description of the column heading, for cases
  /// where the heading might have been abbreviated to keep the column
  /// width to a reasonable size.
  final String tooltip;

  /// Whether this column represents numeric data or not.
  ///
  /// The contents of cells of columns containing numeric data are
  /// right-aligned.
  final bool numeric;

73
  /// Called when the user asks to sort the table using this column.
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
  ///
  /// If null, the column will not be considered sortable.
  ///
  /// See [DataTable.sortColumnIndex] and [DataTable.sortAscending].
  final DataColumnSortCallback onSort;

  bool get _debugInteractive => onSort != null;
}

/// Row configuration and cell data for a [DataTable].
///
/// One row configuration must be provided for each row to
/// display in the table. The list of [DataRow] objects is passed
/// as the `rows` argument to the [new DataTable] constructor.
///
/// The data for this row of the table is provided in the [cells]
/// property of the [DataRow] object.
91
@immutable
92 93 94 95 96 97
class DataRow {
  /// Creates the configuration for a row of a [DataTable].
  ///
  /// The [cells] argument must not be null.
  const DataRow({
    this.key,
98
    this.selected = false,
99
    this.onSelectChanged,
100
    this.color,
101
    @required this.cells,
102
  }) : assert(cells != null);
103

104 105 106 107 108 109
  /// Creates the configuration for a row of a [DataTable], deriving
  /// the key from a row index.
  ///
  /// The [cells] argument must not be null.
  DataRow.byIndex({
    int index,
110
    this.selected = false,
111
    this.onSelectChanged,
112
    this.color,
113
    @required this.cells,
114
  }) : assert(cells != null),
115
       key = ValueKey<int>(index);
116

117 118 119 120 121 122 123 124
  /// A [Key] that uniquely identifies this row. This is used to
  /// ensure that if a row is added or removed, any stateful widgets
  /// related to this row (e.g. an in-progress checkbox animation)
  /// remain on the right row visually.
  ///
  /// If the table never changes once created, no key is necessary.
  final LocalKey key;

125
  /// Called when the user selects or unselects a selectable row.
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155
  ///
  /// If this is not null, then the row is selectable. The current
  /// selection state of the row is given by [selected].
  ///
  /// If any row is selectable, then the table's heading row will have
  /// a checkbox that can be checked to select all selectable rows
  /// (and which is checked if all the rows are selected), and each
  /// subsequent row will have a checkbox to toggle just that row.
  ///
  /// A row whose [onSelectChanged] callback is null is ignored for
  /// the purposes of determining the state of the "all" checkbox,
  /// and its checkbox is disabled.
  final ValueChanged<bool> onSelectChanged;

  /// Whether the row is selected.
  ///
  /// If [onSelectChanged] is non-null for any row in the table, then
  /// a checkbox is shown at the start of each row. If the row is
  /// selected (true), the checkbox will be checked and the row will
  /// be highlighted.
  ///
  /// Otherwise, the checkbox, if present, will not be checked.
  final bool selected;

  /// The data for this row.
  ///
  /// There must be exactly as many cells as there are columns in the
  /// table.
  final List<DataCell> cells;

156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183
  /// The color for the row.
  ///
  /// By default, the color is transparent unless selected. Selected rows has
  /// a grey translucent color.
  ///
  /// The effective color can depend on the [MaterialState] state, if the
  /// row is selected, pressed, hovered, focused, disabled or enabled. The
  /// color is painted as an overlay to the row. To make sure that the row's
  /// [InkWell] is visible (when pressed, hovered and focused), it is
  /// recommended to use a translucent color.
  ///
  /// ```dart
  /// DataRow(
  ///   color: MaterialStateProperty.resolveWith<Color>(Set<MaterialState> states) {
  ///     if (states.contains(MaterialState.selected))
  ///       return Theme.of(context).colorScheme.primary.withOpacity(0.08);
  ///     return null;  // Use the default value.
  ///   },
  ///)
  /// ```
  ///
  /// See also:
  ///
  ///  * The Material Design specification for overlay colors and how they
  ///    match a component's state:
  ///    <https://material.io/design/interaction/states.html#anatomy>.
  final MaterialStateProperty<Color> color;

184 185 186 187 188 189
  bool get _debugInteractive => onSelectChanged != null || cells.any((DataCell cell) => cell._debugInteractive);
}

/// The data for a cell of a [DataTable].
///
/// One list of [DataCell] objects must be provided for each [DataRow]
190
/// in the [DataTable], in the new [DataRow] constructor's `cells`
191
/// argument.
192
@immutable
193 194 195 196
class DataCell {
  /// Creates an object to hold the data for a cell in a [DataTable].
  ///
  /// The first argument is the widget to show for the cell, typically
197
  /// a [Text] or [DropdownButton] widget; this becomes the [child]
198 199 200 201 202
  /// property and must not be null.
  ///
  /// If the cell has no data, then a [Text] widget with placeholder
  /// text should be provided instead, and then the [placeholder]
  /// argument should be set to true.
203 204
  const DataCell(
    this.child, {
205 206
    this.placeholder = false,
    this.showEditIcon = false,
207
    this.onTap,
208
  }) : assert(child != null);
209

210
  /// A cell that has no content and has zero width and height.
211
  static const DataCell empty = DataCell(SizedBox(width: 0.0, height: 0.0));
212

213 214
  /// The data for the row.
  ///
215
  /// Typically a [Text] widget or a [DropdownButton] widget.
216 217 218 219
  ///
  /// If the cell has no data, then a [Text] widget with placeholder
  /// text should be provided instead, and [placeholder] should be set
  /// to true.
220 221
  ///
  /// {@macro flutter.widgets.child}
222
  final Widget child;
223

224
  /// Whether the [child] is actually a placeholder.
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239
  ///
  /// If this is true, the default text style for the cell is changed
  /// to be appropriate for placeholder text.
  final bool placeholder;

  /// Whether to show an edit icon at the end of the cell.
  ///
  /// This does not make the cell actually editable; the caller must
  /// implement editing behavior if desired (initiated from the
  /// [onTap] callback).
  ///
  /// If this is set, [onTap] should also be set, otherwise tapping
  /// the icon will have no effect.
  final bool showEditIcon;

240
  /// Called if the cell is tapped.
241
  ///
242
  /// If non-null, tapping the cell will call this callback. If
243
  /// null, tapping the cell will attempt to select the row (if
244
  /// [DataRow.onSelectChanged] is provided).
245 246 247 248 249 250 251
  final VoidCallback onTap;

  bool get _debugInteractive => onTap != null;
}

/// A material design data table.
///
252 253
/// {@youtube 560 315 https://www.youtube.com/watch?v=ktTajqbhIcY}
///
254 255 256 257 258
/// Displaying data in a table is expensive, because to lay out the
/// table all the data must be measured twice, once to negotiate the
/// dimensions to use for each column, and once to actually lay out
/// the table given the results of the negotiation.
///
259 260 261 262 263
/// For this reason, if you have a lot of data (say, more than a dozen
/// rows with a dozen columns, though the precise limits depend on the
/// target device), it is suggested that you use a
/// [PaginatedDataTable] which automatically splits the data into
/// multiple pages.
264
///
265
/// {@tool dartpad --template=stateless_widget_scaffold}
266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324
///
/// This sample shows how to display a [DataTable] with three columns: name, age, and
/// role. The columns are defined by three [DataColumn] objects. The table
/// contains three rows of data for three example users, the data for which
/// is defined by three [DataRow] objects.
///
/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/data_table.png)
///
/// ```dart
/// Widget build(BuildContext context) {
///   return DataTable(
///     columns: const <DataColumn>[
///       DataColumn(
///         label: Text(
///           'Name',
///           style: TextStyle(fontStyle: FontStyle.italic),
///         ),
///       ),
///       DataColumn(
///         label: Text(
///           'Age',
///           style: TextStyle(fontStyle: FontStyle.italic),
///         ),
///       ),
///       DataColumn(
///         label: Text(
///           'Role',
///           style: TextStyle(fontStyle: FontStyle.italic),
///         ),
///       ),
///     ],
///     rows: const <DataRow>[
///       DataRow(
///         cells: <DataCell>[
///           DataCell(Text('Sarah')),
///           DataCell(Text('19')),
///           DataCell(Text('Student')),
///         ],
///       ),
///       DataRow(
///         cells: <DataCell>[
///           DataCell(Text('Janine')),
///           DataCell(Text('43')),
///           DataCell(Text('Professor')),
///         ],
///       ),
///       DataRow(
///         cells: <DataCell>[
///           DataCell(Text('William')),
///           DataCell(Text('27')),
///           DataCell(Text('Associate Professor')),
///         ],
///       ),
///     ],
///   );
/// }
/// ```
///
/// {@end-tool}
325
///
326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372
///
/// {@tool dartpad --template=stateful_widget_scaffold}
///
/// This sample shows how to display a [DataTable] with alternate colors per
/// row, and a custom color for when the row is selected.
///
/// ```dart
/// static const int numItems = 10;
/// List<bool> selected = List<bool>.generate(numItems, (index) => false);
///
/// @override
/// Widget build(BuildContext context) {
///   return SizedBox(
///     width: double.infinity,
///     child: DataTable(
///       columns: const <DataColumn>[
///         DataColumn(
///           label: const Text('Number'),
///         ),
///       ],
///       rows: List<DataRow>.generate(
///         numItems,
///         (index) => DataRow(
///           color: MaterialStateProperty.resolveWith<Color>((Set<MaterialState> states) {
///             // All rows will have the same selected color.
///             if (states.contains(MaterialState.selected))
///               return Theme.of(context).colorScheme.primary.withOpacity(0.08);
///             // Even rows will have a grey color.
///             if (index % 2 == 0)
///               return Colors.grey.withOpacity(0.3);
///             return null;  // Use default value for other states and odd rows.
///           }),
///           cells: [DataCell(Text('Row $index'))],
///           selected: selected[index],
///           onSelectChanged: (bool value) {
///             setState(() {
///               selected[index] = value;
///             });
///           },
///         ),
///       ),
///     ),
///   );
/// }
/// ```
/// {@end-tool}
///
373 374
/// See also:
///
Adam Barth's avatar
Adam Barth committed
375 376 377 378 379
///  * [DataColumn], which describes a column in the data table.
///  * [DataRow], which contains the data for a row in the data table.
///  * [DataCell], which contains the data for a single cell in the data table.
///  * [PaginatedDataTable], which shows part of the data in a data table and
///    provides controls for paging through the remainder of the data.
380
///  * <https://material.io/design/components/data-tables.html>
381 382 383 384 385 386
class DataTable extends StatelessWidget {
  /// Creates a widget describing a data table.
  ///
  /// 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
387
  /// length greater than zero and must not be null.
388
  ///
389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407
  /// The [rows] argument must be a list of as many [DataRow] objects
  /// as the table is to have rows, ignoring the leading heading row
  /// that contains the column headings (derived from the [columns]
  /// argument). There may be zero rows, but the rows argument must
  /// not be null.
  ///
  /// Each [DataRow] object in [rows] must have as many [DataCell]
  /// objects in the [DataRow.cells] list as the table has columns.
  ///
  /// 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.
  DataTable({
    Key key,
408
    @required this.columns,
409
    this.sortColumnIndex,
410
    this.sortAscending = true,
411
    this.onSelectAll,
412
    this.dataRowHeight = kMinInteractiveDimension,
413
    this.headingRowHeight = 56.0,
414 415
    this.horizontalMargin = 24.0,
    this.columnSpacing = 56.0,
416
    this.showCheckboxColumn = true,
417
    this.dividerThickness = 1.0,
418
    @required this.rows,
419 420 421 422
  }) : assert(columns != null),
       assert(columns.isNotEmpty),
       assert(sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length)),
       assert(sortAscending != null),
423 424
       assert(dataRowHeight != null),
       assert(headingRowHeight != null),
425 426
       assert(horizontalMargin != null),
       assert(columnSpacing != null),
427
       assert(showCheckboxColumn != null),
428 429
       assert(rows != null),
       assert(!rows.any((DataRow row) => row.cells.length != columns.length)),
430
       assert(dividerThickness != null && dividerThickness >= 0),
431 432
       _onlyTextColumn = _initOnlyTextColumn(columns),
       super(key: key);
433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461

  /// The configuration and labels for the columns in the table.
  final List<DataColumn> columns;

  /// The current primary sort key's column.
  ///
  /// If non-null, indicates that the indicated column is the column
  /// by which the data is sorted. The number must correspond to the
  /// index of the relevant column in [columns].
  ///
  /// Setting this will cause the relevant column to have a sort
  /// indicator displayed.
  ///
  /// When this is null, it implies that the table's sort order does
  /// not correspond to any of the columns.
  final int sortColumnIndex;

  /// Whether the column mentioned in [sortColumnIndex], if any, is sorted
  /// in ascending order.
  ///
  /// If true, the order is ascending (meaning the rows with the
  /// smallest values for the current sort column are first in the
  /// table).
  ///
  /// If false, the order is descending (meaning the rows with the
  /// smallest values for the current sort column are last in the
  /// table).
  final bool sortAscending;

462 463 464 465 466 467 468 469 470 471 472
  /// Invoked when the user selects or unselects every row, using the
  /// checkbox in the heading row.
  ///
  /// If this is null, then the [DataRow.onSelectChanged] callback of
  /// every row in the table is invoked appropriately instead.
  ///
  /// To control whether a particular row is selectable or not, see
  /// [DataRow.onSelectChanged]. This callback is only relevant if any
  /// row is selectable.
  final ValueSetter<bool> onSelectAll;

473 474
  /// The height of each row (excluding the row that contains column headings).
  ///
475 476
  /// This value defaults to kMinInteractiveDimension to adhere to the Material
  /// Design specifications.
477 478 479 480 481 482 483
  final double dataRowHeight;

  /// The height of the heading row.
  ///
  /// This value defaults to 56.0 to adhere to the Material Design specifications.
  final double headingRowHeight;

484 485 486 487 488 489 490 491 492 493 494 495 496 497
  /// 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;

498 499 500 501 502 503 504 505 506 507 508
  /// {@template flutter.material.dataTable.showCheckboxColumn}
  /// Whether the widget should display checkboxes for selectable rows.
  ///
  /// If true, a [CheckBox] will be placed at the beginning of each row that is
  /// selectable. However, if [DataRow.onSelectChanged] is not set for any row,
  /// checkboxes will not be placed, even if this value is true.
  ///
  /// If false, all rows will not display a [CheckBox].
  /// {@endtemplate}
  final bool showCheckboxColumn;

509
  /// The data to show in each row (excluding the row that contains
510 511 512
  /// the column headings).
  ///
  /// Must be non-null, but may be empty.
513 514 515 516 517 518 519 520
  final List<DataRow> rows;

  // Set by the constructor to the index of the only Column that is
  // non-numeric, if there is exactly one, otherwise null.
  final int _onlyTextColumn;
  static int _initOnlyTextColumn(List<DataColumn> columns) {
    int result;
    for (int index = 0; index < columns.length; index += 1) {
521
      final DataColumn column = columns[index];
522 523 524 525 526 527 528 529 530 531 532 533 534 535
      if (!column.numeric) {
        if (result != null)
          return null;
        result = index;
      }
    }
    return result;
  }

  bool get _debugInteractive {
    return columns.any((DataColumn column) => column._debugInteractive)
        || rows.any((DataRow row) => row._debugInteractive);
  }

536
  static final LocalKey _headingRowKey = UniqueKey();
537 538

  void _handleSelectAll(bool checked) {
539 540 541
    if (onSelectAll != null) {
      onSelectAll(checked);
    } else {
542
      for (final DataRow row in rows) {
543 544 545
        if ((row.onSelectChanged != null) && (row.selected != checked))
          row.onSelectChanged(checked);
      }
546 547 548
    }
  }

549 550
  static const double _sortArrowPadding = 2.0;
  static const double _headingFontSize = 12.0;
551 552 553
  static const Duration _sortArrowAnimationDuration = Duration(milliseconds: 150);
  static const Color _grey100Opacity = Color(0x0A000000); // Grey 100 as opacity instead of solid color
  static const Color _grey300Opacity = Color(0x1E000000); // Dark theme variant is just a guess.
554

555 556 557 558 559 560
  /// The width of the divider that appears between [TableRow]s.
  ///
  /// Must be non-null and greater than or equal to zero.
  /// This value defaults to 1.0.
  final double dividerThickness;

561
  Widget _buildCheckbox({
562
    Color activeColor,
563 564
    bool checked,
    VoidCallback onRowTap,
565
    ValueChanged<bool> onCheckboxChanged,
566
    MaterialStateProperty<Color> overlayColor,
567
  }) {
568
    Widget contents = Semantics(
569
      container: true,
570
      child: Padding(
571
        padding: EdgeInsetsDirectional.only(start: horizontalMargin, end: horizontalMargin / 2.0),
572 573
        child: Center(
          child: Checkbox(
574
            activeColor: activeColor,
575 576 577
            value: checked,
            onChanged: onCheckboxChanged,
          ),
578 579
        ),
      ),
580 581
    );
    if (onRowTap != null) {
582
      contents = TableRowInkWell(
583
        onTap: onRowTap,
584
        child: contents,
585
        overlayColor: overlayColor,
586 587
      );
    }
588
    return TableCell(
589
      verticalAlignment: TableCellVerticalAlignment.fill,
590
      child: contents,
591 592 593 594
    );
  }

  Widget _buildHeadingCell({
595
    BuildContext context,
596
    EdgeInsetsGeometry padding,
597 598 599 600 601
    Widget label,
    String tooltip,
    bool numeric,
    VoidCallback onSort,
    bool sorted,
602
    bool ascending,
603
  }) {
604 605 606 607 608 609 610 611 612
    List<Widget> arrowWithPadding() {
      return onSort == null ? const <Widget>[] : <Widget>[
        _SortArrow(
          visible: sorted,
          down: sorted ? ascending : null,
          duration: _sortArrowAnimationDuration,
        ),
        const SizedBox(width: _sortArrowPadding),
      ];
613
    }
614 615 616 617 618 619 620
    label = Row(
      textDirection: numeric ? TextDirection.rtl : null,
      children: <Widget>[
        label,
        ...arrowWithPadding(),
      ],
    );
621
    label = Container(
622
      padding: padding,
623
      height: headingRowHeight,
624
      alignment: numeric ? Alignment.centerRight : AlignmentDirectional.centerStart,
625 626
      child: AnimatedDefaultTextStyle(
        style: TextStyle(
627 628
          // TODO(hansmuller): This should use the information provided by
          // textTheme/DataTableTheme, https://github.com/flutter/flutter/issues/56079
629
          fontWeight: FontWeight.w500,
630
          fontSize: _headingFontSize,
631
          height: math.min(1.0, headingRowHeight / _headingFontSize),
632 633
          color: (Theme.of(context).brightness == Brightness.light)
            ? ((onSort != null && sorted) ? Colors.black87 : Colors.black54)
634
            : ((onSort != null && sorted) ? Colors.white : Colors.white70),
635
        ),
636
        softWrap: false,
637
        duration: _sortArrowAnimationDuration,
638 639
        child: label,
      ),
640 641
    );
    if (tooltip != null) {
642
      label = Tooltip(
643
        message: tooltip,
644
        child: label,
645 646
      );
    }
647 648 649 650 651 652
    // TODO(dkwingsmt): Only wrap Inkwell if onSort != null. Blocked by
    // https://github.com/flutter/flutter/issues/51152
    label = InkWell(
      onTap: onSort,
      child: label,
    );
653 654 655 656
    return label;
  }

  Widget _buildDataCell({
Ian Hickson's avatar
Ian Hickson committed
657
    BuildContext context,
658
    EdgeInsetsGeometry padding,
659 660 661 662 663
    Widget label,
    bool numeric,
    bool placeholder,
    bool showEditIcon,
    VoidCallback onTap,
664
    VoidCallback onSelectChanged,
665
    MaterialStateProperty<Color> overlayColor,
666
  }) {
667
    final bool isLightTheme = Theme.of(context).brightness == Brightness.light;
668
    if (showEditIcon) {
669
      const Widget icon = Icon(Icons.edit, size: 18.0);
670 671
      label = Expanded(child: label);
      label = Row(
672 673 674
        textDirection: numeric ? TextDirection.rtl : null,
        children: <Widget>[ label, icon ],
      );
675
    }
676
    label = Container(
677
      padding: padding,
678
      height: dataRowHeight,
679
      alignment: numeric ? Alignment.centerRight : AlignmentDirectional.centerStart,
680 681
      child: DefaultTextStyle(
        style: TextStyle(
682 683
          // TODO(hansmuller): This should use the information provided by
          // textTheme/DataTableTheme, https://github.com/flutter/flutter/issues/56079
684 685 686
          fontSize: 13.0,
          color: isLightTheme
            ? (placeholder ? Colors.black38 : Colors.black87)
687
            : (placeholder ? Colors.white38 : Colors.white70),
688
        ),
689
        child: IconTheme.merge(
690
          data: IconThemeData(
691
            color: isLightTheme ? Colors.black54 : Colors.white70,
692
          ),
693
          child: DropdownButtonHideUnderline(child: label),
694 695
        ),
      ),
696 697
    );
    if (onTap != null) {
698
      label = InkWell(
699
        onTap: onTap,
700
        child: label,
701
        overlayColor: overlayColor,
702 703
      );
    } else if (onSelectChanged != null) {
704
      label = TableRowInkWell(
705
        onTap: onSelectChanged,
706
        child: label,
707
        overlayColor: overlayColor,
708 709 710 711 712 713 714 715 716 717
      );
    }
    return label;
  }

  @override
  Widget build(BuildContext context) {
    assert(!_debugInteractive || debugCheckHasMaterial(context));

    final ThemeData theme = Theme.of(context);
718 719 720 721 722 723 724 725 726 727 728
    final MaterialStateProperty<Color> defaultRowColor = MaterialStateProperty.resolveWith(
      (Set<MaterialState> states) {
        if (states.contains(MaterialState.selected)) {
          // TODO(per): Add theming support for DataTable, https://github.com/flutter/flutter/issues/56079.
          // The color has to be transparent so you can see the ink on
          // the [Material].
          return (Theme.of(context).brightness == Brightness.light) ?
            _grey100Opacity : _grey300Opacity;
        }
        return null;
      },
729
    );
730 731
    final bool anyRowSelectable = rows.any((DataRow row) => row.onSelectChanged != null);
    final bool displayCheckboxColumn = showCheckboxColumn && anyRowSelectable;
732
    final bool allChecked = displayCheckboxColumn && !rows.any((DataRow row) => row.onSelectChanged != null && !row.selected);
733

734
    final List<TableColumnWidth> tableColumns = List<TableColumnWidth>(columns.length + (displayCheckboxColumn ? 1 : 0));
735
    final List<TableRow> tableRows = List<TableRow>.generate(
736 737
      rows.length + 1, // the +1 is for the header row
      (int index) {
738 739 740 741 742 743 744 745 746
        final bool isSelected = index > 0 && rows[index - 1].selected;
        final bool isDisabled = index > 0 && anyRowSelectable && rows[index - 1].onSelectChanged == null;
        final Set<MaterialState> states = <MaterialState>{
          if (isSelected)
            MaterialState.selected,
          if (isDisabled)
            MaterialState.disabled,
        };
        final Color rowColor = index > 0 ? rows[index - 1].color?.resolve(states) : null;
747
        return TableRow(
748
          key: index == 0 ? _headingRowKey : rows[index - 1].key,
749 750 751 752 753 754
          decoration: BoxDecoration(
            border: Border(
              bottom: Divider.createBorderSide(context, width: dividerThickness),
            ),
            color: rowColor ?? defaultRowColor.resolve(states),
          ),
755
          children: List<Widget>(tableColumns.length),
756
        );
757
      },
758 759 760 761 762
    );

    int rowIndex;

    int displayColumnIndex = 0;
763
    if (displayCheckboxColumn) {
764
      tableColumns[0] = FixedColumnWidth(horizontalMargin + Checkbox.width + horizontalMargin / 2.0);
765
      tableRows[0].children[0] = _buildCheckbox(
766
        activeColor: theme.accentColor,
767
        checked: allChecked,
768
        onCheckboxChanged: _handleSelectAll,
769 770
      );
      rowIndex = 1;
771
      for (final DataRow row in rows) {
772
        tableRows[rowIndex].children[0] = _buildCheckbox(
773
          activeColor: theme.accentColor,
774
          checked: row.selected,
775
          onRowTap: () => row.onSelectChanged != null ? row.onSelectChanged(!row.selected) : null ,
776
          onCheckboxChanged: row.onSelectChanged,
777
          overlayColor: row.color,
778 779 780 781 782 783 784
        );
        rowIndex += 1;
      }
      displayColumnIndex += 1;
    }

    for (int dataColumnIndex = 0; dataColumnIndex < columns.length; dataColumnIndex += 1) {
785
      final DataColumn column = columns[dataColumnIndex];
786 787

      double paddingStart;
788
      if (dataColumnIndex == 0 && displayCheckboxColumn) {
789
        paddingStart = horizontalMargin / 2.0;
790
      } else if (dataColumnIndex == 0 && !displayCheckboxColumn) {
791 792 793 794 795 796 797 798 799 800 801 802
        paddingStart = horizontalMargin;
      } else {
        paddingStart = columnSpacing / 2.0;
      }

      double paddingEnd;
      if (dataColumnIndex == columns.length - 1) {
        paddingEnd = horizontalMargin;
      } else {
        paddingEnd = columnSpacing / 2.0;
      }

803
      final EdgeInsetsDirectional padding = EdgeInsetsDirectional.only(
804 805
        start: paddingStart,
        end: paddingEnd,
806 807 808 809 810 811 812
      );
      if (dataColumnIndex == _onlyTextColumn) {
        tableColumns[displayColumnIndex] = const IntrinsicColumnWidth(flex: 1.0);
      } else {
        tableColumns[displayColumnIndex] = const IntrinsicColumnWidth();
      }
      tableRows[0].children[displayColumnIndex] = _buildHeadingCell(
813
        context: context,
814 815 816 817
        padding: padding,
        label: column.label,
        tooltip: column.tooltip,
        numeric: column.numeric,
818
        onSort: column.onSort != null ? () => column.onSort(dataColumnIndex, sortColumnIndex != dataColumnIndex || !sortAscending) : null,
819
        sorted: dataColumnIndex == sortColumnIndex,
820
        ascending: sortAscending,
821 822
      );
      rowIndex = 1;
823
      for (final DataRow row in rows) {
824
        final DataCell cell = row.cells[dataColumnIndex];
825
        tableRows[rowIndex].children[displayColumnIndex] = _buildDataCell(
Ian Hickson's avatar
Ian Hickson committed
826
          context: context,
827
          padding: padding,
828
          label: cell.child,
829 830 831 832
          numeric: column.numeric,
          placeholder: cell.placeholder,
          showEditIcon: cell.showEditIcon,
          onTap: cell.onTap,
833
          onSelectChanged: () => row.onSelectChanged != null ? row.onSelectChanged(!row.selected) : null,
834
          overlayColor: row.color,
835 836 837 838 839 840
        );
        rowIndex += 1;
      }
      displayColumnIndex += 1;
    }

841
    return Table(
842
      columnWidths: tableColumns.asMap(),
843
      children: tableRows,
844 845 846 847 848 849 850 851 852 853
    );
  }
}

/// A rectangular area of a Material that responds to touch but clips
/// its ink splashes to the current table row of the nearest table.
///
/// Must have an ancestor [Material] widget in which to cause ink
/// reactions and an ancestor [Table] widget to establish a row.
///
854
/// The [TableRowInkWell] must be in the same coordinate space (modulo
855 856 857 858
/// translations) as the [Table]. If it's rotated or scaled or
/// otherwise transformed, it will not be able to describe the
/// rectangle of the row in its own coordinate system as a [Rect], and
/// thus the splash will not occur. (In general, this is easy to
859
/// achieve: just put the [TableRowInkWell] as the direct child of the
860
/// [Table], and put the other contents of the cell inside it.)
861
class TableRowInkWell extends InkResponse {
862
  /// Creates an ink well for a table row.
863
  const TableRowInkWell({
864 865 866 867 868
    Key key,
    Widget child,
    GestureTapCallback onTap,
    GestureTapCallback onDoubleTap,
    GestureLongPressCallback onLongPress,
869
    ValueChanged<bool> onHighlightChanged,
870
    MaterialStateProperty<Color> overlayColor,
871 872 873 874 875 876 877 878
  }) : super(
    key: key,
    child: child,
    onTap: onTap,
    onDoubleTap: onDoubleTap,
    onLongPress: onLongPress,
    onHighlightChanged: onHighlightChanged,
    containedInkWell: true,
879
    highlightShape: BoxShape.rectangle,
880
    overlayColor: overlayColor,
881 882 883 884 885 886 887
  );

  @override
  RectCallback getRectCallback(RenderBox referenceBox) {
    return () {
      RenderObject cell = referenceBox;
      AbstractNode table = cell.parent;
888
      final Matrix4 transform = Matrix4.identity();
889
      while (table is RenderObject && table is! RenderTable) {
890
        final RenderObject parentBox = table as RenderObject;
891 892
        parentBox.applyPaintTransform(cell, transform);
        assert(table == cell.parent);
893
        cell = parentBox;
894 895 896
        table = table.parent;
      }
      if (table is RenderTable) {
897
        final TableCellParentData cellParentData = cell.parentData as TableCellParentData;
898
        assert(cellParentData.y != null);
899
        final Rect rect = table.getRowBox(cellParentData.y);
900
        // The rect is in the table's coordinate space. We need to change it to the
901
        // TableRowInkWell's coordinate space.
902
        table.applyPaintTransform(cell, transform);
903
        final Offset offset = MatrixUtils.getAsTranslation(transform);
904 905 906 907 908 909 910 911 912 913 914 915 916 917 918
        if (offset != null)
          return rect.shift(-offset);
      }
      return Rect.zero;
    };
  }

  @override
  bool debugCheckContext(BuildContext context) {
    assert(debugCheckHasTable(context));
    return super.debugCheckContext(context);
  }
}

class _SortArrow extends StatefulWidget {
919
  const _SortArrow({
920 921 922
    Key key,
    this.visible,
    this.down,
923
    this.duration,
924 925 926 927 928 929 930 931 932
  }) : super(key: key);

  final bool visible;

  final bool down;

  final Duration duration;

  @override
933
  _SortArrowState createState() => _SortArrowState();
934 935
}

936
class _SortArrowState extends State<_SortArrow> with TickerProviderStateMixin {
937 938 939 940 941 942 943 944 945 946

  AnimationController _opacityController;
  Animation<double> _opacityAnimation;

  AnimationController _orientationController;
  Animation<double> _orientationAnimation;
  double _orientationOffset = 0.0;

  bool _down;

947 948 949
  static final Animatable<double> _turnTween = Tween<double>(begin: 0.0, end: math.pi)
    .chain(CurveTween(curve: Curves.easeIn));

950 951 952
  @override
  void initState() {
    super.initState();
953 954
    _opacityAnimation = CurvedAnimation(
      parent: _opacityController = AnimationController(
955
        duration: widget.duration,
956
        vsync: this,
957
      ),
958
      curve: Curves.fastOutSlowIn,
959 960
    )
    ..addListener(_rebuild);
961
    _opacityController.value = widget.visible ? 1.0 : 0.0;
962 963 964 965 966 967 968
    _orientationController = AnimationController(
      duration: widget.duration,
      vsync: this,
    );
    _orientationAnimation = _orientationController.drive(_turnTween)
      ..addListener(_rebuild)
      ..addStatusListener(_resetOrientationAnimation);
969
    if (widget.visible)
970
      _orientationOffset = widget.down ? 0.0 : math.pi;
971 972 973 974 975 976 977 978 979 980
  }

  void _rebuild() {
    setState(() {
      // The animations changed, so we need to rebuild.
    });
  }

  void _resetOrientationAnimation(AnimationStatus status) {
    if (status == AnimationStatus.completed) {
981 982
      assert(_orientationAnimation.value == math.pi);
      _orientationOffset += math.pi;
983 984 985 986 987
      _orientationController.value = 0.0; // TODO(ianh): This triggers a pointless rebuild.
    }
  }

  @override
988 989
  void didUpdateWidget(_SortArrow oldWidget) {
    super.didUpdateWidget(oldWidget);
990
    bool skipArrow = false;
991
    final bool newDown = widget.down ?? _down;
992 993
    if (oldWidget.visible != widget.visible) {
      if (widget.visible && (_opacityController.status == AnimationStatus.dismissed)) {
994 995
        _orientationController.stop();
        _orientationController.value = 0.0;
996
        _orientationOffset = newDown ? 0.0 : math.pi;
997 998
        skipArrow = true;
      }
999
      if (widget.visible) {
1000 1001
        _opacityController.forward();
      } else {
1002
        _opacityController.reverse();
1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021
      }
    }
    if ((_down != newDown) && !skipArrow) {
      if (_orientationController.status == AnimationStatus.dismissed) {
        _orientationController.forward();
      } else {
        _orientationController.reverse();
      }
    }
    _down = newDown;
  }

  @override
  void dispose() {
    _opacityController.dispose();
    _orientationController.dispose();
    super.dispose();
  }

1022 1023
  static const double _arrowIconBaselineOffset = -1.5;
  static const double _arrowIconSize = 16.0;
1024 1025 1026

  @override
  Widget build(BuildContext context) {
1027
    return Opacity(
1028
      opacity: _opacityAnimation.value,
1029 1030
      child: Transform(
        transform: Matrix4.rotationZ(_orientationOffset + _orientationAnimation.value)
1031
                             ..setTranslationRaw(0.0, _arrowIconBaselineOffset, 0.0),
1032
        alignment: Alignment.center,
1033
        child: Icon(
Ian Hickson's avatar
Ian Hickson committed
1034
          Icons.arrow_downward,
1035
          size: _arrowIconSize,
1036 1037 1038
          color: (Theme.of(context).brightness == Brightness.light) ? Colors.black87 : Colors.white70,
        ),
      ),
1039 1040 1041 1042
    );
  }

}