Fix DropdownMenu filtering is broken (#177450)

## Description

This PR fixes `DropdownMenu` filtering.
This is mainly a revert of
https://github.com/flutter/flutter/pull/162062.
It adds a test to avoid a similar regression in the future.

It will reeopen https://github.com/flutter/flutter/issues/155660. A
future PR will try to fix that issue.

See
https://github.com/flutter/flutter/pull/174757#issuecomment-3430614390
for more context.

## Related Issue

Fixes [DropdownMenu filtering is
broken](https://github.com/flutter/flutter/issues/174609)
Reeopens [DropdownMenu.didUpdateWidget should re-match initialSelection
when dropdownMenuEntries have
changed](https://github.com/flutter/flutter/issues/155660)

## Tests

Adds 1 test.
Removes 3 tests (reverted tests from
https://github.com/flutter/flutter/pull/162062).
This commit is contained in:
Bruno Leroux 2025-10-25 12:57:22 +02:00 committed by GitHub
parent f62f80bb31
commit b05f001129
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 44 additions and 199 deletions

View File

@ -662,8 +662,6 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
TextEditingController get _effectiveTextEditingController =>
widget.controller ?? (_localTextEditingController ??= TextEditingController());
final FocusNode _internalFocudeNode = FocusNode();
int? _selectedEntryIndex;
late final void Function() _clearSelectedEntryIndex;
FocusNode? _localTrailingIconButtonFocusNode;
FocusNode get _trailingIconButtonFocusNode =>
@ -672,8 +670,6 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
@override
void initState() {
super.initState();
_clearSelectedEntryIndex = () => _selectedEntryIndex = null;
_effectiveTextEditingController.addListener(_clearSelectedEntryIndex);
_enableSearch = widget.enableSearch;
filteredEntries = widget.dropdownMenuEntries;
buttonItemKeys = List<GlobalKey>.generate(filteredEntries.length, (int index) => GlobalKey());
@ -686,7 +682,6 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
text: filteredEntries[index].label,
selection: TextSelection.collapsed(offset: filteredEntries[index].label.length),
);
_selectedEntryIndex = index;
}
refreshLeadingPadding();
_controller = widget.menuController ?? MenuController();
@ -694,7 +689,6 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
@override
void dispose() {
widget.controller?.removeListener(_clearSelectedEntryIndex);
_localTextEditingController?.dispose();
_localTextEditingController = null;
_internalFocudeNode.dispose();
@ -707,11 +701,8 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
void didUpdateWidget(DropdownMenu<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller?.removeListener(_clearSelectedEntryIndex);
_localTextEditingController?.dispose();
_localTextEditingController = null;
_effectiveTextEditingController.addListener(_clearSelectedEntryIndex);
_selectedEntryIndex = null;
}
if (oldWidget.enableFilter != widget.enableFilter) {
if (!widget.enableFilter) {
@ -729,21 +720,6 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
filteredEntries = widget.dropdownMenuEntries;
buttonItemKeys = List<GlobalKey>.generate(filteredEntries.length, (int index) => GlobalKey());
_menuHasEnabledItem = filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled);
if (_selectedEntryIndex != null) {
final T oldSelectionValue = oldWidget.dropdownMenuEntries[_selectedEntryIndex!].value;
final int index = filteredEntries.indexWhere(
(DropdownMenuEntry<T> entry) => entry.value == oldSelectionValue,
);
if (index != -1) {
_effectiveTextEditingController.value = TextEditingValue(
text: filteredEntries[index].label,
selection: TextSelection.collapsed(offset: filteredEntries[index].label.length),
);
_selectedEntryIndex = index;
} else {
_selectedEntryIndex = null;
}
}
}
if (oldWidget.leadingIcon != widget.leadingIcon) {
refreshLeadingPadding();
@ -757,7 +733,6 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
text: filteredEntries[index].label,
selection: TextSelection.collapsed(offset: filteredEntries[index].label.length),
);
_selectedEntryIndex = index;
}
}
if (oldWidget.menuController != widget.menuController) {
@ -963,7 +938,6 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
text: entry.label,
selection: TextSelection.collapsed(offset: entry.label.length),
);
_selectedEntryIndex = i;
currentHighlight = widget.enableSearch ? i : null;
widget.onSelected?.call(entry.value);
_enableFilter = false;
@ -1056,7 +1030,6 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
text: entry.label,
selection: TextSelection.collapsed(offset: entry.label.length),
);
_selectedEntryIndex = currentHighlight;
widget.onSelected?.call(entry.value);
}
} else {

View File

@ -2479,178 +2479,6 @@ void main() {
},
);
testWidgets('Rematch selection against the first entry with the same value', (
WidgetTester tester,
) async {
final TextEditingController controller = TextEditingController();
addTearDown(controller.dispose);
String selectionLabel = 'Initial label';
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Scaffold(
body: DropdownMenu<TestMenu>(
initialSelection: TestMenu.mainMenu0,
dropdownMenuEntries: <DropdownMenuEntry<TestMenu>>[
DropdownMenuEntry<TestMenu>(
value: TestMenu.mainMenu0,
label: '$selectionLabel 0',
),
DropdownMenuEntry<TestMenu>(
value: TestMenu.mainMenu1,
label: '$selectionLabel 1',
),
],
controller: controller,
),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => selectionLabel = 'Updated label'),
),
);
},
),
),
);
// Open the menu
await tester.tap(find.byType(DropdownMenu<TestMenu>));
await tester.pump();
// Select the second item
await tester.tap(findMenuItemButton('$selectionLabel 1'));
await tester.pump();
// Update dropdownMenuEntries labels
await tester.tap(find.byType(FloatingActionButton));
await tester.pump();
expect(controller.text, 'Updated label 1');
});
testWidgets('Forget selection if its value does not map to any entry', (
WidgetTester tester,
) async {
final TextEditingController controller = TextEditingController();
addTearDown(controller.dispose);
String selectionLabel = 'Initial label';
bool selectionInEntries = true;
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Scaffold(
body: Column(
children: <Widget>[
DropdownMenu<TestMenu>(
initialSelection: TestMenu.mainMenu0,
dropdownMenuEntries: <DropdownMenuEntry<TestMenu>>[
DropdownMenuEntry<TestMenu>(
value: TestMenu.mainMenu0,
label: '$selectionLabel 0',
),
if (selectionInEntries)
DropdownMenuEntry<TestMenu>(
value: TestMenu.mainMenu1,
label: '$selectionLabel 1',
),
],
controller: controller,
),
ElevatedButton(
onPressed: () => setState(() => selectionInEntries = !selectionInEntries),
child: null,
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => selectionLabel = 'Updated label'),
),
);
},
),
),
);
// Open the menu
await tester.tap(find.byType(DropdownMenu<TestMenu>));
await tester.pump();
// Select the second item
await tester.tap(findMenuItemButton('$selectionLabel 1'));
await tester.pump();
// Update dropdownMenuEntries labels
await tester.tap(find.byType(FloatingActionButton));
// Remove second item from entires
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
expect(controller.text, 'Initial label 1');
// Put second item back into entries
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
expect(controller.text, 'Initial label 1');
});
testWidgets(
'Do not rematch selection if the text field was edited progrmaticlly via controller',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
addTearDown(controller.dispose);
String selectionLabel = 'Initial label';
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Scaffold(
body: Column(
children: <Widget>[
DropdownMenu<TestMenu>(
initialSelection: TestMenu.mainMenu0,
dropdownMenuEntries: <DropdownMenuEntry<TestMenu>>[
DropdownMenuEntry<TestMenu>(
value: TestMenu.mainMenu0,
label: '$selectionLabel 0',
),
],
controller: controller,
),
ElevatedButton(
onPressed: () => setState(() => controller.text = 'Controller Value'),
child: null,
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => selectionLabel = 'Updated label'),
),
);
},
),
),
);
// Change the text field value via controller
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
// Update dropdownMenuEntries labels
await tester.tap(find.byType(FloatingActionButton));
await tester.pump();
expect(controller.text, 'Controller Value');
},
);
testWidgets('The default text input field should not be focused on mobile platforms '
'when it is tapped', (WidgetTester tester) async {
final ThemeData themeData = ThemeData();
@ -4981,6 +4809,50 @@ void main() {
}, throwsAssertionError);
});
});
// Regression test for https://github.com/flutter/flutter/issues/174609.
testWidgets(
'DropdownMenu keeps the selected item from filtered list after entries list is updated',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return DropdownMenu<TestMenu>(
controller: controller,
requestFocusOnTap: true,
enableFilter: true,
// toList() is used here to simulate list update.
dropdownMenuEntries: menuChildren.toList(),
onSelected: (_) {
setState(() {});
},
);
},
),
),
),
);
// Open the menu.
await tester.tap(find.byType(DropdownMenu<TestMenu>));
await tester.pump();
// Filter the entries to only show 'Menu 1'.
await tester.enterText(find.byType(TextField).first, TestMenu.mainMenu1.label);
await tester.pump();
// Select the 'Menu 1' item.
await tester.tap(findMenuItemButton(TestMenu.mainMenu1.label));
await tester.pumpAndSettle();
expect(controller.text, TestMenu.mainMenu1.label);
},
);
}
enum TestMenu {