diff --git a/examples/flutter_gallery/lib/demo/data_table_demo.dart b/examples/flutter_gallery/lib/demo/data_table_demo.dart index 2b6a0a325e..3565eb7c0d 100644 --- a/examples/flutter_gallery/lib/demo/data_table_demo.dart +++ b/examples/flutter_gallery/lib/demo/data_table_demo.dart @@ -19,6 +19,111 @@ class Desert { bool selected = false; } +class DesertDataSource extends DataTableSource { + final List _deserts = [ + new Desert('Frozen yogurt', 159, 6.0, 24, 4.0, 87, 14, 1), + new Desert('Ice cream sandwich', 237, 9.0, 37, 4.3, 129, 8, 1), + new Desert('Eclair', 262, 16.0, 24, 6.0, 337, 6, 7), + new Desert('Cupcake', 305, 3.7, 67, 4.3, 413, 3, 8), + new Desert('Gingerbread', 356, 16.0, 49, 3.9, 327, 7, 16), + new Desert('Jelly bean', 375, 0.0, 94, 0.0, 50, 0, 0), + new Desert('Lollipop', 392, 0.2, 98, 0.0, 38, 0, 2), + new Desert('Honeycomb', 408, 3.2, 87, 6.5, 562, 0, 45), + new Desert('Donut', 452, 25.0, 51, 4.9, 326, 2, 22), + new Desert('KitKat', 518, 26.0, 65, 7.0, 54, 12, 6), + + new Desert('Frozen yogurt with sugar', 168, 6.0, 26, 4.0, 87, 14, 1), + new Desert('Ice cream sandwich with sugar', 246, 9.0, 39, 4.3, 129, 8, 1), + new Desert('Eclair with sugar', 271, 16.0, 26, 6.0, 337, 6, 7), + new Desert('Cupcake with sugar', 314, 3.7, 69, 4.3, 413, 3, 8), + new Desert('Gingerbread with sugar', 345, 16.0, 51, 3.9, 327, 7, 16), + new Desert('Jelly bean with sugar', 364, 0.0, 96, 0.0, 50, 0, 0), + new Desert('Lollipop with sugar', 401, 0.2, 100, 0.0, 38, 0, 2), + new Desert('Honeycomb with sugar', 417, 3.2, 89, 6.5, 562, 0, 45), + new Desert('Donut with sugar', 461, 25.0, 53, 4.9, 326, 2, 22), + new Desert('KitKat with sugar', 527, 26.0, 67, 7.0, 54, 12, 6), + + new Desert('Frozen yogurt with honey', 223, 6.0, 36, 4.0, 87, 14, 1), + new Desert('Ice cream sandwich with honey', 301, 9.0, 49, 4.3, 129, 8, 1), + new Desert('Eclair with honey', 326, 16.0, 36, 6.0, 337, 6, 7), + new Desert('Cupcake with honey', 369, 3.7, 79, 4.3, 413, 3, 8), + new Desert('Gingerbread with honey', 420, 16.0, 61, 3.9, 327, 7, 16), + new Desert('Jelly bean with honey', 439, 0.0, 106, 0.0, 50, 0, 0), + new Desert('Lollipop with honey', 456, 0.2, 110, 0.0, 38, 0, 2), + new Desert('Honeycomb with honey', 472, 3.2, 99, 6.5, 562, 0, 45), + new Desert('Donut with honey', 516, 25.0, 63, 4.9, 326, 2, 22), + new Desert('KitKat with honey', 582, 26.0, 77, 7.0, 54, 12, 6), + + new Desert('Frozen yogurt with milk', 262, 8.4, 36, 12.0, 194, 44, 1), + new Desert('Ice cream sandwich with milk', 339, 11.4, 49, 12.3, 236, 38, 1), + new Desert('Eclair with milk', 365, 18.4, 36, 14.0, 444, 36, 7), + new Desert('Cupcake with milk', 408, 6.1, 79, 12.3, 520, 33, 8), + new Desert('Gingerbread with milk', 459, 18.4, 61, 11.9, 434, 37, 16), + new Desert('Jelly bean with milk', 478, 2.4, 106, 8.0, 157, 30, 0), + new Desert('Lollipop with milk', 495, 2.6, 110, 8.0, 145, 30, 2), + new Desert('Honeycomb with milk', 511, 5.6, 99, 14.5, 669, 30, 45), + new Desert('Donut with milk', 555, 27.4, 63, 12.9, 433, 32, 22), + new Desert('KitKat with milk', 621, 28.4, 77, 15.0, 161, 42, 6), + + new Desert('Coconut slice and frozen yogurt', 318, 21.0, 31, 5.5, 96, 14, 7), + new Desert('Coconut slice and ice cream sandwich', 396, 24.0, 44, 5.8, 138, 8, 7), + new Desert('Coconut slice and eclair', 421, 31.0, 31, 7.5, 346, 6, 13), + new Desert('Coconut slice and cupcake', 464, 18.7, 74, 5.8, 422, 3, 14), + new Desert('Coconut slice and gingerbread', 515, 31.0, 56, 5.4, 316, 7, 22), + new Desert('Coconut slice and jelly bean', 534, 15.0, 101, 1.5, 59, 0, 6), + new Desert('Coconut slice and lollipop', 551, 15.2, 105, 1.5, 47, 0, 8), + new Desert('Coconut slice and honeycomb', 567, 18.2, 94, 8.0, 571, 0, 51), + new Desert('Coconut slice and donut', 611, 40.0, 58, 6.4, 335, 2, 28), + new Desert('Coconut slice and KitKat', 677, 41.0, 72, 8.5, 63, 12, 12), + ]; + + void _sort/**/(Comparable getField(Desert d), bool ascending) { + _deserts.sort((Desert a, Desert b) { + if (!ascending) { + final Desert c = a; + a = b; + b = c; + } + final Comparable aValue = getField(a); + final Comparable bValue = getField(b); + return Comparable.compare(aValue, bValue); + }); + notifyListeners(); + } + + @override + DataRow getRow(int index) { + assert(index >= 0); + if (index >= _deserts.length) + return null; + final Desert desert = _deserts[index]; + return new DataRow.byIndex( + index: index, + selected: desert.selected, + onSelectChanged: (bool value) { + desert.selected = value; + notifyListeners(); + }, + cells: [ + new DataCell(new Text('${desert.name}')), + new DataCell(new Text('${desert.calories}')), + new DataCell(new Text('${desert.fat.toStringAsFixed(1)}')), + new DataCell(new Text('${desert.carbs}')), + new DataCell(new Text('${desert.protein.toStringAsFixed(1)}')), + new DataCell(new Text('${desert.sodium}')), + new DataCell(new Text('${desert.calcium}%')), + new DataCell(new Text('${desert.iron}%')), + ] + ); + } + + @override + int get rowCount => _deserts.length; + + @override + bool get isRowCountApproximate => true; +} + class DataTableDemo extends StatefulWidget { static const String routeName = '/data-table'; @@ -27,35 +132,13 @@ class DataTableDemo extends StatefulWidget { } class _DataTableDemoState extends State { - int _sortColumnIndex; bool _sortAscending = true; - - final List _deserts = [ - new Desert('Frozen yogurt', 159, 6.0, 24, 4.0, 87, 14, 1), - new Desert('Ice cream sandwich', 237, 9.0, 37, 4.3, 129, 8, 1), - new Desert('Eclair', 262, 16.0, 24, 6.0, 337, 6, 7), - new Desert('Cupcake', 305, 3.7, 67, 4.3, 413, 3, 8), - new Desert('Gingerbread', 356, 16.0, 49, 3.9, 327, 7, 16), - new Desert('Jelly bean', 375, 0.0, 94, 0.0, 50, 0, 0), - new Desert('Lollipop', 392, 0.2, 98, 0.0, 38, 0, 2), - new Desert('Honeycomb', 408, 3.2, 87, 6.5, 562, 0, 45), - new Desert('Donut', 452, 25.0, 51, 4.9, 326, 2, 22), - new Desert('KitKat', 518, 26.0, 65, 7.0, 54, 12, 6), - ]; + DesertDataSource _deserts = new DesertDataSource(); void _sort/**/(Comparable getField(Desert d), int columnIndex, bool ascending) { + _deserts._sort/**/(getField, ascending); setState(() { - _deserts.sort((Desert a, Desert b) { - if (!ascending) { - final Desert c = a; - a = b; - b = c; - } - final Comparable aValue = getField(a); - final Comparable bValue = getField(b); - return Comparable.compare(aValue, bValue); - }); _sortColumnIndex = columnIndex; _sortAscending = ascending; }); @@ -67,77 +150,61 @@ class _DataTableDemoState extends State { appBar: new AppBar(title: new Text('Data tables')), body: new Block( children: [ - new Material( - child: new IntrinsicHeight( - child: new Block( - scrollDirection: Axis.horizontal, - children: [ - new DataTable( - sortColumnIndex: _sortColumnIndex, - sortAscending: _sortAscending, - columns: [ - new DataColumn( - label: new Text('Dessert (100g serving)'), - onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.name, columnIndex, ascending) - ), - new DataColumn( - label: new Text('Calories'), - tooltip: 'The total amount of food energy in the given serving size.', - numeric: true, - onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.calories, columnIndex, ascending) - ), - new DataColumn( - label: new Text('Fat (g)'), - numeric: true, - onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.fat, columnIndex, ascending) - ), - new DataColumn( - label: new Text('Carbs (g)'), - numeric: true, - onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.carbs, columnIndex, ascending) - ), - new DataColumn( - label: new Text('Protein (g)'), - numeric: true, - onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.protein, columnIndex, ascending) - ), - new DataColumn( - label: new Text('Sodium (mg)'), - numeric: true, - onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.sodium, columnIndex, ascending) - ), - new DataColumn( - label: new Text('Calcium (%)'), - tooltip: 'The amount of calcium as a percentage of the recommended daily amount.', - numeric: true, - onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.calcium, columnIndex, ascending) - ), - new DataColumn( - label: new Text('Iron (%)'), - numeric: true, - onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.iron, columnIndex, ascending) - ), - ], - rows: _deserts.map/**/((Desert desert) { - return new DataRow( - key: new ValueKey(desert), - selected: desert.selected, - onSelectChanged: (bool selected) { setState(() { desert.selected = selected; }); }, - cells: [ - new DataCell(new Text('${desert.name}')), - new DataCell(new Text('${desert.calories}')), - new DataCell(new Text('${desert.fat.toStringAsFixed(1)}')), - new DataCell(new Text('${desert.carbs}')), - new DataCell(new Text('${desert.protein.toStringAsFixed(1)}')), - new DataCell(new Text('${desert.sodium}')), - new DataCell(new Text('${desert.calcium}%')), - new DataCell(new Text('${desert.iron}%')), - ] - ); - }).toList(growable: false) - ) - ] - ) + new IntrinsicHeight( + child: new Block( + padding: const EdgeInsets.all(20.0), + scrollDirection: Axis.horizontal, + children: [ + new PaginatedDataTable( + rowsPerPage: 10, + sortColumnIndex: _sortColumnIndex, + sortAscending: _sortAscending, + columns: [ + new DataColumn( + label: new Text('Dessert (100g serving)'), + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.name, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Calories'), + tooltip: 'The total amount of food energy in the given serving size.', + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.calories, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Fat (g)'), + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.fat, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Carbs (g)'), + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.carbs, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Protein (g)'), + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.protein, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Sodium (mg)'), + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.sodium, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Calcium (%)'), + tooltip: 'The amount of calcium as a percentage of the recommended daily amount.', + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.calcium, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Iron (%)'), + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.iron, columnIndex, ascending) + ), + ], + source: _deserts + ) + ] ) ) ] diff --git a/packages/flutter/lib/foundation.dart b/packages/flutter/lib/foundation.dart index 332c7d88ad..7fd13f6d5b 100644 --- a/packages/flutter/lib/foundation.dart +++ b/packages/flutter/lib/foundation.dart @@ -12,4 +12,5 @@ library foundation; export 'src/foundation/assertions.dart'; export 'src/foundation/basic_types.dart'; export 'src/foundation/binding.dart'; +export 'src/foundation/change_notifier.dart'; export 'src/foundation/print.dart'; diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 66374656ed..cb3046ad58 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -23,6 +23,7 @@ export 'src/material/circle_avatar.dart'; export 'src/material/colors.dart'; export 'src/material/constants.dart'; export 'src/material/data_table.dart'; +export 'src/material/data_table_source.dart'; export 'src/material/date_picker.dart'; export 'src/material/date_picker_dialog.dart'; export 'src/material/dialog.dart'; @@ -48,6 +49,7 @@ export 'src/material/list_item.dart'; export 'src/material/material.dart'; export 'src/material/overscroll_indicator.dart'; export 'src/material/page.dart'; +export 'src/material/paginated_data_table.dart'; export 'src/material/popup_menu.dart'; export 'src/material/progress_indicator.dart'; export 'src/material/radio.dart'; diff --git a/packages/flutter/lib/src/foundation/basic_types.dart b/packages/flutter/lib/src/foundation/basic_types.dart index 42b517f080..1884faabc8 100644 --- a/packages/flutter/lib/src/foundation/basic_types.dart +++ b/packages/flutter/lib/src/foundation/basic_types.dart @@ -4,6 +4,8 @@ import 'dart:collection'; +// COMMON SIGNATURES + export 'dart:ui' show VoidCallback; /// Signature for callbacks that report that an underlying value has changed. @@ -28,6 +30,9 @@ typedef T ValueGetter(); /// Signature for callbacks that filter an iterable. typedef Iterable IterableFilter(Iterable input); + +// BITFIELD + /// The largest SMI value. /// /// See @@ -86,6 +91,9 @@ class BitField { } } + +// LAZY CACHING ITERATOR + /// A lazy caching version of [Iterable]. /// /// This iterable is efficient in the following ways: diff --git a/packages/flutter/lib/src/foundation/change_notifier.dart b/packages/flutter/lib/src/foundation/change_notifier.dart new file mode 100644 index 0000000000..7c7848509e --- /dev/null +++ b/packages/flutter/lib/src/foundation/change_notifier.dart @@ -0,0 +1,65 @@ +// Copyright 2015 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 'package:meta/meta.dart'; + +import 'assertions.dart'; +import 'basic_types.dart'; + +/// Abstract class that can be extended or mixed in that provides +/// a change notification API using [VoidCallback] for notifications. +abstract class ChangeNotifier { + List _listeners; + + /// Register a closure to be called when the object changes. + void addListener(VoidCallback listener) { + _listeners ??= []; + _listeners.add(listener); + } + + /// Remove a previously registered closure from the list of closures that are + /// notified when the object changes. + void removeListener(VoidCallback listener) { + _listeners?.remove(listener); + } + + /// Discards any resources used by the object. After this is called, the object + /// is not in a usable state and should be discarded. + /// + /// This method should only be called by the object's owner. + @mustCallSuper + void dispose() { + _listeners = null; + } + + /// Call all the registered listeners. + /// + /// Call this method whenever the object changes, to notify any clients the + /// object may have. + /// + /// Exceptions thrown by listeners will be caught and reported using + /// [FlutterError.reportError]. + @protected + void notifyListeners() { + if (_listeners != null) { + List listeners = new List.from(_listeners); + for (VoidCallback listener in listeners) { + try { + listener(); + } catch (exception, stack) { + FlutterError.reportError(new FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'foundation library', + context: 'while dispatching notifications for $runtimeType', + informationCollector: (StringBuffer information) { + information.writeln('The $runtimeType sending notification was:'); + information.write(' $this'); + } + )); + } + } + } + } +} diff --git a/packages/flutter/lib/src/material/data_table.dart b/packages/flutter/lib/src/material/data_table.dart index 67cae6a294..80e3d0e25d 100644 --- a/packages/flutter/lib/src/material/data_table.dart +++ b/packages/flutter/lib/src/material/data_table.dart @@ -91,6 +91,17 @@ class DataRow { this.cells }); + /// 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, + this.selected: false, + this.onSelectChanged, + this.cells + }) : key = new ValueKey(index); + /// 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) @@ -154,6 +165,8 @@ class DataCell { this.onTap }); + static final DataCell empty = new DataCell(new Container(width: 0.0, height: 0.0)); + /// The data for the row. /// /// Typically a [Text] widget or a [DropDownButton] widget. @@ -196,16 +209,19 @@ class DataCell { /// dimensions to use for each column, and once to actually lay out /// the table given the results of the negotiation. /// -// /// 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 [DataCard] which -// /// automatically splits the data into multiple pages. -// /// +/// 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. +/// /// See also: /// /// * [DataColumn] /// * [DataRow] /// * [DataCell] +/// * [PaginatedDataTable] /// * class DataTable extends StatelessWidget { /// Creates a widget describing a data table. @@ -213,7 +229,7 @@ class DataTable extends StatelessWidget { /// 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. + /// length greater than zero and cannot be null. /// /// The [rows] argument must be a list of as many [DataRow] objects /// as the table is to have rows, ignoring the leading heading row @@ -237,15 +253,16 @@ class DataTable extends StatelessWidget { List columns, this.sortColumnIndex, this.sortAscending: true, + this.onSelectAll, this.rows }) : columns = columns, _onlyTextColumn = _initOnlyTextColumn(columns), super(key: key) { assert(columns != null); assert(columns.length > 0); + assert(sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length)); assert(sortAscending != null); assert(rows != null); assert(!rows.any((DataRow row) => row.cells.length != columns.length)); - assert(sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length)); } /// The configuration and labels for the columns in the table. @@ -276,6 +293,17 @@ class DataTable extends StatelessWidget { /// table). final bool sortAscending; + /// 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 onSelectAll; + /// 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 rows; @@ -304,9 +332,13 @@ class DataTable extends StatelessWidget { static final LocalKey _headingRowKey = new UniqueKey(); void _handleSelectAll(bool checked) { - for (DataRow row in rows) { - if ((row.onSelectChanged != null) && (row.selected != checked)) - row.onSelectChanged(checked); + if (onSelectAll != null) { + onSelectAll(checked); + } else { + for (DataRow row in rows) { + if ((row.onSelectChanged != null) && (row.selected != checked)) + row.onSelectChanged(checked); + } } } diff --git a/packages/flutter/lib/src/material/data_table_source.dart b/packages/flutter/lib/src/material/data_table_source.dart new file mode 100644 index 0000000000..c08a57d39a --- /dev/null +++ b/packages/flutter/lib/src/material/data_table_source.dart @@ -0,0 +1,57 @@ +// 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 'package:flutter/foundation.dart'; +import 'data_table.dart'; + +/// A data source for obtaining row data for [PaginatedDataTable] objects. +/// +/// A data table source provides two main pieces of information: +/// +/// * The number of rows in the data table ([rowCount]). +/// * The data for each row (indexed from `0` to `rowCount - 1`). +/// +/// It also provides a listener API ([addListener]/[removeListener]) so that +/// consumers of the data can be notified when it changes. When the data +/// changes, call [notifyListeners] to send the notifications. +/// +/// DataTableSource objects are expected to be long-lived, not recreated with +/// each build. +abstract class DataTableSource extends ChangeNotifier { + /// Called to obtain the data about a particular row. + /// + /// The [new DataRow.byIndex] constructor provides a convenient way to construct + /// [DataRow] objects for this callback's purposes without having to worry about + /// independently keying each row. + /// + /// If the given index does not correspond to a row, or if no data is yet + /// available for a row, then return null. The row will be left blank and a + /// loading indicator will be displayed over the table. Once data is available + /// or once it is firmly established that the row index in question is beyond + /// the end of the table, call [notifyListeners]. + /// + /// Data returned from this method must be consistent for the lifetime of the + /// object. If the row count changes, then a new delegate must be provided. + DataRow getRow(int index); + + /// Called to obtain the number of rows to tell the user are available. + /// + /// If [isRowCountApproximate] is false, then this must be an accurate number, + /// and [getRow] must return a non-null value for all indices in the range 0 + /// to one less than the row count. + /// + /// If [isRowCountApproximate] is true, then the user will be allowed to + /// attempt to display rows up to this [rowCount], and the display will + /// indicate that the count is approximate. The row count should therefore be + /// greater than the actual number of rows if at all possible. + /// + /// If the row count changes, call [notifyListeners]. + int get rowCount; + + /// Called to establish if [rowCount] is a precise number or might be an + /// over-estimate. If this returns true (i.e. the count is approximate), and + /// then later the exact number becomes available, then call + /// [notifyListeners]. + bool get isRowCountApproximate; +} \ No newline at end of file diff --git a/packages/flutter/lib/src/material/paginated_data_table.dart b/packages/flutter/lib/src/material/paginated_data_table.dart new file mode 100644 index 0000000000..a8d2e36b90 --- /dev/null +++ b/packages/flutter/lib/src/material/paginated_data_table.dart @@ -0,0 +1,237 @@ +// 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 'package:flutter/widgets.dart'; + +import 'card.dart'; +import 'data_table.dart'; +import 'data_table_source.dart'; +import 'progress_indicator.dart'; + +/// A wrapper for [DataTable] that obtains data lazily from a [DataTableSource] +/// and displays it one page at a time. The widget is presented as a [Card]. +class PaginatedDataTable extends StatefulWidget { + /// Creates a widget describing a paginated [DataTable] on a [Card]. + /// + /// 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. + PaginatedDataTable({ + Key key, + this.columns, + this.sortColumnIndex, + this.sortAscending: true, + this.onSelectAll, + this.initialFirstRowIndex: 0, + this.onPageChanged, + this.rowsPerPage: 10, + this.onRowsPerPageChanged, + this.source + }) : super(key: key) { + assert(columns != null); + assert(columns.length > 0); + assert(sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length)); + assert(sortAscending != null); + assert(source != null); + } + + /// The configuration and labels for the columns in the table. + final List 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 onSelectAll; + + /// 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. + final ValueChanged onPageChanged; + + /// The number of rows to show on each page. + /// + /// See also [onRowsPerPageChanged]. + final int rowsPerPage; + + /// 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 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; + + @override + PaginatedDataTableState createState() => new PaginatedDataTableState(); +} + +/// Holds the state of a [PaginatedDataTable]. +/// +/// The table can be programmatically paged using the [pageTo] method. +class PaginatedDataTableState extends State { + int _firstRowIndex; + int _rowCount; + bool _rowCountApproximate; + final Map _rows = {}; + + @override + void initState() { + super.initState(); + _firstRowIndex = PageStorage.of(context)?.readState(context) ?? config.initialFirstRowIndex ?? 0; + config.source.addListener(_handleDataSourceChanged); + _handleDataSourceChanged(); + } + + @override + void didUpdateConfig(PaginatedDataTable oldConfig) { + super.didUpdateConfig(oldConfig); + if (oldConfig.source != config.source) { + oldConfig.source.removeListener(_handleDataSourceChanged); + config.source.addListener(_handleDataSourceChanged); + _handleDataSourceChanged(); + } + } + + @override + void dispose() { + config.source.removeListener(_handleDataSourceChanged); + super.dispose(); + } + + void _handleDataSourceChanged() { + setState(() { + _rowCount = config.source.rowCount; + _rowCountApproximate = config.source.isRowCountApproximate; + _rows.clear(); + }); + } + + /// Ensures that the given row is visible. + void pageTo(double rowIndex) { + setState(() { + final int rowsPerPage = config.rowsPerPage; + _firstRowIndex = (rowIndex ~/ rowsPerPage) * rowsPerPage; + }); + } + + DataRow _getBlankRowFor(int index) { + return new DataRow.byIndex( + index: index, + cells: config.columns.map/**/((DataColumn column) => DataCell.empty).toList() + ); + } + + DataRow _getProgressIndicatorRowFor(int index) { + bool haveProgressIndicator = false; + final List cells = config.columns.map/**/((DataColumn column) { + if (!column.numeric) { + haveProgressIndicator = true; + return new DataCell(new CircularProgressIndicator()); + } + return DataCell.empty; + }).toList(); + if (!haveProgressIndicator) { + haveProgressIndicator = true; + cells[0] = new DataCell(new CircularProgressIndicator()); + } + return new DataRow.byIndex( + index: index, + cells: cells + ); + } + + List _getRows(int firstRowIndex, int rowsPerPage) { + final List result = []; + final int nextPageFirstRowIndex = firstRowIndex + rowsPerPage; + bool haveProgressIndicator = false; + for (int index = firstRowIndex; index < nextPageFirstRowIndex; index += 1) { + DataRow row; + if (index < _rowCount || _rowCountApproximate) { + row = _rows.putIfAbsent(index, () => config.source.getRow(index)); + if (row == null && !haveProgressIndicator) { + row ??= _getProgressIndicatorRowFor(index); + haveProgressIndicator = true; + } + } + row ??= _getBlankRowFor(index); + result.add(row); + } + return result; + } + + final GlobalKey _tableKey = new GlobalKey(); + + @override + Widget build(BuildContext context) { + final List rows = _getRows(_firstRowIndex, config.rowsPerPage); + Widget table = new DataTable( + key: _tableKey, + columns: config.columns, + sortColumnIndex: config.sortColumnIndex, + sortAscending: config.sortAscending, + onSelectAll: config.onSelectAll, + rows: rows + ); + return new Card( + // TODO(ianh): data table card headers + child: table + // TODO(ianh): data table card footers: prev/next page, rows per page, etc + ); + } +} + +/* + +DataTableCard + - top: 64px + - caption, top left + - 20px Roboto Regular, black87 + - persistent actions, top left + - header when there's a selection + - accent 50? + - show number of selected items + - different actions + - actions, top right + - 24px icons, black54 + +bottom: + - 56px + - handles pagination + - 12px Roboto Regular, black54 + +*/ diff --git a/packages/flutter/lib/src/rendering/flow.dart b/packages/flutter/lib/src/rendering/flow.dart index 45564570e7..c33e305e11 100644 --- a/packages/flutter/lib/src/rendering/flow.dart +++ b/packages/flutter/lib/src/rendering/flow.dart @@ -76,7 +76,7 @@ abstract class FlowDelegate { /// By default, the children will receive the given constraints, which are the /// constrains the constraints used to size the container. The children need /// not respect the given constraints, but they are required to respect the - /// returned constraints. For example, the incoming constraings might require + /// returned constraints. For example, the incoming constraints might require /// the container to have a width of exactly 100.0 and a height of exactly /// 100.0, but this function might give the children looser constraints that /// let them be larger or smaller than 100.0 by 100.0. diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 01b9202bc4..0752d71552 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -1353,7 +1353,7 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { } if (targetFrame != null && targetFrame < stack.length) { information.writeln( - 'These invalid constraints were provided to $runtimeType\'s method() ' + 'These invalid constraints were provided to $runtimeType\'s layout() ' 'function by the following function, which probably computed the ' 'invalid constraints in question:' ); diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 5d4201547c..e6edaabd30 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -448,7 +448,7 @@ class RenderAspectRatio extends RenderProxyBox { } // Similar to RenderImage, we iteratively attempt to fit within the given - // constraings while maintaining the given aspect ratio. The order of + // constraints while maintaining the given aspect ratio. The order of // applying the constraints is also biased towards inferring the height // from the width. diff --git a/packages/flutter/lib/src/rendering/shifted_box.dart b/packages/flutter/lib/src/rendering/shifted_box.dart index f69fda7a11..96d82ba842 100644 --- a/packages/flutter/lib/src/rendering/shifted_box.dart +++ b/packages/flutter/lib/src/rendering/shifted_box.dart @@ -617,7 +617,7 @@ class RenderFractionallySizedOverflowBox extends RenderAligningShiftedBox { /// /// If non-null, the child is given a tight width constraint that is the max /// incoming width constraint multipled by this factor. If null, the child is - /// given the incoming width constraings. + /// given the incoming width constraints. double get widthFactor => _widthFactor; double _widthFactor; set widthFactor (double value) { @@ -632,7 +632,7 @@ class RenderFractionallySizedOverflowBox extends RenderAligningShiftedBox { /// /// If non-null, the child is given a tight height constraint that is the max /// incoming width constraint multipled by this factor. If null, the child is - /// given the incoming width constraings. + /// given the incoming width constraints. double get heightFactor => _heightFactor; double _heightFactor; set heightFactor (double value) { diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 3c2e06f73b..d0e1e13581 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -847,12 +847,18 @@ class FractionallySizedBox extends SingleChildRenderObjectWidget { /// /// If non-null, the child is given a tight width constraint that is the max /// incoming width constraint multipled by this factor. + /// + /// If null, the incoming width constraints are passed to the child + /// unmodified. final double widthFactor; /// If non-null, the fraction of the incoming height given to the child. /// /// If non-null, the child is given a tight height constraint that is the max /// incoming height constraint multipled by this factor. + /// + /// If null, the incoming height constraints are passed to the child + /// unmodified. final double heightFactor; /// How to align the child. diff --git a/packages/flutter/test/foundation/change_notifier_test.dart b/packages/flutter/test/foundation/change_notifier_test.dart new file mode 100644 index 0000000000..e1b7ae6f73 --- /dev/null +++ b/packages/flutter/test/foundation/change_notifier_test.dart @@ -0,0 +1,116 @@ + // 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 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class TestNotifier extends ChangeNotifier { + void notify() { + notifyListeners(); + } +} + +void main() { + testWidgets('ChangeNotifier', (WidgetTester tester) async { + final List log = []; + final VoidCallback listener = () { log.add('listener'); }; + final VoidCallback listener1 = () { log.add('listener1'); }; + final VoidCallback listener2 = () { log.add('listener2'); }; + final VoidCallback badListener = () { log.add('badListener'); throw null; }; + + final TestNotifier test = new TestNotifier(); + + test.addListener(listener); + test.addListener(listener); + test.notify(); + expect(log, equals(['listener', 'listener'])); + log.clear(); + + test.removeListener(listener); + test.notify(); + expect(log, equals(['listener'])); + log.clear(); + + test.removeListener(listener); + test.notify(); + expect(log, equals([])); + log.clear(); + + test.removeListener(listener); + test.notify(); + expect(log, equals([])); + log.clear(); + + test.addListener(listener); + test.notify(); + expect(log, equals(['listener'])); + log.clear(); + + test.addListener(listener1); + test.notify(); + expect(log, equals(['listener', 'listener1'])); + log.clear(); + + test.addListener(listener2); + test.notify(); + expect(log, equals(['listener', 'listener1', 'listener2'])); + log.clear(); + + test.removeListener(listener1); + test.notify(); + expect(log, equals(['listener', 'listener2'])); + log.clear(); + + test.addListener(listener1); + test.notify(); + expect(log, equals(['listener', 'listener2', 'listener1'])); + log.clear(); + + test.addListener(badListener); + test.notify(); + expect(log, equals(['listener', 'listener2', 'listener1', 'badListener'])); + expect(tester.takeException(), isNullThrownError); + log.clear(); + + test.addListener(listener1); + test.removeListener(listener); + test.removeListener(listener1); + test.removeListener(listener2); + test.addListener(listener2); + test.notify(); + expect(log, equals(['badListener', 'listener1', 'listener2'])); + expect(tester.takeException(), isNullThrownError); + log.clear(); + }); + + testWidgets('ChangeNotifier with mutating listener', (WidgetTester tester) async { + final TestNotifier test = new TestNotifier(); + final List log = []; + + final VoidCallback listener1 = () { log.add('listener1'); }; + final VoidCallback listener3 = () { log.add('listener3'); }; + final VoidCallback listener4 = () { log.add('listener4'); }; + final VoidCallback listener2 = () { + log.add('listener2'); + test.removeListener(listener1); + test.removeListener(listener3); + test.addListener(listener4); + }; + + test.addListener(listener1); + test.addListener(listener2); + test.addListener(listener3); + test.notify(); + expect(log, equals(['listener1', 'listener2', 'listener3'])); + log.clear(); + + test.notify(); + expect(log, equals(['listener2', 'listener4'])); + log.clear(); + + test.notify(); + expect(log, equals(['listener2', 'listener4', 'listener4'])); + log.clear(); + }); +}