Repository navigation
167114 add keyboard focus support for cupertino context menu action - #179225
O-Hannonen wants to merge 18 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request adds keyboard focus support to CupertinoContextMenu and CupertinoContextMenuAction. It introduces focusNode and focusBorderRadius properties to CupertinoContextMenu, and focusNode and focusColor to CupertinoContextMenuAction. The implementation uses FocusableActionDetector to manage focus and actions, and a Shortcuts widget to enable opening the context menu with a Tab+M keyboard shortcut. Additionally, CupertinoFocusHalo is updated to use RoundedSuperellipseBorder for a more native iOS appearance, and FocusableActionDetector is extended with an onKeyEvent callback to support custom key event handling.
…rtino-context-menu-action
…rtino-context-menu-action
dkwingsmt
left a comment
There was a problem hiding this comment.
Generally ok except for how we handle the tab key
| void _onShowFocusHighlight(bool showHighlight) { | ||
| setState(() { | ||
| _lastKeyEvent = null; | ||
| }); | ||
| } | ||
|
|
||
| KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { | ||
| if (event case KeyDownEvent(logicalKey: LogicalKeyboardKey.tab)) { | ||
| _lastKeyEvent = event; | ||
| return KeyEventResult.handled; | ||
| } | ||
|
|
||
| if (event case KeyUpEvent(logicalKey: LogicalKeyboardKey.tab)) { | ||
| if (_lastKeyEvent case KeyDownEvent(logicalKey: LogicalKeyboardKey.tab)) { | ||
| FocusScope.of(context).nextFocus(); | ||
| } | ||
| _lastKeyEvent = event; | ||
| return KeyEventResult.handled; | ||
| } | ||
|
|
||
| _lastKeyEvent = event; | ||
|
|
||
| return KeyEventResult.ignored; |
There was a problem hiding this comment.
Why do you have to handle key events manually instead of using the Shortcuts framework? What's the recorded _lastKeyEvent used for?
There was a problem hiding this comment.
The Shortcuts framework handles tab quite poorly as a modifier. Immediately when tab is pressed down, the focus moves to next item. So its not really possible to use only it if we want to have a focusable widget that uses tab as modifier key for the shortcut.
The reason for manually listening to key events is to prevent the tab down from bubbling up and only move focus when the tab is released.
But now that I'm saying this aloud, should that be fixed elsewhere so we could use the shortcuts here? So eg. should tab focus traversal be modified to only move to next item when tab is released (vs. the on tab down that its now)? Or should a similar fix to this be added to the Shortcuts (so is it a common use case that we want to support that tab can be modifier)?
Here is a small reproduction highlighting the issue:
Sample code
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Tab as modifier key bug',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const TabModifierKeyBugDemo(),
);
}
}
class TabModifierKeyBugDemo extends StatefulWidget {
const TabModifierKeyBugDemo({super.key});
@override
State<TabModifierKeyBugDemo> createState() => _TabModifierKeyBugDemoState();
}
class _TabModifierKeyBugDemoState extends State<TabModifierKeyBugDemo> {
int tabMCount = 0;
void _handleTabM() {
setState(() {
tabMCount++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Tab + Modifier Key Bug Reproduction'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
const Card(
color: Color.fromARGB(255, 255, 240, 245),
child: Padding(
padding: EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Instructions:',
),
SizedBox(height: 8),
Text(
'1. Press Tab to navigate between buttons\n'
'2. When focused on Button 2, press Tab down. While holding Tab, press M.\n'
'BUG: Focus should NOT move to Button 3, only Tab+M action should trigger. Focus should move to next button only if Tab is released and no other key is pressed.\n',
),
],
),
),
),
Text(
'Tab+M pressed count: $tabMCount',
),
ElevatedButton(
onPressed: () {},
child: const Text('Button 1'),
),
Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.tab, LogicalKeyboardKey.keyM):
const _TabMIntent(),
},
child: Actions(
actions: {
_TabMIntent: CallbackAction<_TabMIntent>(
onInvoke: (_) {
_handleTabM();
return null;
},
),
},
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.orange, width: 2),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(12),
child: ElevatedButton(
onPressed: () {},
child: const Text('Button 2 (Has Tab+M shortcut)'),
),
),
),
),
ElevatedButton(
onPressed: () {},
child: const Text('Button 3'),
),
],
),
),
);
}
}
class _TabMIntent extends Intent {
const _TabMIntent();
}
This PR closes #180719 Related to [this](#179224 (comment)) and [this](#179225 (comment)). ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This PR closes flutter#180719 Related to [this](flutter#179224 (comment)) and [this](flutter#179225 (comment)). ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
|
Hi @O-Hannonen ! When you have a moment, can you check my comment above? Just wanted to make sure it came through correctly. Thank you again for your contribution. |
|
Sorry for the delay, did some further testing myself now too:
This is quite unfortunate :/
Did you figure out any way to open the context menu on keyboard inside apps? Eg. while testing in the Photos app, I was not able to open the context menu in any way with keyboard...
MenuAnchor is quite similar but has few key differences. AFAIK the menu anchor is not intended to have any other onTap action than opening the anchor? Context menu usually has both onTap action in addition to opening the context menu (so both onTap and onLongPress) -> can't just use the regular tap event (space on keyboard) to open the menu. If its really true that Apple FKA does not support opening context menu on keyboard (other than on home screen), we could simply remove the keyboard shortcut part from this PR? So just make |
No, I couldn't either. I think we don't need this shortcut support at all. Overall, here's my theory: I think that the entire focus halo feature is meant to be handled by iOS automatically. It probably reads accessibility data - I'm not certain about it since I'm not familiar with a11y and don't know what's in there, but I'm guessing so mostly because of how However this doesn't explain why iOS has this explicit focus halo component. My guess is that it does some kind of customization (such as shape). If that's true, our focus halo widget should instead modify the a11y data to specify the shape. Even if that is true, it's still a mystery how some iOS components allow a different kind of focus effect that doesn't show focus halo at all (I might be wrong but I vaguely remember that iOS's design guide says that focus halo should not be shown for things like navigation bar buttons.) If you're interested, I'd apprecaite if you can investigate into the detailed mechanism here. :) |
|
I've removed the keyboard shortcut from this PR for now. I still need to dig a bit deeper into the other points discussed, which I’m hoping to find time for shortly. Based on what I've seen so far, I have two potential theories on how we should move forward (though further investigation might prove both wrong! 😄). Either way, I don't think creating Here is how I'm thinking about the two paths forward: Scenario 1: FKA is customizableIf iOS allows us to customize the native FKA focus ring, In this case, Scenario 2: FKA is strictly separate and not customizableIt’s also possible that FKA is (and is intended to be) a completely separate system from basic keyboard traversal, with no room for customization. If that’s the case, our goal is simpler: we just need to ensure that our Cupertino components look and behave correctly under both systems independently. So handling our own basic traversal visuals when FKA is OFF, and letting the system take over when FKA is ON. |
|
Sorry for late reply!
That's true! This reminded me that, if you add I think what I'm observing is leaning towards Scenario 2, and I agree with everything you said. Also if we're expecting more discussion (which I do), then we probably want to close this PR for now and start this discussion in a new issue for better tracking. Thank you for your contribution! |
|
Sounds good! I'll open a new issue, let's continue the discussion there. I'll close this PR for now until its clear what to do. |
This PR closes #180719 Related to [this](flutter/flutter#179224 (comment)) and [this](flutter/flutter#179225 (comment)). ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This PR closes #180719 Related to [this](flutter/flutter#179224 (comment)) and [this](flutter/flutter#179225 (comment)). ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

This PR
CupertinoContextMenuActionCupertinoContextMenuCupertinoContextMenuwith tab + m keyboard shortcut (iOS system keyboard traversal default behavior)CupertinoFocusHaloto use superellipse bordersSample how the new code looks and behaves
Simulator.Screen.Recording.-.iPhone.16.Plus.-.2025-11-28.at.22.26.35.mov
Sample how context menu traversal looks and behaves on iOS
Simulator.Screen.Recording.-.iPhone.16e.-.2025-11-28.at.22.27.28.mov
Code sample
Code sample
///).If you need help, consider asking for advice on the #hackers-new channel on Discord.
Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the
gemini-code-assistbot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.