Unverified Commit 4b39f071 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add controller argument to SubmenuButton (#125000)

## Description

This adds an optional argument to the `SubmenuButton` that allows the creator to supply a `MenuController` for controlling the menu.

## Related Issues
 - Fixes https://github.com/flutter/flutter/issues/124988

## Tests
 - Added tests for new argument.
parent 98dfdf1b
......@@ -1548,6 +1548,7 @@ class SubmenuButton extends StatefulWidget {
this.onFocusChange,
this.onOpen,
this.onClose,
this.controller,
this.style,
this.menuStyle,
this.alignmentOffset,
......@@ -1578,6 +1579,9 @@ class SubmenuButton extends StatefulWidget {
/// A callback that is invoked when the menu is closed.
final VoidCallback? onClose;
/// An optional [MenuController] for this submenu.
final MenuController? controller;
/// Customizes this button's appearance.
///
/// Non-null properties of this style override the corresponding properties in
......@@ -1760,7 +1764,8 @@ class SubmenuButton extends StatefulWidget {
class _SubmenuButtonState extends State<SubmenuButton> {
FocusNode? _internalFocusNode;
bool _waitingToFocusMenu = false;
final MenuController _menuController = MenuController();
MenuController? _internalMenuController;
MenuController get _menuController => widget.controller ?? _internalMenuController!;
_MenuAnchorState? get _anchor => _MenuAnchorState._maybeOf(context);
FocusNode get _buttonFocusNode => widget.focusNode ?? _internalFocusNode!;
bool get _enabled => widget.menuChildren.isNotEmpty;
......@@ -1777,12 +1782,15 @@ class _SubmenuButtonState extends State<SubmenuButton> {
return true;
}());
}
if (widget.controller == null) {
_internalMenuController = MenuController();
}
_buttonFocusNode.addListener(_handleFocusChange);
}
@override
void dispose() {
_internalFocusNode?.removeListener(_handleFocusChange);
_buttonFocusNode.removeListener(_handleFocusChange);
_internalFocusNode?.dispose();
_internalFocusNode = null;
super.dispose();
......@@ -1810,6 +1818,9 @@ class _SubmenuButtonState extends State<SubmenuButton> {
}
_buttonFocusNode.addListener(_handleFocusChange);
}
if (widget.controller != oldWidget.controller) {
_internalMenuController = (oldWidget.controller == null) ? null : MenuController();
}
}
@override
......@@ -1836,7 +1847,16 @@ class _SubmenuButtonState extends State<SubmenuButton> {
alignmentOffset: menuPaddingOffset,
clipBehavior: widget.clipBehavior,
onClose: widget.onClose,
onOpen: widget.onOpen,
onOpen: () {
if (!_waitingToFocusMenu) {
SchedulerBinding.instance.addPostFrameCallback((_) {
_menuController._anchor?._focusButton();
_waitingToFocusMenu = false;
});
_waitingToFocusMenu = true;
}
widget.onOpen?.call();
},
style: widget.menuStyle,
builder: (BuildContext context, MenuController controller, Widget? child) {
// Since we don't want to use the theme style or default style from the
......@@ -1857,16 +1877,6 @@ class _SubmenuButtonState extends State<SubmenuButton> {
controller.close();
} else {
controller.open();
if (!_waitingToFocusMenu) {
// Only schedule this if it's not already scheduled.
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
// This has to happen in the next frame because the menu bar is
// not focusable until the first menu is open.
controller._anchor?._focusButton();
_waitingToFocusMenu = false;
});
_waitingToFocusMenu = true;
}
}
}
......
......@@ -142,19 +142,21 @@ void main() {
}
testWidgets('Menu responds to density changes', (WidgetTester tester) async {
Widget buildMenu({VisualDensity? visualDensity = VisualDensity.standard}) => MaterialApp(
theme: ThemeData(visualDensity: visualDensity),
home: Material(
child: Column(
children: <Widget>[
MenuBar(
children: createTestMenus(onPressed: onPressed),
),
const Expanded(child: Placeholder()),
],
Widget buildMenu({VisualDensity? visualDensity = VisualDensity.standard}) {
return MaterialApp(
theme: ThemeData(visualDensity: visualDensity),
home: Material(
child: Column(
children: <Widget>[
MenuBar(
children: createTestMenus(onPressed: onPressed),
),
const Expanded(child: Placeholder()),
],
),
),
),
);
);
}
await tester.pumpWidget(buildMenu());
await tester.pump();
......@@ -947,27 +949,27 @@ void main() {
testWidgets('MenuAnchor clip behavior', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: MenuAnchor(
menuChildren: const <Widget> [
MenuItemButton(
child: Text('Button 1'),
),
],
builder: (BuildContext context, MenuController controller, Widget? child) {
return FilledButton(
onPressed: () {
controller.open();
},
child: const Text('Tap me'),
);
},
),
)
)
)
MaterialApp(
home: Material(
child: Center(
child: MenuAnchor(
menuChildren: const <Widget>[
MenuItemButton(
child: Text('Button 1'),
),
],
builder: (BuildContext context, MenuController controller, Widget? child) {
return FilledButton(
onPressed: () {
controller.open();
},
child: const Text('Tap me'),
);
},
),
),
),
),
);
await tester.tap(find.text('Tap me'));
await tester.pump();
......@@ -977,28 +979,28 @@ void main() {
await tester.tapAt(const Offset(10.0, 10.0));
await tester.pumpAndSettle();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: MenuAnchor(
clipBehavior: Clip.antiAlias,
menuChildren: const <Widget> [
MenuItemButton(
child: Text('Button 1'),
),
],
builder: (BuildContext context, MenuController controller, Widget? child) {
return FilledButton(
onPressed: () {
controller.open();
},
child: const Text('Tap me'),
);
},
),
)
)
)
MaterialApp(
home: Material(
child: Center(
child: MenuAnchor(
clipBehavior: Clip.antiAlias,
menuChildren: const <Widget>[
MenuItemButton(
child: Text('Button 1'),
),
],
builder: (BuildContext context, MenuController controller, Widget? child) {
return FilledButton(
onPressed: () {
controller.open();
},
child: const Text('Tap me'),
);
},
),
),
),
),
);
await tester.tap(find.text('Tap me'));
await tester.pump();
......@@ -1589,14 +1591,23 @@ void main() {
int acceleratorIndex = -1;
int count = 0;
for (final String key in expected.keys) {
expect(MenuAcceleratorLabel.stripAcceleratorMarkers(key, setIndex: (int index) {
expect(
MenuAcceleratorLabel.stripAcceleratorMarkers(key, setIndex: (int index) {
acceleratorIndex = index;
}), equals(expected[key]),
reason: "'$key' label doesn't match ${expected[key]}");
expect(acceleratorIndex, equals(expectedIndices[count]),
reason: "'$key' index doesn't match ${expectedIndices[count]}");
expect(MenuAcceleratorLabel(key).hasAccelerator, equals(expectedHasAccelerator[count]),
reason: "'$key' hasAccelerator isn't ${expectedHasAccelerator[count]}");
}),
equals(expected[key]),
reason: "'$key' label doesn't match ${expected[key]}",
);
expect(
acceleratorIndex,
equals(expectedIndices[count]),
reason: "'$key' index doesn't match ${expectedIndices[count]}",
);
expect(
MenuAcceleratorLabel(key).hasAccelerator,
equals(expectedHasAccelerator[count]),
reason: "'$key' hasAccelerator isn't ${expectedHasAccelerator[count]}",
);
count += 1;
}
});
......@@ -2064,6 +2075,63 @@ void main() {
expect(find.text('trailingIcon'), findsOneWidget);
});
testWidgets('SubmenuButton uses supplied controller', (WidgetTester tester) async {
final MenuController submenuController = MenuController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: MenuBar(
controller: controller,
children: <Widget>[
SubmenuButton(
controller: submenuController,
menuChildren: <Widget>[
MenuItemButton(
child: Text(TestMenu.subMenu00.label),
),
],
child: Text(TestMenu.mainMenu0.label),
),
],
),
),
),
);
submenuController.open();
await tester.pump();
expect(find.text(TestMenu.subMenu00.label), findsOneWidget);
submenuController.close();
await tester.pump();
expect(find.text(TestMenu.subMenu00.label), findsNothing);
// Now remove the controller and try to control it.
await tester.pumpWidget(
MaterialApp(
home: Material(
child: MenuBar(
controller: controller,
children: <Widget>[
SubmenuButton(
menuChildren: <Widget>[
MenuItemButton(
child: Text(TestMenu.subMenu00.label),
),
],
child: Text(TestMenu.mainMenu0.label),
),
],
),
),
),
);
await expectLater(() => submenuController.open(), throwsAssertionError);
await tester.pump();
expect(find.text(TestMenu.subMenu00.label), findsNothing);
});
testWidgets('diagnostics', (WidgetTester tester) async {
final ButtonStyle style = ButtonStyle(
shape: MaterialStateProperty.all<OutlinedBorder?>(const StadiumBorder()),
......@@ -2123,6 +2191,78 @@ void main() {
),
);
});
testWidgets('MenuItemButton respects closeOnActivate property', (WidgetTester tester) async {
final MenuController controller = MenuController();
await tester.pumpWidget(MaterialApp(
home: Material(
child: Center(
child: MenuAnchor(
controller: controller,
menuChildren: <Widget>[
MenuItemButton(
onPressed: () {},
child: const Text('Button 1'),
),
],
builder: (BuildContext context, MenuController controller, Widget? child) {
return FilledButton(
onPressed: () {
controller.open();
},
child: const Text('Tap me'),
);
},
),
),
),
));
await tester.tap(find.text('Tap me'));
await tester.pump();
expect(find.byType(MenuItemButton), findsNWidgets(1));
// Taps the MenuItemButton which should close the menu
await tester.tap(find.text('Button 1'));
await tester.pump();
expect(find.byType(MenuItemButton), findsNWidgets(0));
await tester.pumpAndSettle();
await tester.pumpWidget(MaterialApp(
home: Material(
child: Center(
child: MenuAnchor(
controller: controller,
menuChildren: <Widget>[
MenuItemButton(
closeOnActivate: false,
onPressed: () {},
child: const Text('Button 1'),
),
],
builder: (BuildContext context, MenuController controller, Widget? child) {
return FilledButton(
onPressed: () {
controller.open();
},
child: const Text('Tap me'),
);
},
),
),
),
));
await tester.tap(find.text('Tap me'));
await tester.pump();
expect(find.byType(MenuItemButton), findsNWidgets(1));
// Taps the MenuItemButton which shouldn't close the menu
await tester.tap(find.text('Button 1'));
await tester.pump();
expect(find.byType(MenuItemButton), findsNWidgets(1));
});
});
group('Layout', () {
......@@ -2338,18 +2478,17 @@ void main() {
child: Align(
alignment: Alignment.topLeft,
child: MenuAnchor(
menuChildren: const <Widget> [
menuChildren: const <Widget>[
SubmenuButton(
alignmentOffset: Offset(10, 0),
menuChildren: <Widget> [
menuChildren: <Widget>[
SubmenuButton(
menuChildren: <Widget> [
menuChildren: <Widget>[
SubmenuButton(
alignmentOffset: Offset(10, 0),
menuChildren: <Widget> [
menuChildren: <Widget>[
SubmenuButton(
menuChildren: <Widget> [
],
menuChildren: <Widget>[],
child: Text('SubMenuButton4'),
),
],
......@@ -2415,18 +2554,17 @@ void main() {
child: Align(
alignment: Alignment.topRight,
child: MenuAnchor(
menuChildren: const <Widget> [
menuChildren: const <Widget>[
SubmenuButton(
alignmentOffset: Offset(10, 0),
menuChildren: <Widget> [
menuChildren: <Widget>[
SubmenuButton(
menuChildren: <Widget> [
menuChildren: <Widget>[
SubmenuButton(
alignmentOffset: Offset(10, 0),
menuChildren: <Widget> [
menuChildren: <Widget>[
SubmenuButton(
menuChildren: <Widget> [
],
menuChildren: <Widget>[],
child: Text('SubMenuButton4'),
),
],
......@@ -2492,8 +2630,9 @@ void main() {
child: Align(
alignment: Alignment.bottomLeft,
child: MenuAnchor(
menuChildren: const <Widget> [
MenuItemButton(child: Text('Button1'),
menuChildren: const <Widget>[
MenuItemButton(
child: Text('Button1'),
),
],
builder: (BuildContext context, MenuController controller, Widget? child) {
......@@ -2530,7 +2669,8 @@ void main() {
);
});
testWidgets('vertically constrained menus are positioned above the anchor with the provided offset', (WidgetTester tester) async {
testWidgets('vertically constrained menus are positioned above the anchor with the provided offset',
(WidgetTester tester) async {
await changeSurfaceSize(tester, const Size(800, 600));
await tester.pumpWidget(
MaterialApp(
......@@ -2542,8 +2682,9 @@ void main() {
alignment: Alignment.bottomLeft,
child: MenuAnchor(
alignmentOffset: const Offset(0, 50),
menuChildren: const <Widget> [
MenuItemButton(child: Text('Button1'),
menuChildren: const <Widget>[
MenuItemButton(
child: Text('Button1'),
),
],
builder: (BuildContext context, MenuController controller, Widget? child) {
......@@ -2580,7 +2721,8 @@ void main() {
);
});
Future<void> buildDensityPaddingApp(WidgetTester tester, {
Future<void> buildDensityPaddingApp(
WidgetTester tester, {
required TextDirection textDirection,
VisualDensity visualDensity = VisualDensity.standard,
EdgeInsetsGeometry? menuPadding,
......@@ -2595,8 +2737,8 @@ void main() {
children: <Widget>[
MenuBar(
style: menuPadding != null
? MenuStyle(padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(menuPadding))
: null,
? MenuStyle(padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(menuPadding))
: null,
children: createTestMenus(onPressed: onPressed),
),
const Expanded(child: Placeholder()),
......@@ -2898,82 +3040,6 @@ void main() {
expect(radioValue, 1);
});
});
testWidgets('MenuItemButton respects closeOnActivate property', (WidgetTester tester) async {
final MenuController controller = MenuController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: MenuAnchor(
controller: controller,
menuChildren: <Widget> [
MenuItemButton(
onPressed: () {},
child: const Text('Button 1'),
),
],
builder: (BuildContext context, MenuController controller, Widget? child) {
return FilledButton(
onPressed: () {
controller.open();
},
child: const Text('Tap me'),
);
},
),
),
),
)
);
await tester.tap(find.text('Tap me'));
await tester.pump();
expect(find.byType(MenuItemButton), findsNWidgets(1));
// Taps the MenuItemButton which should close the menu
await tester.tap(find.text('Button 1'));
await tester.pump();
expect(find.byType(MenuItemButton), findsNWidgets(0));
await tester.pumpAndSettle();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: MenuAnchor(
controller: controller,
menuChildren: <Widget> [
MenuItemButton(
closeOnActivate: false,
onPressed: () {},
child: const Text('Button 1'),
),
],
builder: (BuildContext context, MenuController controller, Widget? child) {
return FilledButton(
onPressed: () {
controller.open();
},
child: const Text('Tap me'),
);
},
),
),
),
)
);
await tester.tap(find.text('Tap me'));
await tester.pump();
expect(find.byType(MenuItemButton), findsNWidgets(1));
// Taps the MenuItemButton which shouldn't close the menu
await tester.tap(find.text('Button 1'));
await tester.pump();
expect(find.byType(MenuItemButton), findsNWidgets(1));
});
}
List<Widget> createTestMenus({
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment