Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/material_ui/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 1.2.0

* Adds `sortIconBuilder` parameter to `DataTable`, `PaginatedDataTable`, and `DataTableThemeData` for custom sorting indicators.

## 1.1.0

- Add missing Widget of the Week videos to widget API docs.
Expand Down
40 changes: 35 additions & 5 deletions packages/material_ui/lib/src/data_table.dart
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ class DataTable extends StatelessWidget {
this.checkboxHorizontalMargin,
this.border,
this.clipBehavior = Clip.none,
this.sortIconBuilder,
}) : assert(columns.isNotEmpty),
assert(
sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length),
Expand Down Expand Up @@ -562,6 +563,27 @@ class DataTable extends StatelessWidget {
/// Ascending order is represented by an upwards-facing arrow.
final bool sortAscending;

/// {@template flutter.material.dataTable.sortIconBuilder}
/// A builder function that returns a widget to use as the sorting indicator
/// icon for the table's header cells.
///
/// When providing a custom sort icon via this builder, the default sort arrow's
/// automatic rotation and opacity transitions are omitted. To achieve animated
/// transitions for custom icons, wrap the returned widget in explicit
/// transition widgets such as [AnimatedRotation] or [AnimatedOpacity].
///
/// Custom icons should target a size of approximately 18.0 logical pixels or
/// be wrapped in a fixed-size container to maintain visually balanced header
/// labels. For columns where [DataColumn.numeric] is true, [DataTable] adds
/// a leading 20.0 logical pixel spacer to mirror the space occupied by the
/// sort icon and its padding, ensuring centered column labels remain properly
/// aligned.
///
/// If null, [DataTableThemeData.sortIconBuilder] is used. If that is also null,
/// the default Material design sort arrow animation is used.
/// {@endtemplate}
final DataTableSortIconBuilder? sortIconBuilder;

/// Invoked when the user selects or unselects every row, using the
/// checkbox in the heading row.
///
Expand Down Expand Up @@ -903,6 +925,11 @@ class DataTable extends StatelessWidget {
}) {
final ThemeData themeData = Theme.of(context);
final DataTableThemeData dataTableTheme = DataTableTheme.of(context);
final DataTableSortIconBuilder? effectiveSortIconBuilder =
sortIconBuilder ??
dataTableTheme.sortIconBuilder ??
themeData.dataTableTheme.sortIconBuilder;
Comment on lines +928 to +931

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The fallback to themeData.dataTableTheme.sortIconBuilder is redundant and inconsistent with Flutter's theme resolution pattern.

DataTableTheme.of(context) already returns Theme.of(context).dataTableTheme if there is no inherited DataTableTheme ancestor. In Flutter, if an inherited theme is present, it completely overrides the global theme rather than merging with it. Therefore, we should only resolve from sortIconBuilder and dataTableTheme.sortIconBuilder.

    final DataTableSortIconBuilder? effectiveSortIconBuilder =
        sortIconBuilder ?? dataTableTheme.sortIconBuilder;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah. This is correct.


label = Semantics(
role: SemanticsRole.columnHeader,
child: Row(
Expand All @@ -913,11 +940,14 @@ class DataTable extends StatelessWidget {
const SizedBox(width: _SortArrowState._arrowIconSize + _sortArrowPadding),
label,
if (onSort != null) ...<Widget>[
_SortArrow(
visible: sorted,
up: sorted ? ascending : null,
duration: _sortArrowAnimationDuration,
),
if (effectiveSortIconBuilder != null)
effectiveSortIconBuilder(context, sorted, ascending)
else
_SortArrow(
visible: sorted,
up: sorted ? ascending : null,
duration: _sortArrowAnimationDuration,
),
const SizedBox(width: _sortArrowPadding),
],
],
Expand Down
24 changes: 23 additions & 1 deletion packages/material_ui/lib/src/data_table_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ import 'theme.dart';
// Examples can assume:
// late BuildContext context;

/// Signature for a builder function that returns a widget to use as a sorting
/// indicator icon in a [DataTable] header cell.
///
/// The [visible] parameter indicates whether the sort icon should be visible
/// (i.e. whether the column is the active sort column).
///
/// The [ascending] parameter indicates whether the sort order is ascending.
typedef DataTableSortIconBuilder =
Widget Function(BuildContext context, bool visible, bool ascending);

/// Defines default property values for descendant [DataTable]
/// widgets.
///
Expand Down Expand Up @@ -59,6 +69,7 @@ class DataTableThemeData with Diagnosticable {
this.headingCellCursor,
this.dataRowCursor,
this.headingRowAlignment,
this.sortIconBuilder,
}) : assert(
dataRowMinHeight == null ||
dataRowMaxHeight == null ||
Expand Down Expand Up @@ -125,6 +136,9 @@ class DataTableThemeData with Diagnosticable {
/// If specified, overrides the default value of [DataColumn.headingRowAlignment].
final MainAxisAlignment? headingRowAlignment;

/// {@macro flutter.material.dataTable.sortIconBuilder}
final DataTableSortIconBuilder? sortIconBuilder;

/// Creates a copy of this object but with the given fields replaced with the
/// new values.
DataTableThemeData copyWith({
Expand All @@ -148,6 +162,7 @@ class DataTableThemeData with Diagnosticable {
WidgetStateProperty<MouseCursor?>? headingCellCursor,
WidgetStateProperty<MouseCursor?>? dataRowCursor,
MainAxisAlignment? headingRowAlignment,
DataTableSortIconBuilder? sortIconBuilder,
}) {
assert(
dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null),
Expand All @@ -172,6 +187,7 @@ class DataTableThemeData with Diagnosticable {
headingCellCursor: headingCellCursor ?? this.headingCellCursor,
dataRowCursor: dataRowCursor ?? this.dataRowCursor,
headingRowAlignment: headingRowAlignment ?? this.headingRowAlignment,
sortIconBuilder: sortIconBuilder ?? this.sortIconBuilder,
);
}

Expand Down Expand Up @@ -207,6 +223,7 @@ class DataTableThemeData with Diagnosticable {
headingCellCursor: t < 0.5 ? a.headingCellCursor : b.headingCellCursor,
dataRowCursor: t < 0.5 ? a.dataRowCursor : b.dataRowCursor,
headingRowAlignment: t < 0.5 ? a.headingRowAlignment : b.headingRowAlignment,
sortIconBuilder: t < 0.5 ? a.sortIconBuilder : b.sortIconBuilder,
);
}

Expand All @@ -227,6 +244,7 @@ class DataTableThemeData with Diagnosticable {
headingCellCursor,
dataRowCursor,
headingRowAlignment,
sortIconBuilder,
);

@override
Expand All @@ -252,7 +270,8 @@ class DataTableThemeData with Diagnosticable {
other.checkboxHorizontalMargin == checkboxHorizontalMargin &&
other.headingCellCursor == headingCellCursor &&
other.dataRowCursor == dataRowCursor &&
other.headingRowAlignment == headingRowAlignment;
other.headingRowAlignment == headingRowAlignment &&
other.sortIconBuilder == sortIconBuilder;
}

@override
Expand Down Expand Up @@ -309,6 +328,9 @@ class DataTableThemeData with Diagnosticable {
defaultValue: null,
),
);
properties.add(
ObjectFlagProperty<DataTableSortIconBuilder>.has('sortIconBuilder', sortIconBuilder),
);
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/material_ui/lib/src/paginated_data_table.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import 'card.dart';
import 'constants.dart';
import 'data_table.dart';
import 'data_table_source.dart';
import 'data_table_theme.dart';
import 'debug.dart';
import 'dropdown.dart';
import 'icon_button.dart';
Expand Down Expand Up @@ -140,6 +141,7 @@ class PaginatedDataTable extends StatefulWidget {
this.headingRowColor,
this.dividerThickness,
this.showEmptyRows = true,
this.sortIconBuilder,
}) : assert(actions == null || (header != null)),
assert(columns.isNotEmpty),
assert(
Expand Down Expand Up @@ -372,6 +374,9 @@ class PaginatedDataTable extends StatefulWidget {
/// When set to `false`, empty rows will not be created.
final bool showEmptyRows;

/// {@macro flutter.material.dataTable.sortIconBuilder}
final DataTableSortIconBuilder? sortIconBuilder;

@override
PaginatedDataTableState createState() => PaginatedDataTableState();
}
Expand Down Expand Up @@ -692,6 +697,7 @@ class PaginatedDataTableState extends State<PaginatedDataTable> {
showBottomBorder: true,
rows: _getRows(_firstRowIndex, widget.rowsPerPage),
headingRowColor: widget.headingRowColor,
sortIconBuilder: widget.sortIconBuilder,
),
),
),
Expand Down
2 changes: 1 addition & 1 deletion packages/material_ui/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: material_ui
description: The official Flutter Material UI Library, implementing Google's Material Design design system.
version: 1.1.0
version: 1.2.0
repository: https://github.com/flutter/packages/tree/main/packages/material_ui
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A%20material%20design%22

Expand Down
101 changes: 101 additions & 0 deletions packages/material_ui/test/data_table_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,107 @@ void main() {
expect(transformOfArrow.transform.getRotation(), equals(Matrix3.rotationZ(math.pi)));
});

