MenuAnchor hover traversal fixes (#150914)
Fixes https://github.com/flutter/flutter/issues/150910 and https://github.com/flutter/flutter/issues/150911. https://github.com/flutter/flutter/issues/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. https://github.com/flutter/flutter/issues/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
This commit is contained in:
parent
0f7bceb9c4
commit
1f4c6ebc97
@ -1072,6 +1072,7 @@ class _MenuItemButtonState extends State<MenuItemButton> {
|
|||||||
FocusNode? _internalFocusNode;
|
FocusNode? _internalFocusNode;
|
||||||
FocusNode get _focusNode => widget.focusNode ?? _internalFocusNode!;
|
FocusNode get _focusNode => widget.focusNode ?? _internalFocusNode!;
|
||||||
_MenuAnchorState? get _anchor => _MenuAnchorState._maybeOf(context);
|
_MenuAnchorState? get _anchor => _MenuAnchorState._maybeOf(context);
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -1115,7 +1116,6 @@ class _MenuItemButtonState extends State<MenuItemButton> {
|
|||||||
|
|
||||||
Widget child = TextButton(
|
Widget child = TextButton(
|
||||||
onPressed: widget.enabled ? _handleSelect : null,
|
onPressed: widget.enabled ? _handleSelect : null,
|
||||||
onHover: widget.enabled ? _handleHover : null,
|
|
||||||
onFocusChange: widget.enabled ? widget.onFocusChange : null,
|
onFocusChange: widget.enabled ? widget.onFocusChange : null,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
style: mergedStyle,
|
style: mergedStyle,
|
||||||
@ -1141,6 +1141,14 @@ class _MenuItemButtonState extends State<MenuItemButton> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (widget.onHover != null || widget.requestFocusOnHover) {
|
||||||
|
child = MouseRegion(
|
||||||
|
onHover: _handlePointerHover,
|
||||||
|
onExit: _handlePointerExit,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return MergeSemantics(child: child);
|
return MergeSemantics(child: child);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1151,11 +1159,29 @@ class _MenuItemButtonState extends State<MenuItemButton> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleHover(bool hovering) {
|
void _handlePointerExit(PointerExitEvent event) {
|
||||||
widget.onHover?.call(hovering);
|
if (_isHovered) {
|
||||||
if (hovering && widget.requestFocusOnHover) {
|
widget.onHover?.call(false);
|
||||||
|
_isHovered = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextButton.onHover and MouseRegion.onHover can't be used without triggering
|
||||||
|
// focus on scroll.
|
||||||
|
void _handlePointerHover(PointerHoverEvent event) {
|
||||||
|
if (!_isHovered) {
|
||||||
|
_isHovered = true;
|
||||||
|
widget.onHover?.call(true);
|
||||||
|
if (widget.requestFocusOnHover) {
|
||||||
assert(_debugMenuInfo('Requesting focus for $_focusNode from hover'));
|
assert(_debugMenuInfo('Requesting focus for $_focusNode from hover'));
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
|
|
||||||
|
// Without invalidating the focus policy, switching to directional focus
|
||||||
|
// may not originate at this node.
|
||||||
|
FocusTraversalGroup.of(context).invalidateScopeData(
|
||||||
|
FocusScope.of(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1839,6 +1865,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
|
|||||||
FocusNode? _internalFocusNode;
|
FocusNode? _internalFocusNode;
|
||||||
FocusNode get _buttonFocusNode => widget.focusNode ?? _internalFocusNode!;
|
FocusNode get _buttonFocusNode => widget.focusNode ?? _internalFocusNode!;
|
||||||
bool get _enabled => widget.menuChildren.isNotEmpty;
|
bool get _enabled => widget.menuChildren.isNotEmpty;
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -1923,7 +1950,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
|
|||||||
?? widget.defaultStyleOf(context);
|
?? widget.defaultStyleOf(context);
|
||||||
mergedStyle = widget.style?.merge(mergedStyle) ?? mergedStyle;
|
mergedStyle = widget.style?.merge(mergedStyle) ?? mergedStyle;
|
||||||
|
|
||||||
void toggleShowMenu(BuildContext context) {
|
void toggleShowMenu() {
|
||||||
if (controller._anchor == null) {
|
if (controller._anchor == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1934,9 +1961,21 @@ class _SubmenuButtonState extends State<SubmenuButton> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called when the pointer is hovering over the menu button.
|
void handlePointerExit(PointerExitEvent event) {
|
||||||
void handleHover(bool hovering, BuildContext context) {
|
if (_isHovered) {
|
||||||
widget.onHover?.call(hovering);
|
widget.onHover?.call(false);
|
||||||
|
_isHovered = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MouseRegion.onEnter and TextButton.onHover are called
|
||||||
|
// if a button is hovered after scrolling. This interferes with
|
||||||
|
// focus traversal and scroll position. MouseRegion.onHover avoids
|
||||||
|
// this issue.
|
||||||
|
void handlePointerHover(PointerHoverEvent event) {
|
||||||
|
if (!_isHovered) {
|
||||||
|
_isHovered = true;
|
||||||
|
widget.onHover?.call(true);
|
||||||
// Don't open the root menu bar menus on hover unless something else
|
// Don't open the root menu bar menus on hover unless something else
|
||||||
// is already open. This means that the user has to first click to
|
// is already open. This means that the user has to first click to
|
||||||
// open a menu on the menu bar before hovering allows them to traverse
|
// open a menu on the menu bar before hovering allows them to traverse
|
||||||
@ -1945,7 +1984,6 @@ class _SubmenuButtonState extends State<SubmenuButton> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hovering) {
|
|
||||||
controller.open();
|
controller.open();
|
||||||
controller._anchor!._focusButton();
|
controller._anchor!._focusButton();
|
||||||
}
|
}
|
||||||
@ -1957,8 +1995,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
|
|||||||
style: mergedStyle,
|
style: mergedStyle,
|
||||||
focusNode: _buttonFocusNode,
|
focusNode: _buttonFocusNode,
|
||||||
onFocusChange: _enabled ? widget.onFocusChange : null,
|
onFocusChange: _enabled ? widget.onFocusChange : null,
|
||||||
onHover: _enabled ? (bool hovering) => handleHover(hovering, context) : null,
|
onPressed: _enabled ? toggleShowMenu : null,
|
||||||
onPressed: _enabled ? () => toggleShowMenu(context) : null,
|
|
||||||
isSemanticButton: null,
|
isSemanticButton: null,
|
||||||
child: _MenuItemLabel(
|
child: _MenuItemLabel(
|
||||||
leadingIcon: widget.leadingIcon,
|
leadingIcon: widget.leadingIcon,
|
||||||
@ -1971,13 +2008,24 @@ class _SubmenuButtonState extends State<SubmenuButton> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_enabled && _platformSupportsAccelerators) {
|
if (!_enabled) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
child = MouseRegion(
|
||||||
|
onHover: handlePointerHover,
|
||||||
|
onExit: handlePointerExit,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_platformSupportsAccelerators) {
|
||||||
return MenuAcceleratorCallbackBinding(
|
return MenuAcceleratorCallbackBinding(
|
||||||
onInvoke: () => toggleShowMenu(context),
|
onInvoke: toggleShowMenu,
|
||||||
hasSubmenu: true,
|
hasSubmenu: true,
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return child;
|
return child;
|
||||||
},
|
},
|
||||||
menuChildren: widget.menuChildren,
|
menuChildren: widget.menuChildren,
|
||||||
|
@ -2021,6 +2021,103 @@ void main() {
|
|||||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 110"))'));
|
expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 110"))'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
testWidgets('hover traversal invalidates directional focus scope data', (WidgetTester tester) async {
|
||||||
|
// Regression test for https://github.com/flutter/flutter/issues/150910
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Material(
|
||||||
|
child: MenuBar(
|
||||||
|
controller: controller,
|
||||||
|
children: createTestMenus(
|
||||||
|
onPressed: onPressed,
|
||||||
|
onOpen: onOpen,
|
||||||
|
onClose: onClose,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
listenForFocusChanges();
|
||||||
|
|
||||||
|
// Have to open a menu initially to start things going.
|
||||||
|
await tester.tap(find.text(TestMenu.mainMenu1.label));
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))'));
|
||||||
|
|
||||||
|
await hoverOver(tester, find.text(TestMenu.subMenu12.label));
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))'));
|
||||||
|
|
||||||
|
// Move pointer to disabled menu
|
||||||
|
await hoverOver(tester, find.text(TestMenu.mainMenu5.label));
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))'));
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))'));
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||||
|
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 10"))'));
|
||||||
|
|
||||||
|
await hoverOver(tester, find.text(TestMenu.subMenu12.label));
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))'));
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))'));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('scrolling does not trigger hover traversal', (WidgetTester tester) async {
|
||||||
|
// Regression test for https://github.com/flutter/flutter/issues/150911
|
||||||
|
final GlobalKey scrolledMenuItemKey = GlobalKey();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Material(
|
||||||
|
child: MenuAnchor(
|
||||||
|
style: const MenuStyle(
|
||||||
|
fixedSize: WidgetStatePropertyAll<Size>(Size.fromHeight(200)),
|
||||||
|
),
|
||||||
|
controller: controller,
|
||||||
|
menuChildren: <Widget>[
|
||||||
|
for (int i = 0; i < 20; i++)
|
||||||
|
MenuItemButton(
|
||||||
|
key: i == 15 ? scrolledMenuItemKey : null,
|
||||||
|
onPressed: () {},
|
||||||
|
child: Text('Item $i'),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
listenForFocusChanges();
|
||||||
|
|
||||||
|
controller.open();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await hoverOver(tester, find.text('Item 1'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusedMenu, equals('MenuItemButton(Text("Item 1"))'));
|
||||||
|
|
||||||
|
// Scroll the menu while the pointer is over a menu item. The focus should
|
||||||
|
// not change.
|
||||||
|
tester.renderObject(find.text('Item 15')).showOnScreen();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(focusedMenu, equals('MenuItemButton(Text("Item 1"))'));
|
||||||
|
|
||||||
|
// Traverse with the keyboard to test that the menu scrolls without hover
|
||||||
|
// focus affecting the focused menu.
|
||||||
|
for (int i = 2; i < 20; i++) {
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusedMenu, equals('MenuItemButton(Text("Item $i"))'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('menus close on ancestor scroll', (WidgetTester tester) async {
|
testWidgets('menus close on ancestor scroll', (WidgetTester tester) async {
|
||||||
final ScrollController scrollController = ScrollController();
|
final ScrollController scrollController = ScrollController();
|
||||||
addTearDown(scrollController.dispose);
|
addTearDown(scrollController.dispose);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user