import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; const Duration _kMenuDuration = Duration(milliseconds: 300); const double _kMenuCloseIntervalEnd = 2.0 / 3.0; const double _kMenuHorizontalPadding = 0.0; const double _kMenuMinWidth = 2.0 * _kMenuWidthStep; const double _kMenuVerticalPadding = 0.0; const double _kMenuWidthStep = 1.0; const double _kMenuScreenPadding = 0.0; // 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 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 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; childParentData.offset = Offset.zero; onLayout(size); } } /// An item in a material design popup menu. /// /// To show a popup menu, use the [customShowMenu] 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 [CustomPopupMenuItem] is a [Text] widget. More /// elaborate menus with icons can use a [ListTile]. By default, a /// [CustomPopupMenuItem] is kMinInteractiveDimension pixels high. If you use a widget /// with a different height, it must be specified in the [height] property. /// /// {@tool sample} /// /// Here, a [Text] widget is used with a popup menu item. The `WhyFarther` type /// is an enum, not shown here. /// /// ```dart /// const CustomPopupMenuItem( /// 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 [CustomPopupMenuItem]s that use [Text] widgets in their [child] /// slot aligned with the text of [CheckedPopupMenuItem]s or of [CustomPopupMenuItem] /// 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 [CustomPopupMenuItem] with a checkmark. /// * [customShowMenu], 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 CustomPopupMenuItem extends PopupMenuEntry { /// Creates an item for a popup menu. /// /// By default, the item is [enabled]. /// /// The `enabled` and `height` arguments must not be null. const CustomPopupMenuItem({ Key key, this.value, this.enabled = true, this.height = kMinInteractiveDimension, this.textStyle, @required this.child, }) : assert(enabled != null), assert(height != null), super(key: key); /// The value that will be returned by [customShowMenu] 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 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 [ThemeData.textTheme.subhead] is used. final TextStyle textStyle; /// 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> createState() => PopupMenuItemState>(); } /// The [State] for [CustomPopupMenuItem] 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 [CustomPopupMenuItem.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 /// [CustomPopupMenuItem.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 /// [CustomPopupMenuItem]. The first, `T`, must match the type argument of that widget /// class, and is the type of values returned from this menu. class PopupMenuItemState> extends State { /// The menu item contents. /// /// Used by the [build] method. /// /// By default, this returns [CustomPopupMenuItem.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 [CustomPopupMenuItem.value] from /// the menu route. @protected void handleTap() { Navigator.pop(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, ); } return InkWell( onTap: widget.enabled ? handleTap : null, canRequestFocus: widget.enabled, child: item, ); } } class _PopupMenu extends StatelessWidget { const _PopupMenu({ Key key, this.route, this.semanticLabel, }) : super(key: key); final _PopupMenuRoute 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 children = []; 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); 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), 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 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)); } @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 extends PopupRoute { _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, this.barrierColor, }) : itemSizes = List(items.length); final RelativeRect position; final List> items; final List itemSizes; final dynamic 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; final Color barrierColor; @override Animation 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 final String barrierLabel; @override Widget buildPage(BuildContext context, Animation animation, Animation 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(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 /// [CustomPopupMenuEntry.represents] method returns true for `initialValue`, and then /// summing the values of [CustomPopupMenuEntry.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: /// /// * [CustomPopupMenuItem], 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 customShowMenu({ @required BuildContext context, @required RelativeRect position, @required List> items, T initialValue, double elevation, String semanticLabel, Color barrierColor, 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( position: position, items: items, initialValue: initialValue, elevation: elevation, semanticLabel: label, theme: Theme.of(context), popupMenuTheme: PopupMenuTheme.of(context), barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, barrierColor: barrierColor, shape: shape, color: color, showMenuContext: context, captureInheritedThemes: captureInheritedThemes, ), ); }