MenuAnchor class

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.

To programmatically control a MenuAnchor, like opening or closing it, or checking its state, you can get its associated MenuController. Use MenuController.maybeOf(BuildContext context) to retrieve the controller for the closest MenuAnchor ancestor of a given BuildContext. More detailed usage of MenuController is available in its class documentation.

This example shows how to use a MenuAnchor to wrap a button and open a cascading menu from the button. This example also shows how to use onAnimationStatusChanged to track animation status and toggle the menu.

To see it in action, copy and run this code snippet on DartPad.

import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';

/// Flutter code sample for [MenuAnchor].

void main() => runApp(const MenuApp());

/// An enhanced enum to define the available menus and their shortcuts.
///
/// Using an enum for menu definition is not required, but this illustrates how
/// they could be used for simple menu systems.
enum MenuEntry {
  about('About'),
  showMessage(
    'Show Message',
    SingleActivator(LogicalKeyboardKey.keyS, control: true),
  ),
  hideMessage(
    'Hide Message',
    SingleActivator(LogicalKeyboardKey.keyS, control: true),
  ),
  colorMenu('Color Menu'),
  colorRed(
    'Red Background',
    SingleActivator(LogicalKeyboardKey.keyR, control: true),
  ),
  colorGreen(
    'Green Background',
    SingleActivator(LogicalKeyboardKey.keyG, control: true),
  ),
  colorBlue(
    'Blue Background',
    SingleActivator(LogicalKeyboardKey.keyB, control: true),
  );

  const MenuEntry(this.label, [this.shortcut]);
  final String label;
  final MenuSerializableShortcut? shortcut;
}

class MyCascadingMenu extends StatefulWidget {
  const MyCascadingMenu({super.key, required this.message});

  final String message;

  @override
  State<MyCascadingMenu> createState() => _MyCascadingMenuState();
}

class _MyCascadingMenuState extends State<MyCascadingMenu> {
  MenuEntry? _lastSelection;
  final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button');
  ShortcutRegistryEntry? _shortcutsEntry;
  AnimationStatus _animationStatus = .dismissed;

  Color get backgroundColor => _backgroundColor;
  Color _backgroundColor = Colors.red;
  set backgroundColor(Color value) {
    if (_backgroundColor != value) {
      setState(() {
        _backgroundColor = value;
      });
    }
  }

  bool get showingMessage => _showingMessage;
  bool _showingMessage = false;
  set showingMessage(bool value) {
    if (_showingMessage != value) {
      setState(() {
        _showingMessage = value;
      });
    }
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Dispose of any previously registered shortcuts, since they are about to
    // be replaced.
    _shortcutsEntry?.dispose();
    // Collect the shortcuts from the different menu selections so that they can
    // be registered to apply to the entire app. Menus don't register their
    // shortcuts, they only display the shortcut hint text.
    final Map<ShortcutActivator, Intent> shortcuts =
        <ShortcutActivator, Intent>{
          for (final MenuEntry item in MenuEntry.values)
            if (item.shortcut != null)
              item.shortcut!: VoidCallbackIntent(() => _activate(item)),
        };
    // Register the shortcuts with the ShortcutRegistry so that they are
    // available to the entire application.
    _shortcutsEntry = ShortcutRegistry.of(context).addAll(shortcuts);
  }

