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