Skip to content

Commit 40776d0

Browse files
authored
Allow users to customize search algorithm in DropdownMenu (#136848)
Fixes #136735 This PR is to add a searchCallback to allow users to customize the search algorithm. This feature is used to fix b/305662376 which needs an exact match algorithm.
1 parent 01eef7c commit 40776d0

File tree

2 files changed

+120
-1
lines changed

2 files changed

+120
-1
lines changed

packages/flutter/lib/src/material/dropdown_menu.dart

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ import 'text_field.dart';
2121
import 'theme.dart';
2222
import 'theme_data.dart';
2323

24+
/// A callback function that returns the index of the item that matches the
25+
/// current contents of a text field.
26+
///
27+
/// If a match doesn't exist then null must be returned.
28+
///
29+
/// Used by [DropdownMenu.searchCallback].
30+
typedef SearchCallback<T> = int? Function(List<DropdownMenuEntry<T>> entries, String query);
2431

2532
// Navigation shortcuts to move the selected menu items up or down.
2633
Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts = <ShortcutActivator, Intent> {
@@ -150,6 +157,7 @@ class DropdownMenu<T> extends StatefulWidget {
150157
this.onSelected,
151158
this.requestFocusOnTap,
152159
this.expandedInsets,
160+
this.searchCallback,
153161
required this.dropdownMenuEntries,
154162
});
155163

@@ -303,6 +311,34 @@ class DropdownMenu<T> extends StatefulWidget {
303311
/// Defaults to null.
304312
final EdgeInsets? expandedInsets;
305313

314+
/// When [DropdownMenu.enableSearch] is true, this callback is used to compute
315+
/// the index of the search result to be highlighted.
316+
///
317+
/// {@tool snippet}
318+
///
319+
/// In this example the `searchCallback` returns the index of the search result
320+
/// that exactly matches the query.
321+
///
322+
/// ```dart
323+
/// DropdownMenu<Text>(
324+
/// searchCallback: (List<DropdownMenuEntry<Text>> entries, String query) {
325+
/// if (query.isEmpty) {
326+
/// return null;
327+
/// }
328+
/// final int index = entries.indexWhere((DropdownMenuEntry<Text> entry) => entry.label == query);
329+
///
330+
/// return index != -1 ? index : null;
331+
/// },
332+
/// dropdownMenuEntries: const <DropdownMenuEntry<Text>>[],
333+
/// )
334+
/// ```
335+
/// {@end-tool}
336+
///
337+
/// Defaults to null. If this is null and [DropdownMenu.enableSearch] is true,
338+
/// the default function will return the index of the first matching result
339+
/// which contains the contents of the text input field.
340+
final SearchCallback<T>? searchCallback;
341+
306342
@override
307343
State<DropdownMenu<T>> createState() => _DropdownMenuState<T>();
308344
}
@@ -564,7 +600,11 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
564600
}
565601

566602
if (widget.enableSearch) {
567-
currentHighlight = search(filteredEntries, _textEditingController);
603+
if (widget.searchCallback != null) {
604+
currentHighlight = widget.searchCallback!.call(filteredEntries, _textEditingController.text);
605+
} else {
606+
currentHighlight = search(filteredEntries, _textEditingController);
607+
}
568608
if (currentHighlight != null) {
569609
scrollToHighlight();
570610
}

packages/flutter/test/material/dropdown_menu_test.dart

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,6 +1726,85 @@ void main() {
17261726
// so there are some extra padding before "Item 1".
17271727
expect(tester.getTopLeft(find.text('Item 1').last).dx, 48.0);
17281728
});
1729+
1730+
testWidgetsWithLeakTracking('DropdownMenu can have customized search algorithm', (WidgetTester tester) async {
1731+
final ThemeData theme = ThemeData();
1732+
Widget dropdownMenu({ SearchCallback<int>? searchCallback }) {
1733+
return MaterialApp(
1734+
theme: theme,
1735+
home: Scaffold(
1736+
body: DropdownMenu<int>(
1737+
requestFocusOnTap: true,
1738+
searchCallback: searchCallback,
1739+
dropdownMenuEntries: const <DropdownMenuEntry<int>>[
1740+
DropdownMenuEntry<int>(value: 0, label: 'All'),
1741+
DropdownMenuEntry<int>(value: 1, label: 'Unread'),
1742+
DropdownMenuEntry<int>(value: 2, label: 'Read'),
1743+
],
1744+
),
1745+
)
1746+
);
1747+
}
1748+
1749+
void checkExpectedHighlight({String? searchResult, required List<String> otherItems}) {
1750+
if (searchResult != null) {
1751+
final Finder material = find.descendant(
1752+
of: find.widgetWithText(MenuItemButton, searchResult).last,
1753+
matching: find.byType(Material),
1754+
);
1755+
final Material itemMaterial = tester.widget<Material>(material);
1756+
expect(itemMaterial.color, theme.colorScheme.onSurface.withOpacity(0.12));
1757+
}
1758+
1759+
for (final String nonHighlight in otherItems) {
1760+
final Finder material = find.descendant(
1761+
of: find.widgetWithText(MenuItemButton, nonHighlight).last,
1762+
matching: find.byType(Material),
1763+
);
1764+
final Material itemMaterial = tester.widget<Material>(material);
1765+
expect(itemMaterial.color, Colors.transparent);
1766+
}
1767+
}
1768+
1769+
// Test default.
1770+
await tester.pumpWidget(dropdownMenu());
1771+
await tester.pump();
1772+
await tester.tap(find.byType(DropdownMenu<int>));
1773+
await tester.pumpAndSettle();
1774+
1775+
await tester.enterText(find.byType(TextField), 'read');
1776+
await tester.pump();
1777+
checkExpectedHighlight(searchResult: 'Unread', otherItems: <String>['All', 'Read']); // Because "Unread" contains "read".
1778+
1779+
// Test custom search algorithm.
1780+
await tester.pumpWidget(dropdownMenu(
1781+
searchCallback: (_, __) => 0
1782+
));
1783+
await tester.pump();
1784+
await tester.enterText(find.byType(TextField), 'read');
1785+
await tester.pump();
1786+
checkExpectedHighlight(searchResult: 'All', otherItems: <String>['Unread', 'Read']); // Because the search result should always be index 0.
1787+
1788+
// Test custom search algorithm - exact match.
1789+
await tester.pumpWidget(dropdownMenu(
1790+
searchCallback: (List<DropdownMenuEntry<int>> entries, String query) {
1791+
if (query.isEmpty) {
1792+
return null;
1793+
}
1794+
final int index = entries.indexWhere((DropdownMenuEntry<int> entry) => entry.label == query);
1795+
1796+
return index != -1 ? index : null;
1797+
},
1798+
));
1799+
await tester.pump();
1800+
1801+
await tester.enterText(find.byType(TextField), 'read');
1802+
await tester.pump();
1803+
checkExpectedHighlight(otherItems: <String>['All', 'Unread', 'Read']); // Because it's case sensitive.
1804+
await tester.enterText(find.byType(TextField), 'Read');
1805+
await tester.pump();
1806+
checkExpectedHighlight(searchResult: 'Read', otherItems: <String>['All', 'Unread']);
1807+
});
17291808
}
17301809

17311810
enum TestMenu {

0 commit comments

Comments
 (0)