  @override
  void dispose() {
    _shortcutsEntry?.dispose();
    _buttonFocusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: .start,
      children: <Widget>[
        MenuAnchor(
          animated: true,
          onAnimationStatusChanged: (AnimationStatus status) {
            // Store the animation status so that it can be used to determine
            // whether the menu is opening or closing when the button is
            // pressed.
            _animationStatus = status;
          },
          childFocusNode: _buttonFocusNode,
          menuChildren: <Widget>[
            MenuItemButton(
              child: Text(MenuEntry.about.label),
              onPressed: () => _activate(MenuEntry.about),
            ),
            if (_showingMessage)
              MenuItemButton(
                onPressed: () => _activate(MenuEntry.hideMessage),
                shortcut: MenuEntry.hideMessage.shortcut,
                child: Text(MenuEntry.hideMessage.label),
              ),
            if (!_showingMessage)
              MenuItemButton(
                onPressed: () => _activate(MenuEntry.showMessage),
                shortcut: MenuEntry.showMessage.shortcut,
                child: Text(MenuEntry.showMessage.label),
              ),
            SubmenuButton(
              animated: true,
              menuChildren: <Widget>[
                MenuItemButton(
                  onPressed: () => _activate(MenuEntry.colorRed),
                  shortcut: MenuEntry.colorRed.shortcut,
                  child: Text(MenuEntry.colorRed.label),
                ),
                MenuItemButton(
                  onPressed: () => _activate(MenuEntry.colorGreen),
                  shortcut: MenuEntry.colorGreen.shortcut,
                  child: Text(MenuEntry.colorGreen.label),
                ),
                MenuItemButton(
                  onPressed: () => _activate(MenuEntry.colorBlue),
                  shortcut: MenuEntry.colorBlue.shortcut,
                  child: Text(MenuEntry.colorBlue.label),
                ),
              ],
              child: const Text('Background Color'),
            ),
          ],
          builder:
              (BuildContext context, MenuController controller, Widget? child) {
                return TextButton(
                  focusNode: _buttonFocusNode,
                  onPressed: () {
                    if (_animationStatus.isForwardOrCompleted) {
                      controller.close();
                    } else {
                      controller.open();
                    }
                  },
                  child: const Text('OPEN MENU'),
                );
              },
        ),
        Expanded(
          child: Container(
            alignment: .center,
            color: backgroundColor,
            child: Column(
              mainAxisAlignment: .center,
              children: <Widget>[
                Padding(
                  padding: const .all(12.0),
                  child: Text(
                    showingMessage ? widget.message : '',
                    style: Theme.of(context).textTheme.headlineSmall,
                  ),
                ),
                Text(
                  _lastSelection != null
                      ? 'Last Selected: ${_lastSelection!.label}'
                      : '',
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }

  void _activate(MenuEntry selection) {
    setState(() {
      _lastSelection = selection;
    });

    switch (selection) {
      case MenuEntry.about:
        showAboutDialog(
          context: context,
          applicationName: 'MenuBar Sample',
          applicationVersion: '1.0.0',
        );
      case MenuEntry.hideMessage:
      case MenuEntry.showMessage:
        showingMessage = !showingMessage;
      case MenuEntry.colorMenu:
        break;
      case MenuEntry.colorRed:
        backgroundColor = Colors.red;
      case MenuEntry.colorGreen:
        backgroundColor = Colors.green;
      case MenuEntry.colorBlue:
        backgroundColor = Colors.blue;
    }
  }
}

class MenuApp extends StatelessWidget {
  const MenuApp({super.key});

  static const String kMessage = '"Talk less. Smile more." - A. Burr';

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: SafeArea(child: MyCascadingMenu(message: kMessage)),
      ),
    );
  }
}

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.

To see it in action, copy and run this code snippet on DartPad.

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';

/// Flutter code sample for [MenuAnchor].

void main() => runApp(const ContextMenuApp());

/// An enhanced enum to define the available menus and their shortcuts.
///
/// Using an enum for menu definition is not required, but this illustrates how
/// they could be used for simple menu systems.
enum MenuEntry {
  about('About'),
  showMessage(
    'Show Message',
    SingleActivator(LogicalKeyboardKey.keyS, control: true),
  ),
  hideMessage(
    'Hide Message',
    SingleActivator(LogicalKeyboardKey.keyS, control: true),
  ),
  colorMenu('Color Menu'),
  colorRed(
    'Red Background',
    SingleActivator(LogicalKeyboardKey.keyR, control: true),
  ),
  colorGreen(
    'Green Background',
    SingleActivator(LogicalKeyboardKey.keyG, control: true),
  ),
  colorBlue(
    'Blue Background',
    SingleActivator(LogicalKeyboardKey.keyB, control: true),
  );

  const MenuEntry(this.label, [this.shortcut]);
  final String label;
  final MenuSerializableShortcut? shortcut;
}

class MyContextMenu extends StatefulWidget {
  const MyContextMenu({super.key, required this.message});

  final String message;

  @override
  State<MyContextMenu> createState() => _MyContextMenuState();
}

class _MyContextMenuState extends State<MyContextMenu> {
  MenuEntry? _lastSelection;
  final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button');
  final MenuController _menuController = MenuController();
  ShortcutRegistryEntry? _shortcutsEntry;
  bool _menuWasEnabled = false;

  Color get backgroundColor => _backgroundColor;
  Color _backgroundColor = Colors.red;
  set backgroundColor(Color value) {
    if (_backgroundColor != value) {
      setState(() {
        _backgroundColor = value;
      });
    }
  }

  bool get showingMessage => _showingMessage;
  bool _showingMessage = false;
  set showingMessage(bool value) {
    if (_showingMessage != value) {
      setState(() {
        _showingMessage = value;
      });
    }
  }

  @override
  void initState() {
    super.initState();
    _disableContextMenu();
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Dispose of any previously registered shortcuts, since they are about to
    // be replaced.
    _shortcutsEntry?.dispose();
    // Collect the shortcuts from the different menu selections so that they can
    // be registered to apply to the entire app. Menus don't register their
    // shortcuts, they only display the shortcut hint text.
    final Map<ShortcutActivator, Intent> shortcuts =
        <ShortcutActivator, Intent>{
          for (final MenuEntry item in MenuEntry.values)
            if (item.shortcut != null)
              item.shortcut!: VoidCallbackIntent(() => _activate(item)),
        };
    // Register the shortcuts with the ShortcutRegistry so that they are
    // available to the entire application.
    _shortcutsEntry = ShortcutRegistry.of(context).addAll(shortcuts);
  }

  @override
  void dispose() {
    _shortcutsEntry?.dispose();
    _buttonFocusNode.dispose();
    _reenableContextMenu();
    super.dispose();
  }

  Future<void> _disableContextMenu() async {
    if (!kIsWeb) {
      // Does nothing on non-web platforms.
      return;
    }
    _menuWasEnabled = BrowserContextMenu.enabled;
    if (_menuWasEnabled) {
      await BrowserContextMenu.disableContextMenu();
    }
  }

  void _reenableContextMenu() {
    if (!kIsWeb) {
      // Does nothing on non-web platforms.
      return;
    }
    if (_menuWasEnabled && !BrowserContextMenu.enabled) {
      BrowserContextMenu.enableContextMenu();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const .all(50),
      child: GestureDetector(
        onTapDown: _handleTapDown,
        onSecondaryTapDown: _handleSecondaryTapDown,
        child: MenuAnchor(
          animated: true,
          controller: _menuController,
          menuChildren: <Widget>[
            MenuItemButton(
              child: Text(MenuEntry.about.label),
              onPressed: () => _activate(MenuEntry.about),
            ),
            if (_showingMessage)
              MenuItemButton(
                onPressed: () => _activate(MenuEntry.hideMessage),
                shortcut: MenuEntry.hideMessage.shortcut,
                child: Text(MenuEntry.hideMessage.label),
              ),
            if (!_showingMessage)
              MenuItemButton(
                onPressed: () => _activate(MenuEntry.showMessage),
                shortcut: MenuEntry.showMessage.shortcut,
                child: Text(MenuEntry.showMessage.label),
              ),
            SubmenuButton(
              animated: true,
              menuChildren: <Widget>[
                MenuItemButton(
                  onPressed: () => _activate(MenuEntry.colorRed),
                  shortcut: MenuEntry.colorRed.shortcut,
                  child: Text(MenuEntry.colorRed.label),
                ),
                MenuItemButton(
                  onPressed: () => _activate(MenuEntry.colorGreen),
                  shortcut: MenuEntry.colorGreen.shortcut,
                  child: Text(MenuEntry.colorGreen.label),
                ),
                MenuItemButton(
                  onPressed: () => _activate(MenuEntry.colorBlue),
                  shortcut: MenuEntry.colorBlue.shortcut,
                  child: Text(MenuEntry.colorBlue.label),
                ),
              ],
              child: const Text('Background Color'),
            ),
          ],
          child: Container(
            alignment: .center,
            color: backgroundColor,
            child: Column(
              mainAxisAlignment: .center,
              children: <Widget>[
                const Padding(
                  padding: .all(8.0),
                  child: Text(
                    'Right-click anywhere on the background to show the menu.',
                  ),
                ),
                Padding(
                  padding: const .all(12.0),
                  child: Text(
                    showingMessage ? widget.message : '',
                    style: Theme.of(context).textTheme.headlineSmall,
                  ),
                ),
                Text(
                  _lastSelection != null
                      ? 'Last Selected: ${_lastSelection!.label}'
                      : '',
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  void _activate(MenuEntry selection) {
    setState(() {
      _lastSelection = selection;
    });
    switch (selection) {
      case MenuEntry.about:
        showAboutDialog(
          context: context,
          applicationName: 'MenuBar Sample',
          applicationVersion: '1.0.0',
        );
      case MenuEntry.showMessage:
      case MenuEntry.hideMessage:
        showingMessage = !showingMessage;
      case MenuEntry.colorMenu:
        break;
      case MenuEntry.colorRed:
        backgroundColor = Colors.red;
      case MenuEntry.colorGreen:
        backgroundColor = Colors.green;
      case MenuEntry.colorBlue:
        backgroundColor = Colors.blue;
    }
  }

  void _handleSecondaryTapDown(TapDownDetails details) {
    _menuController.open(position: details.localPosition);
  }

  void _handleTapDown(TapDownDetails details) {
    if (_menuController.isOpen) {
      _menuController.close();
      return;
    }
    switch (defaultTargetPlatform) {
      case .android:
      case .fuchsia:
      case .linux:
      case .windows:
        // Don't open the menu on these platforms with a Ctrl-tap (or a
        // tap).
        break;
      case .iOS:
      case .macOS:
        // Only open the menu on these platforms if the control button is down
        // when the tap occurs.
        if (HardwareKeyboard.instance.logicalKeysPressed.contains(
              LogicalKeyboardKey.controlLeft,
            ) ||
            HardwareKeyboard.instance.logicalKeysPressed.contains(
              LogicalKeyboardKey.controlRight,
            )) {
          _menuController.open(position: details.localPosition);
        }
    }
  }
}

class ContextMenuApp extends StatelessWidget {
  const ContextMenuApp({super.key});

  static const String kMessage = '"Talk less. Smile more." - A. Burr';

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(body: MyContextMenu(message: kMessage)),
    );
  }
}

This example demonstrates a simplified cascading menu using the MenuAnchor widget.

To see it in action, copy and run this code snippet on DartPad.

import 'package:material_ui/material_ui.dart';

/// Flutter code sample for [SimpleCascadingMenuApp].

void main() => runApp(const SimpleCascadingMenuApp());

/// A Simple Cascading Menu example using the [MenuAnchor] Widget.
class MyCascadingMenu extends StatefulWidget {
  const MyCascadingMenu({super.key});

  @override
  State<MyCascadingMenu> createState() => _MyCascadingMenuState();
}

class _MyCascadingMenuState extends State<MyCascadingMenu> {
  final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button');

  @override
  void dispose() {
    _buttonFocusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MenuAnchor(
      childFocusNode: _buttonFocusNode,
      menuChildren: <Widget>[
        MenuItemButton(onPressed: () {}, child: const Text('Revert')),
        MenuItemButton(onPressed: () {}, child: const Text('Setting')),
        MenuItemButton(onPressed: () {}, child: const Text('Send Feedback')),
      ],
      builder: (_, MenuController controller, Widget? child) {
        return IconButton(
          focusNode: _buttonFocusNode,
          onPressed: () {
            if (controller.isOpen) {
              controller.close();
            } else {
              controller.open();
            }
          },
          icon: const Icon(Icons.more_vert),
        );
      },
    );
  }
}

/// Top Level Application Widget.
class SimpleCascadingMenuApp extends StatelessWidget {
  const SimpleCascadingMenuApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        appBar: AppBar(
          title: const Text('MenuAnchor Simple Example'),
          actions: const <Widget>[MyCascadingMenu()],
        ),
      ),
    );
  }
}

The MenuStyle.visualDensity setting only affects horizontal padding, and it will never make it negative. Vertical padding is not affected at all.

Inheritance

Constructors

Creates a const MenuAnchor.
const

Properties

alignmentOffset Offset?
The offset of the menu relative to the alignment origin determined by MenuStyle.alignment on the style attribute and the ambient Directionality.
final
anchorTapClosesMenu bool
Whether the menus will be closed if the anchor area is tapped.
final
animated bool
Whether this widget should open or close a submenu with an animation.
final
builder MenuAnchorChildBuilder?
The widget that this MenuAnchor surrounds.
final
child Widget?
The optional child to be passed to the builder.
final
childFocusNode FocusNode?
The childFocusNode attribute is the optional FocusNode also associated to the child or builder widget that opens the menu.
final
clipBehavior Clip
The content will be clipped (or not) according to this option.
final
consumeOutsideTap bool
Whether or not a tap event that closes the menu will be permitted to continue on to the gesture arena.
final
controller MenuController?
An optional controller that allows opening and closing of the menu from other widgets.
final
crossAxisUnconstrained bool
Determine if the menu panel can be wrapped by a UnconstrainedBox which allows the panel to render at its "natural" size.
final
hashCode int
The hash code for this object.
no setterinherited
key Key?
Controls how one widget replaces another widget in the tree.
finalinherited
An optional LayerLink to attach the menu to the widget that this MenuAnchor surrounds.
final
A list of children containing the menu items that are the contents of the menu surrounded by this MenuAnchor.
final
onAnimationStatusChanged ValueChanged<AnimationStatus>?
An optional callback that is invoked when the AnimationStatus of the menu changes during open and close animations.
final
onClose VoidCallback?
A callback that is invoked when the menu finishes closing.
final
onOpen VoidCallback?
A callback that is invoked when the menu begins opening.
final
reservedPadding EdgeInsetsGeometry?
The padding between the edge of the safe area and the menu panel.
final
runtimeType Type
A representation of the runtime type of the object.
no setterinherited
style MenuStyle?
The MenuStyle that defines the visual attributes of the menu bar.
final
useRootOverlay bool
Whether the menu panel should be rendered in the root Overlay.
final

Methods

createElement() StatefulElement
Creates a StatefulElement to manage this widget's location in the tree.
inherited
createState() State<MenuAnchor>
Creates the mutable state for this widget at a given location in the tree.
override
debugDescribeChildren() List<DiagnosticsNode>
Returns a list of DiagnosticsNode objects describing this node's children.
override
debugFillProperties(DiagnosticPropertiesBuilder properties) → void
Add additional properties associated with the node.
override
noSuchMethod(Invocation invocation) → dynamic
Invoked when a nonexistent method or property is accessed.
inherited
toDiagnosticsNode({String? name, DiagnosticsTreeStyle? style}) DiagnosticsNode
Returns a debug representation of the object that is used by debugging tools and by DiagnosticsNode.toStringDeep.
inherited
toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) String
A string representation of this object.
inherited
toStringDeep({String prefixLineOne = '', String? prefixOtherLines, DiagnosticLevel minLevel = DiagnosticLevel.debug, int wrapWidth = 65}) String
Returns a string representation of this node and its descendants.
inherited
toStringShallow({String joiner = ', ', DiagnosticLevel minLevel = DiagnosticLevel.debug}) String
Returns a one-line detailed description of the object.
inherited
toStringShort() String
A short, textual description of this widget.
inherited

Operators

operator ==(Object other) bool
The equality operator.
inherited