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

<details>
<summary>expand to view the code sample</summary> 

```dart
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  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: <Widget>[
                      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: <Widget>[
                      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: <Widget>[
                      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,
                      );
                    },
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```

</details>

### Demo

https://github.com/user-attachments/assets/1f79ea6e-c0c6-4dcf-8180-d9dcca1c22c5
This commit is contained in:
Taha Tesser 2024-10-11 23:51:18 +03:00 committed by GitHub
parent 56e2a9d380
commit 45033a29f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 116 additions and 0 deletions

View File

@ -102,6 +102,18 @@ class DropdownMenuEntry<T> {
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<T> 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<T> 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<DropdownMenu<T>> createState() => _DropdownMenuState<T>();
}
@ -722,6 +748,7 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
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<T> extends State<DropdownMenu<T>> {
currentHighlight = widget.enableSearch ? i : null;
widget.onSelected?.call(entry.value);
_enableFilter = false;
if (widget.closeBehavior == DropdownMenuCloseBehavior.self) {
_controller.close();
}
}
: null,
requestFocusOnHover: false,

View File

@ -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: <Widget>[
DropdownMenu<TestMenu>(
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<TestMenu>), findsOneWidget);
MenuAnchor dropdownMenuAnchor = tester.widget<MenuAnchor>(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<TestMenu>), 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<TestMenu>), findsOneWidget);
// Tap the menu item to open the dropdown menu.
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
dropdownMenuAnchor = tester.widget<MenuAnchor>(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<TestMenu>), findsOneWidget);
// Tap the menu item to open the dropdown menu.
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
dropdownMenuAnchor = tester.widget<MenuAnchor>(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 {