diff --git a/packages/flutter/lib/src/material/dropdown_menu.dart b/packages/flutter/lib/src/material/dropdown_menu.dart index d977265f4ec..80730046964 100644 --- a/packages/flutter/lib/src/material/dropdown_menu.dart +++ b/packages/flutter/lib/src/material/dropdown_menu.dart @@ -25,6 +25,12 @@ import 'theme_data.dart'; // late BuildContext context; // late FocusNode myFocusNode; +/// A callback function that returns the list of the items that matches the +/// current applied filter. +/// +/// Used by [DropdownMenu.filterCallback]. +typedef FilterCallback = List> Function(List> entries, String filter); + /// A callback function that returns the index of the item that matches the /// current contents of a text field. /// @@ -163,10 +169,11 @@ class DropdownMenu extends StatefulWidget { this.focusNode, this.requestFocusOnTap, this.expandedInsets, + this.filterCallback, this.searchCallback, required this.dropdownMenuEntries, this.inputFormatters, - }); + }) : assert(filterCallback == null || enableFilter); /// Determine if the [DropdownMenu] is enabled. /// @@ -382,6 +389,41 @@ class DropdownMenu extends StatefulWidget { /// Defaults to null. final EdgeInsets? expandedInsets; + /// When [DropdownMenu.enableFilter] is true, this callback is used to + /// compute the list of filtered items. + /// + /// {@tool snippet} + /// + /// In this example the `filterCallback` returns the items that contains the + /// trimmed query. + /// + /// ```dart + /// DropdownMenu( + /// enableFilter: true, + /// filterCallback: (List> entries, String filter) { + /// final String trimmedFilter = filter.trim().toLowerCase(); + /// if (trimmedFilter.isEmpty) { + /// return entries; + /// } + /// + /// return entries + /// .where((DropdownMenuEntry entry) => + /// entry.label.toLowerCase().contains(trimmedFilter), + /// ) + /// .toList(); + /// }, + /// dropdownMenuEntries: const >[], + /// ) + /// ``` + /// {@end-tool} + /// + /// Defaults to null. If this parameter is null and the + /// [DropdownMenu.enableFilter] property is set to true, the default behavior + /// will return a filtered list. The filtered list will contain items + /// that match the text provided by the input field, with a case-insensitive + /// comparison. When this is not null, `enableFilter` must be set to true. + final FilterCallback? filterCallback; + /// When [DropdownMenu.enableSearch] is true, this callback is used to compute /// the index of the search result to be highlighted. /// @@ -691,7 +733,8 @@ class _DropdownMenuState extends State> { final DropdownMenuThemeData defaults = _DropdownMenuDefaultsM3(context); if (_enableFilter) { - filteredEntries = filter(widget.dropdownMenuEntries, _localTextEditingController!); + filteredEntries = widget.filterCallback?.call(filteredEntries, _localTextEditingController!.text) + ?? filter(widget.dropdownMenuEntries, _localTextEditingController!); } if (widget.enableSearch) { diff --git a/packages/flutter/test/material/dropdown_menu_test.dart b/packages/flutter/test/material/dropdown_menu_test.dart index fcff42b99fd..17901fb2e09 100644 --- a/packages/flutter/test/material/dropdown_menu_test.dart +++ b/packages/flutter/test/material/dropdown_menu_test.dart @@ -1130,6 +1130,70 @@ void main() { } }); + testWidgets('Enable filtering with custom filter callback that filter text case sensitive', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + final TextEditingController controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + requestFocusOnTap: true, + enableFilter: true, + filterCallback: (List> entries, String filter) { + return entries.where((DropdownMenuEntry element) => element.label.contains(filter)).toList(); + }, + dropdownMenuEntries: menuChildren, + controller: controller, + ), + ), + )); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await tester.enterText(find.byType(TextField).first, 'item'); + expect(controller.text, 'item'); + await tester.pumpAndSettle(); + for (final TestMenu menu in TestMenu.values) { + expect(find.widgetWithText(MenuItemButton, menu.label).hitTestable(), findsNothing); + } + + await tester.enterText(find.byType(TextField).first, 'Item'); + expect(controller.text, 'Item'); + await tester.pumpAndSettle(); + expect(find.widgetWithText(MenuItemButton, 'Item 0').hitTestable(), findsOneWidget); + expect(find.widgetWithText(MenuItemButton, 'Menu 1').hitTestable(), findsNothing); + expect(find.widgetWithText(MenuItemButton, 'Item 2').hitTestable(), findsOneWidget); + expect(find.widgetWithText(MenuItemButton, 'Item 3').hitTestable(), findsOneWidget); + expect(find.widgetWithText(MenuItemButton, 'Item 4').hitTestable(), findsOneWidget); + expect(find.widgetWithText(MenuItemButton, 'Item 5').hitTestable(), findsOneWidget); + }); + + testWidgets('Throw assertion error when enable filtering with custom filter callback and enableFilter set on False', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + final TextEditingController controller = TextEditingController(); + addTearDown(controller.dispose); + + expect((){ + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + requestFocusOnTap: true, + filterCallback: (List> entries, String filter) { + return entries.where((DropdownMenuEntry element) => element.label.contains(filter)).toList(); + }, + dropdownMenuEntries: menuChildren, + controller: controller, + ), + ), + ); + }, throwsAssertionError); + }); + testWidgets('The controller can access the value in the input field', (WidgetTester tester) async { final ThemeData themeData = ThemeData(); final TextEditingController controller = TextEditingController();