Add applyFocusChangeIfNeeded, have menus restore focus before activating (#130536)
## Description
This modifies the `MenuAnchor` `onPressed` activation to delay until after the current frame is built, and resolve any focus changes before it invokes the `onPressed`, so that actions that operate on the `primaryFocus` can have a chance of working on the focused item they were meant to work on.
## Related Issues
- Fixes https://github.com/flutter/flutter/issues/118731
## Tests
- No tests yet (hence draft still)
diff --git a/dev/manual_tests/lib/menu_anchor.dart b/dev/manual_tests/lib/menu_anchor.dart
index b6f98d3..11a5ebd 100644
--- a/dev/manual_tests/lib/menu_anchor.dart
+++ b/dev/manual_tests/lib/menu_anchor.dart
@@ -706,6 +706,12 @@
TestMenu.mainMenu3,
menuChildren: <Widget>[
menuItemButton(TestMenu.subMenu8),
+ MenuItemButton(
+ onPressed: () {
+ debugPrint('Focused Item: $primaryFocus');
+ },
+ child: const Text('Print Focused Item'),
+ )
],
),
submenuButton(
@@ -734,7 +740,11 @@
submenuButton(
TestMenu.subSubMenu3,
menuChildren: <Widget>[
- menuItemButton(TestMenu.subSubSubMenu1),
+ for (int i=0; i < 100; ++i)
+ MenuItemButton(
+ onPressed: () {},
+ child: Text('Menu Item $i'),
+ ),
],
),
],
diff --git a/examples/api/test/material/menu_anchor/checkbox_menu_button.0_test.dart b/examples/api/test/material/menu_anchor/checkbox_menu_button.0_test.dart
index 278d19a..76d27b5 100644
--- a/examples/api/test/material/menu_anchor/checkbox_menu_button.0_test.dart
+++ b/examples/api/test/material/menu_anchor/checkbox_menu_button.0_test.dart
@@ -13,13 +13,13 @@
);
await tester.tap(find.byType(TextButton));
- await tester.pump();
+ await tester.pumpAndSettle();
expect(find.text('Show Message'), findsOneWidget);
expect(find.text(example.MenuApp.kMessage), findsNothing);
await tester.tap(find.text('Show Message'));
- await tester.pump();
+ await tester.pumpAndSettle();
expect(find.text('Show Message'), findsNothing);
expect(find.text(example.MenuApp.kMessage), findsOneWidget);
diff --git a/examples/api/test/material/menu_anchor/menu_anchor.0_test.dart b/examples/api/test/material/menu_anchor/menu_anchor.0_test.dart
index 54016dc..dcbabb2 100644
--- a/examples/api/test/material/menu_anchor/menu_anchor.0_test.dart
+++ b/examples/api/test/material/menu_anchor/menu_anchor.0_test.dart
@@ -41,7 +41,7 @@
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
- await tester.pump();
+ await tester.pumpAndSettle();
expect(find.text(example.MenuApp.kMessage), findsOneWidget);
expect(find.text('Last Selected: ${example.MenuEntry.showMessage.label}'), findsOneWidget);
diff --git a/examples/api/test/material/menu_anchor/menu_anchor.1_test.dart b/examples/api/test/material/menu_anchor/menu_anchor.1_test.dart
index b9dc670..0ac0e43 100644
--- a/examples/api/test/material/menu_anchor/menu_anchor.1_test.dart
+++ b/examples/api/test/material/menu_anchor/menu_anchor.1_test.dart
@@ -20,7 +20,7 @@
await tester.pumpWidget(const example.ContextMenuApp());
await tester.tapAt(const Offset(100, 200), buttons: kSecondaryButton);
- await tester.pump();
+ await tester.pumpAndSettle();
expect(tester.getRect(findMenu()), equals(const Rect.fromLTRB(100.0, 200.0, 433.0, 360.0)));
// Make sure tapping in a different place causes the menu to move.
@@ -46,7 +46,7 @@
expect(find.text('Background Color'), findsOneWidget);
await tester.tap(find.text('Background Color'));
- await tester.pump();
+ await tester.pumpAndSettle();
expect(find.text(example.MenuEntry.colorRed.label), findsOneWidget);
expect(find.text(example.MenuEntry.colorGreen.label), findsOneWidget);
@@ -54,7 +54,7 @@
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
- await tester.pump();
+ await tester.pumpAndSettle();
expect(find.text(example.ContextMenuApp.kMessage), findsOneWidget);
expect(find.text('Last Selected: ${example.MenuEntry.showMessage.label}'), findsOneWidget);
diff --git a/examples/api/test/material/menu_anchor/menu_bar.0_test.dart b/examples/api/test/material/menu_anchor/menu_bar.0_test.dart
index b508ba4..ccfe4c9 100644
--- a/examples/api/test/material/menu_anchor/menu_bar.0_test.dart
+++ b/examples/api/test/material/menu_anchor/menu_bar.0_test.dart
@@ -20,7 +20,7 @@
final Finder menuButtonFinder = find.byType(SubmenuButton).first;
await tester.tap(menuButtonFinder);
- await tester.pump();
+ await tester.pumpAndSettle();
expect(find.text('About'), findsOneWidget);
expect(find.text('Show Message'), findsOneWidget);
@@ -34,7 +34,7 @@
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
- await tester.pump();
+ await tester.pumpAndSettle();
expect(find.text('About'), findsOneWidget);
expect(find.text('Show Message'), findsOneWidget);
@@ -46,7 +46,7 @@
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
- await tester.pump();
+ await tester.pumpAndSettle();
expect(find.text(example.MenuBarApp.kMessage), findsOneWidget);
expect(find.text('Last Selected: Show Message'), findsOneWidget);
diff --git a/examples/api/test/material/menu_anchor/radio_menu_button.0_test.dart b/examples/api/test/material/menu_anchor/radio_menu_button.0_test.dart
index ee33556..2b88a77 100644
--- a/examples/api/test/material/menu_anchor/radio_menu_button.0_test.dart
+++ b/examples/api/test/material/menu_anchor/radio_menu_button.0_test.dart
@@ -24,7 +24,7 @@
expect(tester.widget<Container>(find.byType(Container)).color, equals(Colors.red));
await tester.tap(find.text('Green Background'));
- await tester.pump();
+ await tester.pumpAndSettle();
expect(tester.widget<Container>(find.byType(Container)).color, equals(Colors.green));
});
diff --git a/packages/flutter/lib/src/material/menu_anchor.dart b/packages/flutter/lib/src/material/menu_anchor.dart
index 7980783..37aca52 100644
--- a/packages/flutter/lib/src/material/menu_anchor.dart
+++ b/packages/flutter/lib/src/material/menu_anchor.dart
@@ -1099,10 +1099,16 @@
void _handleSelect() {
assert(_debugMenuInfo('Selected ${widget.child} menu'));
- widget.onPressed?.call();
if (widget.closeOnActivate) {
_MenuAnchorState._maybeOf(context)?._root._close();
}
+ // Delay the call to onPressed until post-frame so that the focus is
+ // restored to what it was before the menu was opened before the action is
+ // executed.
+ SchedulerBinding.instance.addPostFrameCallback((Duration _) {
+ FocusManager.instance.applyFocusChangesIfNeeded();
+ widget.onPressed?.call();
+ });
}
void _createInternalFocusNodeIfNeeded() {
diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart
index b167ed1..f534b99 100644
--- a/packages/flutter/lib/src/widgets/focus_manager.dart
+++ b/packages/flutter/lib/src/widgets/focus_manager.dart
@@ -8,6 +8,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/painting.dart';
+import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'binding.dart';
@@ -1601,10 +1602,32 @@
return;
}
_haveScheduledUpdate = true;
- scheduleMicrotask(_applyFocusChange);
+ scheduleMicrotask(applyFocusChangesIfNeeded);
}
- void _applyFocusChange() {
+ /// Applies any pending focus changes and notifies listeners that the focus
+ /// has changed.
+ ///
+ /// Must not be called during the build phase. This method is meant to be
+ /// called in a post-frame callback or microtask when the pending focus
+ /// changes need to be resolved before something else occurs.
+ ///
+ /// It can't be called during the build phase because not all listeners are
+ /// safe to be called with an update during a build.
+ ///
+ /// Typically, this is called automatically by the [FocusManager], but
+ /// sometimes it is necessary to ensure that no focus changes are pending
+ /// before executing an action. For example, the [MenuAnchor] class uses this
+ /// to make sure that the previous focus has been restored before executing a
+ /// menu callback when a menu item is selected.
+ ///
+ /// It is safe to call this if no focus changes are pending.
+ void applyFocusChangesIfNeeded() {
+ assert(
+ SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks,
+ 'applyFocusChangesIfNeeded() should not be called during the build phase.'
+ );
+
_haveScheduledUpdate = false;
final FocusNode? previousFocus = _primaryFocus;
diff --git a/packages/flutter/test/material/menu_anchor_test.dart b/packages/flutter/test/material/menu_anchor_test.dart
index e48e855..6de3e26 100644
--- a/packages/flutter/test/material/menu_anchor_test.dart
+++ b/packages/flutter/test/material/menu_anchor_test.dart
@@ -486,6 +486,53 @@
);
}, variant: TargetPlatformVariant.desktop());
+ testWidgets('focus is returned to previous focus before invoking onPressed', (WidgetTester tester) async {
+ final FocusNode buttonFocus = FocusNode(debugLabel: 'Button Focus');
+ FocusNode? focusInOnPressed;
+
+ void onMenuSelected(TestMenu item) {
+ focusInOnPressed = FocusManager.instance.primaryFocus;
+ }
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Material(
+ child: Column(
+ children: <Widget>[
+ MenuBar(
+ controller: controller,
+ children: createTestMenus(
+ onPressed: onMenuSelected,
+ ),
+ ),
+ ElevatedButton(
+ autofocus: true,
+ onPressed: () {},
+ focusNode: buttonFocus,
+ child: const Text('Press Me'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ await tester.pump();
+ expect(FocusManager.instance.primaryFocus, equals(buttonFocus));
+
+ await tester.tap(find.text(TestMenu.mainMenu1.label));
+ await tester.pump();
+
+ await tester.tap(find.text(TestMenu.subMenu11.label));
+ await tester.pump();
+
+ await tester.tap(find.text(TestMenu.subSubMenu110.label));
+ await tester.pump();
+
+ expect(focusInOnPressed, equals(buttonFocus));
+ expect(FocusManager.instance.primaryFocus, equals(buttonFocus));
+ });
+
group('Menu functions', () {
testWidgets('basic menu structure', (WidgetTester tester) async {
await tester.pumpWidget(
@@ -3064,7 +3111,7 @@
child: Center(
child: MenuItemButton(
style: MenuItemButton.styleFrom(fixedSize: const Size(88.0, 36.0)),
- onPressed: () { },
+ onPressed: () {},
child: const Text('ABC'),
),
),
@@ -3072,27 +3119,30 @@
);
// The flags should not have SemanticsFlag.isButton
- expect(semantics, hasSemantics(
- TestSemantics.root(
- children: <TestSemantics>[
- TestSemantics.rootChild(
- actions: <SemanticsAction>[
- SemanticsAction.tap,
- ],
- label: 'ABC',
- rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0),
- transform: Matrix4.translationValues(356.0, 276.0, 0.0),
- flags: <SemanticsFlag>[
- SemanticsFlag.hasEnabledState,
- SemanticsFlag.isEnabled,
- SemanticsFlag.isFocusable,
- ],
- textDirection: TextDirection.ltr,
- ),
- ],
+ expect(
+ semantics,
+ hasSemantics(
+ TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics.rootChild(
+ actions: <SemanticsAction>[
+ SemanticsAction.tap,
+ ],
+ label: 'ABC',
+ rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0),
+ transform: Matrix4.translationValues(356.0, 276.0, 0.0),
+ flags: <SemanticsFlag>[
+ SemanticsFlag.hasEnabledState,
+ SemanticsFlag.isEnabled,
+ SemanticsFlag.isFocusable,
+ ],
+ textDirection: TextDirection.ltr,
+ ),
+ ],
+ ),
+ ignoreId: true,
),
- ignoreId: true,
- ));
+ );
semantics.dispose();
});
@@ -3114,22 +3164,23 @@
);
// The flags should not have SemanticsFlag.isButton
- expect(semantics, hasSemantics(
- TestSemantics.root(
- children: <TestSemantics>[
- TestSemantics.rootChild(
- label: 'ABC',
- rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0),
- transform: Matrix4.translationValues(356.0, 276.0, 0.0),
- flags: <SemanticsFlag>[
- SemanticsFlag.hasEnabledState,
- ],
- textDirection: TextDirection.ltr,
- ),
- ],
+ expect(
+ semantics,
+ hasSemantics(
+ TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics(
+ rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0),
+ flags: <SemanticsFlag>[SemanticsFlag.hasEnabledState],
+ label: 'ABC',
+ textDirection: TextDirection.ltr,
+ ),
+ ],
+ ),
+ ignoreTransform: true,
+ ignoreId: true,
),
- ignoreId: true,
- ));
+ );
semantics.dispose();
});