Skip to content

Commit 9e8084b

Browse files
davidhicks980TytaniumDev
authored andcommitted
MenuAnchor hover traversal fixes (flutter#150914)
Fixes flutter#150910 and flutter#150911. flutter#150910 is fixed by invalidating the focus scope whenever a hover occurs. I'm interested to hear of better fixes -- it feels a bit extreme to invalidate the focus scope so often. flutter#150911 is fixed by replacing TextButton.onHover with MouseRegion.onHover and MouseRegion.onExit. The issue appears to be that MouseRegion.onEnter is called on scroll, whereas MouseRegion.onHover is not. I'm not confident this is a great solution, so please let me know if you all have any suggestions! @Piinks @dkwingsmt
1 parent 9b64a90 commit 9e8084b

File tree

2 files changed

+166
-21
lines changed

2 files changed

+166
-21
lines changed

packages/flutter/lib/src/material/menu_anchor.dart

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,7 @@ class _MenuItemButtonState extends State<MenuItemButton> {
10721072
FocusNode? _internalFocusNode;
10731073
FocusNode get _focusNode => widget.focusNode ?? _internalFocusNode!;
10741074
_MenuAnchorState? get _anchor => _MenuAnchorState._maybeOf(context);
1075+
bool _isHovered = false;
10751076

10761077
@override
10771078
void initState() {
@@ -1115,7 +1116,6 @@ class _MenuItemButtonState extends State<MenuItemButton> {
11151116

11161117
Widget child = TextButton(
11171118
onPressed: widget.enabled ? _handleSelect : null,
1118-
onHover: widget.enabled ? _handleHover : null,
11191119
onFocusChange: widget.enabled ? widget.onFocusChange : null,
11201120
focusNode: _focusNode,
11211121
style: mergedStyle,
@@ -1141,6 +1141,14 @@ class _MenuItemButtonState extends State<MenuItemButton> {
11411141
);
11421142
}
11431143

1144+
if (widget.onHover != null || widget.requestFocusOnHover) {
1145+
child = MouseRegion(
1146+
onHover: _handlePointerHover,
1147+
onExit: _handlePointerExit,
1148+
child: child,
1149+
);
1150+
}
1151+
11441152
return MergeSemantics(child: child);
11451153
}
11461154

@@ -1151,11 +1159,29 @@ class _MenuItemButtonState extends State<MenuItemButton> {
11511159
}
11521160
}
11531161

1154-
void _handleHover(bool hovering) {
1155-
widget.onHover?.call(hovering);
1156-
if (hovering && widget.requestFocusOnHover) {
1157-
assert(_debugMenuInfo('Requesting focus for $_focusNode from hover'));
1158-
_focusNode.requestFocus();
1162+
void _handlePointerExit(PointerExitEvent event) {
1163+
if (_isHovered) {
1164+
widget.onHover?.call(false);
1165+
_isHovered = false;
1166+
}
1167+
}
1168+
1169+
// TextButton.onHover and MouseRegion.onHover can't be used without triggering
1170+
// focus on scroll.
1171+
void _handlePointerHover(PointerHoverEvent event) {
1172+
if (!_isHovered) {
1173+
_isHovered = true;
1174+
widget.onHover?.call(true);
1175+
if (widget.requestFocusOnHover) {
1176+
assert(_debugMenuInfo('Requesting focus for $_focusNode from hover'));
1177+
_focusNode.requestFocus();
1178+
1179+
// Without invalidating the focus policy, switching to directional focus
1180+
// may not originate at this node.
1181+
FocusTraversalGroup.of(context).invalidateScopeData(
1182+
FocusScope.of(context),
1183+
);
1184+
}
11591185
}
11601186
}
11611187

@@ -1839,6 +1865,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
18391865
FocusNode? _internalFocusNode;
18401866
FocusNode get _buttonFocusNode => widget.focusNode ?? _internalFocusNode!;
18411867
bool get _enabled => widget.menuChildren.isNotEmpty;
1868+
bool _isHovered = false;
18421869

18431870
@override
18441871
void initState() {
@@ -1923,7 +1950,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
19231950
?? widget.defaultStyleOf(context);
19241951
mergedStyle = widget.style?.merge(mergedStyle) ?? mergedStyle;
19251952

1926-
void toggleShowMenu(BuildContext context) {
1953+
void toggleShowMenu() {
19271954
if (controller._anchor == null) {
19281955
return;
19291956
}
@@ -1934,18 +1961,29 @@ class _SubmenuButtonState extends State<SubmenuButton> {
19341961
}
19351962
}
19361963

1937-
// Called when the pointer is hovering over the menu button.
1938-
void handleHover(bool hovering, BuildContext context) {
1939-
widget.onHover?.call(hovering);
1940-
// Don't open the root menu bar menus on hover unless something else
1941-
// is already open. This means that the user has to first click to
1942-
// open a menu on the menu bar before hovering allows them to traverse
1943-
// it.
1944-
if (controller._anchor!._root._orientation == Axis.horizontal && !controller._anchor!._root._isOpen) {
1945-
return;
1964+
void handlePointerExit(PointerExitEvent event) {
1965+
if (_isHovered) {
1966+
widget.onHover?.call(false);
1967+
_isHovered = false;
19461968
}
1969+
}
1970+
1971+
// MouseRegion.onEnter and TextButton.onHover are called
1972+
// if a button is hovered after scrolling. This interferes with
1973+
// focus traversal and scroll position. MouseRegion.onHover avoids
1974+
// this issue.
1975+
void handlePointerHover(PointerHoverEvent event) {
1976+
if (!_isHovered) {
1977+
_isHovered = true;
1978+
widget.onHover?.call(true);
1979+
// Don't open the root menu bar menus on hover unless something else
1980+
// is already open. This means that the user has to first click to
1981+
// open a menu on the menu bar before hovering allows them to traverse
1982+
// it.
1983+
if (controller._anchor!._root._orientation == Axis.horizontal && !controller._anchor!._root._isOpen) {
1984+
return;
1985+
}
19471986

1948-
if (hovering) {
19491987
controller.open();
19501988
controller._anchor!._focusButton();
19511989
}
@@ -1957,8 +1995,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
19571995
style: mergedStyle,
19581996
focusNode: _buttonFocusNode,
19591997
onFocusChange: _enabled ? widget.onFocusChange : null,
1960-
onHover: _enabled ? (bool hovering) => handleHover(hovering, context) : null,
1961-
onPressed: _enabled ? () => toggleShowMenu(context) : null,
1998+
onPressed: _enabled ? toggleShowMenu : null,
19621999
isSemanticButton: null,
19632000
child: _MenuItemLabel(
19642001
leadingIcon: widget.leadingIcon,
@@ -1971,13 +2008,24 @@ class _SubmenuButtonState extends State<SubmenuButton> {
19712008
),
19722009
);
19732010

1974-
if (_enabled && _platformSupportsAccelerators) {
2011+
if (!_enabled) {
2012+
return child;
2013+
}
2014+
2015+
child = MouseRegion(
2016+
onHover: handlePointerHover,
2017+
onExit: handlePointerExit,
2018+
child: child,
2019+
);
2020+
2021+
if (_platformSupportsAccelerators) {
19752022
return MenuAcceleratorCallbackBinding(
1976-
onInvoke: () => toggleShowMenu(context),
2023+
onInvoke: toggleShowMenu,
19772024
hasSubmenu: true,
19782025
child: child,
19792026
);
19802027
}
2028+
19812029
return child;
19822030
},
19832031
menuChildren: widget.menuChildren,

packages/flutter/test/material/menu_anchor_test.dart

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2021,6 +2021,103 @@ void main() {
20212021
expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 110"))'));
20222022
});
20232023

2024+
2025+
testWidgets('hover traversal invalidates directional focus scope data', (WidgetTester tester) async {
2026+
// Regression test for https://github.com/flutter/flutter/issues/150910
2027+
await tester.pumpWidget(
2028+
MaterialApp(
2029+
home: Material(
2030+
child: MenuBar(
2031+
controller: controller,
2032+
children: createTestMenus(
2033+
onPressed: onPressed,
2034+
onOpen: onOpen,
2035+
onClose: onClose,
2036+
),
2037+
),
2038+
),
2039+
),
2040+
);
2041+
2042+
listenForFocusChanges();
2043+
2044+
// Have to open a menu initially to start things going.
2045+
await tester.tap(find.text(TestMenu.mainMenu1.label));
2046+
await tester.pump();
2047+
expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))'));
2048+
2049+
await hoverOver(tester, find.text(TestMenu.subMenu12.label));
2050+
await tester.pump();
2051+
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))'));
2052+
2053+
// Move pointer to disabled menu
2054+
await hoverOver(tester, find.text(TestMenu.mainMenu5.label));
2055+
await tester.pump();
2056+
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))'));
2057+
2058+
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
2059+
await tester.pump();
2060+
expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))'));
2061+
2062+
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
2063+
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 10"))'));
2064+
2065+
await hoverOver(tester, find.text(TestMenu.subMenu12.label));
2066+
await tester.pump();
2067+
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))'));
2068+
2069+
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
2070+
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))'));
2071+
});
2072+
2073+
testWidgets('scrolling does not trigger hover traversal', (WidgetTester tester) async {
2074+
// Regression test for https://github.com/flutter/flutter/issues/150911
2075+
final GlobalKey scrolledMenuItemKey = GlobalKey();
2076+
await tester.pumpWidget(
2077+
MaterialApp(
2078+
home: Material(
2079+
child: MenuAnchor(
2080+
style: const MenuStyle(
2081+
fixedSize: WidgetStatePropertyAll<Size>(Size.fromHeight(200)),
2082+
),
2083+
controller: controller,
2084+
menuChildren: <Widget>[
2085+
for (int i = 0; i < 20; i++)
2086+
MenuItemButton(
2087+
key: i == 15 ? scrolledMenuItemKey : null,
2088+
onPressed: () {},
2089+
child: Text('Item $i'),
2090+
)
2091+
]
2092+
),
2093+
),
2094+
),
2095+
);
2096+
2097+
listenForFocusChanges();
2098+
2099+
controller.open();
2100+
await tester.pumpAndSettle();
2101+
2102+
await hoverOver(tester, find.text('Item 1'));
2103+
await tester.pump();
2104+
expect(focusedMenu, equals('MenuItemButton(Text("Item 1"))'));
2105+
2106+
// Scroll the menu while the pointer is over a menu item. The focus should
2107+
// not change.
2108+
tester.renderObject(find.text('Item 15')).showOnScreen();
2109+
await tester.pumpAndSettle();
2110+
expect(focusedMenu, equals('MenuItemButton(Text("Item 1"))'));
2111+
2112+
// Traverse with the keyboard to test that the menu scrolls without hover
2113+
// focus affecting the focused menu.
2114+
for (int i = 2; i < 20; i++) {
2115+
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
2116+
await tester.pump();
2117+
expect(focusedMenu, equals('MenuItemButton(Text("Item $i"))'));
2118+
}
2119+
});
2120+
20242121
testWidgets('menus close on ancestor scroll', (WidgetTester tester) async {
20252122
final ScrollController scrollController = ScrollController();
20262123
addTearDown(scrollController.dispose);

0 commit comments

Comments
 (0)