data_table.dart 26.5 KB
Newer Older
1 2 3 4 5 6
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:math' as math;

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

import 'checkbox.dart';
import 'colors.dart';
import 'debug.dart';
14
import 'divider.dart';
15
import 'dropdown.dart';
16 17 18 19 20 21 22
import 'icons.dart';
import 'ink_well.dart';
import 'material.dart';
import 'theme.dart';
import 'theme_data.dart';
import 'tooltip.dart';

23
/// Signature for [DataColumn.onSort] callback.
24
typedef void DataColumnSortCallback(int columnIndex, bool ascending);
25 26 27 28 29 30

/// 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.
31
@immutable
32 33 34 35 36
class DataColumn {
  /// Creates the configuration for a column of a [DataTable].
  ///
  /// The [label] argument must not be null.
  const DataColumn({
37
    @required this.label,
38
    this.tooltip,
39
    this.numeric = false,
40
    this.onSort,
41
  }) : assert(label != null);
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64

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

65
  /// Called when the user asks to sort the table using this column.
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
  ///
  /// 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.
83
@immutable
84 85 86 87 88 89
class DataRow {
  /// Creates the configuration for a row of a [DataTable].
  ///
  /// The [cells] argument must not be null.
  const DataRow({
    this.key,
90
    this.selected = false,
91
    this.onSelectChanged,
92
    @required this.cells,
93
  }) : assert(cells != null);
94

95 96 97 98 99 100
  /// 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,
101
    this.selected = false,
102
    this.onSelectChanged,
103
    @required this.cells,
104 105
  }) : assert(cells != null),
       key = new ValueKey<int>(index);
106

107 108 109 110 111 112 113 114
  /// 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;

115
  /// Called when the user selects or unselects a selectable row.
116 117 118 119 120 121 122 123 124 125 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
  ///
  /// 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;

  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]
/// in the [DataTable], in the [new DataRow] constructor's `cells`
/// argument.
154
@immutable
155 156 157 158
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
159
  /// a [Text] or [DropdownButton] widget; this becomes the [child]
160 161 162 163 164
  /// 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.
165
  const DataCell(this.child, {
166 167
    this.placeholder = false,
    this.showEditIcon = false,
168
    this.onTap,
169
  }) : assert(child != null);
170

171
  /// A cell that has no content and has zero width and height.
172 173
  static final DataCell empty = new DataCell(new Container(width: 0.0, height: 0.0));

174 175
  /// The data for the row.
  ///
176
  /// Typically a [Text] widget or a [DropdownButton] widget.
177 178 179 180
  ///
  /// If the cell has no data, then a [Text] widget with placeholder
  /// text should be provided instead, and [placeholder] should be set
  /// to true.
181 182
  ///
  /// {@macro flutter.widgets.child}
183
  final Widget child;
184

185
  /// Whether the [child] is actually a placeholder.
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
  ///
  /// 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;

201
  /// Called if the cell is tapped.
202
  ///
203
  /// If non-null, tapping the cell will call this callback. If
204
  /// null, tapping the cell will attempt to select the row (if
205
  /// [DataRow.onSelectChanged] is provided).
206 207 208 209 210 211 212 213 214 215 216 217
  final VoidCallback onTap;

  bool get _debugInteractive => onTap != null;
}

/// A material design data table.
///
/// 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.
///
218 219 220 221 222 223 224
/// 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.
// TODO(ianh): Also suggest [ScrollingDataTable] once we have it.
///
225 226
/// See also:
///
Adam Barth's avatar
Adam Barth committed
227 228 229 230 231
///  * [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.
232
///  * <https://material.google.com/components/data-tables.html>
233 234 235 236 237 238
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
239
  /// length greater than zero and must not be null.
240
  ///
241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259
  /// 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,
260
    @required this.columns,
261
    this.sortColumnIndex,
262
    this.sortAscending = true,
263
    this.onSelectAll,
264
    @required this.rows,
