From b05f0011295d8cccb74c0acaac0a7e13c1948c76 Mon Sep 17 00:00:00 2001 From: Bruno Leroux Date: Sat, 25 Oct 2025 12:57:22 +0200 Subject: [PATCH] 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). --- .../lib/src/material/dropdown_menu.dart | 27 --- .../test/material/dropdown_menu_test.dart | 216 ++++-------------- 2 files changed, 44 insertions(+), 199 deletions(-) diff --git a/packages/flutter/lib/src/material/dropdown_menu.dart b/packages/flutter/lib/src/material/dropdown_menu.dart index 3d2dfe7a739..ddaebc0d4f3 100644 --- a/packages/flutter/lib/src/material/dropdown_menu.dart +++ b/packages/flutter/lib/src/material/dropdown_menu.dart @@ -662,8 +662,6 @@ class _DropdownMenuState extends State> { 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 extends State> { @override void initState() { super.initState(); - _clearSelectedEntryIndex = () => _selectedEntryIndex = null; - _effectiveTextEditingController.addListener(_clearSelectedEntryIndex); _enableSearch = widget.enableSearch; filteredEntries = widget.dropdownMenuEntries; buttonItemKeys = List.generate(filteredEntries.length, (int index) => GlobalKey()); @@ -686,7 +682,6 @@ class _DropdownMenuState extends State> { 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 extends State> { @override void dispose() { - widget.controller?.removeListener(_clearSelectedEntryIndex); _localTextEditingController?.dispose(); _localTextEditingController = null; _internalFocudeNode.dispose(); @@ -707,11 +701,8 @@ class _DropdownMenuState extends State> { void didUpdateWidget(DropdownMenu 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 extends State> { filteredEntries = widget.dropdownMenuEntries; buttonItemKeys = List.generate(filteredEntries.length, (int index) => GlobalKey()); _menuHasEnabledItem = filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); - if (_selectedEntryIndex != null) { - final T oldSelectionValue = oldWidget.dropdownMenuEntries[_selectedEntryIndex!].value; - final int index = filteredEntries.indexWhere( - (DropdownMenuEntry 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 extends State> { 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 extends State> { 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 extends State> { text: entry.label, selection: TextSelection.collapsed(offset: entry.label.length), ); - _selectedEntryIndex = currentHighlight; widget.onSelected?.call(entry.value); } } else { diff --git a/packages/flutter/test/material/dropdown_menu_test.dart b/packages/flutter/test/material/dropdown_menu_test.dart index 5f19b26f8d3..e3ca967567c 100644 --- a/packages/flutter/test/material/dropdown_menu_test.dart +++ b/packages/flutter/test/material/dropdown_menu_test.dart @@ -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( - initialSelection: TestMenu.mainMenu0, - dropdownMenuEntries: >[ - DropdownMenuEntry( - value: TestMenu.mainMenu0, - label: '$selectionLabel 0', - ), - DropdownMenuEntry( - value: TestMenu.mainMenu1, - label: '$selectionLabel 1', - ), - ], - controller: controller, - ), - floatingActionButton: FloatingActionButton( - onPressed: () => setState(() => selectionLabel = 'Updated label'), - ), - ); - }, - ), - ), - ); - - // Open the menu - await tester.tap(find.byType(DropdownMenu)); - 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: [ - DropdownMenu( - initialSelection: TestMenu.mainMenu0, - dropdownMenuEntries: >[ - DropdownMenuEntry( - value: TestMenu.mainMenu0, - label: '$selectionLabel 0', - ), - if (selectionInEntries) - DropdownMenuEntry( - 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)); - 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: [ - DropdownMenu( - initialSelection: TestMenu.mainMenu0, - dropdownMenuEntries: >[ - DropdownMenuEntry( - 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( + 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)); + 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 {