Implements a PlatformMenuBar widget and associated data structures (#100274)

Implements a PlatformMenuBar widget and associated data structures for defining menu bars that use native APIs for rendering.

This PR includes:
A PlatformMenuBar class, which is a widget that menu bar data can be attached to for sending to the platform.
A PlatformMenuDelegate base, which is the type taken by a new WidgetsBinding.platformMenuDelegate.
An implementation of the above in DefaultPlatformMenuDelegate that talks to the built-in "flutter/menu" channel to talk to the built-in platform implementation. The delegate is so that a plugin could override with its own delegate and provide other platforms with native menu support using the same widgets to define the menus.
This is the framework part of the implementation. The engine part will be in flutter/engine#32080 (and flutter/engine#32358)
This commit is contained in:
Greg Spencer 2022-04-04 15:03:10 -07:00 committed by GitHub
parent 2af2c9a68d
commit 2d9ad26086
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 1621 additions and 4 deletions

View file

@ -0,0 +1,138 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Flutter code sample for PlatformMenuBar
////////////////////////////////////
// THIS SAMPLE ONLY WORKS ON MACOS.
////////////////////////////////////
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() => runApp(const SampleApp());
enum MenuSelection {
about,
showMessage,
}
class SampleApp extends StatelessWidget {
const SampleApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(body: MyMenuBarApp()),
);
}
}
class MyMenuBarApp extends StatefulWidget {
const MyMenuBarApp({Key? key}) : super(key: key);
@override
State<MyMenuBarApp> createState() => _MyMenuBarAppState();
}
class _MyMenuBarAppState extends State<MyMenuBarApp> {
String _message = 'Hello';
bool _showMessage = false;
void _handleMenuSelection(MenuSelection value) {
switch (value) {
case MenuSelection.about:
showAboutDialog(
context: context,
applicationName: 'MenuBar Sample',
applicationVersion: '1.0.0',
);
break;
case MenuSelection.showMessage:
setState(() {
_showMessage = !_showMessage;
});
break;
}
}
@override
Widget build(BuildContext context) {
////////////////////////////////////
// THIS SAMPLE ONLY WORKS ON MACOS.
////////////////////////////////////
// This builds a menu hierarchy that looks like this:
// Flutter API Sample
// About
// (group divider)
// Hide/Show Message
// Messages
// I am not throwing away my shot.
// There's a million things I haven't done, but just you wait.
// Quit
return PlatformMenuBar(
menus: <MenuItem>[
PlatformMenu(
label: 'Flutter API Sample',
menus: <MenuItem>[
PlatformMenuItemGroup(
members: <MenuItem>[
PlatformMenuItem(
label: 'About',
onSelected: () {
_handleMenuSelection(MenuSelection.about);
},
)
],
),
PlatformMenuItemGroup(
members: <MenuItem>[
PlatformMenuItem(
onSelected: () {
_handleMenuSelection(MenuSelection.showMessage);
},
shortcut: const CharacterActivator('m'),
label: _showMessage ? 'Hide Message' : 'Show Message',
),
PlatformMenu(
label: 'Messages',
menus: <MenuItem>[
PlatformMenuItem(
label: 'I am not throwing away my shot.',
shortcut: const SingleActivator(LogicalKeyboardKey.digit1, meta: true),
onSelected: () {
setState(() {
_message = 'I am not throwing away my shot.';
});
},
),
PlatformMenuItem(
label: "There's a million things I haven't done, but just you wait.",
shortcut: const SingleActivator(LogicalKeyboardKey.digit2, meta: true),
onSelected: () {
setState(() {
_message = "There's a million things I haven't done, but just you wait.";
});
},
),
],
)
],
),
if (PlatformProvidedMenuItem.hasMenu(PlatformProvidedMenuItemType.quit))
const PlatformProvidedMenuItem(type: PlatformProvidedMenuItemType.quit),
],
),
],
body: Center(
child: Text(_showMessage
? _message
: 'This space intentionally left blank.\n'
'Show a message here using the menu.'),
),
);
}
}

View file