265 266 267 268 269 270 271 272
  }) : assert(columns != null),
       assert(columns.isNotEmpty),
       assert(sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length)),
       assert(sortAscending != null),
       assert(rows != null),
       assert(!rows.any((DataRow row) => row.cells.length != columns.length)),
       _onlyTextColumn = _initOnlyTextColumn(columns),
       super(key: key);
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

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

302 303 304 305 306 307 308 309 310 311 312
  /// 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;

313 314 315 316 317 318 319 320 321 322
  /// The data to show in each row (excluding the row that contains
  /// the column headings). Must be non-null, but may be empty.
  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) {
323
      final DataColumn column = columns[index];
324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
      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);
  }

  static final LocalKey _headingRowKey = new UniqueKey();

  void _handleSelectAll(bool checked) {
341 342 343 344 345 346 347
    if (onSelectAll != null) {
      onSelectAll(checked);
    } else {
      for (DataRow row in rows) {
        if ((row.onSelectChanged != null) && (row.selected != checked))
          row.onSelectChanged(checked);
      }
348 349 350
    }
  }

351 352 353 354 355 356
  static const double _headingRowHeight = 56.0;
  static const double _dataRowHeight = 48.0;
  static const double _tablePadding = 24.0;
  static const double _columnSpacing = 56.0;
  static const double _sortArrowPadding = 2.0;
  static const double _headingFontSize = 12.0;
357 358 359
  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.
