Unverified Commit ccdf8264 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

PaginatedDataTable improvements (#131374)

- slightly improved assert message when row cell counts don't match column count.
- more breadcrumbs in API documentation. more documentation in general.
- added more documentation for the direction of the "ascending" arrow.
- two samples for PaginatedDataTable.
- make PaginatedDataTable support hot reloading across changes to the number of columns.
- introduce matrix3MoreOrLessEquals. An earlier version of this PR used it in tests, but eventually it was not needed. The function seems useful to keep though.
parent 668a0022
// Copyright 2014 The Flutter 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/material.dart';
/// Flutter code sample for [PaginatedDataTable].
class MyDataSource extends DataTableSource {
@override
int get rowCount => 3;
@override
DataRow? getRow(int index) {
switch (index) {
case 0: return const DataRow(
cells: <DataCell>[
DataCell(Text('Sarah')),
DataCell(Text('19')),
DataCell(Text('Student')),
],
);
case 1: return const DataRow(
cells: <DataCell>[
DataCell(Text('Janine')),
DataCell(Text('43')),
DataCell(Text('Professor')),
],
);
case 2: return const DataRow(
cells: <DataCell>[
DataCell(Text('William')),
DataCell(Text('27')),
DataCell(Text('Associate Professor')),
],
);
default: return null;
}
}
@override
bool get isRowCountApproximate => false;
@override
int get selectedRowCount => 0;
}
final DataTableSource dataSource = MyDataSource();
void main() => runApp(const DataTableExampleApp());
class DataTableExampleApp extends StatelessWidget {
const DataTableExampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: SingleChildScrollView(
padding: EdgeInsets.all(12.0),
child: DataTableExample(),
),
);
}
}
class DataTableExample extends StatelessWidget {
const DataTableExample({super.key});
@override
Widget build(BuildContext context) {
return PaginatedDataTable(
columns: const <DataColumn>[
DataColumn(
label: Text('Name'),
),
DataColumn(
label: Text('Age'),
),
DataColumn(
label: Text('Role'),
),
],
source: dataSource,
);
}
}
// Copyright 2014 The Flutter 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/material.dart';
/// Flutter code sample for [PaginatedDataTable].
class MyDataSource extends DataTableSource {
static const List<int> _displayIndexToRawIndex = <int>[ 0, 3, 4, 5, 6 ];
late List<List<Comparable<Object>>> sortedData;
void setData(List<List<Comparable<Object>>> rawData, int sortColumn, bool sortAscending) {
sortedData = rawData.toList()..sort((List<Comparable<Object>> a, List<Comparable<Object>> b) {
final Comparable<Object> cellA = a[_displayIndexToRawIndex[sortColumn]];
final Comparable<Object> cellB = b[_displayIndexToRawIndex[sortColumn]];
return cellA.compareTo(cellB) * (sortAscending ? 1 : -1);
});
notifyListeners();
}
@override
int get rowCount => sortedData.length;
static DataCell cellFor(Object data) {
String value;
if (data is DateTime) {
value = '${data.year}-${data.month.toString().padLeft(2, '0')}-${data.day.toString().padLeft(2, '0')}';
} else {
value = data.toString();
}
return DataCell(Text(value));
}
@override
DataRow? getRow(int index) {
return DataRow.byIndex(
index: sortedData[index][0] as int,
cells: <DataCell>[
cellFor('S${sortedData[index][1]}E${sortedData[index][2].toString().padLeft(2, '0')}'),
cellFor(sortedData[index][3]),
cellFor(sortedData[index][4]),
cellFor(sortedData[index][5]),
cellFor(sortedData[index][6]),
],
);
}
@override
bool get isRowCountApproximate => false;
@override
int get selectedRowCount => 0;
}
void main() => runApp(const DataTableExampleApp());
class DataTableExampleApp extends StatelessWidget {
const DataTableExampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: SingleChildScrollView(
padding: EdgeInsets.all(12.0),
child: DataTableExample(),
),
);
}
}
class DataTableExample extends StatefulWidget {
const DataTableExample({super.key});
@override
State<DataTableExample> createState() => _DataTableExampleState();
}
class _DataTableExampleState extends State<DataTableExample> {
final MyDataSource dataSource = MyDataSource()
..setData(episodes, 0, true);
int _columnIndex = 0;
bool _columnAscending = true;
void _sort(int columnIndex, bool ascending) {
setState(() {
_columnIndex = columnIndex;
_columnAscending = ascending;
dataSource.setData(episodes, _columnIndex, _columnAscending);
});
}
@override
Widget build(BuildContext context) {
return PaginatedDataTable(
sortColumnIndex: _columnIndex,
sortAscending: _columnAscending,
columns: <DataColumn>[
DataColumn(
label: const Text('Episode'),
onSort: _sort,
),
DataColumn(
label: const Text('Title'),
onSort: _sort,
),
DataColumn(
label: const Text('Director'),
onSort: _sort,
),
DataColumn(
label: const Text('Writer(s)'),
onSort: _sort,
),
DataColumn(
label: const Text('Air Date'),
onSort: _sort,
),
],
source: dataSource,
);
}
}
final List<List<Comparable<Object>>> episodes = <List<Comparable<Object>>>[
<Comparable<Object>>[
1,
1,
1,
'Strange New Worlds',
'Akiva Goldsman',
'Akiva Goldsman, Alex Kurtzman, Jenny Lumet',
DateTime(2022, 5, 5),
],
<Comparable<Object>>[
2,
1,
2,
'Children of the Comet',
'Maja Vrvilo',
'Henry Alonso Myers, Sarah Tarkoff',
DateTime(2022, 5, 12),
],
<Comparable<Object>>[
3,
1,
3,
'Ghosts of Illyria',
'Leslie Hope',
'Akela Cooper, Bill Wolkoff',
DateTime(2022, 5, 19),
],
<Comparable<Object>>[
4,
1,
4,
'Memento Mori',
'Dan Liu',
'Davy Perez, Beau DeMayo',
DateTime(2022, 5, 26),
],
<Comparable<Object>>[
5,
1,
5,
'Spock Amok',
'Rachel Leiterman',
'Henry Alonso Myers, Robin Wasserman',
DateTime(2022, 6, 2),
],
<Comparable<Object>>[
6,
1,
6,
'Lift Us Where Suffering Cannot Reach',
'Andi Armaganian',
'Robin Wasserman, Bill Wolkoff',
DateTime(2022, 6, 9),
],
<Comparable<Object>>[
7,
1,
7,
'The Serene Squall',
'Sydney Freeland',
'Beau DeMayo, Sarah Tarkoff',
DateTime(2022, 6, 16),
],
<Comparable<Object>>[
8,
1,
8,
'The Elysian Kingdom',
'Amanda Row',
'Akela Cooper, Onitra Johnson',
DateTime(2022, 6, 23),
],
<Comparable<Object>>[
9,
1,
9,
'All Those Who Wander',
'Christopher J. Byrne',
'Davy Perez',
DateTime(2022, 6, 30),
],
<Comparable<Object>>[
10,
2,
10,
'A Quality of Mercy',
'Chris Fisher',
'Henry Alonso Myers, Akiva Goldsman',
DateTime(2022, 7, 7),
],
<Comparable<Object>>[
11,
2,
1,
'The Broken Circle',
'Chris Fisher',
'Henry Alonso Myers, Akiva Goldsman',
DateTime(2023, 6, 15),
],
<Comparable<Object>>[
12,
2,
2,
'Ad Astra per Aspera',
'Valerie Weiss',
'Dana Horgan',
DateTime(2023, 6, 22),
],
<Comparable<Object>>[
13,
2,
3,
'Tomorrow and Tomorrow and Tomorrow',
'Amanda Row',
'David Reed',
DateTime(2023, 6, 29),
],
<Comparable<Object>>[
14,
2,
4,
'Among the Lotus Eaters',
'Eduardo Sánchez',
'Kirsten Beyer, Davy Perez',
DateTime(2023, 7, 6),
],
<Comparable<Object>>[
15,
2,
5,
'Charades',
'Jordan Canning',
'Kathryn Lyn, Henry Alonso Myers',
DateTime(2023, 7, 13),
],
<Comparable<Object>>[
16,
2,
6,
'Lost in Translation',
'Dan Liu',
'Onitra Johnson, David Reed',
DateTime(2023, 7, 20),
],
<Comparable<Object>>[
17,
2,
7,
'Those Old Scientists',
'Jonathan Frakes',
'Kathryn Lyn, Bill Wolkoff',
DateTime(2023, 7, 22),
],
<Comparable<Object>>[
18,
2,
8,
'Under the Cloak of War',
'',
'Davy Perez',
DateTime(2023, 7, 27),
],
<Comparable<Object>>[
19,
2,
9,
'Subspace Rhapsody',
'',
'Dana Horgan, Bill Wolkoff',
DateTime(2023, 8, 3),
],
<Comparable<Object>>[
20,
2,
10,
'Hegemony',
'',
'Henry Alonso Myers',
DateTime(2023, 8, 10),
],
];
// Copyright 2014 The Flutter 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_api_samples/material/paginated_data_table/paginated_data_table.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('PaginatedDataTable 0', (WidgetTester tester) async {
await tester.pumpWidget(const example.DataTableExampleApp());
expect(find.text('Associate Professor'), findsOneWidget);
});
}
// Copyright 2014 The Flutter 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/material.dart';
import 'package:flutter_api_samples/material/paginated_data_table/paginated_data_table.1.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('PaginatedDataTable 1', (WidgetTester tester) async {
await tester.pumpWidget(const example.DataTableExampleApp());
expect(find.text('Strange New Worlds'), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_upward).at(1));
await tester.pump();
expect(find.text('Strange New Worlds'), findsNothing);
await tester.tap(find.byIcon(Icons.chevron_right));
await tester.pump();
expect(find.text('Strange New Worlds'), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_upward).at(1));
await tester.pump();
expect(find.text('Strange New Worlds'), findsNothing);
});
}
......@@ -444,7 +444,7 @@ class DataTable extends StatelessWidget {
this.clipBehavior = Clip.none,
}) : assert(columns.isNotEmpty),
assert(sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length)),
assert(!rows.any((DataRow row) => row.cells.length != columns.length)),
assert(!rows.any((DataRow row) => row.cells.length != columns.length), 'All rows must have the same number of cells as there are header cells (${columns.length})'),
assert(dividerThickness == null || dividerThickness >= 0),
assert(dataRowMinHeight == null || dataRowMaxHeight == null || dataRowMaxHeight >= dataRowMinHeight),
assert(dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null),
......@@ -467,6 +467,8 @@ class DataTable extends StatelessWidget {
///
/// When this is null, it implies that the table's sort order does
/// not correspond to any of the columns.
///
/// The direction of the sort is specified using [sortAscending].
final int? sortColumnIndex;
/// Whether the column mentioned in [sortColumnIndex], if any, is sorted
......@@ -479,6 +481,8 @@ class DataTable extends StatelessWidget {
/// If false, the order is descending (meaning the rows with the
/// smallest values for the current sort column are last in the
/// table).
///
/// Ascending order is represented by an upwards-facing arrow.
final bool sortAscending;
/// Invoked when the user selects or unselects every row, using the
......
......@@ -18,21 +18,32 @@ import 'data_table.dart';
///
/// DataTableSource objects are expected to be long-lived, not recreated with
/// each build.
///
/// If a [DataTableSource] is used with a [PaginatedDataTable] that supports
/// sortable columns (see [DataColumn.onSort] and
/// [PaginatedDataTable.sortColumnIndex]), the rows reported by the data source
/// must be reported in the sorted order.
abstract class DataTableSource extends ChangeNotifier {
/// Called to obtain the data about a particular row.
///
/// Rows should be keyed so that state can be maintained when the data source
/// is sorted (e.g. in response to [DataColumn.onSort]). Keys should be
/// consistent for a given [DataRow] regardless of the sort order (i.e. the
/// key represents the data's identity, not the row position).
///
/// The [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.
/// [DataRow] objects for this method's purposes without having to worry about
/// independently keying each row. The index passed to that constructor is the
/// index of the underlying data, which is different than the `index`
/// parameter for [getRow], which represents the _sorted_ position.
///
/// 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].
/// the end of the table, call [notifyListeners]. (See [rowCount].)
///
/// 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.
/// If the underlying data changes, call [notifyListeners].
DataRow? getRow(int index);
/// Called to obtain the number of rows to tell the user are available.
......@@ -58,5 +69,7 @@ abstract class DataTableSource extends ChangeNotifier {
/// Called to obtain the number of rows that are currently selected.
///
/// If the selected row count changes, call [notifyListeners].
///
/// Selected rows are those whose [DataRow.selected] property is set to true.
int get selectedRowCount;
}
......@@ -31,6 +31,23 @@ import 'theme.dart';
/// If the [key] is a [PageStorageKey], the [initialFirstRowIndex] is persisted
/// to [PageStorage].
///
/// {@tool dartpad}
///
/// 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.
///
/// ** See code in examples/api/lib/material/paginated_data_table/paginated_data_table.0.dart **
/// {@end-tool}
///
/// {@tool dartpad}
///
/// This example shows how paginated data tables can supported sorted data.
///
/// ** See code in examples/api/lib/material/paginated_data_table/paginated_data_table.1.dart **
/// {@end-tool}
///
/// See also:
///
/// * [DataTable], which is not paginated.
......@@ -142,13 +159,15 @@ class PaginatedDataTable extends StatefulWidget {
/// The current primary sort key's column.
///
/// See [DataTable.sortColumnIndex].
/// See [DataTable.sortColumnIndex] for details.
///
/// The direction of the sort is specified using [sortAscending].
final int? sortColumnIndex;
/// Whether the column mentioned in [sortColumnIndex], if any, is sorted
/// in ascending order.
///
/// See [DataTable.sortAscending].
/// See [DataTable.sortAscending] for details.
final bool sortAscending;
/// Invoked when the user selects or unselects every row, using the
......@@ -297,10 +316,27 @@ class PaginatedDataTableState extends State<PaginatedDataTable> {
if (oldWidget.source != widget.source) {
oldWidget.source.removeListener(_handleDataSourceChanged);
widget.source.addListener(_handleDataSourceChanged);
_handleDataSourceChanged();
_updateCaches();
}
}
@override
void reassemble() {
super.reassemble();
// This function is called during hot reload.
//
// Normally, if the data source changes, it would notify its listeners and
// thus trigger _handleDataSourceChanged(), which clears the row cache and
// causes the widget to rebuild.
//
// During a hot reload, though, a data source can change in ways that will
// invalidate the row cache (e.g. adding or removing columns) without ever
// triggering a notification, leaving the PaginatedDataTable in an invalid
// state. This method handles this case by clearing the cache any time the
// widget is involved in a hot reload.
_updateCaches();
}
@override
void dispose() {
widget.source.removeListener(_handleDataSourceChanged);
......@@ -308,12 +344,14 @@ class PaginatedDataTableState extends State<PaginatedDataTable> {
}
void _handleDataSourceChanged() {
setState(() {
_rowCount = widget.source.rowCount;
_rowCountApproximate = widget.source.isRowCountApproximate;
_selectedRowCount = widget.source.selectedRowCount;
_rows.clear();
});
setState(_updateCaches);
}
void _updateCaches() {
_rowCount = widget.source.rowCount;
_rowCountApproximate = widget.source.isRowCountApproximate;
_selectedRowCount = widget.source.selectedRowCount;
_rows.clear();
}
/// Ensures that the given row is visible.
......
......@@ -469,11 +469,11 @@ void main() {
await tester.pumpWidget(MaterialApp(
home: Material(child: buildTable()),
));
// The `tester.widget` ensures that there is exactly one upward arrow.
final Finder iconFinder = find.descendant(
of: find.byType(DataTable),
matching: find.widgetWithIcon(Transform, Icons.arrow_upward),
);
// The `tester.widget` ensures that there is exactly one upward arrow.
Transform transformOfArrow = tester.widget<Transform>(iconFinder);
expect(
transformOfArrow.transform.getRotation(),
......@@ -521,11 +521,11 @@ void main() {
await tester.pumpWidget(MaterialApp(
home: Material(child: buildTable()),
));
// The `tester.widget` ensures that there is exactly one upward arrow.
final Finder iconFinder = find.descendant(
of: find.byType(DataTable),
matching: find.widgetWithIcon(Transform, Icons.arrow_upward),
);
// The `tester.widget` ensures that there is exactly one upward arrow.
Transform transformOfArrow = tester.widget<Transform>(iconFinder);
expect(
transformOfArrow.transform.getRotation(),
......@@ -574,11 +574,11 @@ void main() {
await tester.pumpWidget(MaterialApp(
home: Material(child: buildTable()),
));
// The `tester.widget` ensures that there is exactly one upward arrow.
final Finder iconFinder = find.descendant(
of: find.byType(DataTable),
matching: find.widgetWithIcon(Transform, Icons.arrow_upward),
);
// The `tester.widget` ensures that there is exactly one upward arrow.
Transform transformOfArrow = tester.widget<Transform>(iconFinder);
expect(
transformOfArrow.transform.getRotation(),
......
......@@ -12,6 +12,7 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:matcher/expect.dart';
import 'package:matcher/src/expect/async_matcher.dart'; // ignore: implementation_imports
import 'package:vector_math/vector_math_64.dart' show Matrix3;
import '_matchers_io.dart' if (dart.library.html) '_matchers_web.dart' show MatchesGoldenFile, captureImage;
import 'accessibility.dart';
......@@ -345,10 +346,24 @@ Matcher rectMoreOrLessEquals(Rect value, { double epsilon = precisionErrorTolera
///
/// * [moreOrLessEquals], which is for [double]s.
/// * [offsetMoreOrLessEquals], which is for [Offset]s.
/// * [matrix3MoreOrLessEquals], which is for [Matrix3]s.
Matcher matrixMoreOrLessEquals(Matrix4 value, { double epsilon = precisionErrorTolerance }) {
return _IsWithinDistance<Matrix4>(_matrixDistance, value, epsilon);
}
/// Asserts that two [Matrix3]s are equal, within some tolerated error.
///
/// {@macro flutter.flutter_test.moreOrLessEquals}
///
/// See also:
///
/// * [moreOrLessEquals], which is for [double]s.
/// * [offsetMoreOrLessEquals], which is for [Offset]s.
/// * [matrixMoreOrLessEquals], which is for [Matrix4]s.
Matcher matrix3MoreOrLessEquals(Matrix3 value, { double epsilon = precisionErrorTolerance }) {
return _IsWithinDistance<Matrix3>(_matrix3Distance, value, epsilon);
}
/// Asserts that two [Offset]s are equal, within some tolerated error.
///
/// {@macro flutter.flutter_test.moreOrLessEquals}
......@@ -1443,6 +1458,14 @@ double _matrixDistance(Matrix4 a, Matrix4 b) {
return delta;
}
double _matrix3Distance(Matrix3 a, Matrix3 b) {
double delta = 0.0;
for (int i = 0; i < 9; i += 1) {
delta = math.max<double>((a[i] - b[i]).abs(), delta);
}
return delta;
}
double _sizeDistance(Size a, Size b) {
// TODO(a14n): remove ignore when lint is updated, https://github.com/dart-lang/linter/issues/1843
// ignore: unnecessary_parenthesis
......
......@@ -10,6 +10,7 @@ import 'dart:typed_data';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:vector_math/vector_math_64.dart' show Matrix3;
/// Class that makes it easy to mock common toStringDeep behavior.
class _MockToStringDeep {
......@@ -251,6 +252,35 @@ void main() {
);
});
test('matrix3MoreOrLessEquals', () {
expect(
Matrix3.rotationZ(math.pi),
matrix3MoreOrLessEquals(Matrix3.fromList(<double>[
-1, 0, 0,
0, -1, 0,
0, 0, 1,
]))
);
expect(
Matrix3.rotationZ(math.pi),
matrix3MoreOrLessEquals(Matrix3.fromList(<double>[
-2, 0, 0,
0, -2, 0,
0, 0, 1,
]), epsilon: 2)
);
expect(
Matrix3.rotationZ(math.pi),
isNot(matrix3MoreOrLessEquals(Matrix3.fromList(<double>[
-2, 0, 0,
0, -2, 0,
0, 0, 1,
])))
);
});
test('rectMoreOrLessEquals', () {
expect(
const Rect.fromLTRB(0.0, 0.0, 10.0, 10.0),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment