Line data Source code
1 : // Copyright 2014 The Flutter Authors.
2 : // Copyright 2021 Suragch.
3 : // All rights reserved.
4 : // Use of this source code is governed by a BSD-style license that can be
5 : // found in the LICENSE file.
6 :
7 : import 'package:flutter/foundation.dart';
8 : import 'package:flutter/material.dart'
9 : show
10 : VerticalDivider,
11 : ThemeData,
12 : Theme,
13 : PopupMenuThemeData,
14 : PopupMenuTheme,
15 : Brightness,
16 : MaterialStateProperty,
17 : MaterialStateMouseCursor,
18 : MaterialState,
19 : Material,
20 : MaterialType,
21 : MaterialLocalizations,
22 : IconButton,
23 : Icons,
24 : InkWell,
25 : kMinInteractiveDimension,
26 : kThemeChangeDuration;
27 : import 'package:flutter/rendering.dart';
28 : import 'package:flutter/widgets.dart';
29 :
30 : import '../button/mongol_icon_button.dart';
31 : import 'mongol_instrinsic_height.dart';
32 : import 'mongol_tooltip.dart';
33 :
34 : // Examples can assume:
35 : // enum Commands { heroAndScholar, hurricaneCame }
36 : // late bool _heroAndScholar;
37 : // late dynamic _selection;
38 : // late BuildContext context;
39 : // void setState(VoidCallback fn) { }
40 :
41 : const Duration _kMenuDuration = Duration(milliseconds: 300);
42 : const double _kMenuCloseIntervalEnd = 2.0 / 3.0;
43 : const double _kMenuHorizontalPadding = 8.0;
44 : const double _kMenuDividerWidth = 16.0;
45 : const double _kMenuMaxHeight = 5.0 * _kMenuHeightStep;
46 : const double _kMenuMinHeight = 2.0 * _kMenuHeightStep;
47 : const double _kMenuVerticalPadding = 16.0;
48 : const double _kMenuHeightStep = 56.0;
49 : const double _kMenuScreenPadding = 8.0;
50 :
51 : /// A base class for entries in a material design popup menu.
52 : ///
53 : /// The popup menu widget uses this interface to interact with the menu items.
54 : /// To show a popup menu, use the [showMongolMenu] function. To create a button that
55 : /// shows a popup menu, consider using [MongolPopupMenuButton].
56 : ///
57 : /// The type `T` is the type of the value(s) the entry represents. All the
58 : /// entries in a given menu must represent values with consistent types.
59 : ///
60 : /// A [MongolPopupMenuEntry] may represent multiple values, for example a column
61 : /// with several icons, or a single entry, for example a menu item with an icon
62 : /// (see [MongolPopupMenuItem]), or no value at all (for example,
63 : /// [MongolPopupMenuDivider]).
64 : ///
65 : /// See also:
66 : ///
67 : /// * [MongolPopupMenuItem], a popup menu entry for a single value.
68 : /// * [MongolPopupMenuDivider], a popup menu entry that is just a vertical line.
69 : /// * [showMongolMenu], a method to dynamically show a popup menu at a given location.
70 : /// * [MongolPopupMenuButton], an [IconButton] that automatically shows a menu
71 : /// when it is tapped.
72 : abstract class MongolPopupMenuEntry<T> extends StatefulWidget {
73 : /// Abstract const constructor. This constructor enables subclasses to provide
74 : /// const constructors so that they can be used in const expressions.
75 2 : const MongolPopupMenuEntry({Key? key}) : super(key: key);
76 :
77 : /// The amount of horizontal space occupied by this entry.
78 : ///
79 : /// This value is used at the time the [showMongolMenu] method is called, if the
80 : /// `initialValue` argument is provided, to determine the position of this
81 : /// entry when aligning the selected entry over the given `position`. It is
82 : /// otherwise ignored.
83 : double get width;
84 :
85 : /// Whether this entry represents a particular value.
86 : ///
87 : /// This method is used by [showMongolMenu], when it is called, to align the entry
88 : /// representing the `initialValue`, if any, to the given `position`, and then
89 : /// later is called on each entry to determine if it should be highlighted (if
90 : /// the method returns true, the entry will have its background color set to
91 : /// the ambient [ThemeData.highlightColor]). If `initialValue` is null, then
92 : /// this method is not called.
93 : ///
94 : /// If the [MongolPopupMenuEntry] represents a single value, this should
95 : /// return true if the argument matches that value. If it represents multiple
96 : /// values, it should return true if the argument matches any of them.
97 : bool represents(T? value);
98 : }
99 :
100 : /// A vertical divider in a material design popup menu.
101 : ///
102 : /// This widget adapts the [Divider] for use in popup menus.
103 : ///
104 : /// See also:
105 : ///
106 : /// * [MongolPopupMenuItem], for the kinds of items that this widget divides.
107 : /// * [showMongolMenu], a method to dynamically show a popup menu at a given location.
108 : /// * [MongolPopupMenuButton], an [IconButton] that automatically shows a menu
109 : /// when it is tapped.
110 : class MongolPopupMenuDivider extends MongolPopupMenuEntry<Never> {
111 : /// Creates a vertical divider for a popup menu.
112 : ///
113 : /// By default, the divider has a width of 16 logical pixels.
114 0 : const MongolPopupMenuDivider({Key? key, this.width = _kMenuDividerWidth})
115 0 : : super(key: key);
116 :
117 : /// The width of the divider entry.
118 : ///
119 : /// Defaults to 16 pixels.
120 : @override
121 : final double width;
122 :
123 0 : @override
124 : bool represents(void value) => false;
125 :
126 0 : @override
127 0 : _MongolPopupMenuDividerState createState() => _MongolPopupMenuDividerState();
128 : }
129 :
130 : class _MongolPopupMenuDividerState extends State<MongolPopupMenuDivider> {
131 0 : @override
132 0 : Widget build(BuildContext context) => VerticalDivider(width: widget.width);
133 : }
134 :
135 : // This widget only exists to enable _PopupMenuRoute to save the sizes of
136 : // each menu item. The sizes are used by _PopupMenuRouteLayout to compute the
137 : // x coordinate of the menu's origin so that the center of selected menu
138 : // item lines up with the center of its MongolPopupMenuButton.
139 : class _MenuItem extends SingleChildRenderObjectWidget {
140 1 : const _MenuItem({
141 : Key? key,
142 : required this.onLayout,
143 : required Widget? child,
144 1 : }) : super(key: key, child: child);
145 :
146 : final ValueChanged<Size> onLayout;
147 :
148 1 : @override
149 : RenderObject createRenderObject(BuildContext context) {
150 2 : return _RenderMenuItem(onLayout);
151 : }
152 :
153 1 : @override
154 : void updateRenderObject(
155 : BuildContext context, covariant _RenderMenuItem renderObject) {
156 2 : renderObject.onLayout = onLayout;
157 : }
158 : }
159 :
160 : class _RenderMenuItem extends RenderShiftedBox {
161 2 : _RenderMenuItem(this.onLayout, [RenderBox? child]) : super(child);
162 :
163 : ValueChanged<Size> onLayout;
164 :
165 1 : @override
166 : Size computeDryLayout(BoxConstraints constraints) {
167 1 : if (child == null) {
168 : return Size.zero;
169 : }
170 2 : return child!.getDryLayout(constraints);
171 : }
172 :
173 1 : @override
174 : void performLayout() {
175 1 : if (child == null) {
176 0 : size = Size.zero;
177 : } else {
178 3 : child!.layout(constraints, parentUsesSize: true);
179 5 : size = constraints.constrain(child!.size);
180 2 : final BoxParentData childParentData = child!.parentData! as BoxParentData;
181 1 : childParentData.offset = Offset.zero;
182 : }
183 3 : onLayout(size);
184 : }
185 : }
186 :
187 : /// An item in a Mongol material design popup menu.
188 : ///
189 : /// To show a popup menu, use the [showMongolMenu] function. To create a button that
190 : /// shows a popup menu, consider using [MongolPopupMenuButton].
191 : ///
192 : /// Typically the [child] of a [MongolPopupMenuItem] is a [MongolText] widget.
193 : /// More elaborate menus with icons can use a [MongolListTile]. By default, a
194 : /// [MongolPopupMenuItem] is [kMinInteractiveDimension] pixels
195 : /// wide. If you use a widget with a different width, it must be specified in
196 : /// the [width] property.
197 : ///
198 : /// {@tool snippet}
199 : ///
200 : /// Here, a [MongolText] widget is used with a popup menu item. The
201 : /// `WhyFarther` type is an enum, not shown here.
202 : ///
203 : /// ```dart
204 : /// const MongolPopupMenuItem<WhyFarther>(
205 : /// value: WhyFarther.harder,
206 : /// child: MongolText('Working a lot harder'),
207 : /// )
208 : /// ```
209 : /// {@end-tool}
210 : ///
211 : /// See the example at [MongolPopupMenuButton] for how this example could be
212 : /// used in a complete menu.
213 : ///
214 : /// See also:
215 : ///
216 : /// * [MongolPopupMenuDivider], which can be used to divide items from each other.
217 : /// * [showMongolMenu], a method to dynamically show a popup menu at a given location.
218 : /// * [MongolPopupMenuButton], an [IconButton] that automatically shows a menu when
219 : /// it is tapped.
220 : class MongolPopupMenuItem<T> extends MongolPopupMenuEntry<T> {
221 : /// Creates an item for a popup menu.
222 : ///
223 : /// By default, the item is [enabled].
224 : ///
225 : /// The `enabled` and `width` arguments must not be null.
226 2 : const MongolPopupMenuItem({
227 : Key? key,
228 : this.value,
229 : this.onTap,
230 : this.enabled = true,
231 : this.width = kMinInteractiveDimension,
232 : this.padding,
233 : this.textStyle,
234 : this.mouseCursor,
235 : required this.child,
236 1 : }) : super(key: key);
237 :
238 : /// The value that will be returned by [showMongolMenu] if this entry is selected.
239 : final T? value;
240 :
241 : /// Called when the menu item is tapped.
242 : final VoidCallback? onTap;
243 :
244 : /// Whether the user is permitted to select this item.
245 : ///
246 : /// Defaults to true. If this is false, then the item will not react to
247 : /// touches.
248 : final bool enabled;
249 :
250 : /// The minimum width of the menu item.
251 : ///
252 : /// Defaults to [kMinInteractiveDimension] pixels.
253 : @override
254 : final double width;
255 :
256 : /// The padding of the menu item.
257 : ///
258 : /// Note that [width] may interact with the applied padding. For example,
259 : /// If a [width] greater than the width of the sum of the padding and [child]
260 : /// is provided, then the padding's effect will not be visible.
261 : ///
262 : /// When null, the vertical padding defaults to 16.0 on both sides.
263 : final EdgeInsets? padding;
264 :
265 : /// The text style of the popup menu item.
266 : ///
267 : /// If this property is null, then [PopupMenuThemeData.textStyle] is used.
268 : /// If [PopupMenuThemeData.textStyle] is also null, then [TextTheme.subtitle1]
269 : /// of [ThemeData.textTheme] is used.
270 : final TextStyle? textStyle;
271 :
272 : /// The cursor for a mouse pointer when it enters or is hovering over the
273 : /// widget.
274 : ///
275 : /// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>],
276 : /// [MaterialStateProperty.resolve] is used for the following [MaterialState]:
277 : ///
278 : /// * [MaterialState.disabled].
279 : ///
280 : /// If this property is null, [MaterialStateMouseCursor.clickable] will be used.
281 : final MouseCursor? mouseCursor;
282 :
283 : /// The widget below this widget in the tree.
284 : ///
285 : /// Typically a single-line [MongolListTile] (for menus with icons) or a
286 : /// [MongolText]. An appropriate [DefaultTextStyle] is put in scope for the
287 : /// child. In either case, the text should be short enough that it won't wrap.
288 : final Widget? child;
289 :
290 1 : @override
291 2 : bool represents(T? value) => value == this.value;
292 :
293 1 : @override
294 : MongolPopupMenuItemState<T, MongolPopupMenuItem<T>> createState() =>
295 1 : MongolPopupMenuItemState<T, MongolPopupMenuItem<T>>();
296 : }
297 :
298 : /// The [State] for [MongolPopupMenuItem] subclasses.
299 : ///
300 : /// By default this implements the basic styling and layout of Material Design
301 : /// popup menu items.
302 : ///
303 : /// The [buildChild] method can be overridden to adjust exactly what gets placed
304 : /// in the menu. By default it returns [MongolPopupMenuItem.child].
305 : ///
306 : /// The [handleTap] method can be overridden to adjust exactly what happens when
307 : /// the item is tapped. By default, it uses [Navigator.pop] to return the
308 : /// [MongolPopupMenuItem.value] from the menu route.
309 : ///
310 : /// This class takes two type arguments. The second, `W`, is the exact type of
311 : /// the [Widget] that is using this [State]. It must be a subclass of
312 : /// [MongolPopupMenuItem]. The first, `T`, must match the type argument of that widget
313 : /// class, and is the type of values returned from this menu.
314 : class MongolPopupMenuItemState<T, W extends MongolPopupMenuItem<T>>
315 : extends State<W> {
316 : /// The menu item contents.
317 : ///
318 : /// Used by the [build] method.
319 : ///
320 : /// By default, this returns [MongolPopupMenuItem.child]. Override this to put
321 : /// something else in the menu entry.
322 1 : @protected
323 2 : Widget? buildChild() => widget.child;
324 :
325 : /// The handler for when the user selects the menu item.
326 : ///
327 : /// Used by the [InkWell] inserted by the [build] method.
328 : ///
329 : /// By default, uses [Navigator.pop] to return the [MongolPopupMenuItem.value] from
330 : /// the menu route.
331 1 : @protected
332 : void handleTap() {
333 3 : widget.onTap?.call();
334 :
335 4 : Navigator.pop<T>(context, widget.value);
336 : }
337 :
338 1 : @override
339 : Widget build(BuildContext context) {
340 1 : final ThemeData theme = Theme.of(context);
341 1 : final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
342 2 : TextStyle style = widget.textStyle ??
343 1 : popupMenuTheme.textStyle ??
344 2 : theme.textTheme.subtitle1!;
345 :
346 4 : if (!widget.enabled) style = style.copyWith(color: theme.disabledColor);
347 :
348 1 : Widget item = AnimatedDefaultTextStyle(
349 : style: style,
350 : duration: kThemeChangeDuration,
351 1 : child: Container(
352 : alignment: Alignment.topCenter,
353 3 : constraints: BoxConstraints(minWidth: widget.width),
354 2 : padding: widget.padding ??
355 : const EdgeInsets.symmetric(vertical: _kMenuVerticalPadding),
356 1 : child: buildChild(),
357 : ),
358 : );
359 :
360 2 : if (!widget.enabled) {
361 2 : final bool isDark = theme.brightness == Brightness.dark;
362 1 : item = IconTheme.merge(
363 1 : data: IconThemeData(opacity: isDark ? 0.5 : 0.38),
364 : child: item,
365 : );
366 : }
367 : final MouseCursor effectiveMouseCursor =
368 1 : MaterialStateProperty.resolveAs<MouseCursor>(
369 2 : widget.mouseCursor ?? MaterialStateMouseCursor.clickable,
370 : <MaterialState>{
371 3 : if (!widget.enabled) MaterialState.disabled,
372 : },
373 : );
374 :
375 1 : return MergeSemantics(
376 1 : child: Semantics(
377 2 : enabled: widget.enabled,
378 : button: true,
379 1 : child: InkWell(
380 3 : onTap: widget.enabled ? handleTap : null,
381 2 : canRequestFocus: widget.enabled,
382 : mouseCursor: effectiveMouseCursor,
383 : child: item,
384 : ),
385 : ),
386 : );
387 : }
388 : }
389 :
390 : class _PopupMenu<T> extends StatelessWidget {
391 1 : const _PopupMenu({
392 : Key? key,
393 : required this.route,
394 : required this.semanticLabel,
395 1 : }) : super(key: key);
396 :
397 : final _PopupMenuRoute<T> route;
398 : final String? semanticLabel;
399 :
400 1 : @override
401 : Widget build(BuildContext context) {
402 1 : final double unit = 1.0 /
403 4 : (route.items.length +
404 : 1.5); // 1.0 for the height and 0.5 for the last item's fade.
405 1 : final List<Widget> children = <Widget>[];
406 1 : final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
407 :
408 5 : for (int i = 0; i < route.items.length; i += 1) {
409 2 : final double start = (i + 1) * unit;
410 3 : final double end = (start + 1.5 * unit).clamp(0.0, 1.0);
411 1 : final CurvedAnimation opacity = CurvedAnimation(
412 2 : parent: route.animation!,
413 1 : curve: Interval(start, end),
414 : );
415 3 : Widget item = route.items[i];
416 2 : if (route.initialValue != null &&
417 3 : (route.items[i] as MongolPopupMenuItem)
418 3 : .represents(route.initialValue)) {
419 1 : item = Container(
420 2 : color: Theme.of(context).highlightColor,
421 : child: item,
422 : );
423 : }
424 1 : children.add(
425 1 : _MenuItem(
426 1 : onLayout: (Size size) {
427 3 : route.itemSizes[i] = size;
428 : },
429 1 : child: FadeTransition(
430 : opacity: opacity,
431 : child: item,
432 : ),
433 : ),
434 : );
435 : }
436 :
437 : final CurveTween opacity =
438 1 : CurveTween(curve: const Interval(0.0, 1.0 / 3.0));
439 2 : final CurveTween height = CurveTween(curve: Interval(0.0, unit));
440 : final CurveTween width =
441 6 : CurveTween(curve: Interval(0.0, unit * route.items.length));
442 :
443 1 : final Widget child = ConstrainedBox(
444 : constraints: const BoxConstraints(
445 : minHeight: _kMenuMinHeight,
446 : maxHeight: _kMenuMaxHeight,
447 : ),
448 1 : child: MongolIntrinsicHeight(
449 : stepHeight: _kMenuHeightStep,
450 1 : child: Semantics(
451 : scopesRoute: true,
452 : namesRoute: true,
453 : explicitChildNodes: true,
454 1 : label: semanticLabel,
455 1 : child: SingleChildScrollView(
456 : scrollDirection: Axis.horizontal,
457 : padding: const EdgeInsets.symmetric(
458 : horizontal: _kMenuHorizontalPadding,
459 : ),
460 1 : child: ListBody(
461 : mainAxis: Axis.horizontal,
462 : children: children,
463 : ),
464 : ),
465 : ),
466 : ),
467 : );
468 :
469 1 : return AnimatedBuilder(
470 2 : animation: route.animation!,
471 1 : builder: (BuildContext context, Widget? child) {
472 1 : return Opacity(
473 3 : opacity: opacity.evaluate(route.animation!),
474 1 : child: Material(
475 3 : shape: route.shape ?? popupMenuTheme.shape,
476 3 : color: route.color ?? popupMenuTheme.color,
477 : type: MaterialType.card,
478 3 : elevation: route.elevation ?? popupMenuTheme.elevation ?? 8.0,
479 1 : child: Align(
480 : alignment: Alignment.topRight,
481 3 : widthFactor: width.evaluate(route.animation!),
482 3 : heightFactor: height.evaluate(route.animation!),
483 : child: child,
484 : ),
485 : ),
486 : );
487 : },
488 : child: child,
489 : );
490 : }
491 : }
492 :
493 : // Positioning of the menu on the screen.
494 : class _PopupMenuRouteLayout extends SingleChildLayoutDelegate {
495 1 : _PopupMenuRouteLayout(
496 : this.position,
497 : this.itemSizes,
498 : this.selectedItemIndex,
499 : this.padding,
500 : );
501 :
502 : // Rectangle of underlying button, relative to the overlay's dimensions.
503 : final RelativeRect position;
504 :
505 : // The sizes of each item are computed when the menu is laid out, and before
506 : // the route is laid out.
507 : List<Size?> itemSizes;
508 :
509 : // The index of the selected item, or null if MongolPopupMenuButton.initialValue
510 : // was not specified.
511 : final int? selectedItemIndex;
512 :
513 : // The padding of unsafe area.
514 : EdgeInsets padding;
515 :
516 : // We put the child wherever position specifies, so long as it will fit within
517 : // the specified parent size padded (inset) by 8. If necessary, we adjust the
518 : // child's position so that it fits.
519 :
520 1 : @override
521 : BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
522 : // The menu can be at most the size of the overlay minus 8.0 pixels in each
523 : // direction.
524 3 : return BoxConstraints.loose(constraints.biggest).deflate(
525 2 : const EdgeInsets.all(_kMenuScreenPadding) + padding,
526 : );
527 : }
528 :
529 1 : @override
530 : Offset getPositionForChild(Size size, Size childSize) {
531 : // size: The size of the overlay.
532 : // childSize: The size of the menu, when fully open, as determined by
533 : // getConstraintsForChild.
534 :
535 7 : final double buttonWidth = size.width - position.left - position.right;
536 : // Find the ideal horizontal position.
537 2 : double x = position.left;
538 1 : if (selectedItemIndex != null) {
539 : double selectedItemOffset = _kMenuHorizontalPadding;
540 3 : for (int index = 0; index < selectedItemIndex!; index += 1) {
541 4 : selectedItemOffset += itemSizes[index]!.width;
542 : }
543 6 : selectedItemOffset += itemSizes[selectedItemIndex!]!.width / 2;
544 3 : x = x + buttonWidth / 2.0 - selectedItemOffset;
545 : }
546 :
547 : // Find the ideal vertical position.
548 : double y;
549 5 : if (position.top > position.bottom) {
550 : // Menu button is closer to the top edge, so grow to the bottom, aligned to the bottom edge.
551 0 : y = size.height - position.bottom - childSize.height;
552 : } else {
553 : // Menu button is closer to the top edge or is equidistant from both edges, so grow down.
554 2 : y = position.top;
555 : }
556 :
557 : // Avoid going outside an area defined as the rectangle 8.0 pixels from the
558 : // edge of the screen in every direction.
559 4 : if (y < _kMenuScreenPadding + padding.top) {
560 3 : y = _kMenuScreenPadding + padding.top;
561 3 : } else if (y + childSize.height >
562 5 : size.height - _kMenuScreenPadding - padding.bottom) {
563 0 : y = size.height - childSize.height - _kMenuScreenPadding - padding.bottom;
564 : }
565 4 : if (x < _kMenuScreenPadding + padding.left) {
566 3 : x = _kMenuScreenPadding + padding.left;
567 3 : } else if (x + childSize.width >
568 5 : size.width - _kMenuScreenPadding - padding.right) {
569 7 : x = size.width - padding.right - _kMenuScreenPadding - childSize.width;
570 : }
571 :
572 1 : return Offset(x, y);
573 : }
574 :
575 1 : @override
576 : bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) {
577 : // If called when the old and new itemSizes have been initialized then
578 : // we expect them to have the same length because there's no practical
579 : // way to change length of the items list once the menu has been shown.
580 5 : assert(itemSizes.length == oldDelegate.itemSizes.length);
581 :
582 3 : return position != oldDelegate.position ||
583 3 : selectedItemIndex != oldDelegate.selectedItemIndex ||
584 3 : !listEquals(itemSizes, oldDelegate.itemSizes) ||
585 3 : padding != oldDelegate.padding;
586 : }
587 : }
588 :
589 : class _PopupMenuRoute<T> extends PopupRoute<T> {
590 1 : _PopupMenuRoute({
591 : required this.position,
592 : required this.items,
593 : this.initialValue,
594 : this.elevation,
595 : required this.barrierLabel,
596 : this.semanticLabel,
597 : this.shape,
598 : this.color,
599 : required this.capturedThemes,
600 2 : }) : itemSizes = List<Size?>.filled(items.length, null);
601 :
602 : final RelativeRect position;
603 : final List<MongolPopupMenuEntry<T>> items;
604 : final List<Size?> itemSizes;
605 : final T? initialValue;
606 : final double? elevation;
607 : final String? semanticLabel;
608 : final ShapeBorder? shape;
609 : final Color? color;
610 : final CapturedThemes capturedThemes;
611 :
612 1 : @override
613 : Animation<double> createAnimation() {
614 1 : return CurvedAnimation(
615 1 : parent: super.createAnimation(),
616 : curve: Curves.linear,
617 : reverseCurve: const Interval(0.0, _kMenuCloseIntervalEnd),
618 : );
619 : }
620 :
621 1 : @override
622 : Duration get transitionDuration => _kMenuDuration;
623 :
624 1 : @override
625 : bool get barrierDismissible => true;
626 :
627 1 : @override
628 : Color? get barrierColor => null;
629 :
630 : @override
631 : final String barrierLabel;
632 :
633 1 : @override
634 : Widget buildPage(BuildContext context, Animation<double> animation,
635 : Animation<double> secondaryAnimation) {
636 : int? selectedItemIndex;
637 1 : if (initialValue != null) {
638 : for (int index = 0;
639 3 : selectedItemIndex == null && index < items.length;
640 1 : index += 1) {
641 4 : if (items[index].represents(initialValue)) selectedItemIndex = index;
642 : }
643 : }
644 :
645 : final Widget menu =
646 2 : _PopupMenu<T>(route: this, semanticLabel: semanticLabel);
647 1 : final MediaQueryData mediaQuery = MediaQuery.of(context);
648 1 : return MediaQuery.removePadding(
649 : context: context,
650 : removeTop: true,
651 : removeBottom: true,
652 : removeLeft: true,
653 : removeRight: true,
654 1 : child: Builder(
655 1 : builder: (BuildContext context) {
656 1 : return CustomSingleChildLayout(
657 1 : delegate: _PopupMenuRouteLayout(
658 1 : position,
659 1 : itemSizes,
660 : selectedItemIndex,
661 1 : mediaQuery.padding,
662 : ),
663 2 : child: capturedThemes.wrap(menu),
664 : );
665 : },
666 : ),
667 : );
668 : }
669 : }
670 :
671 : /// Show a popup menu that contains the `items` at `position`.
672 : ///
673 : /// `items` should be non-null and not empty.
674 : ///
675 : /// If `initialValue` is specified then the first item with a matching value
676 : /// will be highlighted and the value of `position` gives the rectangle whose
677 : /// horizontal center will be aligned with the horizontal center of the highlighted
678 : /// item (when possible).
679 : ///
680 : /// If `initialValue` is not specified then the right side of the menu will be aligned
681 : /// with the right side of the `position` rectangle.
682 : ///
683 : /// In both cases, the menu position will be adjusted if necessary to fit on the
684 : /// screen.
685 : ///
686 : /// Vertically, the menu is positioned so that it grows in the direction that
687 : /// has the most room. For example, if the `position` describes a rectangle on
688 : /// the top edge of the screen, then the top edge of the menu is aligned with
689 : /// the top edge of the `position`, and the menu grows to the bottom. If both
690 : /// edges of the `position` are equidistant from the opposite edge of the
691 : /// screen, then it grows down.
692 : ///
693 : /// The positioning of the `initialValue` at the `position` is implemented by
694 : /// iterating over the `items` to find the first whose
695 : /// [MongolPopupMenuEntry.represents] method returns true for `initialValue`, and then
696 : /// summing the values of [MongolPopupMenuEntry.width] for all the preceding widgets
697 : /// in the list.
698 : ///
699 : /// The `elevation` argument specifies the z-coordinate at which to place the
700 : /// menu. The elevation defaults to 8, the appropriate elevation for popup
701 : /// menus.
702 : ///
703 : /// The `context` argument is used to look up the [Navigator] and [Theme] for
704 : /// the menu. It is only used when the method is called. Its corresponding
705 : /// widget can be safely removed from the tree before the popup menu is closed.
706 : ///
707 : /// The `useRootNavigator` argument is used to determine whether to push the
708 : /// menu to the [Navigator] furthest from or nearest to the given `context`. It
709 : /// is `false` by default.
710 : ///
711 : /// The `semanticLabel` argument is used by accessibility frameworks to
712 : /// announce screen transitions when the menu is opened and closed. If this
713 : /// label is not provided, it will default to
714 : /// [MaterialLocalizations.popupMenuLabel].
715 : ///
716 : /// See also:
717 : ///
718 : /// * [MongolPopupMenuItem], a popup menu entry for a single value.
719 : /// * [MongolPopupMenuDivider], a popup menu entry that is just a vertical line.
720 : /// * [MongolPopupMenuButton], which provides an [IconButton] that shows a menu by
721 : /// calling this method automatically.
722 : /// * [SemanticsConfiguration.namesRoute], for a description of edge triggered
723 : /// semantics.
724 1 : Future<T?> showMongolMenu<T>({
725 : required BuildContext context,
726 : required RelativeRect position,
727 : required List<MongolPopupMenuEntry<T>> items,
728 : T? initialValue,
729 : double? elevation,
730 : String? semanticLabel,
731 : ShapeBorder? shape,
732 : Color? color,
733 : bool useRootNavigator = false,
734 : }) {
735 1 : assert(items.isNotEmpty);
736 :
737 2 : semanticLabel ??= MaterialLocalizations.of(context).popupMenuLabel;
738 :
739 : final NavigatorState navigator =
740 1 : Navigator.of(context, rootNavigator: useRootNavigator);
741 2 : return navigator.push(_PopupMenuRoute<T>(
742 : position: position,
743 : items: items,
744 : initialValue: initialValue,
745 : elevation: elevation,
746 : semanticLabel: semanticLabel,
747 2 : barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
748 : shape: shape,
749 : color: color,
750 : capturedThemes:
751 2 : InheritedTheme.capture(from: context, to: navigator.context),
752 : ));
753 : }
754 :
755 : /// Signature for the callback invoked when a menu item is selected. The
756 : /// argument is the value of the [MongolPopupMenuItem] that caused its menu to be
757 : /// dismissed.
758 : ///
759 : /// Used by [MongolPopupMenuButton.onSelected].
760 : typedef MongolPopupMenuItemSelected<T> = void Function(T value);
761 :
762 : /// Signature for the callback invoked when a [MongolPopupMenuButton] is dismissed
763 : /// without selecting an item.
764 : ///
765 : /// Used by [MongolPopupMenuButton.onCanceled].
766 : typedef MongolPopupMenuCanceled = void Function();
767 :
768 : /// Signature used by [MongolPopupMenuButton] to lazily construct the items shown when
769 : /// the button is pressed.
770 : ///
771 : /// Used by [MongolPopupMenuButton.itemBuilder].
772 : typedef MongolPopupMenuItemBuilder<T> = List<MongolPopupMenuEntry<T>> Function(
773 : BuildContext context);
774 :
775 : /// Displays a menu when pressed and calls [onSelected] when the menu is dismissed
776 : /// because an item was selected. The value passed to [onSelected] is the value of
777 : /// the selected menu item.
778 : ///
779 : /// One of [child] or [icon] may be provided, but not both. If [icon] is provided,
780 : /// then [MongolPopupMenuButton] behaves like an [IconButton].
781 : ///
782 : /// If both are null, then a standard overflow icon is created (depending on the
783 : /// platform).
784 : ///
785 : /// {@tool snippet}
786 : ///
787 : /// This example shows a menu with four items, selecting between an enum's
788 : /// values and setting a `_selection` field based on the selection.
789 : ///
790 : /// ```dart
791 : /// // This is the type used by the popup menu below.
792 : /// enum WhyFarther { harder, smarter, selfStarter, tradingCharter }
793 : ///
794 : /// // This menu button widget updates a _selection field (of type WhyFarther,
795 : /// // not shown here).
796 : /// MongolPopupMenuButton<WhyFarther>(
797 : /// onSelected: (WhyFarther result) { setState(() { _selection = result; }); },
798 : /// itemBuilder: (BuildContext context) => <MongolPopupMenuEntry<WhyFarther>>[
799 : /// const MongolPopupMenuItem<WhyFarther>(
800 : /// value: WhyFarther.harder,
801 : /// child: MongolText('Working a lot harder'),
802 : /// ),
803 : /// const MongolPopupMenuItem<WhyFarther>(
804 : /// value: WhyFarther.smarter,
805 : /// child: MongolText('Being a lot smarter'),
806 : /// ),
807 : /// const MongolPopupMenuItem<WhyFarther>(
808 : /// value: WhyFarther.selfStarter,
809 : /// child: MongolText('Being a self-starter'),
810 : /// ),
811 : /// const MongolPopupMenuItem<WhyFarther>(
812 : /// value: WhyFarther.tradingCharter,
813 : /// child: MongolText('Placed in charge of trading charter'),
814 : /// ),
815 : /// ],
816 : /// )
817 : /// ```
818 : /// {@end-tool}
819 : ///
820 : /// See also:
821 : ///
822 : /// * [MongolPopupMenuItem], a popup menu entry for a single value.
823 : /// * [MongolPopupMenuDivider], a popup menu entry that is just a vertical line.
824 : /// * [showMongolMenu], a method to dynamically show a popup menu at a given location.
825 : class MongolPopupMenuButton<T> extends StatefulWidget {
826 : /// Creates a button that shows a popup menu.
827 : ///
828 : /// The [itemBuilder] argument must not be null.
829 1 : const MongolPopupMenuButton({
830 : Key? key,
831 : required this.itemBuilder,
832 : this.initialValue,
833 : this.onSelected,
834 : this.onCanceled,
835 : this.tooltip,
836 : this.elevation,
837 : this.padding = const EdgeInsets.all(8.0),
838 : this.child,
839 : this.icon,
840 : this.iconSize,
841 : this.offset = Offset.zero,
842 : this.enabled = true,
843 : this.shape,
844 : this.color,
845 : this.enableFeedback,
846 : }) : assert(
847 1 : !(child != null && icon != null),
848 : 'You can only pass [child] or [icon], not both.',
849 : ),
850 1 : super(key: key);
851 :
852 : /// Called when the button is pressed to create the items to show in the menu.
853 : final MongolPopupMenuItemBuilder<T> itemBuilder;
854 :
855 : /// The value of the menu item, if any, that should be highlighted when the menu opens.
856 : final T? initialValue;
857 :
858 : /// Called when the user selects a value from the popup menu created by this button.
859 : ///
860 : /// If the popup menu is dismissed without selecting a value, [onCanceled] is
861 : /// called instead.
862 : final MongolPopupMenuItemSelected<T>? onSelected;
863 :
864 : /// Called when the user dismisses the popup menu without selecting an item.
865 : ///
866 : /// If the user selects a value, [onSelected] is called instead.
867 : final MongolPopupMenuCanceled? onCanceled;
868 :
869 : /// Text that describes the action that will occur when the button is pressed.
870 : ///
871 : /// This text is displayed when the user long-presses on the button and is
872 : /// used for accessibility.
873 : final String? tooltip;
874 :
875 : /// The z-coordinate at which to place the menu when open. This controls the
876 : /// size of the shadow below the menu.
877 : ///
878 : /// Defaults to 8, the appropriate elevation for popup menus.
879 : final double? elevation;
880 :
881 : /// Matches IconButton's 8 dps padding by default. In some cases, notably where
882 : /// this button appears as the trailing element of a list item, it's useful to be able
883 : /// to set the padding to zero.
884 : final EdgeInsetsGeometry padding;
885 :
886 : /// If provided, [child] is the widget used for this button
887 : /// and the button will utilize an [InkWell] for taps.
888 : final Widget? child;
889 :
890 : /// If provided, the [icon] is used for this button
891 : /// and the button will behave like an [IconButton].
892 : final Widget? icon;
893 :
894 : /// The offset applied to the Popup Menu Button.
895 : ///
896 : /// When not set, the Popup Menu Button will be positioned directly next to
897 : /// the button that was used to create it.
898 : final Offset offset;
899 :
900 : /// Whether this popup menu button is interactive.
901 : ///
902 : /// Must be non-null, defaults to `true`
903 : ///
904 : /// If `true` the button will respond to presses by displaying the menu.
905 : ///
906 : /// If `false`, the button is styled with the disabled color from the
907 : /// current [Theme] and will not respond to presses or show the popup
908 : /// menu and [onSelected], [onCanceled] and [itemBuilder] will not be called.
909 : ///
910 : /// This can be useful in situations where the app needs to show the button,
911 : /// but doesn't currently have anything to show in the menu.
912 : final bool enabled;
913 :
914 : /// If provided, the shape used for the menu.
915 : ///
916 : /// If this property is null, then [PopupMenuThemeData.shape] is used.
917 : /// If [PopupMenuThemeData.shape] is also null, then the default shape for
918 : /// [MaterialType.card] is used. This default shape is a rectangle with
919 : /// rounded edges of BorderRadius.circular(2.0).
920 : final ShapeBorder? shape;
921 :
922 : /// If provided, the background color used for the menu.
923 : ///
924 : /// If this property is null, then [PopupMenuThemeData.color] is used.
925 : /// If [PopupMenuThemeData.color] is also null, then
926 : /// Theme.of(context).cardColor is used.
927 : final Color? color;
928 :
929 : /// Whether detected gestures should provide acoustic and/or haptic feedback.
930 : ///
931 : /// For example, on Android a tap will produce a clicking sound and a
932 : /// long-press will produce a short vibration, when feedback is enabled.
933 : ///
934 : /// See also:
935 : ///
936 : /// * [Feedback] for providing platform-specific feedback to certain actions.
937 : final bool? enableFeedback;
938 :
939 : /// If provided, the size of the [Icon].
940 : ///
941 : /// If this property is null, the default size is 24.0 pixels.
942 : final double? iconSize;
943 :
944 1 : @override
945 : MongolPopupMenuButtonState<T> createState() =>
946 1 : MongolPopupMenuButtonState<T>();
947 : }
948 :
949 : /// The [State] for a [MongolPopupMenuButton].
950 : ///
951 : /// See [showButtonMenu] for a way to programmatically open the popup menu
952 : /// of your button state.
953 : class MongolPopupMenuButtonState<T> extends State<MongolPopupMenuButton<T>> {
954 : /// A method to show a popup menu with the items supplied to
955 : /// [MongolPopupMenuButton.itemBuilder] at the position of your [MongolPopupMenuButton].
956 : ///
957 : /// By default, it is called when the user taps the button and [MongolPopupMenuButton.enabled]
958 : /// is set to `true`. Moreover, you can open the button by calling the method manually.
959 : ///
960 : /// You would access your [MongolPopupMenuButtonState] using a [GlobalKey] and
961 : /// show the menu of the button with `globalKey.currentState.showButtonMenu`.
962 1 : void showButtonMenu() {
963 2 : final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
964 2 : final RenderBox button = context.findRenderObject()! as RenderBox;
965 : final RenderBox overlay =
966 5 : Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox;
967 1 : final RelativeRect position = RelativeRect.fromRect(
968 1 : Rect.fromPoints(
969 3 : button.localToGlobal(widget.offset, ancestor: overlay),
970 1 : button.localToGlobal(
971 5 : button.size.bottomRight(Offset.zero) + widget.offset,
972 : ancestor: overlay),
973 : ),
974 2 : Offset.zero & overlay.size,
975 : );
976 4 : final List<MongolPopupMenuEntry<T>> items = widget.itemBuilder(context);
977 : // Only show the menu if there is something to show
978 1 : if (items.isNotEmpty) {
979 1 : showMongolMenu<T?>(
980 1 : context: context,
981 3 : elevation: widget.elevation ?? popupMenuTheme.elevation,
982 : items: items,
983 2 : initialValue: widget.initialValue,
984 : position: position,
985 3 : shape: widget.shape ?? popupMenuTheme.shape,
986 3 : color: widget.color ?? popupMenuTheme.color,
987 2 : ).then<void>((T? newValue) {
988 1 : if (!mounted) return null;
989 : if (newValue == null) {
990 3 : widget.onCanceled?.call();
991 : return null;
992 : }
993 3 : widget.onSelected?.call(newValue);
994 : });
995 : }
996 : }
997 :
998 1 : bool get _canRequestFocus {
999 3 : final NavigationMode mode = MediaQuery.maybeOf(context)?.navigationMode ??
1000 : NavigationMode.traditional;
1001 : switch (mode) {
1002 1 : case NavigationMode.traditional:
1003 2 : return widget.enabled;
1004 1 : case NavigationMode.directional:
1005 : return true;
1006 : }
1007 : }
1008 :
1009 1 : @override
1010 : Widget build(BuildContext context) {
1011 2 : final bool enableFeedback = widget.enableFeedback ??
1012 2 : PopupMenuTheme.of(context).enableFeedback ??
1013 : true;
1014 :
1015 2 : if (widget.child != null) {
1016 1 : return MongolTooltip(
1017 : message:
1018 4 : widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip,
1019 1 : child: InkWell(
1020 3 : onTap: widget.enabled ? showButtonMenu : null,
1021 1 : canRequestFocus: _canRequestFocus,
1022 2 : child: widget.child,
1023 : enableFeedback: enableFeedback,
1024 : ),
1025 : );
1026 : }
1027 :
1028 1 : return MongolIconButton(
1029 5 : icon: widget.icon ?? Icon(Icons.adaptive.more),
1030 2 : padding: widget.padding,
1031 2 : iconSize: widget.iconSize ?? 24.0,
1032 : mongolTooltip:
1033 4 : widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip,
1034 3 : onPressed: widget.enabled ? showButtonMenu : null,
1035 : enableFeedback: enableFeedback,
1036 : );
1037 : }
1038 : }
|