360 361 362 363 364 365 366

  Widget _buildCheckbox({
    Color color,
    bool checked,
    VoidCallback onRowTap,
    ValueChanged<bool> onCheckboxChanged
  }) {
367 368 369 370 371 372 373 374 375 376
    Widget contents = new Semantics(
      container: true,
      child: new Padding(
        padding: const EdgeInsetsDirectional.only(start: _tablePadding, end: _tablePadding / 2.0),
        child: new Center(
          child: new Checkbox(
            activeColor: color,
            value: checked,
            onChanged: onCheckboxChanged,
          ),
377 378
        ),
      ),
379 380
    );
    if (onRowTap != null) {
381
      contents = new TableRowInkWell(
382
        onTap: onRowTap,
383
        child: contents,
384 385 386 387
      );
    }
    return new TableCell(
      verticalAlignment: TableCellVerticalAlignment.fill,
388
      child: contents,
389 390 391 392
    );
  }

  Widget _buildHeadingCell({
393
    BuildContext context,
394
    EdgeInsetsGeometry padding,
395 396 397 398 399
    Widget label,
    String tooltip,
    bool numeric,
    VoidCallback onSort,
    bool sorted,
400
    bool ascending,
401 402 403 404 405
  }) {
    if (onSort != null) {
      final Widget arrow = new _SortArrow(
        visible: sorted,
        down: sorted ? ascending : null,
406
        duration: _sortArrowAnimationDuration,
407
      );
408
      const Widget arrowPadding = SizedBox(width: _sortArrowPadding);
409
      label = new Row(
410 411
        textDirection: numeric ? TextDirection.rtl : null,
        children: <Widget>[ label, arrowPadding, arrow ],
412 413 414 415
      );
    }
    label = new Container(
      padding: padding,
416
      height: _headingRowHeight,
417
      alignment: numeric ? Alignment.centerRight : AlignmentDirectional.centerStart,
418 419
      child: new AnimatedDefaultTextStyle(
        style: new TextStyle(
420
          // TODO(ianh): font family should match Theme; see https://github.com/flutter/flutter/issues/3116
421
          fontWeight: FontWeight.w500,
422 423
          fontSize: _headingFontSize,
          height: math.min(1.0, _headingRowHeight / _headingFontSize),
424 425
          color: (Theme.of(context).brightness == Brightness.light)
            ? ((onSort != null && sorted) ? Colors.black87 : Colors.black54)
426
            : ((onSort != null && sorted) ? Colors.white : Colors.white70),
427
        ),
428
        softWrap: false,
429
        duration: _sortArrowAnimationDuration,
430 431
        child: label,
      ),
432 433 434 435
    );
    if (tooltip != null) {
      label = new Tooltip(
        message: tooltip,
436
        child: label,
437 438 439 440 441
      );
    }
    if (onSort != null) {
      label = new InkWell(
        onTap: onSort,
442
        child: label,
443 444 445 446 447 448
      );
    }
    return label;
  }

  Widget _buildDataCell({
Ian Hickson's avatar
Ian Hickson committed
449
    BuildContext context,
450
    EdgeInsetsGeometry padding,
451 452 453 454 455
    Widget label,
    bool numeric,
    bool placeholder,
    bool showEditIcon,
    VoidCallback onTap,
456
    VoidCallback onSelectChanged,
457
  }) {
458
    final bool isLightTheme = Theme.of(context).brightness == Brightness.light;
459
    if (showEditIcon) {
460
      const Widget icon = Icon(Icons.edit, size: 18.0);
461
      label = new Expanded(child: label);
462 463 464 465
      label = new Row(
        textDirection: numeric ? TextDirection.rtl : null,
        children: <Widget>[ label, icon ],
      );
466 467 468
    }
    label = new Container(
      padding: padding,
469
      height: _dataRowHeight,
470
      alignment: numeric ? Alignment.centerRight : AlignmentDirectional.centerStart,
471 472 473 474 475 476
      child: new DefaultTextStyle(
        style: new TextStyle(
          // TODO(ianh): font family should be Roboto; see https://github.com/flutter/flutter/issues/3116
          fontSize: 13.0,
          color: isLightTheme
            ? (placeholder ? Colors.black38 : Colors.black87)
477
            : (placeholder ? Colors.white30 : Colors.white70),
478
        ),
479
        child: IconTheme.merge(
480
          data: new IconThemeData(
481
            color: isLightTheme ? Colors.black54 : Colors.white70,
482
          ),
483
          child: new DropdownButtonHideUnderline(child: label),
484 485 486 487 488 489
        )
      )
    );
    if (onTap != null) {
      label = new InkWell(
        onTap: onTap,
490
        child: label,
491 492
      );
    } else if (onSelectChanged != null) {
493
      label = new TableRowInkWell(
494
        onTap: onSelectChanged,
495
        child: label,
496 497 498 499 500 501 502 503 504 505 506
      );
    }
    return label;
  }

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

    final ThemeData theme = Theme.of(context);
    final BoxDecoration _kSelectedDecoration = new BoxDecoration(
507
      border: new Border(bottom: Divider.createBorderSide(context, width: 1.0)),
508
      // The backgroundColor has to be transparent so you can see the ink on the material
509
      color: (Theme.of(context).brightness == Brightness.light) ? _grey100Opacity : _grey300Opacity,
510 511
    );
    final BoxDecoration _kUnselectedDecoration = new BoxDecoration(
512
      border: new Border(bottom: Divider.createBorderSide(context, width: 1.0)),
513 514 515 516 517
    );

    final bool showCheckboxColumn = rows.any((DataRow row) => row.onSelectChanged != null);
    final bool allChecked = showCheckboxColumn && !rows.any((DataRow row) => row.onSelectChanged != null && !row.selected);