testWidgets('DataTable custom sortIconBuilder test', (WidgetTester tester) async {
Widget buildTable({
DataTableSortIconBuilder? sortIconBuilder,
DataTableThemeData? themeData,
int? sortColumnIndex = 0,
bool sortAscending = true,
}) {
final Widget table = DataTable(
sortColumnIndex: sortColumnIndex,
sortAscending: sortAscending,
sortIconBuilder: sortIconBuilder,
columns: <DataColumn>[
DataColumn(label: const Text('Name'), onSort: (int columnIndex, bool ascending) {}),
DataColumn(label: const Text('Calories'), onSort: (int columnIndex, bool ascending) {}),
],
rows: kDesserts.map<DataRow>((Dessert dessert) {
return DataRow(
cells: <DataCell>[DataCell(Text(dessert.name)), DataCell(Text('${dessert.calories}'))],
);
}).toList(),
);

return MaterialApp(
home: Material(
child: themeData != null ? DataTableTheme(data: themeData, child: table) : table,
),
);
}

// Default (null sortIconBuilder): uses standard Icon(Icons.arrow_upward).
await tester.pumpWidget(buildTable());
final Finder defaultIconFinder = find.descendant(
of: find.byType(DataTable),
matching: find.byIcon(Icons.arrow_upward),
);
expect(defaultIconFinder, findsNWidgets(2));

// Custom sortIconBuilder on DataTable.
await tester.pumpWidget(
buildTable(
sortIconBuilder: (BuildContext context, bool visible, bool ascending) {
return Text(visible ? (ascending ? 'ASC' : 'DESC') : 'INACTIVE');
},
),
);
expect(find.text('ASC'), findsOneWidget);
expect(find.text('INACTIVE'), findsOneWidget);

// Verify sortAscending = false passes ascending = false.
await tester.pumpWidget(
buildTable(
sortAscending: false,
sortIconBuilder: (BuildContext context, bool visible, bool ascending) {
return Text(visible ? (ascending ? 'ASC' : 'DESC') : 'INACTIVE');
},
),
);
expect(find.text('DESC'), findsOneWidget);
expect(find.text('INACTIVE'), findsOneWidget);

// Verify sorted column index change passes updated visible flags.
await tester.pumpWidget(
buildTable(
sortColumnIndex: 1,
sortIconBuilder: (BuildContext context, bool visible, bool ascending) {
return Text(visible ? 'SORTED_COL1' : 'UNSORTED_COL0');
},
),
);
expect(find.text('SORTED_COL1'), findsOneWidget);
expect(find.text('UNSORTED_COL0'), findsOneWidget);

// Verify DataTableThemeData.sortIconBuilder theme resolution.
await tester.pumpWidget(
buildTable(
themeData: DataTableThemeData(
sortIconBuilder: (BuildContext context, bool visible, bool ascending) {
return const Text('THEME_BUILDER');
},
),
),
);
expect(find.text('THEME_BUILDER'), findsNWidgets(2));

// Verify DataTable.sortIconBuilder overrides DataTableThemeData.sortIconBuilder.
await tester.pumpWidget(
buildTable(
sortIconBuilder: (BuildContext context, bool visible, bool ascending) {
return const Text('WIDGET_BUILDER');
},
themeData: DataTableThemeData(
sortIconBuilder: (BuildContext context, bool visible, bool ascending) {
return const Text('THEME_BUILDER');
},
),
),
);
expect(find.text('WIDGET_BUILDER'), findsNWidgets(2));
expect(find.text('THEME_BUILDER'), findsNothing);
});

