590 lines
20 KiB
Dart
590 lines
20 KiB
Dart
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,
|
|
}) : 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]) : 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 [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<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 [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<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 CustomPopupMenuItem({
|
|
Key? key,
|
|
this.value,
|
|
this.enabled = true,
|
|
this.height = kMinInteractiveDimension,
|
|
this.textStyle,
|
|
required this.child,
|
|
}) : 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<T, CustomPopupMenuItem<T>> createState() => PopupMenuItemState<T, CustomPopupMenuItem<T>>();
|
|
}
|
|
|
|
/// 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<T, W extends CustomPopupMenuItem<T>> extends State<W> {
|
|
/// 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<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,
|
|
);
|
|
}
|
|
|
|
return InkWell(
|
|
onTap: widget.enabled ? handleTap : null,
|
|
canRequestFocus: widget.enabled,
|
|
child: item,
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
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<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) {
|
|
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.
|
|
late 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;
|
|
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,
|
|
required 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<Size?>.filled(items.length, null, growable: false);
|
|
|
|
final RelativeRect? position;
|
|
final List<PopupMenuEntry<T>> items;
|
|
final List<Size?> 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<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
|
|
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
|
|
/// [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<T?> customShowMenu<T>({
|
|
required BuildContext context,
|
|
required RelativeRect position,
|
|
required List<PopupMenuEntry<T>> items,
|
|
T? initialValue,
|
|
double? elevation,
|
|
String? semanticLabel,
|
|
Color? barrierColor,
|
|
ShapeBorder? shape,
|
|
Color? color,
|
|
bool captureInheritedThemes = true,
|
|
bool useRootNavigator = false,
|
|
}) {
|
|
assert(items.isNotEmpty);
|
|
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),
|
|
popupMenuTheme: PopupMenuTheme.of(context),
|
|
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
|
barrierColor: barrierColor,
|
|
shape: shape,
|
|
color: color,
|
|
showMenuContext: context,
|
|
captureInheritedThemes: captureInheritedThemes,
|
|
),
|
|
);
|
|
}
|