518 519
    final List<TableColumnWidth> tableColumns = new List<TableColumnWidth>(columns.length + (showCheckboxColumn ? 1 : 0));
    final List<TableRow> tableRows = new List<TableRow>.generate(
520 521 522 523 524 525 526 527
      rows.length + 1, // the +1 is for the header row
      (int index) {
        return new TableRow(
          key: index == 0 ? _headingRowKey : rows[index - 1].key,
          decoration: index > 0 && rows[index - 1].selected ? _kSelectedDecoration
                                                            : _kUnselectedDecoration,
          children: new List<Widget>(tableColumns.length)
        );
528
      },
529 530 531 532 533 534
    );

    int rowIndex;

    int displayColumnIndex = 0;
    if (showCheckboxColumn) {
535
      tableColumns[0] = const FixedColumnWidth(_tablePadding + Checkbox.width + _tablePadding / 2.0);
536 537 538
      tableRows[0].children[0] = _buildCheckbox(
        color: theme.accentColor,
        checked: allChecked,
539
        onCheckboxChanged: _handleSelectAll,
540 541 542 543 544 545 546
      );
      rowIndex = 1;
      for (DataRow row in rows) {
        tableRows[rowIndex].children[0] = _buildCheckbox(
          color: theme.accentColor,
          checked: row.selected,
          onRowTap: () => row.onSelectChanged(!row.selected),
547
          onCheckboxChanged: row.onSelectChanged,
548 549 550 551 552 553 554
        );
        rowIndex += 1;
      }
      displayColumnIndex += 1;
    }

    for (int dataColumnIndex = 0; dataColumnIndex < columns.length; dataColumnIndex += 1) {
555
      final DataColumn column = columns[dataColumnIndex];
556
      final EdgeInsetsDirectional padding = new EdgeInsetsDirectional.only(
557 558
        start: dataColumnIndex == 0 ? showCheckboxColumn ? _tablePadding / 2.0 : _tablePadding : _columnSpacing / 2.0,
        end: dataColumnIndex == columns.length - 1 ? _tablePadding : _columnSpacing / 2.0,
559 560 561 562 563 564 565
      );
      if (dataColumnIndex == _onlyTextColumn) {
        tableColumns[displayColumnIndex] = const IntrinsicColumnWidth(flex: 1.0);
      } else {
        tableColumns[displayColumnIndex] = const IntrinsicColumnWidth();
      }
      tableRows[0].children[displayColumnIndex] = _buildHeadingCell(
566
        context: context,
567 568 569 570 571 572
        padding: padding,
        label: column.label,
        tooltip: column.tooltip,
        numeric: column.numeric,
        onSort: () => column.onSort(dataColumnIndex, sortColumnIndex == dataColumnIndex ? !sortAscending : true),
        sorted: dataColumnIndex == sortColumnIndex,
573
        ascending: sortAscending,
574 575 576
      );
      rowIndex = 1;
      for (DataRow row in rows) {
577
        final DataCell cell = row.cells[dataColumnIndex];
578
        tableRows[rowIndex].children[displayColumnIndex] = _buildDataCell(
Ian Hickson's avatar
Ian Hickson committed
579
          context: context,
580
          padding: padding,
581
          label: cell.child,
582 583 584 585
          numeric: column.numeric,
          placeholder: cell.placeholder,
          showEditIcon: cell.showEditIcon,
          onTap: cell.onTap,
586
          onSelectChanged: () => row.onSelectChanged(!row.selected),
587 588 589 590 591 592 593 594
        );
        rowIndex += 1;
      }
      displayColumnIndex += 1;
    }

    return new Table(
      columnWidths: tableColumns.asMap(),
595
      children: tableRows,
596 597 598 599 600 601 602 603 604 605
    );
  }
}

/// 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.
///
606
/// The [TableRowInkWell] must be in the same coordinate space (modulo
607 608 609 610
/// 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
611
/// achieve: just put the [TableRowInkWell] as the direct child of the
612
/// [Table], and put the other contents of the cell inside it.)
613
class TableRowInkWell extends InkResponse {
614
  /// Creates an ink well for a table row.
615
  const TableRowInkWell({
616 617 618 619 620
    Key key,
    Widget child,
    GestureTapCallback onTap,
    GestureTapCallback onDoubleTap,
    GestureLongPressCallback onLongPress,
621
    ValueChanged<bool> onHighlightChanged,
622 623 624 625 626 627 628 629
  }) : super(
    key: key,
    child: child,
    onTap: onTap,
    onDoubleTap: onDoubleTap,
    onLongPress: onLongPress,
    onHighlightChanged: onHighlightChanged,
    containedInkWell: true,
630
    highlightShape: BoxShape.rectangle,
631 632 633 634 635 636 637
  );

