-
Notifications
You must be signed in to change notification settings - Fork 29.7k
Description
Use case
This is a continuation of the discussion in #130536.
Desktop applications are expected to have a main menu. Since Flutter wants to support desktop systems, it should make it easy to implement desktop applications.
Proposal
The problem is that Flutter's focus system is used for both the regular application content and menu items. However, menu items are expected to affect the currently focused widget in the content, for example copy/paste (see #118731).
Flutter already has a system for event flow with its Intents and Actions. This could be used for this, but it's very hard to find out which widget had focus before the menu was opened.
This is the smallest code I could come up with to implement this:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
final class OneIntent extends Intent {
const OneIntent();
}
final class TwoIntent extends Intent {
const TwoIntent();
}
final class ThreeIntent extends Intent {
const ThreeIntent();
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
final contentFocus = FocusNode(debugLabel: 'content');
final class MenuItemInfo {
final String title;
final Intent intent;
const MenuItemInfo(this.title, this.intent);
}
const menuItems = [
MenuItemInfo('Intent 1', OneIntent()),
MenuItemInfo('Intent 2', TwoIntent()),
MenuItemInfo('Intent 3', ThreeIntent()),
];
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final _MenuOpenedNotifier _menuOpenedNotifier = _MenuOpenedNotifier();
FocusNode? _actionTarget;
final List<FocusNode> _actionButtonFocus = [FocusNode(), FocusNode(), FocusNode()];
void _onOpenMenu() {
final primaryFocus = FocusManager.instance.primaryFocus;
if (primaryFocus != null) {
var parent = primaryFocus.parent;
while (parent != null) {
if (parent == contentFocus) {
_actionTarget = primaryFocus;
break;
}
parent = parent.parent;
}
}
_menuOpenedNotifier.onOpen(_actionTarget);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: MenuBar(children: [
SubmenuButton(
menuChildren: menuItems.map(
(m) {
return _IntentDriver(
intent: m.intent,
builder: (context, {required active, required actionTarget}) {
return MenuItemButton(
onPressed: active
? () {
if (actionTarget?.context != null) {
Actions.invoke(actionTarget!.context!, m.intent);
}
}
: null,
child: Text(m.title));
},
changeNotifier: _menuOpenedNotifier);
},
).toList(growable: false),
onOpen: _onOpenMenu,
child: const Text('Menu 1'),
),
SubmenuButton(
menuChildren: const [MenuItemButton(child: Text('just a placeholder'))],
onOpen: _onOpenMenu,
child: const Text('Menu 2'),
),
]),
),
body: Focus(
focusNode: contentFocus,
canRequestFocus: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ActionButton(
title: 'Item 1',
intent: OneIntent,
autofocus: true,
focusNode: _actionButtonFocus[0],
),
ActionButton(
title: 'Item 2',
intent: TwoIntent,
focusNode: _actionButtonFocus[1],
),
ActionButton(
title: 'Item 3',
intent: ThreeIntent,
focusNode: _actionButtonFocus[2],
),
],
),
),
),
);
}
}
class ActionButton extends StatelessWidget {
const ActionButton({
super.key,
this.autofocus = false,
required this.title,
required this.intent,
this.focusNode,
});
final String title;
final bool autofocus;
final Type intent;
final FocusNode? focusNode;
@override
Widget build(BuildContext context) {
return Actions(
actions: {
intent: CallbackAction(onInvoke: (intent) {
print('Action fired!');
return null;
})
},
child: InkWell(
focusNode: focusNode,
onTap: () {
focusNode?.requestFocus();
},
autofocus: autofocus,
focusColor: Colors.red,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(title),
),
),
);
}
}
class _MenuOpenedNotifier extends ChangeNotifier {
FocusNode? actionTarget;
void onOpen(FocusNode? primaryFocus) {
actionTarget = primaryFocus;
notifyListeners();
}
}
class _IntentDriver extends StatefulWidget {
const _IntentDriver({required this.intent, required this.builder, required this.changeNotifier});
final _MenuOpenedNotifier changeNotifier;
final Intent intent;
final Widget Function(BuildContext context, {required bool active, required FocusNode? actionTarget}) builder;
@override
State<_IntentDriver> createState() => _IntentDriverState();
}
class _IntentDriverState extends State<_IntentDriver> {
bool _active = false;
FocusNode? _actionTarget;
@override
void initState() {
super.initState();
widget.changeNotifier.addListener(_refresh);
_refresh();
}
@override
void didUpdateWidget(_IntentDriver oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.changeNotifier.removeListener(_refresh);
widget.changeNotifier.addListener(_refresh);
}
@override
void dispose() {
widget.changeNotifier.removeListener(_refresh);
super.dispose();
}
void _refresh() {
final focus = widget.changeNotifier.actionTarget;
final context = focus?.context;
if (context != null) {
final active = Actions.maybeFind(context, intent: widget.intent) != null;
if (active != _active || _actionsTarget != focus) {
setState(() {
_active = active;
_actionTarget = focus;
});
}
}
}
@override
Widget build(BuildContext context) {
return widget.builder(context, active: _active, actionTarget: _actionTarget);
}
}This demo has three InkWells you can click to make them the focused widget. Then, there are three menu items, one for each InkWell. Only the menu item for the focused InkWell is available.
Note that implementing the onOpen callback for the second menu is also necessary, because when the user first clicks on the second menu and then moves over to the first one, the primaryFocus would be the second menu, not the InkWell. This is also why I have to check the focus tree, if we're really in the body and not on the menu.
As you can see, this is waaaay to long and complex for the very common use case on the Desktop platform. There should be a simpler way to accomplish this.