From 45033a29f9e577a36f6f46641bb8d8eba3383faa Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Fri, 11 Oct 2024 23:51:18 +0300 Subject: [PATCH] Introduce `DropdownMenu.closeBehavior` to control menu closing behavior (#156405) Fixes [Add option to control whether the root DropdownMenu can be closed or not](https://github.com/flutter/flutter/issues/139269) This introduces `DropdownMenu.closeBehavior` to provide control over `DropdownMenu` can be closed in nested menus. ### Code sample
expand to view the code sample ```dart import 'package:flutter/material.dart'; void main() => runApp(const MyApp()); class MyApp extends StatefulWidget { const MyApp({super.key}); @override State createState() => _MyAppState(); } class _MyAppState extends State { String selectedValue = "1"; @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, home: Scaffold( body: Center( child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Column( spacing: 16.0, mainAxisSize: MainAxisSize.min, children: [ const Text("DropdownMenuCloseBehavior.none"), MenuAnchor( menuChildren: [ Padding( padding: const EdgeInsets.all(8.0), child: DropdownMenu( closeBehavior: DropdownMenuCloseBehavior.none, label: const Text('Menu'), initialSelection: selectedValue, onSelected: (String? value) { if (value != null) { setState(() { selectedValue = value; }); } }, dropdownMenuEntries: ["1", "2", "3"] .map( (it) => DropdownMenuEntry( value: it, label: it, ), ) .toList(), ), ) ], child: const Text('Open Menu'), builder: (context, controller, child) { return ElevatedButton( onPressed: () { controller.open(); }, child: child, ); }, ), ], ), Column( spacing: 16.0, mainAxisSize: MainAxisSize.min, children: [ const Text("DropdownMenuCloseBehavior.self"), MenuAnchor( menuChildren: [ Padding( padding: const EdgeInsets.all(8.0), child: DropdownMenu( closeBehavior: DropdownMenuCloseBehavior.self, label: const Text('Menu'), initialSelection: selectedValue, onSelected: (String? value) { if (value != null) { setState(() { selectedValue = value; }); } }, dropdownMenuEntries: ["1", "2", "3"] .map( (it) => DropdownMenuEntry( value: it, label: it, ), ) .toList(), ), ) ], child: const Text('Open Menu'), builder: (context, controller, child) { return ElevatedButton( onPressed: () { controller.open(); }, child: child, ); }, ), ], ), Column( spacing: 16.0, mainAxisSize: MainAxisSize.min, children: [ const Text("DropdownMenuCloseBehavior.all"), MenuAnchor( menuChildren: [ Padding( padding: const EdgeInsets.all(8.0), child: DropdownMenu( closeBehavior: DropdownMenuCloseBehavior.all, label: const Text('Menu'), initialSelection: selectedValue, onSelected: (String? value) { if (value != null) { setState(() { selectedValue = value; }); } }, dropdownMenuEntries: ["1", "2", "3"] .map( (it) => DropdownMenuEntry( value: it, label: it, ), ) .toList(), ), ) ], child: const Text('Open Menu'), builder: (context, controller, child) { return ElevatedButton( onPressed: () { controller.open(); }, child: child, ); }, ), ], ), ], ), ), ), ); } } ```
### Demo https://github.com/user-attachments/assets/1f79ea6e-c0c6-4dcf-8180-d9dcca1c22c5 --- .../lib/src/material/dropdown_menu.dart | 30 +++++++ .../test/material/dropdown_menu_test.dart | 86 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/packages/flutter/lib/src/material/dropdown_menu.dart b/packages/flutter/lib/src/material/dropdown_menu.dart index 14fd7ec1a29..9c11c5523e8 100644 --- a/packages/flutter/lib/src/material/dropdown_menu.dart +++ b/packages/flutter/lib/src/material/dropdown_menu.dart @@ -102,6 +102,18 @@ class DropdownMenuEntry { final ButtonStyle? style; } +/// Defines the behavior for closing the dropdown menu when an item is selected. +enum DropdownMenuCloseBehavior { + /// Closes all open menus in the widget tree. + all, + + /// Closes only the current dropdown menu. + self, + + /// Does not close any menus. + none, +} + /// A dropdown menu that can be opened from a [TextField]. The selected /// menu item is displayed in that field. /// @@ -172,6 +184,7 @@ class DropdownMenu extends StatefulWidget { this.alignmentOffset, required this.dropdownMenuEntries, this.inputFormatters, + this.closeBehavior = DropdownMenuCloseBehavior.all, }) : assert(filterCallback == null || enableFilter); /// Determine if the [DropdownMenu] is enabled. @@ -473,6 +486,19 @@ class DropdownMenu extends StatefulWidget { /// {@macro flutter.material.MenuAnchor.alignmentOffset} final Offset? alignmentOffset; + /// Defines the behavior for closing the dropdown menu when an item is selected. + /// + /// The close behavior can be set to: + /// * [DropdownMenuCloseBehavior.all]: Closes all open menus in the widget tree. + /// * [DropdownMenuCloseBehavior.self]: Closes only the current dropdown menu. + /// * [DropdownMenuCloseBehavior.none]: Does not close any menus. + /// + /// This property allows fine-grained control over the menu's closing behavior, + /// which can be useful for creating nested or complex menu structures. + /// + /// Defaults to [DropdownMenuCloseBehavior.all]. + final DropdownMenuCloseBehavior closeBehavior; + @override State> createState() => _DropdownMenuState(); } @@ -722,6 +748,7 @@ class _DropdownMenuState extends State> { style: effectiveStyle, leadingIcon: entry.leadingIcon, trailingIcon: entry.trailingIcon, + closeOnActivate: widget.closeBehavior == DropdownMenuCloseBehavior.all, onPressed: entry.enabled && widget.enabled ? () { _localTextEditingController?.value = TextEditingValue( @@ -731,6 +758,9 @@ class _DropdownMenuState extends State> { currentHighlight = widget.enableSearch ? i : null; widget.onSelected?.call(entry.value); _enableFilter = false; + if (widget.closeBehavior == DropdownMenuCloseBehavior.self) { + _controller.close(); + } } : null, requestFocusOnHover: false, diff --git a/packages/flutter/test/material/dropdown_menu_test.dart b/packages/flutter/test/material/dropdown_menu_test.dart index c0d1811bc2b..3aa9ab2f831 100644 --- a/packages/flutter/test/material/dropdown_menu_test.dart +++ b/packages/flutter/test/material/dropdown_menu_test.dart @@ -3463,6 +3463,92 @@ void main() { textFieldPosition = tester.getTopLeft(find.byType(TextField)); expect(textFieldPosition, equals(const Offset(16.0, 544.0))); }); + + // Regression test for https://github.com/flutter/flutter/issues/139269. + testWidgets('DropdownMenu.closeBehavior controls menu closing behavior', (WidgetTester tester) async { + Widget buildDropdownMenu({ DropdownMenuCloseBehavior closeBehavior = DropdownMenuCloseBehavior.all }) { + return MaterialApp( + home: Scaffold( + body: MenuAnchor( + menuChildren: [ + DropdownMenu( + closeBehavior: closeBehavior, + dropdownMenuEntries: menuChildren, + ), + ], + child: const Text('Open Menu'), + builder: (BuildContext context, MenuController controller, Widget? child) { + return ElevatedButton( + onPressed: () => controller.open(), + child: child, + ); + }, + ), + ), + ); + } + + // Test closeBehavior set to all. + await tester.pumpWidget(buildDropdownMenu()); + + // Tap the button to open the root anchor. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + // Tap the menu item to open the dropdown menu. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(find.byType(DropdownMenu), findsOneWidget); + + MenuAnchor dropdownMenuAnchor = tester.widget(find.byType(MenuAnchor).last); + expect(dropdownMenuAnchor.controller!.isOpen, true); + + // Tap the dropdown menu item. + await tester.tap(find.widgetWithText(MenuItemButton, TestMenu.mainMenu0.label).last); + await tester.pumpAndSettle(); + // All menus should be closed. + expect(find.byType(DropdownMenu), findsNothing); + expect(find.byType(MenuAnchor), findsOneWidget); + + // Test closeBehavior set to self. + await tester.pumpWidget(buildDropdownMenu(closeBehavior: DropdownMenuCloseBehavior.self)); + + // Tap the button to open the root anchor. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byType(DropdownMenu), findsOneWidget); + + // Tap the menu item to open the dropdown menu. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + dropdownMenuAnchor = tester.widget(find.byType(MenuAnchor).last); + expect(dropdownMenuAnchor.controller!.isOpen, true); + + // Tap the menu item to open the dropdown menu. + await tester.tap(find.widgetWithText(MenuItemButton, TestMenu.mainMenu0.label).last); + await tester.pumpAndSettle(); + // Only the dropdown menu should be closed. + expect(dropdownMenuAnchor.controller!.isOpen, false); + + // Test closeBehavior set to none. + await tester.pumpWidget(buildDropdownMenu(closeBehavior: DropdownMenuCloseBehavior.none)); + + // Tap the button to open the root anchor. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byType(DropdownMenu), findsOneWidget); + + // Tap the menu item to open the dropdown menu. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + dropdownMenuAnchor = tester.widget(find.byType(MenuAnchor).last); + expect(dropdownMenuAnchor.controller!.isOpen, true); + + // Tap the dropdown menu item. + await tester.tap(find.widgetWithText(MenuItemButton, TestMenu.mainMenu0.label).last); + await tester.pumpAndSettle(); + // None of the menus should be closed. + expect(dropdownMenuAnchor.controller!.isOpen, true); + }); } enum TestMenu {