  @override
  RectCallback getRectCallback(RenderBox referenceBox) {
    return () {
      RenderObject cell = referenceBox;
      AbstractNode table = cell.parent;
638
      final Matrix4 transform = new Matrix4.identity();
639
      while (table is RenderObject && table is! RenderTable) {
640
        final RenderTable parentBox = table;
641 642 643 644 645 646
        parentBox.applyPaintTransform(cell, transform);
        assert(table == cell.parent);
        cell = table;
        table = table.parent;
      }
      if (table is RenderTable) {
647
        final TableCellParentData cellParentData = cell.parentData;
648
        assert(cellParentData.y != null);
649
        final Rect rect = table.getRowBox(cellParentData.y);
650
        // The rect is in the table's coordinate space. We need to change it to the
651
        // TableRowInkWell's coordinate space.
652
        table.applyPaintTransform(cell, transform);
653
        final Offset offset = MatrixUtils.getAsTranslation(transform);
654 655 656 657 658 659 660 661 662 663 664 665 666 667 668
        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 {
669
  const _SortArrow({
670 671 672
    Key key,
    this.visible,
    this.down,
673
    this.duration,
674 675 676 677 678 679 680 681 682 683 684 685
  }) : super(key: key);

  final bool visible;

  final bool down;

  final Duration duration;

  @override
  _SortArrowState createState() => new _SortArrowState();
}

686
class _SortArrowState extends State<_SortArrow> with TickerProviderStateMixin {
687 688 689 690 691 692 693 694 695 696 697 698 699 700 701

  AnimationController _opacityController;
  Animation<double> _opacityAnimation;

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

  bool _down;

  @override
  void initState() {
    super.initState();
    _opacityAnimation = new CurvedAnimation(
      parent: _opacityController = new AnimationController(
702
        duration: widget.duration,
703
        vsync: this,
704
      ),
705
      curve: Curves.fastOutSlowIn
706 707
    )
    ..addListener(_rebuild);
708
    _opacityController.value = widget.visible ? 1.0 : 0.0;
709 710
    _orientationAnimation = new Tween<double>(
      begin: 0.0,
711
      end: math.pi,
712 713
    ).animate(new CurvedAnimation(
      parent: _orientationController = new AnimationController(
714
        duration: widget.duration,
715
        vsync: this,
716 717 718 719 720
      ),
      curve: Curves.easeIn
    ))
    ..addListener(_rebuild)
    ..addStatusListener(_resetOrientationAnimation);
721
    if (widget.visible)
722
      _orientationOffset = widget.down ? 0.0 : math.pi;
723 724 725 726 727 728 729 730 731 732
  }

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

  void _resetOrientationAnimation(AnimationStatus status) {
    if (status == AnimationStatus.completed) {
733 734
      assert(_orientationAnimation.value == math.pi);
      _orientationOffset += math.pi;
735 736 737 738 739
      _orientationController.value = 0.0; // TODO(ianh): This triggers a pointless rebuild.
    }
  }

  @override
740 741
  void didUpdateWidget(_SortArrow oldWidget) {
    super.didUpdateWidget(oldWidget);
742
    bool skipArrow = false;
743 744 745
    final bool newDown = widget.down != null ? widget.down : _down;
    if (oldWidget.visible != widget.visible) {
      if (widget.visible && (_opacityController.status == AnimationStatus.dismissed)) {
746 747
        _orientationController.stop();
        _orientationController.value = 0.0;
748
        _orientationOffset = newDown ? 0.0 : math.pi;
749 750
        skipArrow = true;
      }
751
      if (widget.visible) {
752 753
        _opacityController.forward();
      } else {
754
        _opacityController.reverse();
755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773
      }
    }
    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();
  }

774 775
  static const double _arrowIconBaselineOffset = -1.5;
  static const double _arrowIconSize = 16.0;
776 777 778 779 780 781 782

  @override
  Widget build(BuildContext context) {
    return new Opacity(
      opacity: _opacityAnimation.value,
      child: new Transform(
        transform: new Matrix4.rotationZ(_orientationOffset + _orientationAnimation.value)
783
                             ..setTranslationRaw(0.0, _arrowIconBaselineOffset, 0.0),
784
        alignment: Alignment.center,
785
        child: new Icon(
Ian Hickson's avatar
Ian Hickson committed
786
          Icons.arrow_downward,
787
          size: _arrowIconSize,
788 789 790
          color: (Theme.of(context).brightness == Brightness.light) ? Colors.black87 : Colors.white70,
        ),
      ),
791 792 793 794
    );
  }

}