// 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. // @dart = 2.8 import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.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 'theme.dart'; import 'tooltip.dart'; // Examples can assume: // enum Commands { heroAndScholar, hurricaneCame } // dynamic _heroAndScholar; // dynamic _selection; // BuildContext context; // void setState(VoidCallback fn) { } 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({ Key key }) : super(key: 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. // ignore: prefer_void_to_null, https://github.com/dart-lang/sdk/issues/34416 class PopupMenuDivider extends PopupMenuEntry<Null> { /// Creates a horizontal divider for a popup menu. /// /// By default, the divider has a height of 16 logical pixels. const PopupMenuDivider({ Key key, this.height = _kMenuDividerHeight }) : super(key: key); /// The height of the divider entry. /// /// Defaults to 16 pixels. @override final double height; @override bool represents(void value) => false; @override _PopupMenuDividerState 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({ Key key, @required this.onLayout, Widget child, }) : assert(onLayout != null), super(key: key, child: 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]) : assert(onLayout != null), super(child); ValueChanged<Size> onLayout; @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 `WhyFarther` type /// is an enum, not shown here. /// /// ```dart /// const PopupMenuItem<WhyFarther>( /// value: WhyFarther.harder, /// child: Text('Working a lot harder'), /// ) /// ``` /// {@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({ Key key, this.value, this.enabled = true, this.height = kMinInteractiveDimension, this.textStyle, this.mouseCursor, @required this.child, }) : assert(enabled != null), assert(height != null), super(key: key); /// The value that will be returned by [showMenu] if this entry is selected. final T value; /// 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 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.subtitle1] /// of [ThemeData.textTheme] is used. final TextStyle textStyle; /// 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]: /// /// * [MaterialState.disabled]. /// /// If this property is null, [MaterialStateMouseCursor.clickable] will be 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() { Navigator.pop<T>(context, widget.value); } @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); TextStyle style = widget.textStyle ?? popupMenuTheme.textStyle ?? theme.textTheme.subtitle1; if (!widget.enabled) style = style.copyWith(color: theme.disabledColor); Widget item = AnimatedDefaultTextStyle( style: style, duration: kThemeChangeDuration, child: Container( alignment: AlignmentDirectional.centerStart, constraints: BoxConstraints(minHeight: widget.height), 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, ); } final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>( widget.mouseCursor ?? MaterialStateMouseCursor.clickable, <MaterialState>{ if (!widget.enabled) MaterialState.disabled, }, ); return MergeSemantics( child: Semantics( enabled: widget.enabled, button: true, child: InkWell( onTap: widget.enabled ? handleTap : null, canRequestFocus: widget.enabled, mouseCursor: effectiveMouseCursor, 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; }); /// break; /// 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({ Key key, T value, this.checked = false, bool enabled = true, Widget child, }) : assert(checked != null), super( key: key, value: value, enabled: enabled, child: 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 _CheckedPopupMenuItemState<T> createState() => _CheckedPopupMenuItemState<T>(); } class _CheckedPopupMenuItemState<T> extends PopupMenuItemState<T, CheckedPopupMenuItem<T>> with SingleTickerProviderStateMixin { static const Duration _fadeDuration = Duration(milliseconds: 150); 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 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({ Key key, this.route, this.semanticLabel, }) : super(key: key); final _PopupMenuRoute<T> route; final String semanticLabel; @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 PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); for (int i = 0; i < route.items.length; i += 1) { final double start = (i + 1) * unit; final double end = (start + 1.5 * unit).clamp(0.0, 1.0) as double; 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 = Container( 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: 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 Opacity( opacity: opacity.evaluate(route.animation), child: Material( shape: route.shape ?? popupMenuTheme.shape, color: route.color ?? popupMenuTheme.color, type: MaterialType.card, elevation: route.elevation ?? popupMenuTheme.elevation ?? 8.0, 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); // 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; // 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 - const Offset(_kMenuScreenPadding * 2.0, _kMenuScreenPadding * 2.0) as Size, ); } @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. // Find the ideal vertical position. double y = position.top; if (selectedItemIndex != null && itemSizes != null) { double selectedItemOffset = _kMenuVerticalPadding; for (int index = 0; index < selectedItemIndex; index += 1) selectedItemOffset += itemSizes[index].height; selectedItemOffset += itemSizes[selectedItemIndex].height / 2; y = position.top + (size.height - position.top - position.bottom) / 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. assert(textDirection != null); switch (textDirection) { case TextDirection.rtl: x = size.width - position.right - childSize.width; break; case TextDirection.ltr: x = position.left; break; } } // Avoid going outside an area defined as the rectangle 8.0 pixels from the // edge of the screen in every direction. if (x < _kMenuScreenPadding) x = _kMenuScreenPadding; else if (x + childSize.width > size.width - _kMenuScreenPadding) x = size.width - childSize.width - _kMenuScreenPadding; if (y < _kMenuScreenPadding) y = _kMenuScreenPadding; else if (y + childSize.height > size.height - _kMenuScreenPadding) y = size.height - childSize.height - _kMenuScreenPadding; 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); } } class _PopupMenuRoute<T> extends PopupRoute<T> { _PopupMenuRoute({ this.position, this.items, this.initialValue, this.elevation, this.theme, this.popupMenuTheme, this.barrierLabel, this.semanticLabel, this.shape, this.color, this.showMenuContext, this.captureInheritedThemes, }) : itemSizes = List<Size>(items.length); final RelativeRect position; final List<PopupMenuEntry<T>> items; final List<Size> itemSizes; final T initialValue; final double elevation; final ThemeData theme; final String semanticLabel; final ShapeBorder shape; final Color color; final PopupMenuThemeData popupMenuTheme; final BuildContext showMenuContext; final bool captureInheritedThemes; @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; } } Widget menu = _PopupMenu<T>(route: this, semanticLabel: semanticLabel); if (captureInheritedThemes) { menu = InheritedTheme.captureAll(showMenuContext, menu); } else { // For the sake of backwards compatibility. An (unlikely) app that relied // on having menus only inherit from the material Theme could set // captureInheritedThemes to false and get the original behavior. if (theme != null) menu = Theme(data: theme, child: menu); } 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), ), child: menu, ); }, ), ); } } /// 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]. /// /// 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, String semanticLabel, ShapeBorder shape, Color color, bool captureInheritedThemes = true, bool useRootNavigator = false, }) { assert(context != null); assert(position != null); assert(useRootNavigator != null); assert(items != null && items.isNotEmpty); assert(captureInheritedThemes != null); assert(debugCheckHasMaterialLocalizations(context)); String label = semanticLabel; switch (Theme.of(context).platform) { case TargetPlatform.iOS: case TargetPlatform.macOS: label = semanticLabel; break; case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: label = semanticLabel ?? MaterialLocalizations.of(context)?.popupMenuLabel; } return Navigator.of(context, rootNavigator: useRootNavigator).push(_PopupMenuRoute<T>( position: position, items: items, initialValue: initialValue, elevation: elevation, semanticLabel: label, theme: Theme.of(context, shadowThemeOnly: true), popupMenuTheme: PopupMenuTheme.of(context), barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, shape: shape, color: color, showMenuContext: context, captureInheritedThemes: captureInheritedThemes, )); } /// 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). /// /// {@tool snippet} /// /// This example shows a menu with four items, selecting between an enum's /// values and setting a `_selection` field based on the selection. /// /// ```dart /// // This is the type used by the popup menu below. /// enum WhyFarther { harder, smarter, selfStarter, tradingCharter } /// /// // This menu button widget updates a _selection field (of type WhyFarther, /// // not shown here). /// PopupMenuButton<WhyFarther>( /// onSelected: (WhyFarther result) { setState(() { _selection = result; }); }, /// itemBuilder: (BuildContext context) => <PopupMenuEntry<WhyFarther>>[ /// const PopupMenuItem<WhyFarther>( /// value: WhyFarther.harder, /// child: Text('Working a lot harder'), /// ), /// const PopupMenuItem<WhyFarther>( /// value: WhyFarther.smarter, /// child: Text('Being a lot smarter'), /// ), /// const PopupMenuItem<WhyFarther>( /// value: WhyFarther.selfStarter, /// child: Text('Being a self-starter'), /// ), /// const PopupMenuItem<WhyFarther>( /// value: WhyFarther.tradingCharter, /// child: Text('Placed in charge of trading charter'), /// ), /// ], /// ) /// ``` /// {@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({ Key key, @required this.itemBuilder, this.initialValue, this.onSelected, this.onCanceled, this.tooltip, this.elevation, this.padding = const EdgeInsets.all(8.0), this.child, this.icon, this.offset = Offset.zero, this.enabled = true, this.shape, this.color, this.captureInheritedThemes = true, }) : assert(itemBuilder != null), assert(offset != null), assert(enabled != null), assert(captureInheritedThemes != null), assert(!(child != null && icon != null), 'You can only pass [child] or [icon], not both.'), super(key: key); /// 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 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; /// 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; /// 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 applied to the Popup Menu Button. /// /// When not set, the Popup Menu Button will be positioned directly next to /// the button that was used to create it. 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; /// If true (the default) then the menu will be wrapped with copies /// of the [InheritedTheme]s, like [Theme] and [PopupMenuTheme], which /// are defined above the [BuildContext] where the menu is shown. final bool captureInheritedThemes; @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 = Overlay.of(context).context.findRenderObject() as RenderBox; final RelativeRect position = RelativeRect.fromRect( Rect.fromPoints( button.localToGlobal(widget.offset, ancestor: overlay), button.localToGlobal(button.size.bottomRight(Offset.zero), 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) { showMenu<T>( context: context, elevation: widget.elevation ?? popupMenuTheme.elevation, items: items, initialValue: widget.initialValue, position: position, shape: widget.shape ?? popupMenuTheme.shape, color: widget.color ?? popupMenuTheme.color, captureInheritedThemes: widget.captureInheritedThemes, ) .then<void>((T newValue) { if (!mounted) return null; if (newValue == null) { if (widget.onCanceled != null) widget.onCanceled(); return null; } if (widget.onSelected != null) widget.onSelected(newValue); }); } } Icon _getIcon(TargetPlatform platform) { assert(platform != null); switch (platform) { case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: return const Icon(Icons.more_vert); case TargetPlatform.iOS: case TargetPlatform.macOS: return const Icon(Icons.more_horiz); } return null; } bool get _canRequestFocus { final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional; switch (mode) { case NavigationMode.traditional: return widget.enabled; case NavigationMode.directional: return true; } assert(false, 'Navigation mode $mode not handled'); return null; } @override Widget build(BuildContext context) { 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, child: widget.child, ), ); return IconButton( icon: widget.icon ?? _getIcon(Theme.of(context).platform), padding: widget.padding, tooltip: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, onPressed: widget.enabled ? showButtonMenu : null, ); } }