// Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'constants.dart'; import 'divider.dart'; import 'icon_button.dart'; import 'icons.dart'; import 'ink_well.dart'; import 'list_tile.dart'; import 'material.dart'; import 'theme.dart'; const Duration _kMenuDuration = const Duration(milliseconds: 300); const double _kBaselineOffsetFromBottom = 20.0; const double _kMenuCloseIntervalEnd = 2.0 / 3.0; const double _kMenuHorizontalPadding = 16.0; const double _kMenuItemHeight = 48.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. 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(dynamic value) => false; @override _PopupMenuDividerState createState() => new _PopupMenuDividerState(); } class _PopupMenuDividerState extends State<PopupMenuDivider> { @override Widget build(BuildContext context) => new Divider(height: widget.height); } /// 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 48 pixels high. If you use a widget with a different /// height, it must be specified in the [height] property. /// /// ## Sample code /// /// 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: const Text('Working a lot harder'), /// ), /// ``` /// /// 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 `height` and `enabled` arguments must not be null. const PopupMenuItem({ Key key, this.value, this.enabled: true, this.height: _kMenuItemHeight, @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 entry. /// /// Defaults to true. If this is false, then the item will not react to /// touches. final bool enabled; /// The height of the entry. /// /// Defaults to 48 pixels. @override final double height; /// 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<PopupMenuItem<T>> createState() => new _PopupMenuItemState<PopupMenuItem<T>>(); } class _PopupMenuItemState<T extends PopupMenuItem<dynamic>> extends State<T> { // Override this to put something else in the menu entry. Widget buildChild() => widget.child; void handleTap() { Navigator.pop(context, widget.value); } @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); TextStyle style = theme.textTheme.subhead; if (!widget.enabled) style = style.copyWith(color: theme.disabledColor); Widget item = new AnimatedDefaultTextStyle( style: style, duration: kThemeChangeDuration, child: new Baseline( baseline: widget.height - _kBaselineOffsetFromBottom, baselineType: TextBaseline.alphabetic, child: buildChild(), ) ); if (!widget.enabled) { final bool isDark = theme.brightness == Brightness.dark; item = IconTheme.merge( data: new IconThemeData(opacity: isDark ? 0.5 : 0.38), child: item, ); } return new InkWell( onTap: widget.enabled ? handleTap : null, child: new MergeSemantics( child: new Container( height: widget.height, padding: const EdgeInsets.symmetric(horizontal: _kMenuHorizontalPadding), 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 48 pixels high, which matches the default height /// of a [PopupMenuItem]. The horizontal layout uses a [ListTile]; the checkmark /// is an [Icons.done] icon, shown in the [ListTile.leading] position. /// /// ## Sample code /// /// 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 /// new 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>>[ /// new CheckedPopupMenuItem<Commands>( /// checked: _heroAndScholar, /// value: Commands.heroAndScholar, /// child: const Text('Hero and scholar'), /// ), /// const PopupMenuDivider(), /// const PopupMenuItem<Commands>( /// value: Commands.hurricaneCame, /// child: const ListTile(leading: const Icon(null), title: const Text('Bring hurricane')), /// ), /// // ...other items listed here /// ], /// ) /// ``` /// /// 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() => new _CheckedPopupMenuItemState<T>(); } class _CheckedPopupMenuItemState<T> extends _PopupMenuItemState<CheckedPopupMenuItem<T>> with SingleTickerProviderStateMixin { static const Duration _kFadeDuration = const Duration(milliseconds: 150); AnimationController _controller; Animation<double> get _opacity => _controller.view; @override void initState() { super.initState(); _controller = new AnimationController(duration: _kFadeDuration, 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 new ListTile( enabled: widget.enabled, leading: new FadeTransition( opacity: _opacity, child: new Icon(_controller.isDismissed ? null : Icons.done) ), title: widget.child, ); } } class _PopupMenu<T> extends StatelessWidget { const _PopupMenu({ Key key, this.route }) : super(key: key); final _PopupMenuRoute<T> route; @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>[]; 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 = new CurvedAnimation( parent: route.animation, curve: new Interval(start, end) ); Widget item = route.items[i]; if (route.initialValue != null && route.items[i].represents(route.initialValue)) { item = new Container( color: Theme.of(context).highlightColor, child: item, ); } children.add(new FadeTransition( opacity: opacity, child: item, )); } final CurveTween opacity = new CurveTween(curve: const Interval(0.0, 1.0 / 3.0)); final CurveTween width = new CurveTween(curve: new Interval(0.0, unit)); final CurveTween height = new CurveTween(curve: new Interval(0.0, unit * route.items.length)); final Widget child = new ConstrainedBox( constraints: const BoxConstraints( minWidth: _kMenuMinWidth, maxWidth: _kMenuMaxWidth, ), child: new IntrinsicWidth( stepWidth: _kMenuWidthStep, child: new SingleChildScrollView( padding: const EdgeInsets.symmetric( vertical: _kMenuVerticalPadding ), child: new ListBody(children: children), ) ) ); return new AnimatedBuilder( animation: route.animation, builder: (BuildContext context, Widget child) { return new Opacity( opacity: opacity.evaluate(route.animation), child: new Material( type: MaterialType.card, elevation: route.elevation, child: new Align( alignment: FractionalOffset.topRight, widthFactor: width.evaluate(route.animation), heightFactor: height.evaluate(route.animation), child: child, ), ), ); }, child: child, ); } } class _PopupMenuRouteLayout extends SingleChildLayoutDelegate { _PopupMenuRouteLayout(this.position, this.selectedItemOffset); final RelativeRect position; final double selectedItemOffset; @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { return constraints.loosen(); } // Put the child wherever position specifies, so long as it will fit within the // specified parent size padded (inset) by 8. If necessary, adjust the child's // position so that it fits. @override Offset getPositionForChild(Size size, Size childSize) { double x = position?.left ?? (position?.right != null ? size.width - (position.right + childSize.width) : _kMenuScreenPadding); double y = position?.top ?? (position?.bottom != null ? size.height - (position.bottom - childSize.height) : _kMenuScreenPadding); if (selectedItemOffset != null) y -= selectedItemOffset + _kMenuVerticalPadding; if (x < _kMenuScreenPadding) x = _kMenuScreenPadding; else if (x + childSize.width > size.width - 2 * _kMenuScreenPadding) x = size.width - childSize.width - _kMenuScreenPadding; if (y < _kMenuScreenPadding) y = _kMenuScreenPadding; else if (y + childSize.height > size.height - 2 * _kMenuScreenPadding) y = size.height - childSize.height - _kMenuScreenPadding; return new Offset(x, y); } @override bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) { return position != oldDelegate.position; } } class _PopupMenuRoute<T> extends PopupRoute<T> { _PopupMenuRoute({ this.position, this.items, this.initialValue, this.elevation, this.theme }); final RelativeRect position; final List<PopupMenuEntry<T>> items; final dynamic initialValue; final double elevation; final ThemeData theme; @override Animation<double> createAnimation() { return new 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 Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { double selectedItemOffset; if (initialValue != null) { selectedItemOffset = 0.0; for (PopupMenuEntry<T> entry in items) { if (entry.represents(initialValue)) { selectedItemOffset += entry.height / 2.0; break; } selectedItemOffset += entry.height; } } Widget menu = new _PopupMenu<T>(route: this); if (theme != null) menu = new Theme(data: theme, child: menu); return new CustomSingleChildLayout( delegate: new _PopupMenuRouteLayout(position, selectedItemOffset), child: menu, ); } } /// Show a popup menu that contains the `items` at `position`. If `initialValue` /// is specified then the first item with a matching value will be highlighted /// and the value of `position` implies where the left, center point of the /// highlighted item should appear. If `initialValue` is not specified then /// `position` specifies the menu's origin. /// /// The `context` argument is used to look up a [Navigator] to show the menu and /// a [Theme] to use for the menu. /// /// 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 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. /// /// 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. Future<T> showMenu<T>({ @required BuildContext context, RelativeRect position, @required List<PopupMenuEntry<T>> items, T initialValue, double elevation: 8.0 }) { assert(context != null); assert(items != null && items.isNotEmpty); return Navigator.push(context, new _PopupMenuRoute<T>( position: position, items: items, initialValue: initialValue, elevation: elevation, theme: Theme.of(context, shadowThemeOnly: true), )); } /// 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 void PopupMenuItemSelected<T>(T value); /// Signature used by [PopupMenuButton] to lazily construct the items shown when /// the button is pressed. /// /// Used by [PopupMenuButton.itemBuilder]. typedef List<PopupMenuEntry<T>> PopupMenuItemBuilder<T>(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. If child is null then a standard 'navigation/more_vert' /// icon is created. /// /// 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). /// new PopupMenuButton<WhyFarther>( /// onSelected: (WhyFarther result) { setState(() { _selection = result; }); }, /// itemBuilder: (BuildContext context) => <PopupMenuEntry<WhyFarther>>[ /// const PopupMenuItem<WhyFarther>( /// value: WhyFarther.harder, /// child: const Text('Working a lot harder'), /// ), /// const PopupMenuItem<WhyFarther>( /// value: WhyFarther.smarter, /// child: const Text('Being a lot smarter'), /// ), /// const PopupMenuItem<WhyFarther>( /// value: WhyFarther.selfStarter, /// child: const Text('Being a self-starter'), /// ), /// const PopupMenuItem<WhyFarther>( /// value: WhyFarther.tradingCharter, /// child: const Text('Placed in charge of trading charter'), /// ), /// ], /// ) /// ``` /// /// 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.tooltip: 'Show menu', this.elevation: 8.0, this.padding: const EdgeInsets.all(8.0), this.child }) : assert(itemBuilder != null), 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. final PopupMenuItemSelected<T> onSelected; /// 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 EdgeInsets padding; /// The widget below this widget in the tree. final Widget child; @override _PopupMenuButtonState<T> createState() => new _PopupMenuButtonState<T>(); } class _PopupMenuButtonState<T> extends State<PopupMenuButton<T>> { void showButtonMenu() { final RenderBox renderBox = context.findRenderObject(); final Offset topLeft = renderBox.localToGlobal(Offset.zero); showMenu<T>( context: context, elevation: widget.elevation, items: widget.itemBuilder(context), initialValue: widget.initialValue, position: new RelativeRect.fromLTRB( topLeft.dx, topLeft.dy + (widget.initialValue != null ? renderBox.size.height / 2.0 : 0.0), 0.0, 0.0, ) ) .then<Null>((T newValue) { if (!mounted || newValue == null) return null; if (widget.onSelected != null) widget.onSelected(newValue); }); } Icon _getIcon(TargetPlatform platform) { assert(platform != null); switch (platform) { case TargetPlatform.android: case TargetPlatform.fuchsia: return const Icon(Icons.more_vert); case TargetPlatform.iOS: return const Icon(Icons.more_horiz); } return null; } @override Widget build(BuildContext context) { if (widget.child == null) { return new IconButton( icon: _getIcon(Theme.of(context).platform), padding: widget.padding, tooltip: widget.tooltip, onPressed: showButtonMenu, ); } return new InkWell( onTap: showButtonMenu, child: widget.child, ); } }