| // 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 'dart:math' as math; |
| |
| import 'package:flutter/foundation.dart'; |
| import 'package:flutter/rendering.dart'; |
| import 'package:flutter/scheduler.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:flutter/widgets.dart'; |
| |
| import 'button_style.dart'; |
| import 'button_style_button.dart'; |
| import 'checkbox.dart'; |
| import 'color_scheme.dart'; |
| import 'colors.dart'; |
| import 'constants.dart'; |
| import 'icons.dart'; |
| import 'ink_well.dart'; |
| import 'material.dart'; |
| import 'material_localizations.dart'; |
| import 'material_state.dart'; |
| import 'menu_bar_theme.dart'; |
| import 'menu_button_theme.dart'; |
| import 'menu_style.dart'; |
| import 'menu_theme.dart'; |
| import 'radio.dart'; |
| import 'text_button.dart'; |
| import 'theme.dart'; |
| import 'theme_data.dart'; |
| |
| // Examples can assume: |
| // bool _throwShotAway = false; |
| // late BuildContext context; |
| // enum SingingCharacter { lafayette } |
| // late SingingCharacter? _character; |
| // late StateSetter setState; |
| |
| // Enable if you want verbose logging about menu changes. |
| const bool _kDebugMenus = false; |
| |
| // The default size of the arrow in _MenuItemLabel that indicates that a menu |
| // has a submenu. |
| const double _kDefaultSubmenuIconSize = 24; |
| |
| // The default spacing between the leading icon, label, trailing icon, and |
| // shortcut label in a _MenuItemLabel. |
| const double _kLabelItemDefaultSpacing = 12; |
| |
| // The minimum spacing between the leading icon, label, trailing icon, and |
| // shortcut label in a _MenuItemLabel. |
| const double _kLabelItemMinSpacing = 4; |
| |
| // Navigation shortcuts that we need to make sure are active when menus are |
| // open. |
| const Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts = <ShortcutActivator, Intent>{ |
| SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(), |
| SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), |
| SingleActivator(LogicalKeyboardKey.tab): NextFocusIntent(), |
| SingleActivator(LogicalKeyboardKey.tab, shift: true): PreviousFocusIntent(), |
| SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down), |
| SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up), |
| SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent(TraversalDirection.left), |
| SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent(TraversalDirection.right), |
| }; |
| |
| // The minimum vertical spacing on the outside of menus. |
| const double _kMenuVerticalMinPadding = 8; |
| |
| // How close to the edge of the safe area the menu will be placed. |
| const double _kMenuViewPadding = 8; |
| |
| // The minimum horizontal spacing on the outside of the top level menu. |
| const double _kTopLevelMenuHorizontalMinPadding = 4; |
| |
| /// The type of builder function used by [MenuAnchor.builder] to build the |
| /// widget that the [MenuAnchor] surrounds. |
| /// |
| /// The `context` is the context that the widget is being built in. |
| /// |
| /// The `controller` is the [MenuController] that can be used to open and close |
| /// the menu with. |
| /// |
| /// The `child` is an optional child supplied as the [MenuAnchor.child] |
| /// attribute. The child is intended to be incorporated in the result of the |
| /// function. |
| typedef MenuAnchorChildBuilder = Widget Function( |
| BuildContext context, |
| MenuController controller, |
| Widget? child, |
| ); |
| |
| /// A widget used to mark the "anchor" for a set of submenus, defining the |
| /// rectangle used to position the menu, which can be done either with an |
| /// explicit location, or with an alignment. |
| /// |
| /// When creating a menu with [MenuBar] or a [SubmenuButton], a [MenuAnchor] is |
| /// not needed, since they provide their own internally. |
| /// |
| /// The [MenuAnchor] is meant to be a slightly lower level interface than |
| /// [MenuBar], used in situations where a [MenuBar] isn't appropriate, or to |
| /// construct widgets or screen regions that have submenus. |
| /// |
| /// {@tool dartpad} |
| /// This example shows how to use a [MenuAnchor] to wrap a button and open a |
| /// cascading menu from the button. |
| /// |
| /// ** See code in examples/api/lib/material/menu_anchor/menu_anchor.0.dart ** |
| /// {@end-tool} |
| /// |
| /// {@tool dartpad} |
| /// This example shows how to use a [MenuAnchor] to create a cascading context |
| /// menu in a region of the view, positioned where the user clicks the mouse |
| /// with Ctrl pressed. The [anchorTapClosesMenu] attribute is set to true so |
| /// that clicks on the [MenuAnchor] area will cause the menus to be closed. |
| /// |
| /// ** See code in examples/api/lib/material/menu_anchor/menu_anchor.1.dart ** |
| /// {@end-tool} |
| class MenuAnchor extends StatefulWidget { |
| /// Creates a const [MenuAnchor]. |
| /// |
| /// The [menuChildren] argument is required. |
| const MenuAnchor({ |
| super.key, |
| this.controller, |
| this.childFocusNode, |
| this.style, |
| this.alignmentOffset = Offset.zero, |
| this.clipBehavior = Clip.hardEdge, |
| this.anchorTapClosesMenu = false, |
| this.onOpen, |
| this.onClose, |
| this.crossAxisUnconstrained = true, |
| required this.menuChildren, |
| this.builder, |
| this.child, |
| }); |
| |
| /// An optional controller that allows opening and closing of the menu from |
| /// other widgets. |
| final MenuController? controller; |
| |
| /// The [childFocusNode] attribute is the optional [FocusNode] also associated |
| /// the [child] or [builder] widget that opens the menu. |
| /// |
| /// The focus node should be attached to the widget that should receive focus |
| /// if keyboard focus traversal moves the focus off of the submenu with the |
| /// arrow keys. |
| /// |
| /// If not supplied, then keyboard traversal from the menu back to the |
| /// controlling button when the menu is open is disabled. |
| final FocusNode? childFocusNode; |
| |
| /// The [MenuStyle] that defines the visual attributes of the menu bar. |
| /// |
| /// Colors and sizing of the menus is controllable via the [MenuStyle]. |
| /// |
| /// Defaults to the ambient [MenuThemeData.style]. |
| final MenuStyle? style; |
| |
| /// The offset of the menu relative to the alignment origin determined by |
| /// [MenuStyle.alignment] on the [style] attribute and the ambient |
| /// [Directionality]. |
| /// |
| /// Use this for adjustments of the menu placement. |
| /// |
| /// Increasing [Offset.dy] values of [alignmentOffset] move the menu position |
| /// down. |
| /// |
| /// If the [MenuStyle.alignment] from [style] is not an [AlignmentDirectional] |
| /// (e.g. [Alignment]), then increasing [Offset.dx] values of |
| /// [alignmentOffset] move the menu position to the right. |
| /// |
| /// If the [MenuStyle.alignment] from [style] is an [AlignmentDirectional], |
| /// then in a [TextDirection.ltr] [Directionality], increasing [Offset.dx] |
| /// values of [alignmentOffset] move the menu position to the right. In a |
| /// [TextDirection.rtl] directionality, increasing [Offset.dx] values of |
| /// [alignmentOffset] move the menu position to the left. |
| /// |
| /// Defaults to [Offset.zero]. |
| final Offset? alignmentOffset; |
| |
| /// {@macro flutter.material.Material.clipBehavior} |
| /// |
| /// Defaults to [Clip.hardEdge]. |
| final Clip clipBehavior; |
| |
| /// Whether the menus will be closed if the anchor area is tapped. |
| /// |
| /// For menus opened by buttons that toggle the menu, if the button is tapped |
| /// when the menu is open, the button should close the menu. But if |
| /// [anchorTapClosesMenu] is true, then the menu will close, and |
| /// (surprisingly) immediately re-open. This is because tapping on the button |
| /// closes the menu before the `onPressed` or `onTap` handler is called |
| /// because of it being considered to be "outside" the menu system, and then |
| /// the button (seeing that the menu is closed) immediately reopens the menu. |
| /// The result is that the user thinks that tapping on the button does |
| /// nothing. So, for button-initiated menus, this value is typically false so |
| /// that the menu anchor area is considered "inside" of the menu system and |
| /// doesn't cause it to close unless [MenuController.close] is called. |
| /// |
| /// For menus that are positioned using [MenuController.open]'s `position` |
| /// parameter, it is often desirable that clicking on the anchor always closes |
| /// the menu since the anchor area isn't usually considered part of the menu |
| /// system by the user. In this case [anchorTapClosesMenu] should be true. |
| /// |
| /// Defaults to false. |
| final bool anchorTapClosesMenu; |
| |
| /// A callback that is invoked when the menu is opened. |
| final VoidCallback? onOpen; |
| |
| /// A callback that is invoked when the menu is closed. |
| final VoidCallback? onClose; |
| |
| /// Determine if the menu panel can be wrapped by a [UnconstrainedBox] which allows |
| /// the panel to render at its "natural" size. |
| /// |
| /// Defaults to true as it allows developers to render the menu panel at the |
| /// size it should be. When it is set to false, it can be useful when the menu should |
| /// be constrained in both main axis and cross axis, such as a [DropdownMenu]. |
| final bool crossAxisUnconstrained; |
| |
| /// A list of children containing the menu items that are the contents of the |
| /// menu surrounded by this [MenuAnchor]. |
| /// |
| /// {@macro flutter.material.MenuBar.shortcuts_note} |
| final List<Widget> menuChildren; |
| |
| /// The widget that this [MenuAnchor] surrounds. |
| /// |
| /// Typically this is a button used to open the menu by calling |
| /// [MenuController.open] on the `controller` passed to the builder. |
| /// |
| /// If not supplied, then the [MenuAnchor] will be the size that its parent |
| /// allocates for it. |
| final MenuAnchorChildBuilder? builder; |
| |
| /// The optional child to be passed to the [builder]. |
| /// |
| /// Supply this child if there is a portion of the widget tree built in |
| /// [builder] that doesn't depend on the `controller` or `context` supplied to |
| /// the [builder]. It will be more efficient, since Flutter doesn't then need |
| /// to rebuild this child when those change. |
| final Widget? child; |
| |
| @override |
| State<MenuAnchor> createState() => _MenuAnchorState(); |
| |
| @override |
| List<DiagnosticsNode> debugDescribeChildren() { |
| return menuChildren.map<DiagnosticsNode>((Widget child) => child.toDiagnosticsNode()).toList(); |
| } |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| properties.add(FlagProperty('anchorTapClosesMenu', value: anchorTapClosesMenu, ifTrue: 'AUTO-CLOSE')); |
| properties.add(DiagnosticsProperty<FocusNode?>('focusNode', childFocusNode)); |
| properties.add(DiagnosticsProperty<MenuStyle?>('style', style)); |
| properties.add(EnumProperty<Clip>('clipBehavior', clipBehavior)); |
| properties.add(DiagnosticsProperty<Offset?>('alignmentOffset', alignmentOffset)); |
| properties.add(StringProperty('child', child.toString())); |
| } |
| } |
| |
| class _MenuAnchorState extends State<MenuAnchor> { |
| // This is the global key that is used later to determine the bounding rect |
| // for the anchor's region that the CustomSingleChildLayout's delegate |
| // uses to determine where to place the menu on the screen and to avoid the |
| // view's edges. |
| final GlobalKey _anchorKey = GlobalKey(debugLabel: kReleaseMode ? null : 'MenuAnchor'); |
| _MenuAnchorState? _parent; |
| final FocusScopeNode _menuScopeNode = FocusScopeNode(debugLabel: kReleaseMode ? null : 'MenuAnchor sub menu'); |
| MenuController? _internalMenuController; |
| final List<_MenuAnchorState> _anchorChildren = <_MenuAnchorState>[]; |
| ScrollPosition? _position; |
| Size? _viewSize; |
| OverlayEntry? _overlayEntry; |
| Axis get _orientation => Axis.vertical; |
| bool get _isOpen => _overlayEntry != null; |
| bool get _isRoot => _parent == null; |
| bool get _isTopLevel => _parent?._isRoot ?? false; |
| MenuController get _menuController => widget.controller ?? _internalMenuController!; |
| |
| @override |
| void initState() { |
| super.initState(); |
| if (widget.controller == null) { |
| _internalMenuController = MenuController(); |
| } |
| _menuController._attach(this); |
| } |
| |
| @override |
| void dispose() { |
| assert(_debugMenuInfo('Disposing of $this')); |
| if (_isOpen) { |
| _close(inDispose: true); |
| _parent?._removeChild(this); |
| } |
| _anchorChildren.clear(); |
| _menuController._detach(this); |
| _internalMenuController = null; |
| super.dispose(); |
| } |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| _parent?._removeChild(this); |
| _parent = _MenuAnchorState._maybeOf(context); |
| _parent?._addChild(this); |
| _position?.isScrollingNotifier.removeListener(_handleScroll); |
| _position = Scrollable.maybeOf(context)?.position; |
| _position?.isScrollingNotifier.addListener(_handleScroll); |
| final Size newSize = MediaQuery.sizeOf(context); |
| if (_viewSize != null && newSize != _viewSize) { |
| // Close the menus if the view changes size. |
| _root._close(); |
| } |
| _viewSize = newSize; |
| } |
| |
| @override |
| void didUpdateWidget(MenuAnchor oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| if (oldWidget.controller != widget.controller) { |
| oldWidget.controller?._detach(this); |
| if (widget.controller != null) { |
| _internalMenuController?._detach(this); |
| _internalMenuController = null; |
| widget.controller?._attach(this); |
| } else { |
| assert(_internalMenuController == null); |
| _internalMenuController = MenuController().._attach(this); |
| } |
| } |
| assert(_menuController._anchor == this); |
| if (_overlayEntry != null) { |
| // Needs to update the overlay entry on the next frame, since it's in the |
| // overlay. |
| SchedulerBinding.instance.addPostFrameCallback((Duration _) { |
| _overlayEntry?.markNeedsBuild(); |
| }); |
| } |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| Widget child = _buildContents(context); |
| |
| if (!widget.anchorTapClosesMenu) { |
| child = TapRegion( |
| groupId: _root, |
| onTapOutside: (PointerDownEvent event) { |
| assert(_debugMenuInfo('Tapped Outside ${widget.controller}')); |
| _closeChildren(); |
| }, |
| child: child, |
| ); |
| } |
| |
| return _MenuAnchorScope( |
| anchorKey: _anchorKey, |
| anchor: this, |
| isOpen: _isOpen, |
| child: child, |
| ); |
| } |
| |
| Widget _buildContents(BuildContext context) { |
| return Builder( |
| key: _anchorKey, |
| builder: (BuildContext context) { |
| if (widget.builder == null) { |
| return widget.child ?? const SizedBox(); |
| } |
| return widget.builder!( |
| context, |
| _menuController, |
| widget.child, |
| ); |
| }, |
| ); |
| } |
| |
| // Returns the first focusable item in the submenu, where "first" is |
| // determined by the focus traversal policy. |
| FocusNode? get _firstItemFocusNode { |
| if (_menuScopeNode.context == null) { |
| return null; |
| } |
| final FocusTraversalPolicy policy = |
| FocusTraversalGroup.maybeOf(_menuScopeNode.context!) ?? ReadingOrderTraversalPolicy(); |
| return policy.findFirstFocus(_menuScopeNode, ignoreCurrentFocus: true); |
| } |
| |
| void _addChild(_MenuAnchorState child) { |
| assert(_isRoot || _debugMenuInfo('Added root child: $child')); |
| assert(!_anchorChildren.contains(child)); |
| _anchorChildren.add(child); |
| assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}')); |
| } |
| |
| void _removeChild(_MenuAnchorState child) { |
| assert(_isRoot || _debugMenuInfo('Removed root child: $child')); |
| assert(_anchorChildren.contains(child)); |
| _anchorChildren.remove(child); |
| assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}')); |
| } |
| |
| _MenuAnchorState? get _nextSibling { |
| final int index = _parent!._anchorChildren.indexOf(this); |
| assert(index != -1, 'Unable to find this widget $this in parent $_parent'); |
| if (index < _parent!._anchorChildren.length - 1) { |
| return _parent!._anchorChildren[index + 1]; |
| } |
| return null; |
| } |
| |
| _MenuAnchorState? get _previousSibling { |
| final int index = _parent!._anchorChildren.indexOf(this); |
| assert(index != -1, 'Unable to find this widget $this in parent $_parent'); |
| if (index > 0) { |
| return _parent!._anchorChildren[index - 1]; |
| } |
| return null; |
| } |
| |
| _MenuAnchorState get _root { |
| _MenuAnchorState anchor = this; |
| while (anchor._parent != null) { |
| anchor = anchor._parent!; |
| } |
| return anchor; |
| } |
| |
| _MenuAnchorState get _topLevel { |
| _MenuAnchorState handle = this; |
| while (handle._parent!._isTopLevel) { |
| handle = handle._parent!; |
| } |
| return handle; |
| } |
| |
| void _childChangedOpenState() { |
| if (mounted) { |
| _parent?._childChangedOpenState(); |
| setState(() { |
| // Mark dirty, but only if mounted. |
| }); |
| } |
| } |
| |
| void _focusButton() { |
| if (widget.childFocusNode == null) { |
| return; |
| } |
| assert(_debugMenuInfo('Requesting focus for ${widget.childFocusNode}')); |
| widget.childFocusNode!.requestFocus(); |
| } |
| |
| void _handleScroll() { |
| // If an ancestor scrolls, and we're a root anchor, then close the menus. |
| // Don't just close it on *any* scroll, since we want to be able to scroll |
| // menus themselves if they're too big for the view. |
| if (_isRoot) { |
| _root._close(); |
| } |
| } |
| |
| /// Open the menu, optionally at a position relative to the [MenuAnchor]. |
| /// |
| /// Call this when the menu should be shown to the user. |
| /// |
| /// The optional `position` argument will specify the location of the menu in |
| /// the local coordinates of the [MenuAnchor], ignoring any |
| /// [MenuStyle.alignment] and/or [MenuAnchor.alignmentOffset] that were |
| /// specified. |
| void _open({Offset? position}) { |
| assert(_menuController._anchor == this); |
| if (_isOpen && position == null) { |
| assert(_debugMenuInfo("Not opening $this because it's already open")); |
| return; |
| } |
| if (_isOpen && position != null) { |
| // The menu is already open, but we need to move to another location, so |
| // close it first. |
| _close(); |
| } |
| assert(_debugMenuInfo( |
| 'Opening $this at ${position ?? Offset.zero} with alignment offset ${widget.alignmentOffset ?? Offset.zero}')); |
| _parent?._closeChildren(); // Close all siblings. |
| assert(_overlayEntry == null); |
| |
| final BuildContext outerContext = context; |
| _parent?._childChangedOpenState(); |
| setState(() { |
| _overlayEntry = OverlayEntry( |
| builder: (BuildContext context) { |
| final OverlayState overlay = Overlay.of(outerContext); |
| return Positioned.directional( |
| textDirection: Directionality.of(outerContext), |
| top: 0, |
| start: 0, |
| child: Directionality( |
| textDirection: Directionality.of(outerContext), |
| child: InheritedTheme.captureAll( |
| // Copy all the themes from the supplied outer context to the |
| // overlay. |
| outerContext, |
| _MenuAnchorScope( |
| // Re-advertize the anchor here in the overlay, since |
| // otherwise a search for the anchor by descendants won't find |
| // it. |
| anchorKey: _anchorKey, |
| anchor: this, |
| isOpen: _isOpen, |
| child: _Submenu( |
| anchor: this, |
| menuStyle: widget.style, |
| alignmentOffset: widget.alignmentOffset ?? Offset.zero, |
| menuPosition: position, |
| clipBehavior: widget.clipBehavior, |
| menuChildren: widget.menuChildren, |
| crossAxisUnconstrained: widget.crossAxisUnconstrained, |
| ), |
| ), |
| to: overlay.context, |
| ), |
| ), |
| ); |
| }, |
| ); |
| }); |
| |
| Overlay.of(context).insert(_overlayEntry!); |
| widget.onOpen?.call(); |
| } |
| |
| /// Close the menu. |
| /// |
| /// Call this when the menu should be closed. Has no effect if the menu is |
| /// already closed. |
| void _close({bool inDispose = false}) { |
| assert(_debugMenuInfo('Closing $this')); |
| if (!_isOpen) { |
| return; |
| } |
| _closeChildren(inDispose: inDispose); |
| _overlayEntry?.remove(); |
| _overlayEntry = null; |
| if (!inDispose) { |
| // Notify that _childIsOpen changed state, but only if not |
| // currently disposing. |
| _parent?._childChangedOpenState(); |
| widget.onClose?.call(); |
| setState(() {}); |
| } |
| } |
| |
| void _closeChildren({bool inDispose = false}) { |
| assert(_debugMenuInfo('Closing children of $this${inDispose ? ' (dispose)' : ''}')); |
| for (final _MenuAnchorState child in List<_MenuAnchorState>.from(_anchorChildren)) { |
| child._close(inDispose: inDispose); |
| } |
| } |
| |
| // Returns the active anchor in the given context, if any, and creates a |
| // dependency relationship that will rebuild the context when the node |
| // changes. |
| static _MenuAnchorState? _maybeOf(BuildContext context) { |
| return context.dependOnInheritedWidgetOfExactType<_MenuAnchorScope>()?.anchor; |
| } |
| } |
| |
| /// A controller to manage a menu created by a [MenuBar] or [MenuAnchor]. |
| /// |
| /// A [MenuController] is used to control and interrogate a menu after it has |
| /// been created, with methods such as [open] and [close], and state accessors |
| /// like [isOpen]. |
| /// |
| /// See also: |
| /// |
| /// * [MenuAnchor], a widget that defines a region that has submenu. |
| /// * [MenuBar], a widget that creates a menu bar, that can take an optional |
| /// [MenuController]. |
| /// * [SubmenuButton], a widget that has a button that manages a submenu. |
| class MenuController { |
| /// The anchor that this controller controls. |
| /// |
| /// This is set automatically when a [MenuController] is given to the anchor |
| /// it controls. |
| _MenuAnchorState? _anchor; |
| |
| /// Whether or not the associated menu is currently open. |
| bool get isOpen { |
| assert(_anchor != null); |
| return _anchor!._isOpen; |
| } |
| |
| /// Close the menu that this menu controller is associated with. |
| /// |
| /// Associating with a menu is done by passing a [MenuController] to a |
| /// [MenuAnchor]. A [MenuController] is also be received by the |
| /// [MenuAnchor.builder] when invoked. |
| /// |
| /// If the menu's anchor point (either a [MenuBar] or a [MenuAnchor]) is |
| /// scrolled by an ancestor, or the view changes size, then any open menu will |
| /// automatically close. |
| void close() { |
| assert(_anchor != null); |
| _anchor!._close(); |
| } |
| |
| /// Opens the menu that this menu controller is associated with. |
| /// |
| /// If `position` is given, then the menu will open at the position given, in |
| /// the coordinate space of the [MenuAnchor] this controller is attached to. |
| /// |
| /// If given, the `position` will override the [MenuAnchor.alignmentOffset] |
| /// given to the [MenuAnchor]. |
| /// |
| /// If the menu's anchor point (either a [MenuBar] or a [MenuAnchor]) is |
| /// scrolled by an ancestor, or the view changes size, then any open menu will |
| /// automatically close. |
| void open({Offset? position}) { |
| assert(_anchor != null); |
| _anchor!._open(position: position); |
| } |
| |
| // ignore: use_setters_to_change_properties |
| void _attach(_MenuAnchorState anchor) { |
| _anchor = anchor; |
| } |
| |
| void _detach(_MenuAnchorState anchor) { |
| if (_anchor == anchor) { |
| _anchor = null; |
| } |
| } |
| } |
| |
| /// A menu bar that manages cascading child menus. |
| /// |
| /// This is a Material Design menu bar that typically resides above the main |
| /// body of an application (but can go anywhere) that defines a menu system for |
| /// invoking callbacks in response to user selection of a menu item. |
| /// |
| /// The menus can be opened with a click or tap. Once a menu is opened, it can |
| /// be navigated by using the arrow and tab keys or via mouse hover. Selecting a |
| /// menu item can be done by pressing enter, or by clicking or tapping on the |
| /// menu item. Clicking or tapping on any part of the user interface that isn't |
| /// part of the menu system controlled by the same controller will cause all of |
| /// the menus controlled by that controller to close, as will pressing the |
| /// escape key. |
| /// |
| /// When a menu item with a submenu is clicked on, it toggles the visibility of |
| /// the submenu. When the menu item is hovered over, the submenu will open, and |
| /// hovering over other items will close the previous menu and open the newly |
| /// hovered one. When those open/close transitions occur, |
| /// [SubmenuButton.onOpen], and [SubmenuButton.onClose] are called on the |
| /// corresponding [SubmenuButton] child of the menu bar. |
| /// |
| /// {@template flutter.material.MenuBar.shortcuts_note} |
| /// Menus using [MenuItemButton] can have a [SingleActivator] or |
| /// [CharacterActivator] assigned to them as their [MenuItemButton.shortcut], |
| /// which will display an appropriate shortcut hint. Even though the shortcut |
| /// labels are displayed in the menu, shortcuts are not automatically handled. |
| /// They must be available in whatever context they are appropriate, and handled |
| /// via another mechanism. |
| /// |
| /// If shortcuts should be generally enabled, but are not easily defined in a |
| /// context surrounding the menu bar, consider registering them with a |
| /// [ShortcutRegistry] (one is already included in the [WidgetsApp], and thus |
| /// also [MaterialApp] and [CupertinoApp]), as shown in the example below. To be |
| /// sure that selecting a menu item and triggering the shortcut do the same |
| /// thing, it is recommended that they call the same callback. |
| /// |
| /// {@tool dartpad} This example shows a [MenuBar] that contains a single top |
| /// level menu, containing three items: "About", a checkbox menu item for |
| /// showing a message, and "Quit". The items are identified with an enum value, |
| /// and the shortcuts are registered globally with the [ShortcutRegistry]. |
| /// |
| /// ** See code in examples/api/lib/material/menu_anchor/menu_bar.0.dart ** |
| /// {@end-tool} |
| /// {@endtemplate} |
| /// |
| /// {@macro flutter.material.MenuAcceleratorLabel.accelerator_sample} |
| /// |
| /// See also: |
| /// |
| /// * [MenuAnchor], a widget that creates a region with a submenu and shows it |
| /// when requested. |
| /// * [SubmenuButton], a menu item which manages a submenu. |
| /// * [MenuItemButton], a leaf menu item which displays the label, an optional |
| /// shortcut label, and optional leading and trailing icons. |
| /// * [PlatformMenuBar], which creates a menu bar that is rendered by the host |
| /// platform instead of by Flutter (on macOS, for example). |
| /// * [ShortcutRegistry], a registry of shortcuts that apply for the entire |
| /// application. |
| /// * [VoidCallbackIntent], to define intents that will call a [VoidCallback] and |
| /// work with the [Actions] and [Shortcuts] system. |
| /// * [CallbackShortcuts], to define shortcuts that call a callback without |
| /// involving [Actions]. |
| class MenuBar extends StatelessWidget { |
| /// Creates a const [MenuBar]. |
| /// |
| /// The [children] argument is required. |
| const MenuBar({ |
| super.key, |
| this.style, |
| this.clipBehavior = Clip.none, |
| this.controller, |
| required this.children, |
| }); |
| |
| /// The [MenuStyle] that defines the visual attributes of the menu bar. |
| /// |
| /// Colors and sizing of the menus is controllable via the [MenuStyle]. |
| /// |
| /// Defaults to the ambient [MenuThemeData.style]. |
| final MenuStyle? style; |
| |
| /// {@macro flutter.material.Material.clipBehavior} |
| /// |
| /// Defaults to [Clip.none]. |
| final Clip clipBehavior; |
| |
| /// The [MenuController] to use for this menu bar. |
| final MenuController? controller; |
| |
| /// The list of menu items that are the top level children of the [MenuBar]. |
| /// |
| /// A Widget in Flutter is immutable, so directly modifying the [children] |
| /// with [List] APIs such as `someMenuBarWidget.menus.add(...)` will result in |
| /// incorrect behaviors. Whenever the menus list is modified, a new list |
| /// object must be provided. |
| /// |
| /// {@macro flutter.material.MenuBar.shortcuts_note} |
| final List<Widget> children; |
| |
| @override |
| Widget build(BuildContext context) { |
| assert(debugCheckHasOverlay(context)); |
| return _MenuBarAnchor( |
| controller: controller, |
| clipBehavior: clipBehavior, |
| style: style, |
| menuChildren: children, |
| ); |
| } |
| |
| @override |
| List<DiagnosticsNode> debugDescribeChildren() { |
| return <DiagnosticsNode>[ |
| ...children.map<DiagnosticsNode>( |
| (Widget item) => item.toDiagnosticsNode(), |
| ), |
| ]; |
| } |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| properties.add(DiagnosticsProperty<MenuStyle?>('style', style, defaultValue: null)); |
| properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: null)); |
| } |
| } |
| |
| /// A button for use in a [MenuBar], in a menu created with [MenuAnchor], or on |
| /// its own, that can be activated by click or keyboard navigation. |
| /// |
| /// This widget represents a leaf entry in a menu hierarchy that is typically |
| /// part of a [MenuBar], but may be used independently, or as part of a menu |
| /// created with a [MenuAnchor]. |
| /// |
| /// {@macro flutter.material.MenuBar.shortcuts_note} |
| /// |
| /// See also: |
| /// |
| /// * [MenuBar], a class that creates a top level menu bar in a Material Design |
| /// style. |
| /// * [MenuAnchor], a widget that creates a region with a submenu and shows it |
| /// when requested. |
| /// * [SubmenuButton], a menu item similar to this one which manages a submenu. |
| /// * [PlatformMenuBar], which creates a menu bar that is rendered by the host |
| /// platform instead of by Flutter (on macOS, for example). |
| /// * [ShortcutRegistry], a registry of shortcuts that apply for the entire |
| /// application. |
| /// * [VoidCallbackIntent], to define intents that will call a [VoidCallback] and |
| /// work with the [Actions] and [Shortcuts] system. |
| /// * [CallbackShortcuts] to define shortcuts that call a callback without |
| /// involving [Actions]. |
| class MenuItemButton extends StatefulWidget { |
| /// Creates a const [MenuItemButton]. |
| /// |
| /// The [child] attribute is required. |
| const MenuItemButton({ |
| super.key, |
| this.onPressed, |
| this.onHover, |
| this.requestFocusOnHover = true, |
| this.onFocusChange, |
| this.focusNode, |
| this.shortcut, |
| this.style, |
| this.statesController, |
| this.clipBehavior = Clip.none, |
| this.leadingIcon, |
| this.trailingIcon, |
| this.closeOnActivate = true, |
| required this.child, |
| }); |
| |
| /// Called when the button is tapped or otherwise activated. |
| /// |
| /// If this callback is null, then the button will be disabled. |
| /// |
| /// See also: |
| /// |
| /// * [enabled], which is true if the button is enabled. |
| final VoidCallback? onPressed; |
| |
| /// Called when a pointer enters or exits the button response area. |
| /// |
| /// The value passed to the callback is true if a pointer has entered button |
| /// area and false if a pointer has exited. |
| final ValueChanged<bool>? onHover; |
| |
| /// Determine if hovering can request focus. |
| /// |
| /// Defaults to true. |
| final bool requestFocusOnHover; |
| |
| /// Handler called when the focus changes. |
| /// |
| /// Called with true if this widget's node gains focus, and false if it loses |
| /// focus. |
| final ValueChanged<bool>? onFocusChange; |
| |
| /// {@macro flutter.widgets.Focus.focusNode} |
| final FocusNode? focusNode; |
| |
| /// The optional shortcut that selects this [MenuItemButton]. |
| /// |
| /// {@macro flutter.material.MenuBar.shortcuts_note} |
| final MenuSerializableShortcut? shortcut; |
| |
| /// Customizes this button's appearance. |
| /// |
| /// Non-null properties of this style override the corresponding properties in |
| /// [themeStyleOf] and [defaultStyleOf]. [MaterialStateProperty]s that resolve |
| /// to non-null values will similarly override the corresponding |
| /// [MaterialStateProperty]s in [themeStyleOf] and [defaultStyleOf]. |
| /// |
| /// Null by default. |
| final ButtonStyle? style; |
| |
| /// {@macro flutter.material.inkwell.statesController} |
| final MaterialStatesController? statesController; |
| |
| /// {@macro flutter.material.Material.clipBehavior} |
| /// |
| /// Defaults to [Clip.none]. |
| final Clip clipBehavior; |
| |
| /// An optional icon to display before the [child] label. |
| final Widget? leadingIcon; |
| |
| /// An optional icon to display after the [child] label. |
| final Widget? trailingIcon; |
| |
| /// {@template flutter.material.menu_anchor.closeOnActivate} |
| /// Determines if the menu will be closed when a [MenuItemButton] |
| /// is pressed. |
| /// |
| /// Defaults to true. |
| /// {@endtemplate} |
| final bool closeOnActivate; |
| |
| /// The widget displayed in the center of this button. |
| /// |
| /// Typically this is the button's label, using a [Text] widget. |
| /// |
| /// {@macro flutter.widgets.ProxyWidget.child} |
| final Widget? child; |
| |
| /// Whether the button is enabled or disabled. |
| /// |
| /// To enable a button, set its [onPressed] property to a non-null value. |
| bool get enabled => onPressed != null; |
| |
| @override |
| State<MenuItemButton> createState() => _MenuItemButtonState(); |
| |
| /// Defines the button's default appearance. |
| /// |
| /// {@macro flutter.material.text_button.default_style_of} |
| /// |
| /// {@macro flutter.material.text_button.material3_defaults} |
| ButtonStyle defaultStyleOf(BuildContext context) { |
| return _MenuButtonDefaultsM3(context); |
| } |
| |
| /// Returns the [MenuButtonThemeData.style] of the closest |
| /// [MenuButtonTheme] ancestor. |
| ButtonStyle? themeStyleOf(BuildContext context) { |
| return MenuButtonTheme.of(context).style; |
| } |
| |
| /// A static convenience method that constructs a [MenuItemButton]'s |
| /// [ButtonStyle] given simple values. |
| /// |
| /// The [foregroundColor] color is used to create a [MaterialStateProperty] |
| /// [ButtonStyle.foregroundColor] value. Specify a value for [foregroundColor] |
| /// to specify the color of the button's icons. Use [backgroundColor] for the |
| /// button's background fill color. Use [disabledForegroundColor] and |
| /// [disabledBackgroundColor] to specify the button's disabled icon and fill |
| /// color. |
| /// |
| /// All of the other parameters are either used directly or used to create a |
| /// [MaterialStateProperty] with a single value for all states. |
| /// |
| /// All parameters default to null, by default this method returns a |
| /// [ButtonStyle] that doesn't override anything. |
| /// |
| /// For example, to override the default foreground color for a |
| /// [MenuItemButton], as well as its overlay color, with all of the standard |
| /// opacity adjustments for the pressed, focused, and hovered states, one |
| /// could write: |
| /// |
| /// ```dart |
| /// MenuItemButton( |
| /// leadingIcon: const Icon(Icons.pets), |
| /// style: MenuItemButton.styleFrom(foregroundColor: Colors.green), |
| /// onPressed: () { |
| /// // ... |
| /// }, |
| /// child: const Text('Button Label'), |
| /// ), |
| /// ``` |
| static ButtonStyle styleFrom({ |
| Color? foregroundColor, |
| Color? backgroundColor, |
| Color? disabledForegroundColor, |
| Color? disabledBackgroundColor, |
| Color? shadowColor, |
| Color? surfaceTintColor, |
| Color? iconColor, |
| TextStyle? textStyle, |
| double? elevation, |
| EdgeInsetsGeometry? padding, |
| Size? minimumSize, |
| Size? fixedSize, |
| Size? maximumSize, |
| MouseCursor? enabledMouseCursor, |
| MouseCursor? disabledMouseCursor, |
| BorderSide? side, |
| OutlinedBorder? shape, |
| VisualDensity? visualDensity, |
| MaterialTapTargetSize? tapTargetSize, |
| Duration? animationDuration, |
| bool? enableFeedback, |
| AlignmentGeometry? alignment, |
| InteractiveInkFeatureFactory? splashFactory, |
| }) { |
| return TextButton.styleFrom( |
| foregroundColor: foregroundColor, |
| backgroundColor: backgroundColor, |
| disabledBackgroundColor: disabledBackgroundColor, |
| disabledForegroundColor: disabledForegroundColor, |
| shadowColor: shadowColor, |
| surfaceTintColor: surfaceTintColor, |
| iconColor: iconColor, |
| textStyle: textStyle, |
| elevation: elevation, |
| padding: padding, |
| minimumSize: minimumSize, |
| fixedSize: fixedSize, |
| maximumSize: maximumSize, |
| enabledMouseCursor: enabledMouseCursor, |
| disabledMouseCursor: disabledMouseCursor, |
| side: side, |
| shape: shape, |
| visualDensity: visualDensity, |
| tapTargetSize: tapTargetSize, |
| animationDuration: animationDuration, |
| enableFeedback: enableFeedback, |
| alignment: alignment, |
| splashFactory: splashFactory, |
| ); |
| } |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| properties.add(FlagProperty('enabled', value: onPressed != null, ifFalse: 'DISABLED')); |
| properties.add(DiagnosticsProperty<String>('child', child.toString())); |
| properties.add(DiagnosticsProperty<ButtonStyle?>('style', style, defaultValue: null)); |
| properties.add(DiagnosticsProperty<MenuSerializableShortcut?>('shortcut', shortcut, defaultValue: null)); |
| properties.add(DiagnosticsProperty<Widget?>('leadingIcon', leadingIcon, defaultValue: null)); |
| properties.add(DiagnosticsProperty<Widget?>('trailingIcon', trailingIcon, defaultValue: null)); |
| properties.add(DiagnosticsProperty<FocusNode?>('focusNode', focusNode, defaultValue: null)); |
| properties.add(EnumProperty<Clip>('clipBehavior', clipBehavior, defaultValue: Clip.none)); |
| properties.add(DiagnosticsProperty<MaterialStatesController?>('statesController', statesController, defaultValue: null)); |
| } |
| } |
| |
| class _MenuItemButtonState extends State<MenuItemButton> { |
| // If a focus node isn't given to the widget, then we have to manage our own. |
| FocusNode? _internalFocusNode; |
| FocusNode get _focusNode => widget.focusNode ?? _internalFocusNode!; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _createInternalFocusNodeIfNeeded(); |
| _focusNode.addListener(_handleFocusChange); |
| } |
| |
| @override |
| void dispose() { |
| _focusNode.removeListener(_handleFocusChange); |
| _internalFocusNode?.dispose(); |
| _internalFocusNode = null; |
| super.dispose(); |
| } |
| |
| @override |
| void didUpdateWidget(MenuItemButton oldWidget) { |
| if (widget.focusNode != oldWidget.focusNode) { |
| _focusNode.removeListener(_handleFocusChange); |
| if (widget.focusNode != null) { |
| _internalFocusNode?.dispose(); |
| _internalFocusNode = null; |
| } |
| _createInternalFocusNodeIfNeeded(); |
| _focusNode.addListener(_handleFocusChange); |
| } |
| super.didUpdateWidget(oldWidget); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| // Since we don't want to use the theme style or default style from the |
| // TextButton, we merge the styles, merging them in the right order when |
| // each type of style exists. Each "*StyleOf" function is only called once. |
| ButtonStyle mergedStyle = widget.themeStyleOf(context)?.merge(widget.defaultStyleOf(context)) |
| ?? widget.defaultStyleOf(context); |
| if (widget.style != null) { |
| mergedStyle = widget.style!.merge(mergedStyle); |
| } |
| |
| Widget child = TextButton( |
| onPressed: widget.enabled ? _handleSelect : null, |
| onHover: widget.enabled ? _handleHover : null, |
| onFocusChange: widget.enabled ? widget.onFocusChange : null, |
| focusNode: _focusNode, |
| style: mergedStyle, |
| statesController: widget.statesController, |
| clipBehavior: widget.clipBehavior, |
| isSemanticButton: null, |
| child: _MenuItemLabel( |
| leadingIcon: widget.leadingIcon, |
| shortcut: widget.shortcut, |
| trailingIcon: widget.trailingIcon, |
| hasSubmenu: false, |
| child: widget.child!, |
| ), |
| ); |
| |
| if (_platformSupportsAccelerators && widget.enabled) { |
| child = MenuAcceleratorCallbackBinding( |
| onInvoke: _handleSelect, |
| child: child, |
| ); |
| } |
| |
| return MergeSemantics(child: child); |
| } |
| |
| void _handleFocusChange() { |
| if (!_focusNode.hasPrimaryFocus) { |
| // Close any child menus of this button's menu. |
| _MenuAnchorState._maybeOf(context)?._closeChildren(); |
| } |
| } |
| |
| void _handleHover(bool hovering) { |
| widget.onHover?.call(hovering); |
| if (hovering && widget.requestFocusOnHover) { |
| assert(_debugMenuInfo('Requesting focus for $_focusNode from hover')); |
| _focusNode.requestFocus(); |
| } |
| } |
| |
| void _handleSelect() { |
| assert(_debugMenuInfo('Selected ${widget.child} menu')); |
| if (widget.closeOnActivate) { |
| _MenuAnchorState._maybeOf(context)?._root._close(); |
| } |
| // Delay the call to onPressed until post-frame so that the focus is |
| // restored to what it was before the menu was opened before the action is |
| // executed. |
| SchedulerBinding.instance.addPostFrameCallback((Duration _) { |
| FocusManager.instance.applyFocusChangesIfNeeded(); |
| widget.onPressed?.call(); |
| }); |
| } |
| |
| void _createInternalFocusNodeIfNeeded() { |
| if (widget.focusNode == null) { |
| _internalFocusNode = FocusNode(); |
| assert(() { |
| if (_internalFocusNode != null) { |
| _internalFocusNode!.debugLabel = '$MenuItemButton(${widget.child})'; |
| } |
| return true; |
| }()); |
| } |
| } |
| } |
| |
| /// A menu item that combines a [Checkbox] widget with a [MenuItemButton]. |
| /// |
| /// To style the checkbox separately from the button, add a [CheckboxTheme] |
| /// ancestor. |
| /// |
| /// {@tool dartpad} |
| /// This example shows a menu with a checkbox that shows a message in the body |
| /// of the app if checked. |
| /// |
| /// ** See code in examples/api/lib/material/menu_anchor/checkbox_menu_button.0.dart ** |
| /// {@end-tool} |
| /// |
| /// See also: |
| /// |
| /// - [MenuBar], a widget that creates a menu bar of cascading menu items. |
| /// - [MenuAnchor], a widget that defines a region which can host a cascading |
| /// menu. |
| class CheckboxMenuButton extends StatelessWidget { |
| /// Creates a const [CheckboxMenuButton]. |
| /// |
| /// The [child], [value], and [onChanged] attributes are required. |
| const CheckboxMenuButton({ |
| super.key, |
| required this.value, |
| this.tristate = false, |
| this.isError = false, |
| required this.onChanged, |
| this.onHover, |
| this.onFocusChange, |
| this.focusNode, |
| this.shortcut, |
| this.style, |
| this.statesController, |
| this.clipBehavior = Clip.none, |
| this.trailingIcon, |
| this.closeOnActivate = true, |
| required this.child, |
| }); |
| |
| /// Whether this checkbox is checked. |
| /// |
| /// When [tristate] is true, a value of null corresponds to the mixed state. |
| /// When [tristate] is false, this value must not be null. |
| final bool? value; |
| |
| /// If true, then the checkbox's [value] can be true, false, or null. |
| /// |
| /// [CheckboxMenuButton] displays a dash when its value is null. |
| /// |
| /// When a tri-state checkbox ([tristate] is true) is tapped, its [onChanged] |
| /// callback will be applied to true if the current value is false, to null if |
| /// value is true, and to false if value is null (i.e. it cycles through false |
| /// => true => null => false when tapped). |
| /// |
| /// If tristate is false (the default), [value] must not be null. |
| final bool tristate; |
| |
| /// True if this checkbox wants to show an error state. |
| /// |
| /// The checkbox will have different default container color and check color when |
| /// this is true. This is only used when [ThemeData.useMaterial3] is set to true. |
| /// |
| /// Must not be null. Defaults to false. |
| final bool isError; |
| |
| /// Called when the value of the checkbox should change. |
| /// |
| /// The checkbox passes the new value to the callback but does not actually |
| /// change state until the parent widget rebuilds the checkbox with the new |
| /// value. |
| /// |
| /// If this callback is null, the menu item will be displayed as disabled |
| /// and will not respond to input gestures. |
| /// |
| /// When the checkbox is tapped, if [tristate] is false (the default) then the |
| /// [onChanged] callback will be applied to `!value`. If [tristate] is true |
| /// this callback cycle from false to true to null and then back to false |
| /// again. |
| /// |
| /// The callback provided to [onChanged] should update the state of the parent |
| /// [StatefulWidget] using the [State.setState] method, so that the parent |
| /// gets rebuilt; for example: |
| /// |
| /// ```dart |
| /// CheckboxMenuButton( |
| /// value: _throwShotAway, |
| /// child: const Text('THROW'), |
| /// onChanged: (bool? newValue) { |
| /// setState(() { |
| /// _throwShotAway = newValue!; |
| /// }); |
| /// }, |
| /// ) |
| /// ``` |
| final ValueChanged<bool?>? onChanged; |
| |
| /// Called when a pointer enters or exits the button response area. |
| /// |
| /// The value passed to the callback is true if a pointer has entered button |
| /// area and false if a pointer has exited. |
| final ValueChanged<bool>? onHover; |
| |
| /// Handler called when the focus changes. |
| /// |
| /// Called with true if this widget's node gains focus, and false if it loses |
| /// focus. |
| final ValueChanged<bool>? onFocusChange; |
| |
| /// {@macro flutter.widgets.Focus.focusNode} |
| final FocusNode? focusNode; |
| |
| /// The optional shortcut that selects this [MenuItemButton]. |
| /// |
| /// {@macro flutter.material.MenuBar.shortcuts_note} |
| final MenuSerializableShortcut? shortcut; |
| |
| /// Customizes this button's appearance. |
| /// |
| /// Non-null properties of this style override the corresponding properties in |
| /// [MenuItemButton.themeStyleOf] and [MenuItemButton.defaultStyleOf]. |
| /// [MaterialStateProperty]s that resolve to non-null values will similarly |
| /// override the corresponding [MaterialStateProperty]s in |
| /// [MenuItemButton.themeStyleOf] and [MenuItemButton.defaultStyleOf]. |
| /// |
| /// Null by default. |
| final ButtonStyle? style; |
| |
| /// {@macro flutter.material.inkwell.statesController} |
| final MaterialStatesController? statesController; |
| |
| /// {@macro flutter.material.Material.clipBehavior} |
| /// |
| /// Defaults to [Clip.none]. |
| final Clip clipBehavior; |
| |
| /// An optional icon to display after the [child] label. |
| final Widget? trailingIcon; |
| |
| /// {@macro flutter.material.menu_anchor.closeOnActivate} |
| final bool closeOnActivate; |
| |
| /// The widget displayed in the center of this button. |
| /// |
| /// Typically this is the button's label, using a [Text] widget. |
| /// |
| /// {@macro flutter.widgets.ProxyWidget.child} |
| final Widget? child; |
| |
| /// Whether the button is enabled or disabled. |
| /// |
| /// To enable a button, set its [onChanged] property to a non-null value. |
| bool get enabled => onChanged != null; |
| |
| @override |
| Widget build(BuildContext context) { |
| return MenuItemButton( |
| key: key, |
| onPressed: onChanged == null ? null : () { |
| switch (value) { |
| case false: |
| onChanged!.call(true); |
| case true: |
| onChanged!.call(tristate ? null : false); |
| case null: |
| onChanged!.call(false); |
| } |
| }, |
| onHover: onHover, |
| onFocusChange: onFocusChange, |
| focusNode: focusNode, |
| style: style, |
| shortcut: shortcut, |
| statesController: statesController, |
| leadingIcon: ExcludeFocus( |
| child: IgnorePointer( |
| child: ConstrainedBox( |
| constraints: const BoxConstraints( |
| maxHeight: Checkbox.width, |
| maxWidth: Checkbox.width, |
| ), |
| child: Checkbox( |
| tristate: tristate, |
| value: value, |
| onChanged: onChanged, |
| isError: isError, |
| ), |
| ), |
| ), |
| ), |
| clipBehavior: clipBehavior, |
| trailingIcon: trailingIcon, |
| closeOnActivate: closeOnActivate, |
| child: child, |
| ); |
| } |
| } |
| |
| /// A menu item that combines a [Radio] widget with a [MenuItemButton]. |
| /// |
| /// To style the radio button separately from the overall button, add a |
| /// [RadioTheme] ancestor. |
| /// |
| /// {@tool dartpad} |
| /// This example shows a menu with three radio buttons with shortcuts that |
| /// changes the background color of the body when the buttons are selected. |
| /// |
| /// ** See code in examples/api/lib/material/menu_anchor/radio_menu_button.0.dart ** |
| /// {@end-tool} |
| /// |
| /// See also: |
| /// |
| /// - [MenuBar], a widget that creates a menu bar of cascading menu items. |
| /// - [MenuAnchor], a widget that defines a region which can host a cascading |
| /// menu. |
| class RadioMenuButton<T> extends StatelessWidget { |
| /// Creates a const [RadioMenuButton]. |
| /// |
| /// The [child] attribute is required. |
| const RadioMenuButton({ |
| super.key, |
| required this.value, |
| required this.groupValue, |
| required this.onChanged, |
| this.toggleable = false, |
| this.onHover, |
| this.onFocusChange, |
| this.focusNode, |
| this.shortcut, |
| this.style, |
| this.statesController, |
| this.clipBehavior = Clip.none, |
| this.trailingIcon, |
| this.closeOnActivate = true, |
| required this.child, |
| }); |
| |
| /// The value represented by this radio button. |
| /// |
| /// This radio button is considered selected if its [value] matches the |
| /// [groupValue]. |
| final T value; |
| |
| /// The currently selected value for a group of radio buttons. |
| /// |
| /// This radio button is considered selected if its [value] matches the |
| /// [groupValue]. |
| final T? groupValue; |
| |
| /// Set to true if this radio button is allowed to be returned to an |
| /// indeterminate state by selecting it again when selected. |
| /// |
| /// To indicate returning to an indeterminate state, [onChanged] will be |
| /// called with null. |
| /// |
| /// If true, [onChanged] can be called with [value] when selected while |
| /// [groupValue] != [value], or with null when selected again while |
| /// [groupValue] == [value]. |
| /// |
| /// If false, [onChanged] will be called with [value] when it is selected |
| /// while [groupValue] != [value], and only by selecting another radio button |
| /// in the group (i.e. changing the value of [groupValue]) can this radio |
| /// button be unselected. |
| /// |
| /// The default is false. |
| final bool toggleable; |
| |
| /// Called when the user selects this radio button. |
| /// |
| /// The radio button passes [value] as a parameter to this callback. The radio |
| /// button does not actually change state until the parent widget rebuilds the |
| /// radio button with the new [groupValue]. |
| /// |
| /// If null, the radio button will be displayed as disabled. |
| /// |
| /// The provided callback will not be invoked if this radio button is already |
| /// selected. |
| /// |
| /// The callback provided to [onChanged] should update the state of the parent |
| /// [StatefulWidget] using the [State.setState] method, so that the parent |
| /// gets rebuilt; for example: |
| /// |
| /// ```dart |
| /// RadioMenuButton<SingingCharacter>( |
| /// value: SingingCharacter.lafayette, |
| /// groupValue: _character, |
| /// onChanged: (SingingCharacter? newValue) { |
| /// setState(() { |
| /// _character = newValue; |
| /// }); |
| /// }, |
| /// child: const Text('Lafayette'), |
| /// ) |
| /// ``` |
| final ValueChanged<T?>? onChanged; |
| |
| /// Called when a pointer enters or exits the button response area. |
| /// |
| /// The value passed to the callback is true if a pointer has entered button |
| /// area and false if a pointer has exited. |
| final ValueChanged<bool>? onHover; |
| |
| /// Handler called when the focus changes. |
| /// |
| /// Called with true if this widget's node gains focus, and false if it loses |
| /// focus. |
| final ValueChanged<bool>? onFocusChange; |
| |
| /// {@macro flutter.widgets.Focus.focusNode} |
| final FocusNode? focusNode; |
| |
| /// The optional shortcut that selects this [MenuItemButton]. |
| /// |
| /// {@macro flutter.material.MenuBar.shortcuts_note} |
| final MenuSerializableShortcut? shortcut; |
| |
| /// Customizes this button's appearance. |
| /// |
| /// Non-null properties of this style override the corresponding properties in |
| /// [MenuItemButton.themeStyleOf] and [MenuItemButton.defaultStyleOf]. |
| /// [MaterialStateProperty]s that resolve to non-null values will similarly |
| /// override the corresponding [MaterialStateProperty]s in |
| /// [MenuItemButton.themeStyleOf] and [MenuItemButton.defaultStyleOf]. |
| /// |
| /// Null by default. |
| final ButtonStyle? style; |
| |
| /// {@macro flutter.material.inkwell.statesController} |
| final MaterialStatesController? statesController; |
| |
| /// {@macro flutter.material.Material.clipBehavior} |
| /// |
| /// Defaults to [Clip.none]. |
| final Clip clipBehavior; |
| |
| /// An optional icon to display after the [child] label. |
| final Widget? trailingIcon; |
| |
| /// {@macro flutter.material.menu_anchor.closeOnActivate} |
| final bool closeOnActivate; |
| |
| /// The widget displayed in the center of this button. |
| /// |
| /// Typically this is the button's label, using a [Text] widget. |
| /// |
| /// {@macro flutter.widgets.ProxyWidget.child} |
| final Widget? child; |
| |
| /// Whether the button is enabled or disabled. |
| /// |
| /// To enable a button, set its [onChanged] property to a non-null value. |
| bool get enabled => onChanged != null; |
| |
| @override |
| Widget build(BuildContext context) { |
| return MenuItemButton( |
| key: key, |
| onPressed: onChanged == null ? null : () { |
| if (toggleable && groupValue == value) { |
| onChanged!.call(null); |
| return; |
| } |
| onChanged!.call(value); |
| }, |
| onHover: onHover, |
| onFocusChange: onFocusChange, |
| focusNode: focusNode, |
| style: style, |
| shortcut: shortcut, |
| statesController: statesController, |
| leadingIcon: ExcludeFocus( |
| child: IgnorePointer( |
| child: ConstrainedBox( |
| constraints: const BoxConstraints( |
| maxHeight: Checkbox.width, |
| maxWidth: Checkbox.width, |
| ), |
| child: Radio<T>( |
| value: value, |
| groupValue: groupValue, |
| onChanged: onChanged, |
| toggleable: toggleable, |
| ), |
| ), |
| ), |
| ), |
| clipBehavior: clipBehavior, |
| trailingIcon: trailingIcon, |
| closeOnActivate: closeOnActivate, |
| child: child, |
| ); |
| } |
| } |
| |
| /// A menu button that displays a cascading menu. |
| /// |
| /// It can be used as part of a [MenuBar], or as a standalone widget. |
| /// |
| /// This widget represents a menu item that has a submenu. Like the leaf |
| /// [MenuItemButton], it shows a label with an optional leading or trailing |
| /// icon, but additionally shows an arrow icon showing that it has a submenu. |
| /// |
| /// By default the submenu will appear to the side of the controlling button. |
| /// The alignment and offset of the submenu can be controlled by setting |
| /// [MenuStyle.alignment] on the [style] and the [alignmentOffset] argument, |
| /// respectively. |
| /// |
| /// When activated (by being clicked, through keyboard navigation, or via |
| /// hovering with a mouse), it will open a submenu containing the |
| /// [menuChildren]. |
| /// |
| /// If [menuChildren] is empty, then this menu item will appear disabled. |
| /// |
| /// See also: |
| /// |
| /// * [MenuItemButton], a widget that represents a leaf menu item that does not |
| /// host a submenu. |
| /// * [MenuBar], a widget that renders menu items in a row in a Material Design |
| /// style. |
| /// * [MenuAnchor], a widget that creates a region with a submenu and shows it |
| /// when requested. |
| /// * [PlatformMenuBar], a widget that renders similar menu bar items from a |
| /// [PlatformMenuItem] using platform-native APIs instead of Flutter. |
| class SubmenuButton extends StatefulWidget { |
| /// Creates a const [SubmenuButton]. |
| /// |
| /// The [child] and [menuChildren] attributes are required. |
| const SubmenuButton({ |
| super.key, |
| this.onHover, |
| this.onFocusChange, |
| this.onOpen, |
| this.onClose, |
| this.controller, |
| this.style, |
| this.menuStyle, |
| this.alignmentOffset, |
| this.clipBehavior = Clip.hardEdge, |
| this.focusNode, |
| this.statesController, |
| this.leadingIcon, |
| this.trailingIcon, |
| required this.menuChildren, |
| required this.child, |
| }); |
| |
| /// Called when a pointer enters or exits the button response area. |
| /// |
| /// The value passed to the callback is true if a pointer has entered this |
| /// part of the button and false if a pointer has exited. |
| final ValueChanged<bool>? onHover; |
| |
| /// Handler called when the focus changes. |
| /// |
| /// Called with true if this widget's [focusNode] gains focus, and false if it |
| /// loses focus. |
| final ValueChanged<bool>? onFocusChange; |
| |
| /// A callback that is invoked when the menu is opened. |
| final VoidCallback? onOpen; |
| |
| /// A callback that is invoked when the menu is closed. |
| final VoidCallback? onClose; |
| |
| /// An optional [MenuController] for this submenu. |
| final MenuController? controller; |
| |
| /// Customizes this button's appearance. |
| /// |
| /// Non-null properties of this style override the corresponding properties in |
| /// [themeStyleOf] and [defaultStyleOf]. [MaterialStateProperty]s that resolve |
| /// to non-null values will similarly override the corresponding |
| /// [MaterialStateProperty]s in [themeStyleOf] and [defaultStyleOf]. |
| /// |
| /// Null by default. |
| final ButtonStyle? style; |
| |
| /// The [MenuStyle] of the menu specified by [menuChildren]. |
| /// |
| /// Defaults to the value of [MenuThemeData.style] of the ambient [MenuTheme]. |
| final MenuStyle? menuStyle; |
| |
| /// The offset of the menu relative to the alignment origin determined by |
| /// [MenuStyle.alignment] on the [style] attribute. |
| /// |
| /// Use this for fine adjustments of the menu placement. |
| /// |
| /// Defaults to an offset that takes into account the padding of the menu so |
| /// that the top starting corner of the first menu item is aligned with the |
| /// top of the [MenuAnchor] region. |
| final Offset? alignmentOffset; |
| |
| /// {@macro flutter.material.Material.clipBehavior} |
| /// |
| /// Defaults to [Clip.hardEdge]. |
| final Clip clipBehavior; |
| |
| /// {@macro flutter.widgets.Focus.focusNode} |
| final FocusNode? focusNode; |
| |
| /// {@macro flutter.material.inkwell.statesController} |
| final MaterialStatesController? statesController; |
| |
| /// An optional icon to display before the [child]. |
| final Widget? leadingIcon; |
| |
| /// An optional icon to display after the [child]. |
| final Widget? trailingIcon; |
| |
| /// The list of widgets that appear in the menu when it is opened. |
| /// |
| /// These can be any widget, but are typically either [MenuItemButton] or |
| /// [SubmenuButton] widgets. |
| /// |
| /// If [menuChildren] is empty, then the button for this menu item will be |
| /// disabled. |
| final List<Widget> menuChildren; |
| |
| /// The widget displayed in the middle portion of this button. |
| /// |
| /// Typically this is the button's label, using a [Text] widget. |
| /// |
| /// {@macro flutter.widgets.ProxyWidget.child} |
| final Widget? child; |
| |
| @override |
| State<SubmenuButton> createState() => _SubmenuButtonState(); |
| |
| /// Defines the button's default appearance. |
| /// |
| /// {@macro flutter.material.text_button.default_style_of} |
| /// |
| /// {@macro flutter.material.text_button.material3_defaults} |
| ButtonStyle defaultStyleOf(BuildContext context) { |
| return _MenuButtonDefaultsM3(context); |
| } |
| |
| /// Returns the [MenuButtonThemeData.style] of the closest [MenuButtonTheme] |
| /// ancestor. |
| ButtonStyle? themeStyleOf(BuildContext context) { |
| return MenuButtonTheme.of(context).style; |
| } |
| |
| /// A static convenience method that constructs a [SubmenuButton]'s |
| /// [ButtonStyle] given simple values. |
| /// |
| /// The [foregroundColor] color is used to create a [MaterialStateProperty] |
| /// [ButtonStyle.foregroundColor] value. Specify a value for [foregroundColor] |
| /// to specify the color of the button's icons. Use [backgroundColor] for the |
| /// button's background fill color. Use [disabledForegroundColor] and |
| /// [disabledBackgroundColor] to specify the button's disabled icon and fill |
| /// color. |
| /// |
| /// All of the other parameters are either used directly or used to create a |
| /// [MaterialStateProperty] with a single value for all states. |
| /// |
| /// All parameters default to null, by default this method returns a |
| /// [ButtonStyle] that doesn't override anything. |
| /// |
| /// For example, to override the default foreground color for a |
| /// [SubmenuButton], as well as its overlay color, with all of the standard |
| /// opacity adjustments for the pressed, focused, and hovered states, one |
| /// could write: |
| /// |
| /// ```dart |
| /// SubmenuButton( |
| /// leadingIcon: const Icon(Icons.pets), |
| /// style: SubmenuButton.styleFrom(foregroundColor: Colors.green), |
| /// menuChildren: const <Widget>[ /* ... */ ], |
| /// child: const Text('Button Label'), |
| /// ), |
| /// ``` |
| static ButtonStyle styleFrom({ |
| Color? foregroundColor, |
| Color? backgroundColor, |
| Color? disabledForegroundColor, |
| Color? disabledBackgroundColor, |
| Color? shadowColor, |
| Color? surfaceTintColor, |
| Color? iconColor, |
| TextStyle? textStyle, |
| double? elevation, |
| EdgeInsetsGeometry? padding, |
| Size? minimumSize, |
| Size? fixedSize, |
| Size? maximumSize, |
| MouseCursor? enabledMouseCursor, |
| MouseCursor? disabledMouseCursor, |
| BorderSide? side, |
| OutlinedBorder? shape, |
| VisualDensity? visualDensity, |
| MaterialTapTargetSize? tapTargetSize, |
| Duration? animationDuration, |
| bool? enableFeedback, |
| AlignmentGeometry? alignment, |
| InteractiveInkFeatureFactory? splashFactory, |
| }) { |
| return TextButton.styleFrom( |
| foregroundColor: foregroundColor, |
| backgroundColor: backgroundColor, |
| disabledBackgroundColor: disabledBackgroundColor, |
| disabledForegroundColor: disabledForegroundColor, |
| shadowColor: shadowColor, |
| surfaceTintColor: surfaceTintColor, |
| iconColor: iconColor, |
| textStyle: textStyle, |
| elevation: elevation, |
| padding: padding, |
| minimumSize: minimumSize, |
| fixedSize: fixedSize, |
| maximumSize: maximumSize, |
| enabledMouseCursor: enabledMouseCursor, |
| disabledMouseCursor: disabledMouseCursor, |
| side: side, |
| shape: shape, |
| visualDensity: visualDensity, |
| tapTargetSize: tapTargetSize, |
| animationDuration: animationDuration, |
| enableFeedback: enableFeedback, |
| alignment: alignment, |
| splashFactory: splashFactory, |
| ); |
| } |
| |
| @override |
| List<DiagnosticsNode> debugDescribeChildren() { |
| return <DiagnosticsNode>[ |
| ...menuChildren.map<DiagnosticsNode>((Widget child) { |
| return child.toDiagnosticsNode(); |
| }) |
| ]; |
| } |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| properties.add(DiagnosticsProperty<Widget>('leadingIcon', leadingIcon, defaultValue: null)); |
| properties.add(DiagnosticsProperty<String>('child', child.toString())); |
| properties.add(DiagnosticsProperty<Widget>('trailingIcon', trailingIcon, defaultValue: null)); |
| properties.add(DiagnosticsProperty<FocusNode?>('focusNode', focusNode)); |
| properties.add(DiagnosticsProperty<MenuStyle>('menuStyle', menuStyle, defaultValue: null)); |
| properties.add(DiagnosticsProperty<Offset>('alignmentOffset', alignmentOffset)); |
| properties.add(EnumProperty<Clip>('clipBehavior', clipBehavior)); |
| } |
| } |
| |
| class _SubmenuButtonState extends State<SubmenuButton> { |
| FocusNode? _internalFocusNode; |
| bool _waitingToFocusMenu = false; |
| MenuController? _internalMenuController; |
| MenuController get _menuController => widget.controller ?? _internalMenuController!; |
| _MenuAnchorState? get _anchor => _MenuAnchorState._maybeOf(context); |
| FocusNode get _buttonFocusNode => widget.focusNode ?? _internalFocusNode!; |
| bool get _enabled => widget.menuChildren.isNotEmpty; |
| |
| @override |
| void initState() { |
| super.initState(); |
| if (widget.focusNode == null) { |
| _internalFocusNode = FocusNode(); |
| assert(() { |
| if (_internalFocusNode != null) { |
| _internalFocusNode!.debugLabel = '$SubmenuButton(${widget.child})'; |
| } |
| return true; |
| }()); |
| } |
| if (widget.controller == null) { |
| _internalMenuController = MenuController(); |
| } |
| _buttonFocusNode.addListener(_handleFocusChange); |
| } |
| |
| @override |
| void dispose() { |
| _buttonFocusNode.removeListener(_handleFocusChange); |
| _internalFocusNode?.dispose(); |
| _internalFocusNode = null; |
| super.dispose(); |
| } |
| |
| @override |
| void didUpdateWidget(SubmenuButton oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| if (widget.focusNode != oldWidget.focusNode) { |
| if (oldWidget.focusNode == null) { |
| _internalFocusNode?.removeListener(_handleFocusChange); |
| _internalFocusNode?.dispose(); |
| _internalFocusNode = null; |
| } else { |
| oldWidget.focusNode!.removeListener(_handleFocusChange); |
| } |
| if (widget.focusNode == null) { |
| _internalFocusNode ??= FocusNode(); |
| assert(() { |
| if (_internalFocusNode != null) { |
| _internalFocusNode!.debugLabel = '$SubmenuButton(${widget.child})'; |
| } |
| return true; |
| }()); |
| } |
| _buttonFocusNode.addListener(_handleFocusChange); |
| } |
| if (widget.controller != oldWidget.controller) { |
| _internalMenuController = (oldWidget.controller == null) ? null : MenuController(); |
| } |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| Offset menuPaddingOffset = widget.alignmentOffset ?? Offset.zero; |
| final EdgeInsets menuPadding = _computeMenuPadding(context); |
| // Move the submenu over by the size of the menu padding, so that |
| // the first menu item aligns with the submenu button that opens it. |
| switch (_anchor?._orientation ?? Axis.vertical) { |
| case Axis.horizontal: |
| switch (Directionality.of(context)) { |
| case TextDirection.rtl: |
| menuPaddingOffset += Offset(menuPadding.right, 0); |
| case TextDirection.ltr: |
| menuPaddingOffset += Offset(-menuPadding.left, 0); |
| } |
| case Axis.vertical: |
| menuPaddingOffset += Offset(0, -menuPadding.top); |
| } |
| |
| return MenuAnchor( |
| controller: _menuController, |
| childFocusNode: _buttonFocusNode, |
| alignmentOffset: menuPaddingOffset, |
| clipBehavior: widget.clipBehavior, |
| onClose: widget.onClose, |
| onOpen: () { |
| if (!_waitingToFocusMenu) { |
| SchedulerBinding.instance.addPostFrameCallback((_) { |
| _menuController._anchor?._focusButton(); |
| _waitingToFocusMenu = false; |
| }); |
| _waitingToFocusMenu = true; |
| } |
| widget.onOpen?.call(); |
| }, |
| style: widget.menuStyle, |
| builder: (BuildContext context, MenuController controller, Widget? child) { |
| // Since we don't want to use the theme style or default style from the |
| // TextButton, we merge the styles, merging them in the right order when |
| // each type of style exists. Each "*StyleOf" function is only called |
| // once. |
| ButtonStyle mergedStyle = widget.themeStyleOf(context)?.merge(widget.defaultStyleOf(context)) |
| ?? widget.defaultStyleOf(context); |
| if (widget.style != null) { |
| mergedStyle = widget.style!.merge(mergedStyle); |
| } |
| |
| void toggleShowMenu(BuildContext context) { |
| if (controller._anchor == null) { |
| return; |
| } |
| if (controller.isOpen) { |
| controller.close(); |
| } else { |
| controller.open(); |
| } |
| } |
| |
| // Called when the pointer is hovering over the menu button. |
| void handleHover(bool hovering, BuildContext context) { |
| widget.onHover?.call(hovering); |
| // 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 |
| // open a menu on the menu bar before hovering allows them to traverse |
| // it. |
| if (controller._anchor!._root._orientation == Axis.horizontal && !controller._anchor!._root._isOpen) { |
| return; |
| } |
| |
| if (hovering) { |
| controller.open(); |
| controller._anchor!._focusButton(); |
| } |
| } |
| child = MergeSemantics( |
| child: Semantics( |
| expanded: controller.isOpen, |
| child: TextButton( |
| style: mergedStyle, |
| focusNode: _buttonFocusNode, |
| onHover: _enabled ? (bool hovering) => handleHover(hovering, context) : null, |
| onPressed: _enabled ? () => toggleShowMenu(context) : null, |
| isSemanticButton: null, |
| child: _MenuItemLabel( |
| leadingIcon: widget.leadingIcon, |
| trailingIcon: widget.trailingIcon, |
| hasSubmenu: true, |
| showDecoration: (controller._anchor!._parent?._orientation ?? Axis.horizontal) == Axis.vertical, |
| child: child ?? const SizedBox(), |
| ), |
| ), |
| ), |
| ); |
| |
| if (_enabled && _platformSupportsAccelerators) { |
| return MenuAcceleratorCallbackBinding( |
| onInvoke: () => toggleShowMenu(context), |
| hasSubmenu: true, |
| child: child, |
| ); |
| } |
| return child; |
| }, |
| menuChildren: widget.menuChildren, |
| child: widget.child, |
| ); |
| } |
| |
| EdgeInsets _computeMenuPadding(BuildContext context) { |
| final MaterialStateProperty<EdgeInsetsGeometry?> insets = |
| widget.menuStyle?.padding ?? |
| MenuTheme.of(context).style?.padding ?? |
| _MenuDefaultsM3(context).padding!; |
| return insets |
| .resolve(widget.statesController?.value ?? const <MaterialState>{})! |
| .resolve(Directionality.of(context)); |
| } |
| |
| void _handleFocusChange() { |
| if (_buttonFocusNode.hasPrimaryFocus) { |
| if (!_menuController.isOpen) { |
| _menuController.open(); |
| } |
| } else { |
| if (!_menuController._anchor!._menuScopeNode.hasFocus && _menuController.isOpen) { |
| _menuController.close(); |
| } |
| } |
| } |
| } |
| |
| /// An action that closes all the menus associated with the given |
| /// [MenuController]. |
| /// |
| /// See also: |
| /// |
| /// * [MenuAnchor], a widget that hosts a cascading submenu. |
| /// * [MenuBar], a widget that defines a menu bar with cascading submenus. |
| class DismissMenuAction extends DismissAction { |
| /// Creates a [DismissMenuAction]. |
| DismissMenuAction({required this.controller}); |
| |
| /// The [MenuController] associated with the menus that should be closed. |
| final MenuController controller; |
| |
| @override |
| void invoke(DismissIntent intent) { |
| assert(_debugMenuInfo('$runtimeType: Dismissing all open menus.')); |
| controller._anchor!._root._close(); |
| } |
| |
| @override |
| bool isEnabled(DismissIntent intent) { |
| return controller.isOpen; |
| } |
| } |
| |
| /// A helper class used to generate shortcut labels for a |
| /// [MenuSerializableShortcut] (a subset of the subclasses of |
| /// [ShortcutActivator]). |
| /// |
| /// This helper class is typically used by the [MenuItemButton] and |
| /// [SubmenuButton] classes to display a label for their assigned shortcuts. |
| /// |
| /// Call [getShortcutLabel] with the [MenuSerializableShortcut] to get a label |
| /// for it. |
| /// |
| /// For instance, calling [getShortcutLabel] with `SingleActivator(trigger: |
| /// LogicalKeyboardKey.keyA, control: true)` would return "⌃ A" on macOS, "Ctrl |
| /// A" in an US English locale, and "Strg A" in a German locale. |
| class _LocalizedShortcutLabeler { |
| _LocalizedShortcutLabeler._(); |
| |
| static _LocalizedShortcutLabeler? _instance; |
| |
| static final Map<LogicalKeyboardKey, String> _shortcutGraphicEquivalents = <LogicalKeyboardKey, String>{ |
| LogicalKeyboardKey.arrowLeft: '←', |
| LogicalKeyboardKey.arrowRight: '→', |
| LogicalKeyboardKey.arrowUp: '↑', |
| LogicalKeyboardKey.arrowDown: '↓', |
| LogicalKeyboardKey.enter: '↵', |
| }; |
| |
| static final Set<LogicalKeyboardKey> _modifiers = <LogicalKeyboardKey>{ |
| LogicalKeyboardKey.alt, |
| LogicalKeyboardKey.control, |
| LogicalKeyboardKey.meta, |
| LogicalKeyboardKey.shift, |
| LogicalKeyboardKey.altLeft, |
| LogicalKeyboardKey.controlLeft, |
| LogicalKeyboardKey.metaLeft, |
| LogicalKeyboardKey.shiftLeft, |
| LogicalKeyboardKey.altRight, |
| LogicalKeyboardKey.controlRight, |
| LogicalKeyboardKey.metaRight, |
| LogicalKeyboardKey.shiftRight, |
| }; |
| |
| /// Return the instance for this singleton. |
| static _LocalizedShortcutLabeler get instance { |
| return _instance ??= _LocalizedShortcutLabeler._(); |
| } |
| |
| // Caches the created shortcut key maps so that creating one of these isn't |
| // expensive after the first time for each unique localizations object. |
| final Map<MaterialLocalizations, Map<LogicalKeyboardKey, String>> _cachedShortcutKeys = |
| <MaterialLocalizations, Map<LogicalKeyboardKey, String>>{}; |
| |
| /// Returns the label to be shown to the user in the UI when a |
| /// [MenuSerializableShortcut] is used as a keyboard shortcut. |
| /// |
| /// When [defaultTargetPlatform] is [TargetPlatform.macOS] or |
| /// [TargetPlatform.iOS], this will return graphical key representations when |
| /// it can. For instance, the default [LogicalKeyboardKey.shift] will return |
| /// '⇧', and the arrow keys will return arrows. The key |
| /// [LogicalKeyboardKey.meta] will show as '⌘', [LogicalKeyboardKey.control] |
| /// will show as '˄', and [LogicalKeyboardKey.alt] will show as '⌥'. |
| /// |
| /// The keys are joined by spaces on macOS and iOS, and by "+" on other |
| /// platforms. |
| String getShortcutLabel(MenuSerializableShortcut shortcut, MaterialLocalizations localizations) { |
| final ShortcutSerialization serialized = shortcut.serializeForMenu(); |
| final String keySeparator; |
| if (_usesSymbolicModifiers) { |
| // Use "⌃ ⇧ A" style on macOS and iOS. |
| keySeparator = ' '; |
| } else { |
| // Use "Ctrl+Shift+A" style. |
| keySeparator = '+'; |
| } |
| if (serialized.trigger != null) { |
| final List<String> modifiers = <String>[]; |
| final LogicalKeyboardKey trigger = serialized.trigger!; |
| if (_usesSymbolicModifiers) { |
| // macOS/iOS platform convention uses this ordering, with ⌘ always last. |
| if (serialized.control!) { |
| modifiers.add(_getModifierLabel(LogicalKeyboardKey.control, localizations)); |
| } |
| if (serialized.alt!) { |
| modifiers.add(_getModifierLabel(LogicalKeyboardKey.alt, localizations)); |
| } |
| if (serialized.shift!) { |
| modifiers.add(_getModifierLabel(LogicalKeyboardKey.shift, localizations)); |
| } |
| if (serialized.meta!) { |
| modifiers.add(_getModifierLabel(LogicalKeyboardKey.meta, localizations)); |
| } |
| } else { |
| // These should be in this order, to match the LogicalKeySet version. |
| if (serialized.alt!) { |
| modifiers.add(_getModifierLabel(LogicalKeyboardKey.alt, localizations)); |
| } |
| if (serialized.control!) { |
| modifiers.add(_getModifierLabel(LogicalKeyboardKey.control, localizations)); |
| } |
| if (serialized.meta!) { |
| modifiers.add(_getModifierLabel(LogicalKeyboardKey.meta, localizations)); |
| } |
| if (serialized.shift!) { |
| modifiers.add(_getModifierLabel(LogicalKeyboardKey.shift, localizations)); |
| } |
| } |
| String? shortcutTrigger; |
| final int logicalKeyId = trigger.keyId; |
| if (_shortcutGraphicEquivalents.containsKey(trigger)) { |
| shortcutTrigger = _shortcutGraphicEquivalents[trigger]; |
| } else { |
| // Otherwise, look it up, and if we don't have a translation for it, |
| // then fall back to the key label. |
| shortcutTrigger = _getLocalizedName(trigger, localizations); |
| if (shortcutTrigger == null && logicalKeyId & LogicalKeyboardKey.planeMask == 0x0) { |
| // If the trigger is a Unicode-character-producing key, then use the |
| // character. |
| shortcutTrigger = String.fromCharCode(logicalKeyId & LogicalKeyboardKey.valueMask).toUpperCase(); |
| } |
| // Fall back to the key label if all else fails. |
| shortcutTrigger ??= trigger.keyLabel; |
| } |
| return <String>[ |
| ...modifiers, |
| if (shortcutTrigger != null && shortcutTrigger.isNotEmpty) shortcutTrigger, |
| ].join(keySeparator); |
| } else if (serialized.character != null) { |
| return serialized.character!; |
| } |
| throw UnimplementedError('Shortcut labels for ShortcutActivators that do not implement ' |
| 'MenuSerializableShortcut (e.g. ShortcutActivators other than SingleActivator or ' |
| 'CharacterActivator) are not supported.'); |
| } |
| |
| // Tries to look up the key in an internal table, and if it can't find it, |
| // then fall back to the key's keyLabel. |
| String? _getLocalizedName(LogicalKeyboardKey key, MaterialLocalizations localizations) { |
| // Since this is an expensive table to build, we cache it based on the |
| // localization object. There's currently no way to clear the cache, but |
| // it's unlikely that more than one or two will be cached for each run, and |
| // they're not huge. |
| _cachedShortcutKeys[localizations] ??= <LogicalKeyboardKey, String>{ |
| LogicalKeyboardKey.altGraph: localizations.keyboardKeyAltGraph, |
| LogicalKeyboardKey.backspace: localizations.keyboardKeyBackspace, |
| LogicalKeyboardKey.capsLock: localizations.keyboardKeyCapsLock, |
| LogicalKeyboardKey.channelDown: localizations.keyboardKeyChannelDown, |
| LogicalKeyboardKey.channelUp: localizations.keyboardKeyChannelUp, |
| LogicalKeyboardKey.delete: localizations.keyboardKeyDelete, |
| LogicalKeyboardKey.eject: localizations.keyboardKeyEject, |
| LogicalKeyboardKey.end: localizations.keyboardKeyEnd, |
| LogicalKeyboardKey.escape: localizations.keyboardKeyEscape, |
| LogicalKeyboardKey.fn: localizations.keyboardKeyFn, |
| LogicalKeyboardKey.home: localizations.keyboardKeyHome, |
| LogicalKeyboardKey.insert: localizations.keyboardKeyInsert, |
| LogicalKeyboardKey.numLock: localizations.keyboardKeyNumLock, |
| LogicalKeyboardKey.numpad1: localizations.keyboardKeyNumpad1, |
| LogicalKeyboardKey.numpad2: localizations.keyboardKeyNumpad2, |
| LogicalKeyboardKey.numpad3: localizations.keyboardKeyNumpad3, |
| LogicalKeyboardKey.numpad4: localizations.keyboardKeyNumpad4, |
| LogicalKeyboardKey.numpad5: localizations.keyboardKeyNumpad5, |
| LogicalKeyboardKey.numpad6: localizations.keyboardKeyNumpad6, |
| LogicalKeyboardKey.numpad7: localizations.keyboardKeyNumpad7, |
| LogicalKeyboardKey.numpad8: localizations.keyboardKeyNumpad8, |
| LogicalKeyboardKey.numpad9: localizations.keyboardKeyNumpad9, |
| LogicalKeyboardKey.numpad0: localizations.keyboardKeyNumpad0, |
| LogicalKeyboardKey.numpadAdd: localizations.keyboardKeyNumpadAdd, |
| LogicalKeyboardKey.numpadComma: localizations.keyboardKeyNumpadComma, |
| LogicalKeyboardKey.numpadDecimal: localizations.keyboardKeyNumpadDecimal, |
| LogicalKeyboardKey.numpadDivide: localizations.keyboardKeyNumpadDivide, |
| LogicalKeyboardKey.numpadEnter: localizations.keyboardKeyNumpadEnter, |
| LogicalKeyboardKey.numpadEqual: localizations.keyboardKeyNumpadEqual, |
| LogicalKeyboardKey.numpadMultiply: localizations.keyboardKeyNumpadMultiply, |
| LogicalKeyboardKey.numpadParenLeft: localizations.keyboardKeyNumpadParenLeft, |
| LogicalKeyboardKey.numpadParenRight: localizations.keyboardKeyNumpadParenRight, |
| LogicalKeyboardKey.numpadSubtract: localizations.keyboardKeyNumpadSubtract, |
| LogicalKeyboardKey.pageDown: localizations.keyboardKeyPageDown, |
| LogicalKeyboardKey.pageUp: localizations.keyboardKeyPageUp, |
| LogicalKeyboardKey.power: localizations.keyboardKeyPower, |
| LogicalKeyboardKey.powerOff: localizations.keyboardKeyPowerOff, |
| LogicalKeyboardKey.printScreen: localizations.keyboardKeyPrintScreen, |
| LogicalKeyboardKey.scrollLock: localizations.keyboardKeyScrollLock, |
| LogicalKeyboardKey.select: localizations.keyboardKeySelect, |
| LogicalKeyboardKey.space: localizations.keyboardKeySpace, |
| }; |
| return _cachedShortcutKeys[localizations]![key]; |
| } |
| |
| String _getModifierLabel(LogicalKeyboardKey modifier, MaterialLocalizations localizations) { |
| assert(_modifiers.contains(modifier), '${modifier.keyLabel} is not a modifier key'); |
| if (modifier == LogicalKeyboardKey.meta || |
| modifier == LogicalKeyboardKey.metaLeft || |
| modifier == LogicalKeyboardKey.metaRight) { |
| switch (defaultTargetPlatform) { |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| return localizations.keyboardKeyMeta; |
| case TargetPlatform.windows: |
| return localizations.keyboardKeyMetaWindows; |
| case TargetPlatform.iOS: |
| case TargetPlatform.macOS: |
| return '⌘'; |
| } |
| } |
| if (modifier == LogicalKeyboardKey.alt || |
| modifier == LogicalKeyboardKey.altLeft || |
| modifier == LogicalKeyboardKey.altRight) { |
| switch (defaultTargetPlatform) { |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| case TargetPlatform.windows: |
| return localizations.keyboardKeyAlt; |
| case TargetPlatform.iOS: |
| case TargetPlatform.macOS: |
| return '⌥'; |
| } |
| } |
| if (modifier == LogicalKeyboardKey.control || |
| modifier == LogicalKeyboardKey.controlLeft || |
| modifier == LogicalKeyboardKey.controlRight) { |
| // '⎈' (a boat helm wheel, not an asterisk) is apparently the standard |
| // icon for "control", but only seems to appear on the French Canadian |
| // keyboard. A '✲' (an open center asterisk) appears on some Microsoft |
| // keyboards. For all but macOS (which has standardized on "⌃", it seems), |
| // we just return the local translation of "Ctrl". |
| switch (defaultTargetPlatform) { |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| case TargetPlatform.windows: |
| return localizations.keyboardKeyControl; |
| case TargetPlatform.iOS: |
| case TargetPlatform.macOS: |
| return '⌃'; |
| } |
| } |
| if (modifier == LogicalKeyboardKey.shift || |
| modifier == LogicalKeyboardKey.shiftLeft || |
| modifier == LogicalKeyboardKey.shiftRight) { |
| switch (defaultTargetPlatform) { |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| case TargetPlatform.windows: |
| return localizations.keyboardKeyShift; |
| case TargetPlatform.iOS: |
| case TargetPlatform.macOS: |
| return '⇧'; |
| } |
| } |
| throw ArgumentError('Keyboard key ${modifier.keyLabel} is not a modifier.'); |
| } |
| } |
| |
| class _MenuAnchorScope extends InheritedWidget { |
| const _MenuAnchorScope({ |
| required super.child, |
| required this.anchorKey, |
| required this.anchor, |
| required this.isOpen, |
| }); |
| |
| final GlobalKey anchorKey; |
| final _MenuAnchorState anchor; |
| final bool isOpen; |
| |
| @override |
| bool updateShouldNotify(_MenuAnchorScope oldWidget) { |
| return anchorKey != oldWidget.anchorKey |
| || anchor != oldWidget.anchor |
| || isOpen != oldWidget.isOpen; |
| } |
| } |
| |
| /// MenuBar-specific private specialization of [MenuAnchor] so that it can act |
| /// differently in regards to orientation, how open works, and what gets built. |
| class _MenuBarAnchor extends MenuAnchor { |
| const _MenuBarAnchor({ |
| required super.menuChildren, |
| super.controller, |
| super.clipBehavior, |
| super.style, |
| }); |
| |
| @override |
| State<MenuAnchor> createState() => _MenuBarAnchorState(); |
| } |
| |
| class _MenuBarAnchorState extends _MenuAnchorState { |
| @override |
| bool get _isOpen { |
| // If it's a bar, then it's "open" if any of its children are open. |
| for (final _MenuAnchorState child in _anchorChildren) { |
| if (child._isOpen) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| @override |
| Axis get _orientation => Axis.horizontal; |
| |
| @override |
| Widget _buildContents(BuildContext context) { |
| return FocusScope( |
| node: _menuScopeNode, |
| skipTraversal: !_isOpen, |
| canRequestFocus: _isOpen, |
| child: ExcludeFocus( |
| excluding: !_isOpen, |
| child: Shortcuts( |
| shortcuts: _kMenuTraversalShortcuts, |
| child: Actions( |
| actions: <Type, Action<Intent>>{ |
| DirectionalFocusIntent: _MenuDirectionalFocusAction(), |
| DismissIntent: DismissMenuAction(controller: _menuController), |
| }, |
| child: Builder(builder: (BuildContext context) { |
| return _MenuPanel( |
| menuStyle: widget.style, |
| clipBehavior: widget.clipBehavior, |
| orientation: Axis.horizontal, |
| children: widget.menuChildren, |
| ); |
| }), |
| ), |
| ), |
| ), |
| ); |
| } |
| |
| @override |
| void _open({Offset? position}) { |
| assert(_menuController._anchor == this); |
| // Menu bars can't be opened, because they're already always open. |
| return; |
| } |
| } |
| |
| class _MenuDirectionalFocusAction extends DirectionalFocusAction { |
| /// Creates a [DirectionalFocusAction]. |
| _MenuDirectionalFocusAction(); |
| |
| @override |
| void invoke(DirectionalFocusIntent intent) { |
| assert(_debugMenuInfo('_MenuDirectionalFocusAction invoked with $intent')); |
| final BuildContext? context = FocusManager.instance.primaryFocus?.context; |
| if (context == null) { |
| super.invoke(intent); |
| return; |
| } |
| final _MenuAnchorState? anchor = _MenuAnchorState._maybeOf(context); |
| if (anchor == null || !anchor._root._isOpen) { |
| super.invoke(intent); |
| return; |
| } |
| final bool buttonIsFocused = anchor.widget.childFocusNode?.hasPrimaryFocus ?? false; |
| Axis orientation; |
| if (buttonIsFocused) { |
| orientation = anchor._parent!._orientation; |
| } else { |
| orientation = anchor._orientation; |
| } |
| final bool firstItemIsFocused = anchor._firstItemFocusNode?.hasPrimaryFocus ?? false; |
| assert(_debugMenuInfo('In _MenuDirectionalFocusAction, current node is ${anchor.widget.childFocusNode?.debugLabel}, ' |
| 'button is${buttonIsFocused ? '' : ' not'} focused. Assuming ${orientation.name} orientation.')); |
| |
| switch (intent.direction) { |
| case TraversalDirection.up: |
| switch (orientation) { |
| case Axis.horizontal: |
| if (_moveToParent(anchor)) { |
| return; |
| } |
| case Axis.vertical: |
| if (firstItemIsFocused) { |
| if (_moveToParent(anchor)) { |
| return; |
| } |
| } |
| if (_moveToPrevious(anchor)) { |
| return; |
| } |
| } |
| case TraversalDirection.down: |
| switch (orientation) { |
| case Axis.horizontal: |
| if (_moveToSubmenu(anchor)) { |
| return; |
| } |
| case Axis.vertical: |
| if (_moveToNext(anchor)) { |
| return; |
| } |
| } |
| case TraversalDirection.left: |
| switch (orientation) { |
| case Axis.horizontal: |
| switch (Directionality.of(context)) { |
| case TextDirection.rtl: |
| if (_moveToNext(anchor)) { |
| return; |
| } |
| case TextDirection.ltr: |
| if (_moveToPrevious(anchor)) { |
| return; |
| } |
| } |
| case Axis.vertical: |
| switch (Directionality.of(context)) { |
| case TextDirection.rtl: |
| if (buttonIsFocused) { |
| if (_moveToSubmenu(anchor)) { |
| return; |
| } |
| } else { |
| if (_moveToNextTopLevel(anchor)) { |
| return; |
| } |
| } |
| case TextDirection.ltr: |
| switch (anchor._parent!._orientation) { |
| case Axis.horizontal: |
| if (_moveToPreviousTopLevel(anchor)) { |
| return; |
| } |
| case Axis.vertical: |
| if (buttonIsFocused) { |
| if (_moveToPreviousTopLevel(anchor)) { |
| return; |
| } |
| } else { |
| if (_moveToParent(anchor)) { |
| return; |
| } |
| } |
| } |
| } |
| } |
| case TraversalDirection.right: |
| switch (orientation) { |
| case Axis.horizontal: |
| switch (Directionality.of(context)) { |
| case TextDirection.rtl: |
| if (_moveToPrevious(anchor)) { |
| return; |
| } |
| case TextDirection.ltr: |
| if (_moveToNext(anchor)) { |
| return; |
| } |
| } |
| case Axis.vertical: |
| switch (Directionality.of(context)) { |
| case TextDirection.rtl: |
| switch (anchor._parent!._orientation) { |
| case Axis.horizontal: |
| if (_moveToPreviousTopLevel(anchor)) { |
| return; |
| } |
| case Axis.vertical: |
| if (_moveToParent(anchor)) { |
| return; |
| } |
| } |
| case TextDirection.ltr: |
| if (buttonIsFocused) { |
| if (_moveToSubmenu(anchor)) { |
| return; |
| } |
| } else { |
| if (_moveToNextTopLevel(anchor)) { |
| return; |
| } |
| } |
| } |
| } |
| } |
| super.invoke(intent); |
| } |
| |
| bool _moveToNext(_MenuAnchorState currentMenu) { |
| assert(_debugMenuInfo('Moving focus to next item in menu')); |
| // Need to invalidate the scope data because we're switching scopes, and |
| // otherwise the anti-hysteresis code will interfere with moving to the |
| // correct node. |
| if (currentMenu.widget.childFocusNode != null) { |
| final FocusTraversalPolicy? policy = FocusTraversalGroup.maybeOf(primaryFocus!.context!); |
| if (currentMenu.widget.childFocusNode!.nearestScope != null) { |
| policy?.invalidateScopeData(currentMenu.widget.childFocusNode!.nearestScope!); |
| } |
| return false; |
| } |
| return false; |
| } |
| |
| bool _moveToNextTopLevel(_MenuAnchorState currentMenu) { |
| final _MenuAnchorState? sibling = currentMenu._topLevel._nextSibling; |
| if (sibling == null) { |
| // Wrap around to the first top level. |
| currentMenu._topLevel._parent!._anchorChildren.first._focusButton(); |
| } else { |
| sibling._focusButton(); |
| } |
| return true; |
| } |
| |
| bool _moveToParent(_MenuAnchorState currentMenu) { |
| assert(_debugMenuInfo('Moving focus to parent menu button')); |
| if (!(currentMenu.widget.childFocusNode?.hasPrimaryFocus ?? true)) { |
| currentMenu._focusButton(); |
| } |
| return true; |
| } |
| |
| bool _moveToPrevious(_MenuAnchorState currentMenu) { |
| assert(_debugMenuInfo('Moving focus to previous item in menu')); |
| // Need to invalidate the scope data because we're switching scopes, and |
| // otherwise the anti-hysteresis code will interfere with moving to the |
| // correct node. |
| if (currentMenu.widget.childFocusNode != null) { |
| final FocusTraversalPolicy? policy = FocusTraversalGroup.maybeOf(primaryFocus!.context!); |
| if (currentMenu.widget.childFocusNode!.nearestScope != null) { |
| policy?.invalidateScopeData(currentMenu.widget.childFocusNode!.nearestScope!); |
| } |
| return false; |
| } |
| return false; |
| } |
| |
| bool _moveToPreviousTopLevel(_MenuAnchorState currentMenu) { |
| final _MenuAnchorState? sibling = currentMenu._topLevel._previousSibling; |
| if (sibling == null) { |
| // Already on the first one, wrap around to the last one. |
| currentMenu._topLevel._parent!._anchorChildren.last._focusButton(); |
| } else { |
| sibling._focusButton(); |
| } |
| return true; |
| } |
| |
| bool _moveToSubmenu(_MenuAnchorState currentMenu) { |
| assert(_debugMenuInfo('Opening submenu')); |
| if (!currentMenu._isOpen) { |
| // If no submenu is open, then an arrow opens the submenu. |
| currentMenu._open(); |
| return true; |
| } else { |
| final FocusNode? firstNode = currentMenu._firstItemFocusNode; |
| if (firstNode != null && firstNode.nearestScope != firstNode) { |
| // Don't request focus if the "first" found node is a focus scope, since |
| // that means that nothing else in the submenu is focusable. |
| firstNode.requestFocus(); |
| } |
| return true; |
| } |
| } |
| } |
| |
| /// An [InheritedWidget] that provides a descendant [MenuAcceleratorLabel] with |
| /// the function to invoke when the accelerator is pressed. |
| /// |
| /// This is used when creating your own custom menu item for use with |
| /// [MenuAnchor] or [MenuBar]. Provided menu items such as [MenuItemButton] and |
| /// [SubmenuButton] already supply this wrapper internally. |
| class MenuAcceleratorCallbackBinding extends InheritedWidget { |
| /// Create a const [MenuAcceleratorCallbackBinding]. |
| /// |
| /// The [child] parameter is required. |
| const MenuAcceleratorCallbackBinding({ |
| super.key, |
| this.onInvoke, |
| this.hasSubmenu = false, |
| required super.child, |
| }); |
| |
| /// The function that pressing the accelerator defined in a descendant |
| /// [MenuAcceleratorLabel] will invoke. |
| /// |
| /// If set to null, then the accelerator won't be enabled. |
| final VoidCallback? onInvoke; |
| |
| /// Whether or not the associated label will host its own submenu or not. |
| /// |
| /// This setting determines when accelerators are active, since accelerators |
| /// for menu items that open submenus shouldn't be active when the submenu is |
| /// open. |
| final bool hasSubmenu; |
| |
| @override |
| bool updateShouldNotify(MenuAcceleratorCallbackBinding oldWidget) { |
| return onInvoke != oldWidget.onInvoke || hasSubmenu != oldWidget.hasSubmenu; |
| } |
| |
| /// Returns the active [MenuAcceleratorCallbackBinding] in the given context, if any, |
| /// and creates a dependency relationship that will rebuild the context when |
| /// [onInvoke] changes. |
| /// |
| /// If no [MenuAcceleratorCallbackBinding] is found, returns null. |
| /// |
| /// See also: |
| /// |
| /// * [of], which is similar, but asserts if no [MenuAcceleratorCallbackBinding] |
| /// is found. |
| static MenuAcceleratorCallbackBinding? maybeOf(BuildContext context) { |
| return context.dependOnInheritedWidgetOfExactType<MenuAcceleratorCallbackBinding>(); |
| } |
| |
| /// Returns the active [MenuAcceleratorCallbackBinding] in the given context, and |
| /// creates a dependency relationship that will rebuild the context when |
| /// [onInvoke] changes. |
| /// |
| /// If no [MenuAcceleratorCallbackBinding] is found, returns will assert in debug mode |
| /// and throw an exception in release mode. |
| /// |
| /// See also: |
| /// |
| /// * [maybeOf], which is similar, but returns null if no |
| /// [MenuAcceleratorCallbackBinding] is found. |
| static MenuAcceleratorCallbackBinding of(BuildContext context) { |
| final MenuAcceleratorCallbackBinding? result = maybeOf(context); |
| assert(() { |
| if (result == null) { |
| throw FlutterError( |
| 'MenuAcceleratorWrapper.of() was called with a context that does not ' |
| 'contain a MenuAcceleratorWrapper in the given context.\n' |
| 'No MenuAcceleratorWrapper ancestor could be found in the context that ' |
| 'was passed to MenuAcceleratorWrapper.of(). This can happen because ' |
| 'you are using a widget that looks for a MenuAcceleratorWrapper ' |
| 'ancestor, and do not have a MenuAcceleratorWrapper widget ancestor.\n' |
| 'The context used was:\n' |
| ' $context', |
| ); |
| } |
| return true; |
| }()); |
| return result!; |
| } |
| } |
| |
| /// The type of builder function used for building a [MenuAcceleratorLabel]'s |
| /// [MenuAcceleratorLabel.builder] function. |
| /// |
| /// {@template flutter.material.menu_anchor.menu_accelerator_child_builder.args} |
| /// The arguments to the function are as follows: |
| /// |
| /// * The `context` supplies the [BuildContext] to use. |
| /// * The `label` is the [MenuAcceleratorLabel.label] attribute for the relevant |
| /// [MenuAcceleratorLabel] with the accelerator markers stripped out of it. |
| /// * The `index` is the index of the accelerator character within the |
| /// `label.characters` that applies to this accelerator. If it is -1, then the |
| /// accelerator should not be highlighted. Otherwise, the given character |
| /// should be highlighted somehow in the rendered label (typically with an |
| /// underscore). Importantly, `index` is not an index into the [String] |
| /// `label`, it is an index into the [Characters] iterable returned by |
| /// `label.characters`, so that it is in terms of user-visible characters |
| /// (a.k.a. grapheme clusters), not Unicode code points. |
| /// {@endtemplate} |
| /// |
| /// See also: |
| /// |
| /// * [MenuAcceleratorLabel.defaultLabelBuilder], which is the implementation |
| /// used as the default value for [MenuAcceleratorLabel.builder]. |
| typedef MenuAcceleratorChildBuilder = Widget Function( |
| BuildContext context, |
| String label, |
| int index, |
| ); |
| |
| /// A widget that draws the label text for a menu item (typically a |
| /// [MenuItemButton] or [SubmenuButton]) and renders its child with information |
| /// about the currently active keyboard accelerator. |
| /// |
| /// On platforms other than macOS and iOS, this widget listens for the Alt key |
| /// to be pressed, and when it is down, will update the label by calling the |
| /// builder again with the position of the accelerator in the label string. |
| /// While the Alt key is pressed, it registers a shortcut with the |
| /// [ShortcutRegistry] mapped to a [VoidCallbackIntent] containing the callback |
| /// defined by the nearest [MenuAcceleratorCallbackBinding]. |
| /// |
| /// Because the accelerators are registered with the [ShortcutRegistry], any |
| /// other shortcuts in the widget tree between the [primaryFocus] and the |
| /// [ShortcutRegistry] that define Alt-based shortcuts using the same keys will |
| /// take precedence over the accelerators. |
| /// |
| /// Because accelerators aren't used on macOS and iOS, the label ignores the Alt |
| /// key on those platforms, and the [builder] is always given -1 as an |
| /// accelerator index. Accelerator labels are still stripped of their |
| /// accelerator markers. |
| /// |
| /// The built-in menu items [MenuItemButton] and [SubmenuButton] already provide |
| /// the appropriate [MenuAcceleratorCallbackBinding], so unless you are creating |
| /// your own custom menu item type that takes a [MenuAcceleratorLabel], it is |
| /// not necessary to provide one. |
| /// |
| /// {@template flutter.material.MenuAcceleratorLabel.accelerator_sample} |
| /// {@tool dartpad} This example shows a [MenuBar] that handles keyboard |
| /// accelerators using [MenuAcceleratorLabel]. To use the accelerators, press |
| /// the Alt key to see which letters are underlined in the menu bar, and then |
| /// press the appropriate letter. Accelerators are not supported on macOS or iOS |
| /// since those platforms don't support them natively, so this demo will only |
| /// show a regular Material menu bar on those platforms. |
| /// |
| /// ** See code in examples/api/lib/material/menu_anchor/menu_accelerator_label.0.dart ** |
| /// {@end-tool} |
| /// {@endtemplate} |
| class MenuAcceleratorLabel extends StatefulWidget { |
| /// Creates a const [MenuAcceleratorLabel]. |
| /// |
| /// The [label] parameter is required. |
| const MenuAcceleratorLabel( |
| this.label, { |
| super.key, |
| this.builder = defaultLabelBuilder, |
| }); |
| |
| /// The label string that should be displayed. |
| /// |
| /// The label string provides the label text, as well as the possible |
| /// characters which could be used as accelerators in the menu system. |
| /// |
| /// {@template flutter.material.menu_anchor.menu_accelerator_label.label} |
| /// To indicate which letters in the label are to be used as accelerators, add |
| /// an "&" character before the character in the string. If more than one |
| /// character has an "&" in front of it, then the characters appearing earlier |
| /// in the string are preferred. To represent a literal "&", insert "&&" into |
| /// the string. All other ampersands will be removed from the string before |
| /// calling [MenuAcceleratorLabel.builder]. Bare ampersands at the end of the |
| /// string or before whitespace are stripped and ignored. |
| /// {@endtemplate} |
| /// |
| /// See also: |
| /// |
| /// * [displayLabel], which returns the [label] with all of the ampersands |
| /// stripped out of it, and double ampersands converted to ampersands. |
| /// * [stripAcceleratorMarkers], which returns the supplied string with all of |
| /// the ampersands stripped out of it, and double ampersands converted to |
| /// ampersands, and optionally calls a callback with the index of the |
| /// accelerator character found. |
| final String label; |
| |
| /// Returns the [label] with any accelerator markers removed. |
| /// |
| /// This getter just calls [stripAcceleratorMarkers] with the [label]. |
| String get displayLabel => stripAcceleratorMarkers(label); |
| |
| /// The optional [MenuAcceleratorChildBuilder] which is used to build the |
| /// widget that displays the label itself. |
| /// |
| /// The [defaultLabelBuilder] function serves as the default value for |
| /// [builder], rendering the label as a [RichText] widget with appropriate |
| /// [TextSpan]s for rendering the label with an underscore under the selected |
| /// accelerator for the label when accelerators have been activated. |
| /// |
| /// {@macro flutter.material.menu_anchor.menu_accelerator_child_builder.args} |
| /// |
| /// When writing the builder function, it's not necessary to take the current |
| /// platform into account. On platforms which don't support accelerators (e.g. |
| /// macOS and iOS), the passed accelerator index will always be -1, and the |
| /// accelerator markers will already be stripped. |
| final MenuAcceleratorChildBuilder builder; |
| |
| /// Whether [label] contains an accelerator definition. |
| /// |
| /// {@macro flutter.material.menu_anchor.menu_accelerator_label.label} |
| bool get hasAccelerator => RegExp(r'&(?!([&\s]|$))').hasMatch(label); |
| |
| /// Serves as the default value for [builder], rendering the label as a |
| /// [RichText] widget with appropriate [TextSpan]s for rendering the label |
| /// with an underscore under the selected accelerator for the label when the |
| /// [index] is non-negative, and a [Text] widget when the [index] is negative. |
| /// |
| /// {@macro flutter.material.menu_anchor.menu_accelerator_child_builder.args} |
| static Widget defaultLabelBuilder( |
| BuildContext context, |
| String label, |
| int index, |
| ) { |
| if (index < 0) { |
| return Text(label); |
| } |
| final TextStyle defaultStyle = DefaultTextStyle.of(context).style; |
| final Characters characters = label.characters; |
| return RichText( |
| text: TextSpan( |
| children: <TextSpan>[ |
| if (index > 0) |
| TextSpan(text: characters.getRange(0, index).toString(), style: defaultStyle), |
| TextSpan( |
| text: characters.getRange(index, index + 1).toString(), |
| style: defaultStyle.copyWith(decoration: TextDecoration.underline), |
| ), |
| if (index < characters.length - 1) |
| TextSpan(text: characters.getRange(index + 1).toString(), style: defaultStyle), |
| ], |
| ), |
| ); |
| } |
| |
| /// Strips out any accelerator markers from the given [label], and unescapes |
| /// any escaped ampersands. |
| /// |
| /// If [setIndex] is supplied, it will be called before this function returns |
| /// with the index in the returned string of the accelerator character. |
| /// |
| /// {@macro flutter.material.menu_anchor.menu_accelerator_label.label} |
| static String stripAcceleratorMarkers(String label, {void Function(int index)? setIndex}) { |
| int quotedAmpersands = 0; |
| final StringBuffer displayLabel = StringBuffer(); |
| int acceleratorIndex = -1; |
| // Use characters so that we don't split up surrogate pairs and interpret |
| // them incorrectly. |
| final Characters labelChars = label.characters; |
| final Characters ampersand = '&'.characters; |
| bool lastWasAmpersand = false; |
| for (int i = 0; i < labelChars.length; i += 1) { |
| // Stop looking one before the end, since a single ampersand at the end is |
| // just treated as a quoted ampersand. |
| final Characters character = labelChars.characterAt(i); |
| if (lastWasAmpersand) { |
| lastWasAmpersand = false; |
| displayLabel.write(character); |
| continue; |
| } |
| if (character != ampersand) { |
| displayLabel.write(character); |
| continue; |
| } |
| if (i == labelChars.length - 1) { |
| // Strip bare ampersands at the end of a string. |
| break; |
| } |
| lastWasAmpersand = true; |
| final Characters acceleratorCharacter = labelChars.characterAt(i + 1); |
| if (acceleratorIndex == -1 && acceleratorCharacter != ampersand && |
| acceleratorCharacter.toString().trim().isNotEmpty) { |
| // Don't set the accelerator index if the character is an ampersand, |
| // or whitespace. |
| acceleratorIndex = i - quotedAmpersands; |
| } |
| // As we encounter '&<character>' pairs, the following indices must be |
| // adjusted so that they correspond with indices in the stripped string. |
| quotedAmpersands += 1; |
| } |
| setIndex?.call(acceleratorIndex); |
| return displayLabel.toString(); |
| } |
| |
| @override |
| State<MenuAcceleratorLabel> createState() => _MenuAcceleratorLabelState(); |
| |
| @override |
| String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { |
| return '$MenuAcceleratorLabel("$label")'; |
| } |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| properties.add(StringProperty('label', label)); |
| } |
| } |
| |
| class _MenuAcceleratorLabelState extends State<MenuAcceleratorLabel> { |
| late String _displayLabel; |
| int _acceleratorIndex = -1; |
| MenuAcceleratorCallbackBinding? _binding; |
| _MenuAnchorState? _anchor; |
| ShortcutRegistry? _shortcutRegistry; |
| ShortcutRegistryEntry? _shortcutRegistryEntry; |
| bool _showAccelerators = false; |
| |
| @override |
| void initState() { |
| super.initState(); |
| if (_platformSupportsAccelerators) { |
| _showAccelerators = _altIsPressed(); |
| HardwareKeyboard.instance.addHandler(_handleKeyEvent); |
| } |
| _updateDisplayLabel(); |
| } |
| |
| @override |
| void dispose() { |
| assert(_platformSupportsAccelerators || _shortcutRegistryEntry == null); |
| _displayLabel = ''; |
| if (_platformSupportsAccelerators) { |
| _shortcutRegistryEntry?.dispose(); |
| _shortcutRegistryEntry = null; |
| _shortcutRegistry = null; |
| _anchor = null; |
| HardwareKeyboard.instance.removeHandler(_handleKeyEvent); |
| } |
| super.dispose(); |
| } |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| if (!_platformSupportsAccelerators) { |
| return; |
| } |
| _binding = MenuAcceleratorCallbackBinding.maybeOf(context); |
| _anchor = _MenuAnchorState._maybeOf(context); |
| _shortcutRegistry = ShortcutRegistry.maybeOf(context); |
| _updateAcceleratorShortcut(); |
| } |
| |
| @override |
| void didUpdateWidget(MenuAcceleratorLabel oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| if (widget.label != oldWidget.label) { |
| _updateDisplayLabel(); |
| } |
| } |
| |
| static bool _altIsPressed() { |
| return HardwareKeyboard.instance.logicalKeysPressed.intersection( |
| <LogicalKeyboardKey>{ |
| LogicalKeyboardKey.altLeft, |
| LogicalKeyboardKey.altRight, |
| LogicalKeyboardKey.alt, |
| }, |
| ).isNotEmpty; |
| } |
| |
| bool _handleKeyEvent(KeyEvent event) { |
| assert(_platformSupportsAccelerators); |
| final bool altIsPressed = _altIsPressed(); |
| if (altIsPressed != _showAccelerators) { |
| setState(() { |
| _showAccelerators = altIsPressed; |
| _updateAcceleratorShortcut(); |
| }); |
| } |
| // Just listening, does't ever handle a key. |
| return false; |
| } |
| |
| void _updateAcceleratorShortcut() { |
| assert(_platformSupportsAccelerators); |
| _shortcutRegistryEntry?.dispose(); |
| _shortcutRegistryEntry = null; |
| // Before registering an accelerator as a shortcut it should meet these |
| // conditions: |
| // |
| // 1) Is showing accelerators (i.e. Alt key is down). |
| // 2) Has an accelerator marker in the label. |
| // 3) Has an associated action callback for the label (from the |
| // MenuAcceleratorCallbackBinding). |
| // 4) Is part of an anchor that either doesn't have a submenu, or doesn't |
| // have any submenus currently open (only the "deepest" open menu should |
| // have accelerator shortcuts registered). |
| if (_showAccelerators && _acceleratorIndex != -1 && _binding?.onInvoke != null && !(_binding!.hasSubmenu && (_anchor?._isOpen ?? false))) { |
| final String acceleratorCharacter = _displayLabel[_acceleratorIndex].toLowerCase(); |
| _shortcutRegistryEntry = _shortcutRegistry?.addAll( |
| <ShortcutActivator, Intent>{ |
| CharacterActivator(acceleratorCharacter, alt: true): VoidCallbackIntent(_binding!.onInvoke!), |
| }, |
| ); |
| } |
| } |
| |
| void _updateDisplayLabel() { |
| _displayLabel = MenuAcceleratorLabel.stripAcceleratorMarkers( |
| widget.label, |
| setIndex: (int index) { |
| _acceleratorIndex = index; |
| }, |
| ); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final int index = _showAccelerators ? _acceleratorIndex : -1; |
| return widget.builder(context, _displayLabel, index); |
| } |
| } |
| |
| /// A label widget that is used as the label for a [MenuItemButton] or |
| /// [SubmenuButton]. |
| /// |
| /// It not only shows the [SubmenuButton.child] or [MenuItemButton.child], but if |
| /// there is a shortcut associated with the [MenuItemButton], it will display a |
| /// mnemonic for the shortcut. For [SubmenuButton]s, it will display a visual |
| /// indicator that there is a submenu. |
| class _MenuItemLabel extends StatelessWidget { |
| /// Creates a const [_MenuItemLabel]. |
| /// |
| /// The [child] and [hasSubmenu] arguments are required. |
| const _MenuItemLabel({ |
| required this.hasSubmenu, |
| this.showDecoration = true, |
| this.leadingIcon, |
| this.trailingIcon, |
| this.shortcut, |
| required this.child, |
| }); |
| |
| /// Whether or not this menu has a submenu. |
| /// |
| /// Determines whether the submenu arrow is shown or not. |
| final bool hasSubmenu; |
| |
| /// Whether or not this item should show decorations like shortcut labels or |
| /// submenu arrows. Items in a [MenuBar] don't show these decorations when |
| /// they are laid out horizontally. |
| final bool showDecoration; |
| |
| /// The optional icon that comes before the [child]. |
| final Widget? leadingIcon; |
| |
| /// The optional icon that comes after the [child]. |
| final Widget? trailingIcon; |
| |
| /// The shortcut for this label, so that it can generate a string describing |
| /// the shortcut. |
| final MenuSerializableShortcut? shortcut; |
| |
| /// The required label child widget. |
| final Widget child; |
| |
| @override |
| Widget build(BuildContext context) { |
| final VisualDensity density = Theme.of(context).visualDensity; |
| final double horizontalPadding = math.max( |
| _kLabelItemMinSpacing, |
| _kLabelItemDefaultSpacing + density.horizontal * 2, |
| ); |
| return Row( |
| mainAxisAlignment: MainAxisAlignment.spaceBetween, |
| children: <Widget>[ |
| Row( |
| mainAxisSize: MainAxisSize.min, |
| children: <Widget>[ |
| if (leadingIcon != null) leadingIcon!, |
| Padding( |
| padding: leadingIcon != null ? EdgeInsetsDirectional.only(start: horizontalPadding) : EdgeInsets.zero, |
| child: child, |
| ), |
| ], |
| ), |
| if (trailingIcon != null) |
| Padding( |
| padding: EdgeInsetsDirectional.only(start: horizontalPadding), |
| child: trailingIcon, |
| ), |
| if (showDecoration && shortcut != null) |
| Padding( |
| padding: EdgeInsetsDirectional.only(start: horizontalPadding), |
| child: Text( |
| _LocalizedShortcutLabeler.instance.getShortcutLabel( |
| shortcut!, |
| MaterialLocalizations.of(context), |
| ), |
| ), |
| ), |
| if (showDecoration && hasSubmenu) |
| Padding( |
| padding: EdgeInsetsDirectional.only(start: horizontalPadding), |
| child: const Icon( |
| Icons.arrow_right, // Automatically switches with text direction. |
| size: _kDefaultSubmenuIconSize, |
| ), |
| ), |
| ], |
| ); |
| } |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| properties.add(DiagnosticsProperty<String>('child', child.toString())); |
| properties.add(DiagnosticsProperty<MenuSerializableShortcut>('shortcut', shortcut, defaultValue: null)); |
| properties.add(DiagnosticsProperty<bool>('hasSubmenu', hasSubmenu)); |
| properties.add(DiagnosticsProperty<bool>('showDecoration', showDecoration)); |
| } |
| } |
| |
| // Positions the menu in the view while trying to keep as much as possible |
| // visible in the view. |
| class _MenuLayout extends SingleChildLayoutDelegate { |
| const _MenuLayout({ |
| required this.anchorRect, |
| required this.textDirection, |
| required this.alignment, |
| required this.alignmentOffset, |
| required this.menuPosition, |
| required this.menuPadding, |
| required this.avoidBounds, |
| required this.orientation, |
| required this.parentOrientation, |
| }); |
| |
| // Rectangle of underlying button, relative to the overlay's dimensions. |
| final Rect anchorRect; |
| |
| // Whether to prefer going to the left or to the right. |
| final TextDirection textDirection; |
| |
| // The alignment to use when finding the ideal location for the menu. |
| final AlignmentGeometry alignment; |
| |
| // The offset from the alignment position to find the ideal location for the |
| // menu. |
| final Offset alignmentOffset; |
| |
| // The position passed to the open method, if any. |
| final Offset? menuPosition; |
| |
| // The padding on the inside of the menu, so it can be accounted for when |
| // positioning. |
| final EdgeInsetsGeometry menuPadding; |
| |
| // List of rectangles that we should avoid overlapping. Unusable screen area. |
| final Set<Rect> avoidBounds; |
| |
| // The orientation of this menu |
| final Axis orientation; |
| |
| // The orientation of this menu's parent. |
| final Axis parentOrientation; |
| |
| @override |
| BoxConstraints getConstraintsForChild(BoxConstraints constraints) { |
| // The menu can be at most the size of the overlay minus _kMenuViewPadding |
| // pixels in each direction. |
| return BoxConstraints.loose(constraints.biggest).deflate( |
| const EdgeInsets.all(_kMenuViewPadding), |
| ); |
| } |
| |
| @override |
| Offset getPositionForChild(Size size, Size childSize) { |
| // size: The size of the overlay. |
| // childSize: The size of the menu, when fully open, as determined by |
| // getConstraintsForChild. |
| final Rect overlayRect = Offset.zero & size; |
| double x; |
| double y; |
| if (menuPosition == null) { |
| Offset desiredPosition = alignment.resolve(textDirection).withinRect(anchorRect); |
| final Offset directionalOffset; |
| if (alignment is AlignmentDirectional) { |
| switch (textDirection) { |
| case TextDirection.rtl: |
| directionalOffset = Offset(-alignmentOffset.dx, alignmentOffset.dy); |
| case TextDirection.ltr: |
| directionalOffset = alignmentOffset; |
| } |
| } else { |
| directionalOffset = alignmentOffset; |
| } |
| desiredPosition += directionalOffset; |
| x = desiredPosition.dx; |
| y = desiredPosition.dy; |
| switch (textDirection) { |
| case TextDirection.rtl: |
| x -= childSize.width; |
| case TextDirection.ltr: |
| break; |
| } |
| } else { |
| final Offset adjustedPosition = menuPosition! + anchorRect.topLeft; |
| x = adjustedPosition.dx; |
| y = adjustedPosition.dy; |
| } |
| |
| final Iterable<Rect> subScreens = DisplayFeatureSubScreen.subScreensInBounds(overlayRect, avoidBounds); |
| final Rect allowedRect = _closestScreen(subScreens, anchorRect.center); |
| bool offLeftSide(double x) => x < allowedRect.left; |
| bool offRightSide(double x) => x + childSize.width > allowedRect.right; |
| bool offTop(double y) => y < allowedRect.top; |
| bool offBottom(double y) => y + childSize.height > allowedRect.bottom; |
| // Avoid going outside an area defined as the rectangle offset from the |
| // edge of the screen by the button padding. If the menu is off of the screen, |
| // move the menu to the other side of the button first, and then if it |
| // doesn't fit there, then just move it over as much as needed to make it |
| // fit. |
| if (childSize.width >= allowedRect.width) { |
| // It just doesn't fit, so put as much on the screen as possible. |
| x = allowedRect.left; |
| } else { |
| if (offLeftSide(x)) { |
| // If the parent is a different orientation than the current one, then |
| // just push it over instead of trying the other side. |
| if (parentOrientation != orientation) { |
| x = allowedRect.left; |
| } else { |
| final double newX = anchorRect.right + alignmentOffset.dx; |
| if (!offRightSide(newX)) { |
| x = newX; |
| } else { |
| x = allowedRect.left; |
| } |
| } |
| } else if (offRightSide(x)) { |
| if (parentOrientation != orientation) { |
| x = allowedRect.right - childSize.width; |
| } else { |
| final double newX = anchorRect.left - childSize.width - alignmentOffset.dx; |
| if (!offLeftSide(newX)) { |
| x = newX; |
| } else { |
| x = allowedRect.right - childSize.width; |
| } |
| } |
| } |
| } |
| if (childSize.height >= allowedRect.height) { |
| // Too tall to fit, fit as much on as possible. |
| y = allowedRect.top; |
| } else { |
| if (offTop(y)) { |
| final double newY = anchorRect.bottom; |
| if (!offBottom(newY)) { |
| y = newY; |
| } else { |
| y = allowedRect.top; |
| } |
| |