@ -28,7 +28,7 @@ class ChildLayoutHelper {
/// This method calls [RenderBox.getDryLayout] on the given [RenderBox].
///
/// This method should only be called by the parent of the provided
/// [RenderBox] child as it bounds parent and child together (if the child
/// [RenderBox] child as it binds parent and child together (if the child
/// is marked as dirty, the child will also be marked as dirty).
///
/// See also:
@ -46,7 +46,7 @@ class ChildLayoutHelper {
/// `parentUsesSize` set to true to receive its [Size].
///
/// This method should only be called by the parent of the provided
/// [RenderBox] child as it bounds parent and child together (if the child
/// [RenderBox] child as it binds parent and child together (if the child
/// is marked as dirty, the child will also be marked as dirty).
///
/// See also:

View file

@ -392,4 +392,53 @@ class SystemChannels {
'flutter/localization',
JSONMethodCodec(),
);
/// A [MethodChannel] for platform menu specification and control.
///
/// The following outgoing method is defined for this channel (invoked using
/// [OptionalMethodChannel.invokeMethod]):
///
/// * `Menu.setMenu`: sends the configuration of the platform menu, including
/// labels, enable/disable information, and unique integer identifiers for
/// each menu item. The configuration is sent as a `Map<String, Object?>`
/// encoding the list of top level menu items in window "0", which each
/// have a hierarchy of `Map<String, Object?>` containing the required
/// data, sent via a [StandardMessageCodec]. It is typically generated from
/// a list of [MenuItem]s, and ends up looking like this example:
///
/// ```dart
/// List<Map<String, Object?>> menu = <String, Object?>{
/// '0': <Map<String, Object?>>[
/// <String, Object?>{
/// 'id': 1,
/// 'label': 'First Menu Label',
/// 'enabled': true,
/// 'children': <Map<String, Object?>>[
/// <String, Object?>{
/// 'id': 2,
/// 'label': 'Sub Menu Label',
/// 'enabled': true,
/// },
/// ],
/// },
/// ],
/// };
/// ```
///
/// The following incoming methods are defined for this channel (registered
/// using [MethodChannel.setMethodCallHandler]).
///
/// * `Menu.selectedCallback`: Called when a menu item is selected, along
/// with the unique ID of the menu item selected.
///
/// * `Menu.opened`: Called when a submenu is opened, along with the unique
/// ID of the submenu.
///
/// * `Menu.closed`: Called when a submenu is closed, along with the unique
/// ID of the submenu.
///
/// See also:
///
/// * [DefaultPlatformMenuDelegate], which uses this channel.
static const MethodChannel menu = OptionalMethodChannel('flutter/menu');
}

View file

@ -16,6 +16,7 @@ import 'app.dart';
import 'debug.dart';
import 'focus_manager.dart';
import 'framework.dart';
import 'platform_menu_bar.dart';
import 'router.dart';
import 'widget_inspector.dart';
@ -294,6 +295,7 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB
FlutterErrorDetails.propertiesTransformers.add(debugTransformDebugCreator);
return true;
}());
platformMenuDelegate = DefaultPlatformMenuDelegate();
}
/// The current [WidgetsBinding], if one has been created.
@ -523,6 +525,13 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB
/// See [FocusManager] for more details.
FocusManager get focusManager => _buildOwner!.focusManager;
/// A delegate that communicates with a platform plugin for serializing and
/// managing platform-rendered menu bars created by [PlatformMenuBar].
///
/// This is set by default to a [DefaultPlatformMenuDelegate] instance in
/// [initInstances].
late PlatformMenuDelegate platformMenuDelegate;
final List<WidgetsBindingObserver> _observers = <WidgetsBindingObserver>[];
/// Registers the given object as a binding observer. Binding

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,7 @@ import 'focus_manager.dart';
import 'focus_scope.dart';
import 'framework.dart';
import 'inherited_notifier.dart';
import 'platform_menu_bar.dart';
/// A set of [KeyboardKey]s that can be used as the keys in a [Map].
///
@ -397,7 +398,7 @@ class ShortcutMapProperty extends DiagnosticsProperty<Map<ShortcutActivator, Int
///
/// * [CharacterActivator], an activator that represents key combinations
/// that result in the specified character, such as question mark.
class SingleActivator with Diagnosticable implements ShortcutActivator {
class SingleActivator with Diagnosticable, MenuSerializableShortcut implements ShortcutActivator {
/// Triggered when the [trigger] key is pressed while the modifiers are held.
///
/// The `trigger` should be the non-modifier key that is pressed after all the
@ -517,6 +518,17 @@ class SingleActivator with Diagnosticable implements ShortcutActivator {
&& (meta == (pressed.contains(LogicalKeyboardKey.metaLeft) || pressed.contains(LogicalKeyboardKey.metaRight)));
}
@override
ShortcutSerialization serializeForMenu() {
return ShortcutSerialization.modifier(
trigger,
shift: shift,
alt: alt,
meta: meta,
control: control,
);
}
/// Returns a short and readable description of the key combination.
///
/// Intended to be used in debug mode for logging purposes. In release mode,
@ -572,7 +584,7 @@ class SingleActivator with Diagnosticable implements ShortcutActivator {
///
/// * [SingleActivator], an activator that represents a single key combined
/// with modifiers, such as `Ctrl+C`.
class CharacterActivator with Diagnosticable implements ShortcutActivator {
class CharacterActivator with Diagnosticable, MenuSerializableShortcut implements ShortcutActivator {
/// Create a [CharacterActivator] from the triggering character.
const CharacterActivator(this.character);
@ -608,6 +620,11 @@ class CharacterActivator with Diagnosticable implements ShortcutActivator {
return result;
}
@override
ShortcutSerialization serializeForMenu() {
return ShortcutSerialization.character(character);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);

View file

@ -83,6 +83,7 @@ export 'src/widgets/page_view.dart';
export 'src/widgets/pages.dart';
export 'src/widgets/performance_overlay.dart';
export 'src/widgets/placeholder.dart';
export 'src/widgets/platform_menu_bar.dart';
export 'src/widgets/platform_view.dart';
export 'src/widgets/preferred_size.dart';
export 'src/widgets/primary_scroll_controller.dart';

View file

@ -0,0 +1,400 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/src/foundation/diagnostics.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeMenuChannel fakeMenuChannel;
late PlatformMenuDelegate originalDelegate;
late DefaultPlatformMenuDelegate delegate;
final List<String> activated = <String>[];
final List<String> opened = <String>[];
final List<String> closed = <String>[];
void onActivate(String item) {
activated.add(item);
}
void onOpen(String item) {
opened.add(item);
}
void onClose(String item) {
closed.add(item);
}
setUp(() {
fakeMenuChannel = FakeMenuChannel((MethodCall call) async {});
delegate = DefaultPlatformMenuDelegate(channel: fakeMenuChannel);
originalDelegate = WidgetsBinding.instance.platformMenuDelegate;
WidgetsBinding.instance.platformMenuDelegate = delegate;
activated.clear();
opened.clear();
closed.clear();
});
tearDown(() {
WidgetsBinding.instance.platformMenuDelegate = originalDelegate;
});
group('PlatformMenuBar', () {
testWidgets('basic menu structure is transmitted to platform', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Material(
child: PlatformMenuBar(
body: const Center(child: Text('Body')),
menus: createTestMenus(
onActivate: onActivate,
onOpen: onOpen,
onClose: onClose,
shortcuts: <String, MenuSerializableShortcut>{
subSubMenu10[0]: const SingleActivator(LogicalKeyboardKey.keyA, control: true),
subSubMenu10[1]: const SingleActivator(LogicalKeyboardKey.keyB, shift: true),
subSubMenu10[2]: const SingleActivator(LogicalKeyboardKey.keyC, alt: true),
subSubMenu10[3]: const SingleActivator(LogicalKeyboardKey.keyD, meta: true),
},
),
),
),
),
);
expect(fakeMenuChannel.outgoingCalls.last.method, equals('Menu.setMenu'));
expect(
fakeMenuChannel.outgoingCalls.last.arguments,
equals(
<String, Object?>{
'0': <Map<String, Object?>>[
<String, Object?>{
'id': 2,
'label': 'Menu 0',
'enabled': true,
'children': <Map<String, Object?>>[
<String, Object?>{
'id': 1,
'label': 'Sub Menu 00',
'enabled': true,
}
]
},
<String, Object?>{
'id': 12,
'label': 'Menu 1',
'enabled': true,
'children': <Map<String, Object?>>[
<String, Object?>{
'id': 3,
'label': 'Sub Menu 10',
'enabled': true,
},
<String, Object?>{
'id': 4,
'isDivider': true,
},
<String, Object?>{
'id': 10,
'label': 'Sub Menu 11',
'enabled': true,
'children': <Map<String, Object?>>[
<String, Object?>{
'id': 5,
'label': 'Sub Sub Menu 100',
'enabled': true,
'shortcutTrigger': 97,
'shortcutModifiers': 8
},
<String, Object?>{
'id': 6,
'isDivider': true,
},
<String, Object?>{
'id': 7,
'label': 'Sub Sub Menu 101',
'enabled': true,
'shortcutTrigger': 98,
'shortcutModifiers': 2
},
<String, Object?>{
'id': 8,
'label': 'Sub Sub Menu 102',
'enabled': true,
'shortcutTrigger': 99,
'shortcutModifiers': 4
},
<String, Object?>{
'id': 9,
'label': 'Sub Sub Menu 103',
'enabled': true,
'shortcutTrigger': 100,
'shortcutModifiers': 1
}
]
},
<String, Object?>{
'id': 11,
'label': 'Sub Menu 12',
'enabled': true,
}
]
},
<String, Object?>{
'id': 14,
'label': 'Menu 2',
'enabled': true,
'children': <Map<String, Object?>>[
<String, Object?>{
'id': 13,
'label': 'Sub Menu 20',
'enabled': false,
}
]
},
<String, Object?>{
'id': 15,
'label': 'Menu 3',
'enabled': false,
'children': <Map<String, Object?>>[],
},
],
},
),
);
});
testWidgets('asserts when more than one has locked the delegate', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: PlatformMenuBar(
body: PlatformMenuBar(
body: SizedBox(),
menus: <MenuItem>[],
),
menus: <MenuItem>[],
),
),
),
);
expect(tester.takeException(), isA<AssertionError>());
});
testWidgets('diagnostics', (WidgetTester tester) async {
const PlatformMenuItem item = PlatformMenuItem(
label: 'label2',
shortcut: SingleActivator(LogicalKeyboardKey.keyA),
);
const PlatformMenuBar menuBar = PlatformMenuBar(
body: SizedBox(),
menus: <MenuItem>[item],
);
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: menuBar,
),
),
);
await tester.pump();
expect(
menuBar.toStringDeep(),
equalsIgnoringHashCodes(
'PlatformMenuBar#00000\n'
' └─PlatformMenuItem#00000\n'
' label: "label2"\n'
' shortcut: SingleActivator#00000(keys: Key A)\n'
' DISABLED\n',
),
);
});
});
group('PlatformMenuBarItem', () {
testWidgets('diagnostics', (WidgetTester tester) async {
const PlatformMenuItem childItem = PlatformMenuItem(
label: 'label',
);
const PlatformMenu item = PlatformMenu(
label: 'label',
menus: <MenuItem>[childItem],
);
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
item.debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description, <String>[
'label: "label"',
]);
});
});
}
const List<String> mainMenu = <String>[
'Menu 0',
'Menu 1',
'Menu 2',
'Menu 3',
];
const List<String> subMenu0 = <String>[
'Sub Menu 00',
];
const List<String> subMenu1 = <String>[
'Sub Menu 10',
'Sub Menu 11',
'Sub Menu 12',
];
const List<String> subSubMenu10 = <String>[
'Sub Sub Menu 100',
'Sub Sub Menu 101',
'Sub Sub Menu 102',
'Sub Sub Menu 103',
];
const List<String> subMenu2 = <String>[
'Sub Menu 20',
];
List<MenuItem> createTestMenus({
void Function(String)? onActivate,
void Function(String)? onOpen,
void Function(String)? onClose,
Map<String, MenuSerializableShortcut> shortcuts = const <String, MenuSerializableShortcut>{},
bool includeStandard = false,
}) {
final List<MenuItem> result = <MenuItem>[
PlatformMenu(
label: mainMenu[0],
onOpen: onOpen != null ? () => onOpen(mainMenu[0]) : null,
onClose: onClose != null ? () => onClose(mainMenu[0]) : null,
menus: <MenuItem>[
PlatformMenuItem(
label: subMenu0[0],
onSelected: onActivate != null ? () => onActivate(subMenu0[0]) : null,
shortcut: shortcuts[subMenu0[0]],
),
],
),
PlatformMenu(
label: mainMenu[1],
onOpen: onOpen != null ? () => onOpen(mainMenu[1]) : null,
onClose: onClose != null ? () => onClose(mainMenu[1]) : null,
menus: <MenuItem>[
PlatformMenuItemGroup(
members: <MenuItem>[
PlatformMenuItem(
label: subMenu1[0],
onSelected: onActivate != null ? () => onActivate(subMenu1[0]) : null,
shortcut: shortcuts[subMenu1[0]],
),
],
),
PlatformMenu(
label: subMenu1[1],
onOpen: onOpen != null ? () => onOpen(subMenu1[1]) : null,
onClose: onClose != null ? () => onClose(subMenu1[1]) : null,
menus: <MenuItem>[
PlatformMenuItemGroup(
members: <MenuItem>[
PlatformMenuItem(
label: subSubMenu10[0],
onSelected: onActivate != null ? () => onActivate(subSubMenu10[0]) : null,
shortcut: shortcuts[subSubMenu10[0]],
),
],
),
PlatformMenuItem(
label: subSubMenu10[1],
onSelected: onActivate != null ? () => onActivate(subSubMenu10[1]) : null,
shortcut: shortcuts[subSubMenu10[1]],
),
PlatformMenuItem(
label: subSubMenu10[2],
onSelected: onActivate != null ? () => onActivate(subSubMenu10[2]) : null,
shortcut: shortcuts[subSubMenu10[2]],
),
PlatformMenuItem(
label: subSubMenu10[3],
onSelected: onActivate != null ? () => onActivate(subSubMenu10[3]) : null,
shortcut: shortcuts[subSubMenu10[3]],
),
],
),
PlatformMenuItem(
label: subMenu1[2],
onSelected: onActivate != null ? () => onActivate(subMenu1[2]) : null,
shortcut: shortcuts[subMenu1[2]],
),
],
),
PlatformMenu(
label: mainMenu[2],
onOpen: onOpen != null ? () => onOpen(mainMenu[2]) : null,
onClose: onClose != null ? () => onClose(mainMenu[2]) : null,
menus: <MenuItem>[
PlatformMenuItem(
// Always disabled.
label: subMenu2[0],
shortcut: shortcuts[subMenu2[0]],
),
],
),
// Disabled menu
PlatformMenu(
label: mainMenu[3],
onOpen: onOpen != null ? () => onOpen(mainMenu[2]) : null,
onClose: onClose != null ? () => onClose(mainMenu[2]) : null,
menus: <MenuItem>[],
),
];
return result;
}
class FakeMenuChannel implements MethodChannel {
FakeMenuChannel(this.outgoing) : assert(outgoing != null);
Future<dynamic> Function(MethodCall) outgoing;
Future<void> Function(MethodCall)? incoming;
List<MethodCall> outgoingCalls = <MethodCall>[];
@override
BinaryMessenger get binaryMessenger => throw UnimplementedError();
@override
MethodCodec get codec => const StandardMethodCodec();
@override
Future<List<T>> invokeListMethod<T>(String method, [dynamic arguments]) => throw UnimplementedError();
@override
Future<Map<K, V>> invokeMapMethod<K, V>(String method, [dynamic arguments]) => throw UnimplementedError();
@override
Future<T> invokeMethod<T>(String method, [dynamic arguments]) async {
final MethodCall call = MethodCall(method, arguments);
outgoingCalls.add(call);
return await outgoing(call) as T;
}
@override
String get name => 'flutter/menu';
@override
void setMethodCallHandler(Future<void> Function(MethodCall call)? handler) => incoming = handler;
}