// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'color_scheme.dart'; import 'constants.dart'; import 'debug.dart'; import 'divider.dart'; import 'icon_button.dart'; import 'icons.dart'; import 'ink_well.dart'; import 'list_tile.dart'; import 'material.dart'; import 'material_localizations.dart'; import 'material_state.dart'; import 'popup_menu_theme.dart'; import 'text_theme.dart'; import 'theme.dart'; import 'tooltip.dart'; // Examples can assume: // enum Commands { heroAndScholar, hurricaneCame } // late bool _heroAndScholar; // late dynamic _selection; // late BuildContext context; // void setState(VoidCallback fn) { } // enum Menu { itemOne, itemTwo, itemThree, itemFour } const Duration _kMenuDuration = Duration(milliseconds: 300); const double _kMenuCloseIntervalEnd = 2.0 / 3.0; const double _kMenuHorizontalPadding = 16.0; const double _kMenuDividerHeight = 16.0; const double _kMenuMaxWidth = 5.0 * _kMenuWidthStep; const double _kMenuMinWidth = 2.0 * _kMenuWidthStep; const double _kMenuVerticalPadding = 8.0; const double _kMenuWidthStep = 56.0; const double _kMenuScreenPadding = 8.0; /// A base class for entries in a Material Design popup menu. /// /// The popup menu widget uses this interface to interact with the menu items. /// To show a popup menu, use the [showMenu] function. To create a button that /// shows a popup menu, consider using [PopupMenuButton]. /// /// The type `T` is the type of the value(s) the entry represents. All the /// entries in a given menu must represent values with consistent types. /// /// A [PopupMenuEntry] may represent multiple values, for example a row with /// several icons, or a single entry, for example a menu item with an icon (see /// [PopupMenuItem]), or no value at all (for example, [PopupMenuDivider]). /// /// See also: /// /// * [PopupMenuItem], a popup menu entry for a single value. /// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. /// * [CheckedPopupMenuItem], a popup menu item with a checkmark. /// * [showMenu], a method to dynamically show a popup menu at a given location. /// * [PopupMenuButton], an [IconButton] that automatically shows a menu when /// it is tapped. abstract class PopupMenuEntry<T> extends StatefulWidget { /// Abstract const constructor. This constructor enables subclasses to provide /// const constructors so that they can be used in const expressions. const PopupMenuEntry({ super.key }); /// The amount of vertical space occupied by this entry. /// /// This value is used at the time the [showMenu] method is called, if the /// `initialValue` argument is provided, to determine the position of this /// entry when aligning the selected entry over the given `position`. It is /// otherwise ignored. double get height; /// Whether this entry represents a particular value. /// /// This method is used by [showMenu], when it is called, to align the entry /// representing the `initialValue`, if any, to the given `position`, and then /// later is called on each entry to determine if it should be highlighted (if /// the method returns true, the entry will have its background color set to /// the ambient [ThemeData.highlightColor]). If `initialValue` is null, then /// this method is not called. /// /// If the [PopupMenuEntry] represents a single value, this should return true /// if the argument matches that value. If it represents multiple values, it /// should return true if the argument matches any of them. bool represents(T? value); } /// A horizontal divider in a Material Design popup menu. /// /// This widget adapts the [Divider] for use in popup menus. /// /// See also: /// /// * [PopupMenuItem], for the kinds of items that this widget divides. /// * [showMenu], a method to dynamically show a popup menu at a given location. /// * [PopupMenuButton], an [IconButton] that automatically shows a menu when /// it is tapped. class PopupMenuDivider extends PopupMenuEntry<Never> { /// Creates a horizontal divider for a popup menu. /// /// By default, the divider has a height of 16 logical pixels. const PopupMenuDivider({ super.key, this.height = _kMenuDividerHeight }); /// The height of the divider entry. /// /// Defaults to 16 pixels. @override final double height; @override bool represents(void value) => false; @override State<PopupMenuDivider> createState() => _PopupMenuDividerState(); } class _PopupMenuDividerState extends State<PopupMenuDivider> { @override Widget build(BuildContext context) => Divider(height: widget.height); } // This widget only exists to enable _PopupMenuRoute to save the sizes of // each menu item. The sizes are used by _PopupMenuRouteLayout to compute the // y coordinate of the menu's origin so that the center of selected menu // item lines up with the center of its PopupMenuButton. class _MenuItem extends SingleChildRenderObjectWidget { const _MenuItem({ required this.onLayout, required super.child, }); final ValueChanged<Size> onLayout; @override RenderObject createRenderObject(BuildContext context) { return _RenderMenuItem(onLayout); } @override void updateRenderObject(BuildContext context, covariant _RenderMenuItem renderObject) { renderObject.onLayout = onLayout; } } class _RenderMenuItem extends RenderShiftedBox { _RenderMenuItem(this.onLayout, [RenderBox? child]) : super(child); ValueChanged<Size> onLayout; @override Size computeDryLayout(BoxConstraints constraints) { if (child == null) { return Size.zero; } return child!.getDryLayout(constraints); } @override void performLayout() { if (child == null) { size = Size.zero; } else { child!.layout(constraints, parentUsesSize: true); size = constraints.constrain(child!.size); final BoxParentData childParentData = child!.parentData! as BoxParentData; childParentData.offset = Offset.zero; } onLayout(size); } } /// An item in a Material Design popup menu. /// /// To show a popup menu, use the [showMenu] function. To create a button that /// shows a popup menu, consider using [PopupMenuButton]. /// /// To show a checkmark next to a popup menu item, consider using /// [CheckedPopupMenuItem]. /// /// Typically the [child] of a [PopupMenuItem] is a [Text] widget. More /// elaborate menus with icons can use a [ListTile]. By default, a /// [PopupMenuItem] is [kMinInteractiveDimension] pixels high. If you use a widget /// with a different height, it must be specified in the [height] property. /// /// {@tool snippet} /// /// Here, a [Text] widget is used with a popup menu item. The `Menu` type /// is an enum, not shown here. /// /// ```dart /// const PopupMenuItem<Menu>( /// value: Menu.itemOne, /// child: Text('Item 1'), /// ) /// ``` /// {@end-tool} /// /// See the example at [PopupMenuButton] for how this example could be used in a /// complete menu, and see the example at [CheckedPopupMenuItem] for one way to /// keep the text of [PopupMenuItem]s that use [Text] widgets in their [child] /// slot aligned with the text of [CheckedPopupMenuItem]s or of [PopupMenuItem] /// that use a [ListTile] in their [child] slot. /// /// See also: /// /// * [PopupMenuDivider], which can be used to divide items from each other. /// * [CheckedPopupMenuItem], a variant of [PopupMenuItem] with a checkmark. /// * [showMenu], a method to dynamically show a popup menu at a given location. /// * [PopupMenuButton], an [IconButton] that automatically shows a menu when /// it is tapped. class PopupMenuItem<T> extends PopupMenuEntry<T> { /// Creates an item for a popup menu. /// /// By default, the item is [enabled]. /// /// The `enabled` and `height` arguments must not be null. const PopupMenuItem({ super.key, this.value, this.onTap, this.enabled = true, this.height = kMinInteractiveDimension, this.padding, this.textStyle, this.labelTextStyle, this.mouseCursor, required this.child, }); /// The value that will be returned by [showMenu] if this entry is selected. final T? value; /// Called when the menu item is tapped. final VoidCallback? onTap; /// Whether the user is permitted to select this item. /// /// Defaults to true. If this is false, then the item will not react to /// touches. final bool enabled; /// The minimum height of the menu item. /// /// Defaults to [kMinInteractiveDimension] pixels. @override final double height; /// The padding of the menu item. /// /// The [height] property may interact with the applied padding. For example, /// If a [height] greater than the height of the sum of the padding and [child] /// is provided, then the padding's effect will not be visible. /// /// When null, the horizontal padding defaults to 16.0 on both sides. final EdgeInsets? padding; /// The text style of the popup menu item. /// /// If this property is null, then [PopupMenuThemeData.textStyle] is used. /// If [PopupMenuThemeData.textStyle] is also null, then [TextTheme.titleMedium] /// of [ThemeData.textTheme] is used. final TextStyle? textStyle; /// The label style of the popup menu item. /// /// When [ThemeData.useMaterial3] is true, this styles the text of the popup menu item. /// /// If this property is null, then [PopupMenuThemeData.labelTextStyle] is used. /// If [PopupMenuThemeData.labelTextStyle] is also null, then [TextTheme.labelLarge] /// is used with the [ColorScheme.onSurface] color when popup menu item is enabled and /// the [ColorScheme.onSurface] color with 0.38 opacity when the popup menu item is disabled. final MaterialStateProperty<TextStyle?>? labelTextStyle; /// {@template flutter.material.popupmenu.mouseCursor} /// The cursor for a mouse pointer when it enters or is hovering over the /// widget. /// /// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>], /// [MaterialStateProperty.resolve] is used for the following [MaterialState]s: /// /// * [MaterialState.hovered]. /// * [MaterialState.focused]. /// * [MaterialState.disabled]. /// {@endtemplate} /// /// If null, then the value of [PopupMenuThemeData.mouseCursor] is used. If /// that is also null, then [MaterialStateMouseCursor.clickable] is used. final MouseCursor? mouseCursor; /// The widget below this widget in the tree. /// /// Typically a single-line [ListTile] (for menus with icons) or a [Text]. An /// appropriate [DefaultTextStyle] is put in scope for the child. In either /// case, the text should be short enough that it won't wrap. final Widget? child; @override bool represents(T? value) => value == this.value; @override PopupMenuItemState<T, PopupMenuItem<T>> createState() => PopupMenuItemState<T, PopupMenuItem<T>>(); } /// The [State] for [PopupMenuItem] subclasses. /// /// By default this implements the basic styling and layout of Material Design /// popup menu items. /// /// The [buildChild] method can be overridden to adjust exactly what gets placed /// in the menu. By default it returns [PopupMenuItem.child]. /// /// The [handleTap] method can be overridden to adjust exactly what happens when /// the item is tapped. By default, it uses [Navigator.pop] to return the /// [PopupMenuItem.value] from the menu route. /// /// This class takes two type arguments. The second, `W`, is the exact type of /// the [Widget] that is using this [State]. It must be a subclass of /// [PopupMenuItem]. The first, `T`, must match the type argument of that widget /// class, and is the type of values returned from this menu. class PopupMenuItemState<T, W extends PopupMenuItem<T>> extends State<W> { /// The menu item contents. /// /// Used by the [build] method. /// /// By default, this returns [PopupMenuItem.child]. Override this to put /// something else in the menu entry. @protected Widget? buildChild() => widget.child; /// The handler for when the user selects the menu item. /// /// Used by the [InkWell] inserted by the [build] method. /// /// By default, uses [Navigator.pop] to return the [PopupMenuItem.value] from /// the menu route. @protected void handleTap() { // Need to pop the navigator first in case onTap may push new route onto navigator. Navigator.pop<T>(context, widget.value); widget.onTap?.call(); } @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); final PopupMenuThemeData defaults = theme.useMaterial3 ? _PopupMenuDefaultsM3(context) : _PopupMenuDefaultsM2(context); final Set<MaterialState> states = <MaterialState>{ if (!widget.enabled) MaterialState.disabled, }; TextStyle style = theme.useMaterial3 ? (widget.labelTextStyle?.resolve(states) ?? popupMenuTheme.labelTextStyle?.resolve(states)! ?? defaults.labelTextStyle!.resolve(states)!) : (widget.textStyle ?? popupMenuTheme.textStyle ?? defaults.textStyle!); if (!widget.enabled && !theme.useMaterial3) { style = style.copyWith(color: theme.disabledColor); } Widget item = AnimatedDefaultTextStyle( style: style, duration: kThemeChangeDuration, child: Container( alignment: AlignmentDirectional.centerStart, constraints: BoxConstraints(minHeight: widget.height), padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: _kMenuHorizontalPadding), child: buildChild(), ), ); if (!widget.enabled) { final bool isDark = theme.brightness == Brightness.dark; item = IconTheme.merge( data: IconThemeData(opacity: isDark ? 0.5 : 0.38), child: item, ); } return MergeSemantics( child: Semantics( enabled: widget.enabled, button: true, child: InkWell( onTap: widget.enabled ? handleTap : null, canRequestFocus: widget.enabled, mouseCursor: _EffectiveMouseCursor(widget.mouseCursor, popupMenuTheme.mouseCursor), child: item, ), ), ); } } /// An item with a checkmark in a Material Design popup menu. /// /// To show a popup menu, use the [showMenu] function. To create a button that /// shows a popup menu, consider using [PopupMenuButton]. /// /// A [CheckedPopupMenuItem] is kMinInteractiveDimension pixels high, which /// matches the default minimum height of a [PopupMenuItem]. The horizontal /// layout uses [ListTile]; the checkmark is an [Icons.done] icon, shown in the /// [ListTile.leading] position. /// /// {@tool snippet} /// /// Suppose a `Commands` enum exists that lists the possible commands from a /// particular popup menu, including `Commands.heroAndScholar` and /// `Commands.hurricaneCame`, and further suppose that there is a /// `_heroAndScholar` member field which is a boolean. The example below shows a /// menu with one menu item with a checkmark that can toggle the boolean, and /// one menu item without a checkmark for selecting the second option. (It also /// shows a divider placed between the two menu items.) /// /// ```dart /// PopupMenuButton<Commands>( /// onSelected: (Commands result) { /// switch (result) { /// case Commands.heroAndScholar: /// setState(() { _heroAndScholar = !_heroAndScholar; }); /// case Commands.hurricaneCame: /// // ...handle hurricane option /// break; /// // ...other items handled here /// } /// }, /// itemBuilder: (BuildContext context) => <PopupMenuEntry<Commands>>[ /// CheckedPopupMenuItem<Commands>( /// checked: _heroAndScholar, /// value: Commands.heroAndScholar, /// child: const Text('Hero and scholar'), /// ), /// const PopupMenuDivider(), /// const PopupMenuItem<Commands>( /// value: Commands.hurricaneCame, /// child: ListTile(leading: Icon(null), title: Text('Bring hurricane')), /// ), /// // ...other items listed here /// ], /// ) /// ``` /// {@end-tool} /// /// In particular, observe how the second menu item uses a [ListTile] with a /// blank [Icon] in the [ListTile.leading] position to get the same alignment as /// the item with the checkmark. /// /// See also: /// /// * [PopupMenuItem], a popup menu entry for picking a command (as opposed to /// toggling a value). /// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. /// * [showMenu], a method to dynamically show a popup menu at a given location. /// * [PopupMenuButton], an [IconButton] that automatically shows a menu when /// it is tapped. class CheckedPopupMenuItem<T> extends PopupMenuItem<T> { /// Creates a popup menu item with a checkmark. /// /// By default, the menu item is [enabled] but unchecked. To mark the item as /// checked, set [checked] to true. /// /// The `checked` and `enabled` arguments must not be null. const CheckedPopupMenuItem({ super.key, super.value, this.checked = false, super.enabled, super.padding, super.height, super.mouseCursor, super.child, }); /// Whether to display a checkmark next to the menu item. /// /// Defaults to false. /// /// When true, an [Icons.done] checkmark is displayed. /// /// When this popup menu item is selected, the checkmark will fade in or out /// as appropriate to represent the implied new state. final bool checked; /// The widget below this widget in the tree. /// /// Typically a [Text]. An appropriate [DefaultTextStyle] is put in scope for /// the child. The text should be short enough that it won't wrap. /// /// This widget is placed in the [ListTile.title] slot of a [ListTile] whose /// [ListTile.leading] slot is an [Icons.done] icon. @override Widget? get child => super.child; @override PopupMenuItemState<T, CheckedPopupMenuItem<T>> createState() => _CheckedPopupMenuItemState<T>(); } class _CheckedPopupMenuItemState<T> extends PopupMenuItemState<T, CheckedPopupMenuItem<T>> with SingleTickerProviderStateMixin { static const Duration _fadeDuration = Duration(milliseconds: 150); late AnimationController _controller; Animation<double> get _opacity => _controller.view; @override void initState() { super.initState(); _controller = AnimationController(duration: _fadeDuration, vsync: this) ..value = widget.checked ? 1.0 : 0.0 ..addListener(() => setState(() { /* animation changed */ })); } @override void handleTap() { // This fades the checkmark in or out when tapped. if (widget.checked) { _controller.reverse(); } else { _controller.forward(); } super.handleTap(); } @override Widget buildChild() { return IgnorePointer( child: ListTile( enabled: widget.enabled, leading: FadeTransition( opacity: _opacity, child: Icon(_controller.isDismissed ? null : Icons.done), ), title: widget.child, ), ); } } class _PopupMenu<T> extends StatelessWidget { const _PopupMenu({ super.key, required this.route, required this.semanticLabel, this.constraints, required this.clipBehavior, }); final _PopupMenuRoute<T> route; final String? semanticLabel; final BoxConstraints? constraints; final Clip clipBehavior; @override Widget build(BuildContext context) { final double unit = 1.0 / (route.items.length + 1.5); // 1.0 for the width and 0.5 for the last item's fade. final List<Widget> children = <Widget>[]; final ThemeData theme = Theme.of(context); final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); final PopupMenuThemeData defaults = theme.useMaterial3 ? _PopupMenuDefaultsM3(context) : _PopupMenuDefaultsM2(context); for (int i = 0; i < route.items.length; i += 1) { final double start = (i + 1) * unit; final double end = clampDouble(start + 1.5 * unit, 0.0, 1.0); final CurvedAnimation opacity = CurvedAnimation( parent: route.animation!, curve: Interval(start, end), ); Widget item = route.items[i]; if (route.initialValue != null && route.items[i].represents(route.initialValue)) { item = ColoredBox( color: Theme.of(context).highlightColor, child: item, ); } children.add( _MenuItem( onLayout: (Size size) { route.itemSizes[i] = size; }, child: FadeTransition( opacity: opacity, child: item, ), ), ); } final CurveTween opacity = CurveTween(curve: const Interval(0.0, 1.0 / 3.0)); final CurveTween width = CurveTween(curve: Interval(0.0, unit)); final CurveTween height = CurveTween(curve: Interval(0.0, unit * route.items.length)); final Widget child = ConstrainedBox( constraints: constraints ?? const BoxConstraints( minWidth: _kMenuMinWidth, maxWidth: _kMenuMaxWidth, ), child: IntrinsicWidth( stepWidth: _kMenuWidthStep, child: Semantics( scopesRoute: true, namesRoute: true, explicitChildNodes: true, label: semanticLabel, child: SingleChildScrollView( padding: const EdgeInsets.symmetric( vertical: _kMenuVerticalPadding, ), child: ListBody(children: children), ), ), ), ); return AnimatedBuilder( animation: route.animation!, builder: (BuildContext context, Widget? child) { return FadeTransition( opacity: opacity.animate(route.animation!), child: Material( shape: route.shape ?? popupMenuTheme.shape ?? defaults.shape, color: route.color ?? popupMenuTheme.color ?? defaults.color, clipBehavior: clipBehavior, type: MaterialType.card, elevation: route.elevation ?? popupMenuTheme.elevation ?? defaults.elevation!, shadowColor: route.shadowColor ?? popupMenuTheme.shadowColor ?? defaults.shadowColor, surfaceTintColor: route.surfaceTintColor ?? popupMenuTheme.surfaceTintColor ?? defaults.surfaceTintColor, child: Align( alignment: AlignmentDirectional.topEnd, widthFactor: width.evaluate(route.animation!), heightFactor: height.evaluate(route.animation!), child: child, ), ), ); }, child: child, ); } } // Positioning of the menu on the screen. class _PopupMenuRouteLayout extends SingleChildLayoutDelegate { _PopupMenuRouteLayout( this.position, this.itemSizes, this.selectedItemIndex, this.textDirection, this.padding, this.avoidBounds, ); // Rectangle of underlying button, relative to the overlay's dimensions. final RelativeRect position; // The sizes of each item are computed when the menu is laid out, and before // the route is laid out. List<Size?> itemSizes; // The index of the selected item, or null if PopupMenuButton.initialValue // was not specified. final int? selectedItemIndex; // Whether to prefer going to the left or to the right. final TextDirection textDirection; // The padding of unsafe area. EdgeInsets padding; // List of rectangles that we should avoid overlapping. Unusable screen area. final Set<Rect> avoidBounds; // We put the child wherever position specifies, so long as it will fit within // the specified parent size padded (inset) by 8. If necessary, we adjust the // child's position so that it fits. @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { // The menu can be at most the size of the overlay minus 8.0 pixels in each // direction. return BoxConstraints.loose(constraints.biggest).deflate( const EdgeInsets.all(_kMenuScreenPadding) + padding, ); } @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 double buttonHeight = size.height - position.top - position.bottom; // Find the ideal vertical position. double y = position.top; if (selectedItemIndex != null) { double selectedItemOffset = _kMenuVerticalPadding; for (int index = 0; index < selectedItemIndex!; index += 1) { selectedItemOffset += itemSizes[index]!.height; } selectedItemOffset += itemSizes[selectedItemIndex!]!.height / 2; y = y + buttonHeight / 2.0 - selectedItemOffset; } // Find the ideal horizontal position. double x; if (position.left > position.right) { // Menu button is closer to the right edge, so grow to the left, aligned to the right edge. x = size.width - position.right - childSize.width; } else if (position.left < position.right) { // Menu button is closer to the left edge, so grow to the right, aligned to the left edge. x = position.left; } else { // Menu button is equidistant from both edges, so grow in reading direction. switch (textDirection) { case TextDirection.rtl: x = size.width - position.right - childSize.width; case TextDirection.ltr: x = position.left; } } final Offset wantedPosition = Offset(x, y); final Offset originCenter = position.toRect(Offset.zero & size).center; final Iterable<Rect> subScreens = DisplayFeatureSubScreen.subScreensInBounds(Offset.zero & size, avoidBounds); final Rect subScreen = _closestScreen(subScreens, originCenter); return _fitInsideScreen(subScreen, childSize, wantedPosition); } Rect _closestScreen(Iterable<Rect> screens, Offset point) { Rect closest = screens.first; for (final Rect screen in screens) { if ((screen.center - point).distance < (closest.center - point).distance) { closest = screen; } } return closest; } Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition){ double x = wantedPosition.dx; double y = wantedPosition.dy; // Avoid going outside an area defined as the rectangle 8.0 pixels from the // edge of the screen in every direction. if (x < screen.left + _kMenuScreenPadding + padding.left) { x = screen.left + _kMenuScreenPadding + padding.left; } else if (x + childSize.width > screen.right - _kMenuScreenPadding - padding.right) { x = screen.right - childSize.width - _kMenuScreenPadding - padding.right; } if (y < screen.top + _kMenuScreenPadding + padding.top) { y = _kMenuScreenPadding + padding.top; } else if (y + childSize.height > screen.bottom - _kMenuScreenPadding - padding.bottom) { y = screen.bottom - childSize.height - _kMenuScreenPadding - padding.bottom; } return Offset(x,y); } @override bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) { // If called when the old and new itemSizes have been initialized then // we expect them to have the same length because there's no practical // way to change length of the items list once the menu has been shown. assert(itemSizes.length == oldDelegate.itemSizes.length); return position != oldDelegate.position || selectedItemIndex != oldDelegate.selectedItemIndex || textDirection != oldDelegate.textDirection || !listEquals(itemSizes, oldDelegate.itemSizes) || padding != oldDelegate.padding || !setEquals(avoidBounds, oldDelegate.avoidBounds); } } class _PopupMenuRoute<T> extends PopupRoute<T> { _PopupMenuRoute({ required this.position, required this.items, this.initialValue, this.elevation, this.surfaceTintColor, this.shadowColor, required this.barrierLabel, this.semanticLabel, this.shape, this.color, required this.capturedThemes, this.constraints, required this.clipBehavior, super.settings, }) : itemSizes = List<Size?>.filled(items.length, null), // Menus always cycle focus through their items irrespective of the // focus traversal edge behavior set in the Navigator. super(traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop); final RelativeRect position; final List<PopupMenuEntry<T>> items; final List<Size?> itemSizes; final T? initialValue; final double? elevation; final Color? surfaceTintColor; final Color? shadowColor; final String? semanticLabel; final ShapeBorder? shape; final Color? color; final CapturedThemes capturedThemes; final BoxConstraints? constraints; final Clip clipBehavior; @override Animation<double> createAnimation() { return CurvedAnimation( parent: super.createAnimation(), curve: Curves.linear, reverseCurve: const Interval(0.0, _kMenuCloseIntervalEnd), ); } @override Duration get transitionDuration => _kMenuDuration; @override bool get barrierDismissible => true; @override Color? get barrierColor => null; @override final String barrierLabel; @override Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { int? selectedItemIndex; if (initialValue != null) { for (int index = 0; selectedItemIndex == null && index < items.length; index += 1) { if (items[index].represents(initialValue)) { selectedItemIndex = index; } } } final Widget menu = _PopupMenu<T>( route: this, semanticLabel: semanticLabel, constraints: constraints, clipBehavior: clipBehavior, ); final MediaQueryData mediaQuery = MediaQuery.of(context); return MediaQuery.removePadding( context: context, removeTop: true, removeBottom: true, removeLeft: true, removeRight: true, child: Builder( builder: (BuildContext context) { return CustomSingleChildLayout( delegate: _PopupMenuRouteLayout( position, itemSizes, selectedItemIndex, Directionality.of(context), mediaQuery.padding, _avoidBounds(mediaQuery), ), child: capturedThemes.wrap(menu), ); }, ), ); } Set<Rect> _avoidBounds(MediaQueryData mediaQuery) { return DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet(); } } /// Show a popup menu that contains the `items` at `position`. /// /// `items` should be non-null and not empty. /// /// If `initialValue` is specified then the first item with a matching value /// will be highlighted and the value of `position` gives the rectangle whose /// vertical center will be aligned with the vertical center of the highlighted /// item (when possible). /// /// If `initialValue` is not specified then the top of the menu will be aligned /// with the top of the `position` rectangle. /// /// In both cases, the menu position will be adjusted if necessary to fit on the /// screen. /// /// Horizontally, the menu is positioned so that it grows in the direction that /// has the most room. For example, if the `position` describes a rectangle on /// the left edge of the screen, then the left edge of the menu is aligned with /// the left edge of the `position`, and the menu grows to the right. If both /// edges of the `position` are equidistant from the opposite edge of the /// screen, then the ambient [Directionality] is used as a tie-breaker, /// preferring to grow in the reading direction. /// /// The positioning of the `initialValue` at the `position` is implemented by /// iterating over the `items` to find the first whose /// [PopupMenuEntry.represents] method returns true for `initialValue`, and then /// summing the values of [PopupMenuEntry.height] for all the preceding widgets /// in the list. /// /// The `elevation` argument specifies the z-coordinate at which to place the /// menu. The elevation defaults to 8, the appropriate elevation for popup /// menus. /// /// The `context` argument is used to look up the [Navigator] and [Theme] for /// the menu. It is only used when the method is called. Its corresponding /// widget can be safely removed from the tree before the popup menu is closed. /// /// The `useRootNavigator` argument is used to determine whether to push the /// menu to the [Navigator] furthest from or nearest to the given `context`. It /// is `false` by default. /// /// The `semanticLabel` argument is used by accessibility frameworks to /// announce screen transitions when the menu is opened and closed. If this /// label is not provided, it will default to /// [MaterialLocalizations.popupMenuLabel]. /// /// The `clipBehavior` argument is used to clip the shape of the menu. Defaults to /// [Clip.none]. /// /// See also: /// /// * [PopupMenuItem], a popup menu entry for a single value. /// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. /// * [CheckedPopupMenuItem], a popup menu item with a checkmark. /// * [PopupMenuButton], which provides an [IconButton] that shows a menu by /// calling this method automatically. /// * [SemanticsConfiguration.namesRoute], for a description of edge triggered /// semantics. Future<T?> showMenu<T>({ required BuildContext context, required RelativeRect position, required List<PopupMenuEntry<T>> items, T? initialValue, double? elevation, Color? shadowColor, Color? surfaceTintColor, String? semanticLabel, ShapeBorder? shape, Color? color, bool useRootNavigator = false, BoxConstraints? constraints, Clip clipBehavior = Clip.none, RouteSettings? routeSettings, }) { assert(items.isNotEmpty); assert(debugCheckHasMaterialLocalizations(context)); switch (Theme.of(context).platform) { case TargetPlatform.iOS: case TargetPlatform.macOS: break; case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: semanticLabel ??= MaterialLocalizations.of(context).popupMenuLabel; } final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator); return navigator.push(_PopupMenuRoute<T>( position: position, items: items, initialValue: initialValue, elevation: elevation, shadowColor: shadowColor, surfaceTintColor: surfaceTintColor, semanticLabel: semanticLabel, barrierLabel: MaterialLocalizations.of(context).menuDismissLabel, shape: shape, color: color, capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), constraints: constraints, clipBehavior: clipBehavior, settings: routeSettings, )); } /// Signature for the callback invoked when a menu item is selected. The /// argument is the value of the [PopupMenuItem] that caused its menu to be /// dismissed. /// /// Used by [PopupMenuButton.onSelected]. typedef PopupMenuItemSelected<T> = void Function(T value); /// Signature for the callback invoked when a [PopupMenuButton] is dismissed /// without selecting an item. /// /// Used by [PopupMenuButton.onCanceled]. typedef PopupMenuCanceled = void Function(); /// Signature used by [PopupMenuButton] to lazily construct the items shown when /// the button is pressed. /// /// Used by [PopupMenuButton.itemBuilder]. typedef PopupMenuItemBuilder<T> = List<PopupMenuEntry<T>> Function(BuildContext context); /// Displays a menu when pressed and calls [onSelected] when the menu is dismissed /// because an item was selected. The value passed to [onSelected] is the value of /// the selected menu item. /// /// One of [child] or [icon] may be provided, but not both. If [icon] is provided, /// then [PopupMenuButton] behaves like an [IconButton]. /// /// If both are null, then a standard overflow icon is created (depending on the /// platform). /// /// /// ## Updating to [MenuAnchor] /// /// There is a Material 3 component, /// [MenuAnchor] that is preferred for applications that are configured /// for Material 3 (see [ThemeData.useMaterial3]). /// The [MenuAnchor] widget's visuals /// are a little bit different, see the Material 3 spec at /// <https://m3.material.io/components/menus/guidelines> for /// more details. /// /// The [MenuAnchor] widget's API is also slightly different. /// [MenuAnchor]'s were built to be lower level interface for /// creating menus that are displayed from an anchor. /// /// There are a few steps you would take to migrate from /// [PopupMenuButton] to [MenuAnchor]: /// /// 1. Instead of using the [PopupMenuButton.itemBuilder] to build /// a list of [PopupMenuEntry]s, you would use the [MenuAnchor.menuChildren] /// which takes a list of [Widget]s. Usually, you would use a list of /// [MenuItemButton]s as shown in the example below. /// /// 2. Instead of using the [PopupMenuButton.onSelected] callback, you would /// set individual callbacks for each of the [MenuItemButton]s using the /// [MenuItemButton.onPressed] property. /// /// 3. To anchor the [MenuAnchor] to a widget, you would use the [MenuAnchor.builder] /// to return the widget of choice - usually a [TextButton] or an [IconButton]. /// /// 4. You may want to style the [MenuItemButton]s, see the [MenuItemButton] /// documentation for details. /// /// Use the sample below for an example of migrating from [PopupMenuButton] to /// [MenuAnchor]. /// /// {@tool dartpad} /// This example shows a menu with three items, selecting between an enum's /// values and setting a `selectedMenu` field based on the selection. /// /// ** See code in examples/api/lib/material/popup_menu/popup_menu.0.dart ** /// {@end-tool} /// /// {@tool dartpad} /// This example shows how to migrate the above to a [MenuAnchor]. /// /// ** See code in examples/api/lib/material/menu_anchor/menu_anchor.2.dart ** /// {@end-tool} /// /// {@tool dartpad} /// This sample shows the creation of a popup menu, as described in: /// https://m3.material.io/components/menus/overview /// /// ** See code in examples/api/lib/material/popup_menu/popup_menu.1.dart ** /// {@end-tool} /// /// See also: /// /// * [PopupMenuItem], a popup menu entry for a single value. /// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. /// * [CheckedPopupMenuItem], a popup menu item with a checkmark. /// * [showMenu], a method to dynamically show a popup menu at a given location. class PopupMenuButton<T> extends StatefulWidget { /// Creates a button that shows a popup menu. /// /// The [itemBuilder] argument must not be null. const PopupMenuButton({ super.key, required this.itemBuilder, this.initialValue, this.onOpened, this.onSelected, this.onCanceled, this.tooltip, this.elevation, this.shadowColor, this.surfaceTintColor, this.padding = const EdgeInsets.all(8.0), this.child, this.splashRadius, this.icon, this.iconSize, this.offset = Offset.zero, this.enabled = true, this.shape, this.color, this.enableFeedback, this.constraints, this.position, this.clipBehavior = Clip.none, }) : assert( !(child != null && icon != null), 'You can only pass [child] or [icon], not both.', ); /// Called when the button is pressed to create the items to show in the menu. final PopupMenuItemBuilder<T> itemBuilder; /// The value of the menu item, if any, that should be highlighted when the menu opens. final T? initialValue; /// Called when the popup menu is shown. final VoidCallback? onOpened; /// Called when the user selects a value from the popup menu created by this button. /// /// If the popup menu is dismissed without selecting a value, [onCanceled] is /// called instead. final PopupMenuItemSelected<T>? onSelected; /// Called when the user dismisses the popup menu without selecting an item. /// /// If the user selects a value, [onSelected] is called instead. final PopupMenuCanceled? onCanceled; /// Text that describes the action that will occur when the button is pressed. /// /// This text is displayed when the user long-presses on the button and is /// used for accessibility. final String? tooltip; /// The z-coordinate at which to place the menu when open. This controls the /// size of the shadow below the menu. /// /// Defaults to 8, the appropriate elevation for popup menus. final double? elevation; /// The color used to paint the shadow below the menu. /// /// If null then the ambient [PopupMenuThemeData.shadowColor] is used. /// If that is null too, then the overall theme's [ThemeData.shadowColor] /// (default black) is used. final Color? shadowColor; /// The color used as an overlay on [color] to indicate elevation. /// /// If null, [PopupMenuThemeData.surfaceTintColor] is used. If that /// is also null, the default value is [ColorScheme.surfaceTint]. /// /// See [Material.surfaceTintColor] for more details on how this /// overlay is applied. final Color? surfaceTintColor; /// Matches IconButton's 8 dps padding by default. In some cases, notably where /// this button appears as the trailing element of a list item, it's useful to be able /// to set the padding to zero. final EdgeInsetsGeometry padding; /// The splash radius. /// /// If null, default splash radius of [InkWell] or [IconButton] is used. final double? splashRadius; /// If provided, [child] is the widget used for this button /// and the button will utilize an [InkWell] for taps. final Widget? child; /// If provided, the [icon] is used for this button /// and the button will behave like an [IconButton]. final Widget? icon; /// The offset is applied relative to the initial position /// set by the [position]. /// /// When not set, the offset defaults to [Offset.zero]. final Offset offset; /// Whether this popup menu button is interactive. /// /// Must be non-null, defaults to `true` /// /// If `true` the button will respond to presses by displaying the menu. /// /// If `false`, the button is styled with the disabled color from the /// current [Theme] and will not respond to presses or show the popup /// menu and [onSelected], [onCanceled] and [itemBuilder] will not be called. /// /// This can be useful in situations where the app needs to show the button, /// but doesn't currently have anything to show in the menu. final bool enabled; /// If provided, the shape used for the menu. /// /// If this property is null, then [PopupMenuThemeData.shape] is used. /// If [PopupMenuThemeData.shape] is also null, then the default shape for /// [MaterialType.card] is used. This default shape is a rectangle with /// rounded edges of BorderRadius.circular(2.0). final ShapeBorder? shape; /// If provided, the background color used for the menu. /// /// If this property is null, then [PopupMenuThemeData.color] is used. /// If [PopupMenuThemeData.color] is also null, then /// Theme.of(context).cardColor is used. final Color? color; /// Whether detected gestures should provide acoustic and/or haptic feedback. /// /// For example, on Android a tap will produce a clicking sound and a /// long-press will produce a short vibration, when feedback is enabled. /// /// See also: /// /// * [Feedback] for providing platform-specific feedback to certain actions. final bool? enableFeedback; /// If provided, the size of the [Icon]. /// /// If this property is null, then [IconThemeData.size] is used. /// If [IconThemeData.size] is also null, then /// default size is 24.0 pixels. final double? iconSize; /// Optional size constraints for the menu. /// /// When unspecified, defaults to: /// ```dart /// const BoxConstraints( /// minWidth: 2.0 * 56.0, /// maxWidth: 5.0 * 56.0, /// ) /// ``` /// /// The default constraints ensure that the menu width matches maximum width /// recommended by the Material Design guidelines. /// Specifying this parameter enables creation of menu wider than /// the default maximum width. final BoxConstraints? constraints; /// Whether the popup menu is positioned over or under the popup menu button. /// /// [offset] is used to change the position of the popup menu relative to the /// position set by this parameter. /// /// If this property is `null`, then [PopupMenuThemeData.position] is used. If /// [PopupMenuThemeData.position] is also `null`, then the position defaults /// to [PopupMenuPosition.over] which makes the popup menu appear directly /// over the button that was used to create it. final PopupMenuPosition? position; /// {@macro flutter.material.Material.clipBehavior} /// /// The [clipBehavior] argument is used the clip shape of the menu. /// /// Defaults to [Clip.none], and must not be null. final Clip clipBehavior; @override PopupMenuButtonState<T> createState() => PopupMenuButtonState<T>(); } /// The [State] for a [PopupMenuButton]. /// /// See [showButtonMenu] for a way to programmatically open the popup menu /// of your button state. class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> { /// A method to show a popup menu with the items supplied to /// [PopupMenuButton.itemBuilder] at the position of your [PopupMenuButton]. /// /// By default, it is called when the user taps the button and [PopupMenuButton.enabled] /// is set to `true`. Moreover, you can open the button by calling the method manually. /// /// You would access your [PopupMenuButtonState] using a [GlobalKey] and /// show the menu of the button with `globalKey.currentState.showButtonMenu`. void showButtonMenu() { final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); final RenderBox button = context.findRenderObject()! as RenderBox; final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox; final PopupMenuPosition popupMenuPosition = widget.position ?? popupMenuTheme.position ?? PopupMenuPosition.over; late Offset offset; switch (popupMenuPosition) { case PopupMenuPosition.over: offset = widget.offset; case PopupMenuPosition.under: offset = Offset(0.0, button.size.height) + widget.offset; if (widget.child == null) { // Remove the padding of the icon button. offset -= Offset(0.0, widget.padding.vertical / 2); } } final RelativeRect position = RelativeRect.fromRect( Rect.fromPoints( button.localToGlobal(offset, ancestor: overlay), button.localToGlobal(button.size.bottomRight(Offset.zero) + offset, ancestor: overlay), ), Offset.zero & overlay.size, ); final List<PopupMenuEntry<T>> items = widget.itemBuilder(context); // Only show the menu if there is something to show if (items.isNotEmpty) { widget.onOpened?.call(); showMenu<T?>( context: context, elevation: widget.elevation ?? popupMenuTheme.elevation, shadowColor: widget.shadowColor ?? popupMenuTheme.shadowColor, surfaceTintColor: widget.surfaceTintColor ?? popupMenuTheme.surfaceTintColor, items: items, initialValue: widget.initialValue, position: position, shape: widget.shape ?? popupMenuTheme.shape, color: widget.color ?? popupMenuTheme.color, constraints: widget.constraints, clipBehavior: widget.clipBehavior, ) .then<void>((T? newValue) { if (!mounted) { return null; } if (newValue == null) { widget.onCanceled?.call(); return null; } widget.onSelected?.call(newValue); }); } } bool get _canRequestFocus { final NavigationMode mode = MediaQuery.maybeNavigationModeOf(context) ?? NavigationMode.traditional; switch (mode) { case NavigationMode.traditional: return widget.enabled; case NavigationMode.directional: return true; } } @override Widget build(BuildContext context) { final IconThemeData iconTheme = IconTheme.of(context); final bool enableFeedback = widget.enableFeedback ?? PopupMenuTheme.of(context).enableFeedback ?? true; assert(debugCheckHasMaterialLocalizations(context)); if (widget.child != null) { return Tooltip( message: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, child: InkWell( onTap: widget.enabled ? showButtonMenu : null, canRequestFocus: _canRequestFocus, radius: widget.splashRadius, enableFeedback: enableFeedback, child: widget.child, ), ); } return IconButton( icon: widget.icon ?? Icon(Icons.adaptive.more), padding: widget.padding, splashRadius: widget.splashRadius, iconSize: widget.iconSize ?? iconTheme.size, color: widget.color ?? iconTheme.color, tooltip: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, onPressed: widget.enabled ? showButtonMenu : null, enableFeedback: enableFeedback, ); } } // This MaterialStateProperty is passed along to the menu item's InkWell which // resolves the property against MaterialState.disabled, MaterialState.hovered, // MaterialState.focused. class _EffectiveMouseCursor extends MaterialStateMouseCursor { const _EffectiveMouseCursor(this.widgetCursor, this.themeCursor); final MouseCursor? widgetCursor; final MaterialStateProperty<MouseCursor?>? themeCursor; @override MouseCursor resolve(Set<MaterialState> states) { return MaterialStateProperty.resolveAs<MouseCursor?>(widgetCursor, states) ?? themeCursor?.resolve(states) ?? MaterialStateMouseCursor.clickable.resolve(states); } @override String get debugDescription => 'MaterialStateMouseCursor(PopupMenuItemState)'; } class _PopupMenuDefaultsM2 extends PopupMenuThemeData { _PopupMenuDefaultsM2(this.context) : super(elevation: 8.0); final BuildContext context; late final ThemeData _theme = Theme.of(context); late final TextTheme _textTheme = _theme.textTheme; @override TextStyle? get textStyle => _textTheme.subtitle1; } // BEGIN GENERATED TOKEN PROPERTIES - PopupMenu // Do not edit by hand. The code between the "BEGIN GENERATED" and // "END GENERATED" comments are generated from data in the Material // Design token database by the script: // dev/tools/gen_defaults/bin/gen_defaults.dart. class _PopupMenuDefaultsM3 extends PopupMenuThemeData { _PopupMenuDefaultsM3(this.context) : super(elevation: 3.0); final BuildContext context; late final ThemeData _theme = Theme.of(context); late final ColorScheme _colors = _theme.colorScheme; late final TextTheme _textTheme = _theme.textTheme; @override MaterialStateProperty<TextStyle?>? get labelTextStyle { return MaterialStateProperty.resolveWith((Set<MaterialState> states) { final TextStyle style = _textTheme.labelLarge!; if (states.contains(MaterialState.disabled)) { return style.apply(color: _colors.onSurface.withOpacity(0.38)); } return style.apply(color: _colors.onSurface); }); } @override Color? get color => _colors.surface; @override Color? get shadowColor => _colors.shadow; @override Color? get surfaceTintColor => _colors.surfaceTint; @override ShapeBorder? get shape => const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); } // END GENERATED TOKEN PROPERTIES - PopupMenu