diff --git a/packages/material_ui/CHANGELOG.md b/packages/material_ui/CHANGELOG.md index 1f05474e3080..7f04bb044c50 100644 --- a/packages/material_ui/CHANGELOG.md +++ b/packages/material_ui/CHANGELOG.md @@ -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. diff --git a/packages/material_ui/lib/src/data_table.dart b/packages/material_ui/lib/src/data_table.dart index d16cc15ef77f..5773008eb119 100644 --- a/packages/material_ui/lib/src/data_table.dart +++ b/packages/material_ui/lib/src/data_table.dart @@ -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), @@ -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. /// @@ -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; + label = Semantics( role: SemanticsRole.columnHeader, child: Row( @@ -913,11 +940,14 @@ class DataTable extends StatelessWidget { const SizedBox(width: _SortArrowState._arrowIconSize + _sortArrowPadding), label, if (onSort != null) ...[ - _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), ], ], diff --git a/packages/material_ui/lib/src/data_table_theme.dart b/packages/material_ui/lib/src/data_table_theme.dart index af00ee9be04b..f6296281d1a8 100644 --- a/packages/material_ui/lib/src/data_table_theme.dart +++ b/packages/material_ui/lib/src/data_table_theme.dart @@ -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. /// @@ -59,6 +69,7 @@ class DataTableThemeData with Diagnosticable { this.headingCellCursor, this.dataRowCursor, this.headingRowAlignment, + this.sortIconBuilder, }) : assert( dataRowMinHeight == null || dataRowMaxHeight == null || @@ -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({ @@ -148,6 +162,7 @@ class DataTableThemeData with Diagnosticable { WidgetStateProperty? headingCellCursor, WidgetStateProperty? dataRowCursor, MainAxisAlignment? headingRowAlignment, + DataTableSortIconBuilder? sortIconBuilder, }) { assert( dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null), @@ -172,6 +187,7 @@ class DataTableThemeData with Diagnosticable { headingCellCursor: headingCellCursor ?? this.headingCellCursor, dataRowCursor: dataRowCursor ?? this.dataRowCursor, headingRowAlignment: headingRowAlignment ?? this.headingRowAlignment, + sortIconBuilder: sortIconBuilder ?? this.sortIconBuilder, ); } @@ -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, ); } @@ -227,6 +244,7 @@ class DataTableThemeData with Diagnosticable { headingCellCursor, dataRowCursor, headingRowAlignment, + sortIconBuilder, ); @override @@ -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 @@ -309,6 +328,9 @@ class DataTableThemeData with Diagnosticable { defaultValue: null, ), ); + properties.add( + ObjectFlagProperty.has('sortIconBuilder', sortIconBuilder), + ); } } diff --git a/packages/material_ui/lib/src/paginated_data_table.dart b/packages/material_ui/lib/src/paginated_data_table.dart index e014153125cd..851efa9e62e2 100644 --- a/packages/material_ui/lib/src/paginated_data_table.dart +++ b/packages/material_ui/lib/src/paginated_data_table.dart @@ -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'; @@ -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( @@ -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(); } @@ -692,6 +697,7 @@ class PaginatedDataTableState extends State { showBottomBorder: true, rows: _getRows(_firstRowIndex, widget.rowsPerPage), headingRowColor: widget.headingRowColor, + sortIconBuilder: widget.sortIconBuilder, ), ), ), diff --git a/packages/material_ui/pubspec.yaml b/packages/material_ui/pubspec.yaml index 6ee348a404c6..858529f465ff 100644 --- a/packages/material_ui/pubspec.yaml +++ b/packages/material_ui/pubspec.yaml @@ -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 diff --git a/packages/material_ui/test/data_table_test.dart b/packages/material_ui/test/data_table_test.dart index e3d9a7b7ac14..4783584326dd 100644 --- a/packages/material_ui/test/data_table_test.dart +++ b/packages/material_ui/test/data_table_test.dart @@ -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(label: const Text('Name'), onSort: (int columnIndex, bool ascending) {}), + DataColumn(label: const Text('Calories'), onSort: (int columnIndex, bool ascending) {}), + ], + rows: kDesserts.map((Dessert dessert) { + return DataRow( + cells: [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 { diff --git a/packages/material_ui/test/data_table_theme_test.dart b/packages/material_ui/test/data_table_theme_test.dart index 6a59af053371..6227155769f4 100644 --- a/packages/material_ui/test/data_table_theme_test.dart +++ b/packages/material_ui/test/data_table_theme_test.dart @@ -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); @@ -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 { @@ -99,6 +101,7 @@ void main() { headingCellCursor: const MaterialStatePropertyAll(SystemMouseCursors.grab), dataRowCursor: const MaterialStatePropertyAll(SystemMouseCursors.forbidden), headingRowAlignment: MainAxisAlignment.center, + sortIconBuilder: (BuildContext context, bool visible, bool ascending) => const SizedBox(), ).debugFillProperties(builder); final List description = builder.properties @@ -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 { diff --git a/packages/material_ui/test/paginated_data_table_test.dart b/packages/material_ui/test/paginated_data_table_test.dart index c716991d072b..b8b86a6e07eb 100644 --- a/packages/material_ui/test/paginated_data_table_test.dart +++ b/packages/material_ui/test/paginated_data_table_test.dart @@ -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(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); + }); }