testWidgets('DataTable sort indicator orientation does not change on state update', (
WidgetTester tester,
) async {
Expand Down
4 changes: 4 additions & 0 deletions packages/material_ui/test/data_table_theme_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ void main() {
expect(themeData.headingCellCursor, null);
expect(themeData.dataRowCursor, null);
expect(themeData.headingRowAlignment, null);
expect(themeData.sortIconBuilder, null);

const theme = DataTableTheme(data: DataTableThemeData(), child: SizedBox());
expect(theme.data.decoration, null);
Expand All @@ -63,6 +64,7 @@ void main() {
expect(theme.data.headingCellCursor, null);
expect(theme.data.dataRowCursor, null);
expect(theme.data.headingRowAlignment, null);
expect(theme.data.sortIconBuilder, null);
});

testWidgets('Default DataTableThemeData debugFillProperties', (WidgetTester tester) async {
Expand Down Expand Up @@ -99,6 +101,7 @@ void main() {
headingCellCursor: const MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.grab),
dataRowCursor: const MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.forbidden),
headingRowAlignment: MainAxisAlignment.center,
sortIconBuilder: (BuildContext context, bool visible, bool ascending) => const SizedBox(),
).debugFillProperties(builder);

final List<String> description = builder.properties
Expand All @@ -121,6 +124,7 @@ void main() {
expect(description[12], 'headingCellCursor: WidgetStatePropertyAll(SystemMouseCursor(grab))');
expect(description[13], 'dataRowCursor: WidgetStatePropertyAll(SystemMouseCursor(forbidden))');
expect(description[14], 'headingRowAlignment: center');
expect(description[15], 'has sortIconBuilder');
});

testWidgets('DataTable is themeable', (WidgetTester tester) async {
Expand Down
25 changes: 25 additions & 0 deletions packages/material_ui/test/paginated_data_table_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1534,4 +1534,29 @@ void main() {
);
expect(tester.getSize(find.byType(PaginatedDataTable)), Size.zero);
});

testWidgets('PaginatedDataTable custom sortIconBuilder test', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: PaginatedDataTable(
sortColumnIndex: 0,
sortIconBuilder: (BuildContext context, bool visible, bool ascending) {
return Text(
visible ? (ascending ? 'PAGINATED_ASC' : 'PAGINATED_DESC') : 'PAGINATED_INACTIVE',
);
},
header: const Text('Test Table'),
rowsPerPage: 2,
columns: <DataColumn>[
DataColumn(label: const Text('Name'), onSort: (int columnIndex, bool ascending) {}),
const DataColumn(label: Text('Calories'), numeric: true),
const DataColumn(label: Text('Generation')),
],
source: source,
),
),
);

expect(find.text('PAGINATED_ASC'), findsOneWidget);
});
}
Loading