popup_menu.dart 44.6 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'package:flutter/foundation.dart';
6
import 'package:flutter/rendering.dart';
7
import 'package:flutter/widgets.dart';
8

9
import 'constants.dart';
10
import 'debug.dart';
Hans Muller's avatar
Hans Muller committed
11
import 'divider.dart';
Hans Muller's avatar
Hans Muller committed
12
import 'icon_button.dart';
13
import 'icons.dart';
14
import 'ink_well.dart';
15
import 'list_tile.dart';
16
import 'material.dart';
17
import 'material_localizations.dart';
18
import 'material_state.dart';
19
import 'popup_menu_theme.dart';
20
import 'theme.dart';
21
import 'tooltip.dart';
22

23 24
// Examples can assume:
// enum Commands { heroAndScholar, hurricaneCame }
25 26
// late bool _heroAndScholar;
// late dynamic _selection;
27
// late BuildContext context;
28
// void setState(VoidCallback fn) { }
29
// enum Menu { itemOne, itemTwo, itemThree, itemFour }
30

31
const Duration _kMenuDuration = Duration(milliseconds: 300);
32
const double _kMenuCloseIntervalEnd = 2.0 / 3.0;
33
const double _kMenuHorizontalPadding = 16.0;
Ian Hickson's avatar
Ian Hickson committed
34
const double _kMenuDividerHeight = 16.0;
35 36
const double _kMenuMaxWidth = 5.0 * _kMenuWidthStep;
const double _kMenuMinWidth = 2.0 * _kMenuWidthStep;
37
const double _kMenuVerticalPadding = 8.0;
38
const double _kMenuWidthStep = 56.0;
Hans Muller's avatar
Hans Muller committed
39
const double _kMenuScreenPadding = 8.0;
40

41 42 43 44 45 46 47 48
/// Used to configure how the [PopupMenuButton] positions its popup menu.
enum PopupMenuPosition {
  /// Menu is positioned over the anchor.
  over,
  /// Menu is positioned under the anchor.
  under,
}

49
/// A base class for entries in a Material Design popup menu.
50 51 52 53 54
///
/// The popup menu widget uses this interface to interact with the menu items.
/// To show a popup menu, use the [showMenu] function. To create a button that
/// shows a popup menu, consider using [PopupMenuButton].
///
Ian Hickson's avatar
Ian Hickson committed
55 56 57 58 59 60
/// The type `T` is the type of the value(s) the entry represents. All the
/// entries in a given menu must represent values with consistent types.
///
/// A [PopupMenuEntry] may represent multiple values, for example a row with
/// several icons, or a single entry, for example a menu item with an icon (see
/// [PopupMenuItem]), or no value at all (for example, [PopupMenuDivider]).
61 62 63
///
/// See also:
///
Ian Hickson's avatar
Ian Hickson committed
64 65 66 67 68 69
///  * [PopupMenuItem], a popup menu entry for a single value.
///  * [PopupMenuDivider], a popup menu entry that is just a horizontal line.
///  * [CheckedPopupMenuItem], a popup menu item with a checkmark.
///  * [showMenu], a method to dynamically show a popup menu at a given location.
///  * [PopupMenuButton], an [IconButton] that automatically shows a menu when
///    it is tapped.
70
abstract class PopupMenuEntry<T> extends StatefulWidget {
71 72
  /// Abstract const constructor. This constructor enables subclasses to provide
  /// const constructors so that they can be used in const expressions.
73
  const PopupMenuEntry({ super.key });
Hans Muller's avatar
Hans Muller committed
74

75 76
  /// The amount of vertical space occupied by this entry.
  ///
Ian Hickson's avatar
Ian Hickson committed
77 78 79 80
  /// This value is used at the time the [showMenu] method is called, if the
  /// `initialValue` argument is provided, to determine the position of this
  /// entry when aligning the selected entry over the given `position`. It is
  /// otherwise ignored.
Hans Muller's avatar
Hans Muller committed
81
  double get height;
82

Ian Hickson's avatar
Ian Hickson committed
83 84 85 86 87 88 89 90 91 92 93 94
  /// Whether this entry represents a particular value.
  ///
  /// This method is used by [showMenu], when it is called, to align the entry
  /// representing the `initialValue`, if any, to the given `position`, and then
  /// later is called on each entry to determine if it should be highlighted (if
  /// the method returns true, the entry will have its background color set to
  /// the ambient [ThemeData.highlightColor]). If `initialValue` is null, then
  /// this method is not called.
  ///
  /// If the [PopupMenuEntry] represents a single value, this should return true
  /// if the argument matches that value. If it represents multiple values, it
  /// should return true if the argument matches any of them.
95
  bool represents(T? value);
Hans Muller's avatar
Hans Muller committed
96 97
}

98
/// A horizontal divider in a Material Design popup menu.
99
///
Ian Hickson's avatar
Ian Hickson committed
100
/// This widget adapts the [Divider] for use in popup menus.
101 102 103
///
/// See also:
///
Ian Hickson's avatar
Ian Hickson committed
104 105 106 107
///  * [PopupMenuItem], for the kinds of items that this widget divides.
///  * [showMenu], a method to dynamically show a popup menu at a given location.
///  * [PopupMenuButton], an [IconButton] that automatically shows a menu when
///    it is tapped.
108
class PopupMenuDivider extends PopupMenuEntry<Never> {
109 110
  /// Creates a horizontal divider for a popup menu.
  ///
Ian Hickson's avatar
Ian Hickson committed
111
  /// By default, the divider has a height of 16 logical pixels.
112
  const PopupMenuDivider({ super.key, this.height = _kMenuDividerHeight });
Hans Muller's avatar
Hans Muller committed
113

Ian Hickson's avatar
Ian Hickson committed
114 115 116
  /// The height of the divider entry.
  ///
  /// Defaults to 16 pixels.
117
  @override
Hans Muller's avatar
Hans Muller committed
118 119
  final double height;

120
  @override
121
  bool represents(void value) => false;
122

123
  @override
124
  State<PopupMenuDivider> createState() => _PopupMenuDividerState();
125 126 127
}

class _PopupMenuDividerState extends State<PopupMenuDivider> {
128
  @override
129
  Widget build(BuildContext context) => Divider(height: widget.height);
Hans Muller's avatar
Hans Muller committed
130 131
}

132 133 134 135 136 137
// 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({
138
    required this.onLayout,
139 140
    required super.child,
  }) : assert(onLayout != null);
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155

  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 {
156
  _RenderMenuItem(this.onLayout, [RenderBox? child]) : assert(onLayout != null), super(child);
157 158 159

  ValueChanged<Size> onLayout;

160 161 162 163 164 165 166 167
  @override
  Size computeDryLayout(BoxConstraints constraints) {
    if (child == null) {
      return Size.zero;
    }
    return child!.getDryLayout(constraints);
  }

168 169 170 171 172
  @override
  void performLayout() {
    if (child == null) {
      size = Size.zero;
    } else {
173 174 175 176
      child!.layout(constraints, parentUsesSize: true);
      size = constraints.constrain(child!.size);
      final BoxParentData childParentData = child!.parentData! as BoxParentData;
      childParentData.offset = Offset.zero;
177 178 179 180 181
    }
    onLayout(size);
  }
}

182
/// An item in a Material Design popup menu.
183 184 185 186 187 188 189
///
/// To show a popup menu, use the [showMenu] function. To create a button that
/// shows a popup menu, consider using [PopupMenuButton].
///
/// To show a checkmark next to a popup menu item, consider using
/// [CheckedPopupMenuItem].
///
Ian Hickson's avatar
Ian Hickson committed
190 191
/// Typically the [child] of a [PopupMenuItem] is a [Text] widget. More
/// elaborate menus with icons can use a [ListTile]. By default, a
192
/// [PopupMenuItem] is [kMinInteractiveDimension] pixels high. If you use a widget
193
/// with a different height, it must be specified in the [height] property.
Ian Hickson's avatar
Ian Hickson committed
194
///
195
/// {@tool snippet}
Ian Hickson's avatar
Ian Hickson committed
196
///
197
/// Here, a [Text] widget is used with a popup menu item. The `Menu` type
Ian Hickson's avatar
Ian Hickson committed
198 199 200
/// is an enum, not shown here.
///
/// ```dart
201 202 203
/// const PopupMenuItem<Menu>(
///   value: Menu.itemOne,
///   child: Text('Item 1'),
204
/// )
Ian Hickson's avatar
Ian Hickson committed
205
/// ```
206
/// {@end-tool}
Ian Hickson's avatar
Ian Hickson committed
207 208 209 210 211 212 213
///
/// See the example at [PopupMenuButton] for how this example could be used in a
/// complete menu, and see the example at [CheckedPopupMenuItem] for one way to
/// keep the text of [PopupMenuItem]s that use [Text] widgets in their [child]
/// slot aligned with the text of [CheckedPopupMenuItem]s or of [PopupMenuItem]
/// that use a [ListTile] in their [child] slot.
///
214 215
/// See also:
///
Ian Hickson's avatar
Ian Hickson committed
216 217 218 219 220
///  * [PopupMenuDivider], which can be used to divide items from each other.
///  * [CheckedPopupMenuItem], a variant of [PopupMenuItem] with a checkmark.
///  * [showMenu], a method to dynamically show a popup menu at a given location.
///  * [PopupMenuButton], an [IconButton] that automatically shows a menu when
///    it is tapped.
Hans Muller's avatar
Hans Muller committed
221
class PopupMenuItem<T> extends PopupMenuEntry<T> {
222 223
  /// Creates an item for a popup menu.
  ///
Ian Hickson's avatar
Ian Hickson committed
224 225
  /// By default, the item is [enabled].
  ///
226
  /// The `enabled` and `height` arguments must not be null.
227
  const PopupMenuItem({
228
    super.key,
229
    this.value,
230
    this.onTap,
231
    this.enabled = true,
232
    this.height = kMinInteractiveDimension,
233
    this.padding,
234
    this.textStyle,
235
    this.mouseCursor,
236
    required this.child,
Ian Hickson's avatar
Ian Hickson committed
237
  }) : assert(enabled != null),
238
       assert(height != null);
239

Ian Hickson's avatar
Ian Hickson committed
240
  /// The value that will be returned by [showMenu] if this entry is selected.
241
  final T? value;
242

243 244 245
  /// Called when the menu item is tapped.
  final VoidCallback? onTap;

246
  /// Whether the user is permitted to select this item.
Ian Hickson's avatar
Ian Hickson committed
247 248 249
  ///
  /// Defaults to true. If this is false, then the item will not react to
  /// touches.
250
  final bool enabled;
251

giga10's avatar
giga10 committed
252
  /// The minimum height of the menu item.
Ian Hickson's avatar
Ian Hickson committed
253
  ///
254
  /// Defaults to [kMinInteractiveDimension] pixels.
Ian Hickson's avatar
Ian Hickson committed
255 256 257
  @override
  final double height;

258 259 260 261 262 263 264 265 266
  /// The padding of the menu item.
  ///
  /// Note that [height] 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;

267
  /// The text style of the popup menu item.
268 269
  ///
  /// If this property is null, then [PopupMenuThemeData.textStyle] is used.
270
  /// If [PopupMenuThemeData.textStyle] is also null, then [TextTheme.titleMedium]
271
  /// of [ThemeData.textTheme] is used.
272
  final TextStyle? textStyle;
273

274
  /// {@template flutter.material.popupmenu.mouseCursor}
275 276 277 278
  /// The cursor for a mouse pointer when it enters or is hovering over the
  /// widget.
  ///
  /// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>],
279
  /// [MaterialStateProperty.resolve] is used for the following [MaterialState]s:
280
  ///
281 282
  ///  * [MaterialState.hovered].
  ///  * [MaterialState.focused].
283
  ///  * [MaterialState.disabled].
284
  /// {@endtemplate}
285
  ///
286 287
  /// If null, then the value of [PopupMenuThemeData.mouseCursor] is used. If
  /// that is also null, then [MaterialStateMouseCursor.clickable] is used.
288
  final MouseCursor? mouseCursor;
289

290
  /// The widget below this widget in the tree.
Ian Hickson's avatar
Ian Hickson committed
291 292 293 294
  ///
  /// 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.
295
  final Widget? child;
296

297
  @override
298
  bool represents(T? value) => value == this.value;
Hans Muller's avatar
Hans Muller committed
299

300
  @override
301
  PopupMenuItemState<T, PopupMenuItem<T>> createState() => PopupMenuItemState<T, PopupMenuItem<T>>();
302 303
}

304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327
/// The [State] for [PopupMenuItem] 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 [PopupMenuItem.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
/// [PopupMenuItem.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
/// [PopupMenuItem]. The first, `T`, must match the type argument of that widget
/// class, and is the type of values returned from this menu.
class PopupMenuItemState<T, W extends PopupMenuItem<T>> extends State<W> {
  /// The menu item contents.
  ///
  /// Used by the [build] method.
  ///
  /// By default, this returns [PopupMenuItem.child]. Override this to put
  /// something else in the menu entry.
  @protected
328
  Widget? buildChild() => widget.child;
329

330 331 332 333 334 335 336
  /// 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 [PopupMenuItem.value] from
  /// the menu route.
  @protected
Ian Hickson's avatar
Ian Hickson committed
337
  void handleTap() {
338 339
    widget.onTap?.call();

340
    Navigator.pop<T>(context, widget.value);
341 342
  }

343
  @override
344
  Widget build(BuildContext context) {
345
    final ThemeData theme = Theme.of(context);
346
    final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
347
    TextStyle style = widget.textStyle ?? popupMenuTheme.textStyle ?? theme.textTheme.titleMedium!;
348

349
    if (!widget.enabled) {
Hans Muller's avatar
Hans Muller committed
350
      style = style.copyWith(color: theme.disabledColor);
351
    }
Hans Muller's avatar
Hans Muller committed
352

353
    Widget item = AnimatedDefaultTextStyle(
354
      style: style,
355
      duration: kThemeChangeDuration,
356 357 358
      child: Container(
        alignment: AlignmentDirectional.centerStart,
        constraints: BoxConstraints(minHeight: widget.height),
359
        padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: _kMenuHorizontalPadding),
Ian Hickson's avatar
Ian Hickson committed
360
        child: buildChild(),
361
      ),
362
    );
363

364
    if (!widget.enabled) {
365
      final bool isDark = theme.brightness == Brightness.dark;
366
      item = IconTheme.merge(
367
        data: IconThemeData(opacity: isDark ? 0.5 : 0.38),
Ian Hickson's avatar
Ian Hickson committed
368
        child: item,
369 370 371
      );
    }

372 373 374 375 376 377 378
    return MergeSemantics(
      child: Semantics(
        enabled: widget.enabled,
        button: true,
        child: InkWell(
          onTap: widget.enabled ? handleTap : null,
          canRequestFocus: widget.enabled,
379
          mouseCursor: _EffectiveMouseCursor(widget.mouseCursor, popupMenuTheme.mouseCursor),
380 381
          child: item,
        ),
382
      ),
383 384 385
    );
  }
}
386

387
/// An item with a checkmark in a Material Design popup menu.
388 389 390 391
///
/// To show a popup menu, use the [showMenu] function. To create a button that
/// shows a popup menu, consider using [PopupMenuButton].
///
392
/// A [CheckedPopupMenuItem] is kMinInteractiveDimension pixels high, which
393 394
/// matches the default minimum height of a [PopupMenuItem]. The horizontal
/// layout uses [ListTile]; the checkmark is an [Icons.done] icon, shown in the
395
/// [ListTile.leading] position.
Ian Hickson's avatar
Ian Hickson committed
396
///
397
/// {@tool snippet}
Ian Hickson's avatar
Ian Hickson committed
398 399 400 401 402 403 404 405 406 407
///
/// 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
408
/// PopupMenuButton<Commands>(
Ian Hickson's avatar
Ian Hickson committed
409 410 411 412 413 414 415 416 417 418 419 420
///   onSelected: (Commands result) {
///     switch (result) {
///       case Commands.heroAndScholar:
///         setState(() { _heroAndScholar = !_heroAndScholar; });
///         break;
///       case Commands.hurricaneCame:
///         // ...handle hurricane option
///         break;
///       // ...other items handled here
///     }
///   },
///   itemBuilder: (BuildContext context) => <PopupMenuEntry<Commands>>[
421
///     CheckedPopupMenuItem<Commands>(
Ian Hickson's avatar
Ian Hickson committed
422 423 424 425 426 427 428
///       checked: _heroAndScholar,
///       value: Commands.heroAndScholar,
///       child: const Text('Hero and scholar'),
///     ),
///     const PopupMenuDivider(),
///     const PopupMenuItem<Commands>(
///       value: Commands.hurricaneCame,
429
///       child: ListTile(leading: Icon(null), title: Text('Bring hurricane')),
Ian Hickson's avatar
Ian Hickson committed
430 431 432 433 434
///     ),
///     // ...other items listed here
///   ],
/// )
/// ```
435
/// {@end-tool}
Ian Hickson's avatar
Ian Hickson committed
436 437 438 439 440
///
/// 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.
///
441 442
/// See also:
///
Ian Hickson's avatar
Ian Hickson committed
443 444 445 446 447 448
///  * [PopupMenuItem], a popup menu entry for picking a command (as opposed to
///    toggling a value).
///  * [PopupMenuDivider], a popup menu entry that is just a horizontal line.
///  * [showMenu], a method to dynamically show a popup menu at a given location.
///  * [PopupMenuButton], an [IconButton] that automatically shows a menu when
///    it is tapped.
449
class CheckedPopupMenuItem<T> extends PopupMenuItem<T> {
450 451
  /// Creates a popup menu item with a checkmark.
  ///
Ian Hickson's avatar
Ian Hickson committed
452 453 454 455
  /// 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.
456
  const CheckedPopupMenuItem({
457 458
    super.key,
    super.value,
459
    this.checked = false,
460 461 462
    super.enabled,
    super.padding,
    super.height,
463
    super.mouseCursor,
464 465
    super.child,
  }) : assert(checked != null);
466

467
  /// Whether to display a checkmark next to the menu item.
Ian Hickson's avatar
Ian Hickson committed
468 469 470 471 472 473 474
  ///
  /// 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.
475 476
  final bool checked;

Ian Hickson's avatar
Ian Hickson committed
477 478 479 480 481 482 483 484
  /// 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
485
  Widget? get child => super.child;
Ian Hickson's avatar
Ian Hickson committed
486

487
  @override
488
  PopupMenuItemState<T, CheckedPopupMenuItem<T>> createState() => _CheckedPopupMenuItemState<T>();
489 490
}

491
class _CheckedPopupMenuItemState<T> extends PopupMenuItemState<T, CheckedPopupMenuItem<T>> with SingleTickerProviderStateMixin {
492
  static const Duration _fadeDuration = Duration(milliseconds: 150);
493
  late AnimationController _controller;
494 495
  Animation<double> get _opacity => _controller.view;

496
  @override
497 498
  void initState() {
    super.initState();
499
    _controller = AnimationController(duration: _fadeDuration, vsync: this)
500
      ..value = widget.checked ? 1.0 : 0.0
501 502 503
      ..addListener(() => setState(() { /* animation changed */ }));
  }

504
  @override
Ian Hickson's avatar
Ian Hickson committed
505
  void handleTap() {
506
    // This fades the checkmark in or out when tapped.
507
    if (widget.checked) {
508
      _controller.reverse();
509
    } else {
510
      _controller.forward();
511
    }
Ian Hickson's avatar
Ian Hickson committed
512
    super.handleTap();
513 514
  }

515
  @override
516
  Widget buildChild() {
517 518 519 520 521 522 523 524
    return IgnorePointer(
      child: ListTile(
        enabled: widget.enabled,
        leading: FadeTransition(
          opacity: _opacity,
          child: Icon(_controller.isDismissed ? null : Icons.done),
        ),
        title: widget.child,
525 526 527
      ),
    );
  }
528 529
}

530
class _PopupMenu<T> extends StatelessWidget {
531
  const _PopupMenu({
532
    super.key,
533 534
    required this.route,
    required this.semanticLabel,
535
    this.constraints,
536
    required this.clipBehavior,
537
  });
538

Hixie's avatar
Hixie committed
539
  final _PopupMenuRoute<T> route;
540
  final String? semanticLabel;
541
  final BoxConstraints? constraints;
542
  final Clip clipBehavior;
543

544
  @override
545
  Widget build(BuildContext context) {
546 547
    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>[];
548
    final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
549

Ian Hickson's avatar
Ian Hickson committed
550
    for (int i = 0; i < route.items.length; i += 1) {
Hans Muller's avatar
Hans Muller committed
551
      final double start = (i + 1) * unit;
552
      final double end = clampDouble(start + 1.5 * unit, 0.0, 1.0);
553
      final CurvedAnimation opacity = CurvedAnimation(
554
        parent: route.animation!,
555
        curve: Interval(start, end),
556
      );
Hans Muller's avatar
Hans Muller committed
557
      Widget item = route.items[i];
Ian Hickson's avatar
Ian Hickson committed
558
      if (route.initialValue != null && route.items[i].represents(route.initialValue)) {
559
        item = Container(
560
          color: Theme.of(context).highlightColor,
Ian Hickson's avatar
Ian Hickson committed
561
          child: item,
Hans Muller's avatar
Hans Muller committed
562 563
        );
      }
564 565 566 567 568 569 570 571 572 573 574
      children.add(
        _MenuItem(
          onLayout: (Size size) {
            route.itemSizes[i] = size;
          },
          child: FadeTransition(
            opacity: opacity,
            child: item,
          ),
        ),
      );
575
    }
576

577 578 579
    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));
580

581
    final Widget child = ConstrainedBox(
582
      constraints: constraints ?? const BoxConstraints(
583
        minWidth: _kMenuMinWidth,
584
        maxWidth: _kMenuMaxWidth,
585
      ),
586
      child: IntrinsicWidth(
587
        stepWidth: _kMenuWidthStep,
588
        child: Semantics(
589 590 591 592
          scopesRoute: true,
          namesRoute: true,
          explicitChildNodes: true,
          label: semanticLabel,
593
          child: SingleChildScrollView(
594 595 596
            padding: const EdgeInsets.symmetric(
              vertical: _kMenuVerticalPadding,
            ),
597
            child: ListBody(children: children),
598
          ),
599 600
        ),
      ),
601
    );
Adam Barth's avatar
Adam Barth committed
602

603
    return AnimatedBuilder(
604 605
      animation: route.animation!,
      builder: (BuildContext context, Widget? child) {
606 607
        return FadeTransition(
          opacity: opacity.animate(route.animation!),
608
          child: Material(
609 610
            shape: route.shape ?? popupMenuTheme.shape,
            color: route.color ?? popupMenuTheme.color,
611
            clipBehavior: clipBehavior,
612
            type: MaterialType.card,
613
            elevation: route.elevation ?? popupMenuTheme.elevation ?? 8.0,
614
            child: Align(
Ian Hickson's avatar
Ian Hickson committed
615
              alignment: AlignmentDirectional.topEnd,
616 617
              widthFactor: width.evaluate(route.animation!),
              heightFactor: height.evaluate(route.animation!),
618
              child: child,
619 620
            ),
          ),
621
        );
622
      },
Ian Hickson's avatar
Ian Hickson committed
623
      child: child,
624 625 626
    );
  }
}
627

Ian Hickson's avatar
Ian Hickson committed
628
// Positioning of the menu on the screen.
629
class _PopupMenuRouteLayout extends SingleChildLayoutDelegate {
630 631 632 633 634
  _PopupMenuRouteLayout(
    this.position,
    this.itemSizes,
    this.selectedItemIndex,
    this.textDirection,
635
    this.padding,
636
    this.avoidBounds,
637
  );
Hans Muller's avatar
Hans Muller committed
638

Ian Hickson's avatar
Ian Hickson committed
639
  // Rectangle of underlying button, relative to the overlay's dimensions.
640
  final RelativeRect position;
Ian Hickson's avatar
Ian Hickson committed
641

642 643
  // The sizes of each item are computed when the menu is laid out, and before
  // the route is laid out.
644
  List<Size?> itemSizes;
645 646 647

  // The index of the selected item, or null if PopupMenuButton.initialValue
  // was not specified.
648
  final int? selectedItemIndex;
Hans Muller's avatar
Hans Muller committed
649

Ian Hickson's avatar
Ian Hickson committed
650 651 652
  // Whether to prefer going to the left or to the right.
  final TextDirection textDirection;

653 654
  // The padding of unsafe area.
  EdgeInsets padding;
655

656 657 658
  // List of rectangles that we should avoid overlapping. Unusable screen area.
  final Set<Rect> avoidBounds;

Ian Hickson's avatar
Ian Hickson committed
659 660 661 662
  // 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.

663
  @override
Hans Muller's avatar
Hans Muller committed
664
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
Ian Hickson's avatar
Ian Hickson committed
665 666
    // The menu can be at most the size of the overlay minus 8.0 pixels in each
    // direction.
667
    return BoxConstraints.loose(constraints.biggest).deflate(
668
      const EdgeInsets.all(_kMenuScreenPadding) + padding,
669
    );
Hans Muller's avatar
Hans Muller committed
670 671
  }

672
  @override
Hans Muller's avatar
Hans Muller committed
673
  Offset getPositionForChild(Size size, Size childSize) {
Ian Hickson's avatar
Ian Hickson committed
674 675 676 677
    // size: The size of the overlay.
    // childSize: The size of the menu, when fully open, as determined by
    // getConstraintsForChild.

678
    final double buttonHeight = size.height - position.top - position.bottom;
Ian Hickson's avatar
Ian Hickson committed
679
    // Find the ideal vertical position.
680
    double y = position.top;
681 682
    if (selectedItemIndex != null && itemSizes != null) {
      double selectedItemOffset = _kMenuVerticalPadding;
683
      for (int index = 0; index < selectedItemIndex!; index += 1) {
684
        selectedItemOffset += itemSizes[index]!.height;
685
      }
686
      selectedItemOffset += itemSizes[selectedItemIndex!]!.height / 2;
687
      y = y + buttonHeight / 2.0 - selectedItemOffset;
Ian Hickson's avatar
Ian Hickson committed
688
    }
Hans Muller's avatar
Hans Muller committed
689

Ian Hickson's avatar
Ian Hickson committed
690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709
    // Find the ideal horizontal position.
    double x;
    if (position.left > position.right) {
      // Menu button is closer to the right edge, so grow to the left, aligned to the right edge.
      x = size.width - position.right - childSize.width;
    } else if (position.left < position.right) {
      // Menu button is closer to the left edge, so grow to the right, aligned to the left edge.
      x = position.left;
    } else {
      // Menu button is equidistant from both edges, so grow in reading direction.
      assert(textDirection != null);
      switch (textDirection) {
        case TextDirection.rtl:
          x = size.width - position.right - childSize.width;
          break;
        case TextDirection.ltr:
          x = position.left;
          break;
      }
    }
710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725
    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;
  }
Hans Muller's avatar
Hans Muller committed
726

727 728 729
  Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition){
    double x = wantedPosition.dx;
    double y = wantedPosition.dy;
Ian Hickson's avatar
Ian Hickson committed
730 731
    // Avoid going outside an area defined as the rectangle 8.0 pixels from the
    // edge of the screen in every direction.
732
    if (x < screen.left + _kMenuScreenPadding + padding.left) {
733
      x = screen.left + _kMenuScreenPadding + padding.left;
734
    } else if (x + childSize.width > screen.right - _kMenuScreenPadding - padding.right) {
735
      x = screen.right - childSize.width - _kMenuScreenPadding - padding.right;
736 737
    }
    if (y < screen.top + _kMenuScreenPadding + padding.top) {
738
      y = _kMenuScreenPadding + padding.top;
739
    } else if (y + childSize.height > screen.bottom - _kMenuScreenPadding - padding.bottom) {
740
      y = screen.bottom - childSize.height - _kMenuScreenPadding - padding.bottom;
741
    }
742

743
    return Offset(x,y);
Hans Muller's avatar
Hans Muller committed
744 745
  }

746
  @override
Hans Muller's avatar
Hans Muller committed
747
  bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) {
748 749 750 751 752 753
    // 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
754 755 756
      || selectedItemIndex != oldDelegate.selectedItemIndex
      || textDirection != oldDelegate.textDirection
      || !listEquals(itemSizes, oldDelegate.itemSizes)
757 758
      || padding != oldDelegate.padding
      || !setEquals(avoidBounds, oldDelegate.avoidBounds);
Hans Muller's avatar
Hans Muller committed
759 760 761
  }
}

Hixie's avatar
Hixie committed
762 763
class _PopupMenuRoute<T> extends PopupRoute<T> {
  _PopupMenuRoute({
764 765
    required this.position,
    required this.items,
Hans Muller's avatar
Hans Muller committed
766
    this.initialValue,
767
    this.elevation,
768
    required this.barrierLabel,
769
    this.semanticLabel,
770 771
    this.shape,
    this.color,
772
    required this.capturedThemes,
773
    this.constraints,
774
    required this.clipBehavior,
775
  }) : itemSizes = List<Size?>.filled(items.length, null);
776

777
  final RelativeRect position;
Hans Muller's avatar
Hans Muller committed
778
  final List<PopupMenuEntry<T>> items;
779 780 781 782 783 784
  final List<Size?> itemSizes;
  final T? initialValue;
  final double? elevation;
  final String? semanticLabel;
  final ShapeBorder? shape;
  final Color? color;
785
  final CapturedThemes capturedThemes;
786
  final BoxConstraints? constraints;
787
  final Clip clipBehavior;
788

789
  @override
790
  Animation<double> createAnimation() {
791
    return CurvedAnimation(
792
      parent: super.createAnimation(),
793
      curve: Curves.linear,
794
      reverseCurve: const Interval(0.0, _kMenuCloseIntervalEnd),
795
    );
796 797
  }

798
  @override
799
  Duration get transitionDuration => _kMenuDuration;
800 801

  @override
802
  bool get barrierDismissible => true;
803 804

  @override
805
  Color? get barrierColor => null;
806

807 808 809
  @override
  final String barrierLabel;

810
  @override
811
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
812

813
    int? selectedItemIndex;
Hans Muller's avatar
Hans Muller committed
814
    if (initialValue != null) {
815
      for (int index = 0; selectedItemIndex == null && index < items.length; index += 1) {
816
        if (items[index].represents(initialValue)) {
817
          selectedItemIndex = index;
818
        }
Hans Muller's avatar
Hans Muller committed
819
      }
Hans Muller's avatar
Hans Muller committed
820
    }
821

822 823 824 825
    final Widget menu = _PopupMenu<T>(
      route: this,
      semanticLabel: semanticLabel,
      constraints: constraints,
826
      clipBehavior: clipBehavior,
827
    );
828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843
    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: _PopupMenuRouteLayout(
              position,
              itemSizes,
              selectedItemIndex,
              Directionality.of(context),
              mediaQuery.padding,
844
              _avoidBounds(mediaQuery),
845 846 847 848 849
            ),
            child: capturedThemes.wrap(menu),
          );
        },
      ),
Hans Muller's avatar
Hans Muller committed
850
    );
851
  }
852 853 854 855

  Set<Rect> _avoidBounds(MediaQueryData mediaQuery) {
    return DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet();
  }
856 857
}

Ian Hickson's avatar
Ian Hickson committed
858
/// Show a popup menu that contains the `items` at `position`.
859
///
860 861
/// `items` should be non-null and not empty.
///
Ian Hickson's avatar
Ian Hickson committed
862 863 864 865
/// 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).
866
///
Ian Hickson's avatar
Ian Hickson committed
867 868 869 870 871 872 873 874 875 876 877 878 879
/// 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.
Ian Hickson's avatar
Ian Hickson committed
880 881 882 883 884 885 886
///
/// The positioning of the `initialValue` at the `position` is implemented by
/// iterating over the `items` to find the first whose
/// [PopupMenuEntry.represents] method returns true for `initialValue`, and then
/// summing the values of [PopupMenuEntry.height] for all the preceding widgets
/// in the list.
///
Ian Hickson's avatar
Ian Hickson committed
887 888 889 890 891 892 893
/// 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.
894
///
895 896 897 898
/// 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.
///
899 900 901
/// 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
902
/// [MaterialLocalizations.popupMenuLabel].
903
///
904 905 906
/// The `clipBehavior` argument is used to clip the shape of the menu. Defaults to
/// [Clip.none].
///
Ian Hickson's avatar
Ian Hickson committed
907 908 909 910 911 912 913
/// See also:
///
///  * [PopupMenuItem], a popup menu entry for a single value.
///  * [PopupMenuDivider], a popup menu entry that is just a horizontal line.
///  * [CheckedPopupMenuItem], a popup menu item with a checkmark.
///  * [PopupMenuButton], which provides an [IconButton] that shows a menu by
///    calling this method automatically.
914 915
///  * [SemanticsConfiguration.namesRoute], for a description of edge triggered
///    semantics.
916
Future<T?> showMenu<T>({
917 918 919 920 921 922 923 924
  required BuildContext context,
  required RelativeRect position,
  required List<PopupMenuEntry<T>> items,
  T? initialValue,
  double? elevation,
  String? semanticLabel,
  ShapeBorder? shape,
  Color? color,
925
  bool useRootNavigator = false,
926
  BoxConstraints? constraints,
927
  Clip clipBehavior = Clip.none,
Hans Muller's avatar
Hans Muller committed
928 929
}) {
  assert(context != null);
930
  assert(position != null);
931
  assert(useRootNavigator != null);
932
  assert(items != null && items.isNotEmpty);
933
  assert(debugCheckHasMaterialLocalizations(context));
934

935
  switch (Theme.of(context).platform) {
936
    case TargetPlatform.iOS:
937
    case TargetPlatform.macOS:
938 939 940
      break;
    case TargetPlatform.android:
    case TargetPlatform.fuchsia:
941 942
    case TargetPlatform.linux:
    case TargetPlatform.windows:
943
      semanticLabel ??= MaterialLocalizations.of(context).popupMenuLabel;
944 945
  }

946
  final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator);
947
  return navigator.push(_PopupMenuRoute<T>(
948
    position: position,
Adam Barth's avatar
Adam Barth committed
949
    items: items,
Hans Muller's avatar
Hans Muller committed
950
    initialValue: initialValue,
951
    elevation: elevation,
952 953
    semanticLabel: semanticLabel,
    barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
954 955
    shape: shape,
    color: color,
956
    capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
957
    constraints: constraints,
958
    clipBehavior: clipBehavior,
959 960
  ));
}
Hans Muller's avatar
Hans Muller committed
961

962 963 964 965 966
/// Signature for the callback invoked when a menu item is selected. The
/// argument is the value of the [PopupMenuItem] that caused its menu to be
/// dismissed.
///
/// Used by [PopupMenuButton.onSelected].
967
typedef PopupMenuItemSelected<T> = void Function(T value);
Hans Muller's avatar
Hans Muller committed
968

969 970 971 972
/// Signature for the callback invoked when a [PopupMenuButton] is dismissed
/// without selecting an item.
///
/// Used by [PopupMenuButton.onCanceled].
973
typedef PopupMenuCanceled = void Function();
974

975 976 977 978
/// Signature used by [PopupMenuButton] to lazily construct the items shown when
/// the button is pressed.
///
/// Used by [PopupMenuButton.itemBuilder].
979
typedef PopupMenuItemBuilder<T> = List<PopupMenuEntry<T>> Function(BuildContext context);
980

Hans Muller's avatar
Hans Muller committed
981 982
/// 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
983 984 985 986 987 988 989
/// the selected menu item.
///
/// One of [child] or [icon] may be provided, but not both. If [icon] is provided,
/// then [PopupMenuButton] behaves like an [IconButton].
///
/// If both are null, then a standard overflow icon is created (depending on the
/// platform).
Ian Hickson's avatar
Ian Hickson committed
990
///
991
/// {@tool dartpad}
Ian Hickson's avatar
Ian Hickson committed
992
/// This example shows a menu with four items, selecting between an enum's
993
/// values and setting a `_selectedMenu` field based on the selection
Ian Hickson's avatar
Ian Hickson committed
994
///
995
/// ** See code in examples/api/lib/material/popupmenu/popupmenu.0.dart **
996
/// {@end-tool}
Ian Hickson's avatar
Ian Hickson committed
997 998 999 1000 1001 1002 1003
///
/// See also:
///
///  * [PopupMenuItem], a popup menu entry for a single value.
///  * [PopupMenuDivider], a popup menu entry that is just a horizontal line.
///  * [CheckedPopupMenuItem], a popup menu item with a checkmark.
///  * [showMenu], a method to dynamically show a popup menu at a given location.
1004
class PopupMenuButton<T> extends StatefulWidget {
1005 1006 1007
  /// Creates a button that shows a popup menu.
  ///
  /// The [itemBuilder] argument must not be null.
1008
  const PopupMenuButton({
1009
    super.key,
1010
    required this.itemBuilder,
Hans Muller's avatar
Hans Muller committed
1011
    this.initialValue,
1012
    this.onOpened,
Hans Muller's avatar
Hans Muller committed
1013
    this.onSelected,
1014
    this.onCanceled,
1015
    this.tooltip,
1016
    this.elevation,
1017
    this.padding = const EdgeInsets.all(8.0),
1018
    this.child,
1019
    this.splashRadius,
1020
    this.icon,
1021
    this.iconSize,
1022
    this.offset = Offset.zero,
1023
    this.enabled = true,
1024 1025
    this.shape,
    this.color,
1026
    this.enableFeedback,
1027
    this.constraints,
1028
    this.position = PopupMenuPosition.over,
1029
    this.clipBehavior = Clip.none,
1030
  }) : assert(itemBuilder != null),
1031
       assert(enabled != null),
1032 1033 1034
       assert(
         !(child != null && icon != null),
         'You can only pass [child] or [icon], not both.',
1035
       );
Hans Muller's avatar
Hans Muller committed
1036

1037 1038
  /// Called when the button is pressed to create the items to show in the menu.
  final PopupMenuItemBuilder<T> itemBuilder;
1039

1040
  /// The value of the menu item, if any, that should be highlighted when the menu opens.
1041
  final T? initialValue;
1042

1043 1044 1045
  /// Called when the popup menu is shown.
  final VoidCallback? onOpened;

1046
  /// Called when the user selects a value from the popup menu created by this button.
1047 1048 1049
  ///
  /// If the popup menu is dismissed without selecting a value, [onCanceled] is
  /// called instead.
1050
  final PopupMenuItemSelected<T>? onSelected;
1051

1052 1053 1054
  /// Called when the user dismisses the popup menu without selecting an item.
  ///
  /// If the user selects a value, [onSelected] is called instead.
1055
  final PopupMenuCanceled? onCanceled;
1056

1057 1058 1059 1060
  /// 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.
1061
  final String? tooltip;
1062

1063 1064 1065 1066
  /// 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.
1067
  final double? elevation;
1068

1069 1070 1071
  /// 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.
1072
  final EdgeInsetsGeometry padding;
1073

1074 1075 1076 1077 1078
  /// The splash radius.
  ///
  /// If null, default splash radius of [InkWell] or [IconButton] is used.
  final double? splashRadius;

1079 1080
  /// If provided, [child] is the widget used for this button
  /// and the button will utilize an [InkWell] for taps.
1081
  final Widget? child;
Hans Muller's avatar
Hans Muller committed
1082

1083 1084
  /// If provided, the [icon] is used for this button
  /// and the button will behave like an [IconButton].
1085
  final Widget? icon;
1086

1087 1088
  /// The offset is applied relative to the initial position
  /// set by the [position].
1089
  ///
1090
  /// When not set, the offset defaults to [Offset.zero].
1091 1092
  final Offset offset;

1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106
  /// 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;

1107 1108 1109 1110 1111 1112
  /// 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).
1113
  final ShapeBorder? shape;
1114 1115 1116 1117 1118 1119

  /// 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.
1120
  final Color? color;
1121

1122 1123 1124 1125 1126 1127 1128 1129 1130 1131
  /// 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;

1132 1133
  /// If provided, the size of the [Icon].
  ///
1134 1135 1136
  /// If this property is null, then [IconThemeData.size] is used.
  /// If [IconThemeData.size] is also null, then
  /// default size is 24.0 pixels.
1137 1138
  final double? iconSize;

1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149
  /// 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
1150
  /// recommended by the Material Design guidelines.
1151 1152 1153 1154
  /// Specifying this parameter enables creation of menu wider than
  /// the default maximum width.
  final BoxConstraints? constraints;

1155 1156 1157 1158 1159 1160 1161 1162 1163
  /// 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.
  ///
  /// When not set, 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;

1164 1165 1166 1167 1168 1169 1170
  /// {@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;

1171
  @override
1172
  PopupMenuButtonState<T> createState() => PopupMenuButtonState<T>();
Hans Muller's avatar
Hans Muller committed
1173 1174
}

1175 1176 1177 1178 1179 1180
/// The [State] for a [PopupMenuButton].
///
/// See [showButtonMenu] for a way to programmatically open the popup menu
/// of your button state.
class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> {
  /// A method to show a popup menu with the items supplied to
1181
  /// [PopupMenuButton.itemBuilder] at the position of your [PopupMenuButton].
1182 1183 1184 1185 1186 1187
  ///
  /// By default, it is called when the user taps the button and [PopupMenuButton.enabled]
  /// is set to `true`. Moreover, you can open the button by calling the method manually.
  ///
  /// You would access your [PopupMenuButtonState] using a [GlobalKey] and
  /// show the menu of the button with `globalKey.currentState.showButtonMenu`.
1188
  void showButtonMenu() {
1189
    final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
1190 1191
    final RenderBox button = context.findRenderObject()! as RenderBox;
    final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox;
1192 1193 1194 1195 1196 1197 1198 1199 1200
    final Offset offset;
    switch (widget.position) {
      case PopupMenuPosition.over:
        offset = widget.offset;
        break;
      case PopupMenuPosition.under:
        offset = Offset(0.0, button.size.height - (widget.padding.vertical / 2)) + widget.offset;
        break;
    }
1201 1202
    final RelativeRect position = RelativeRect.fromRect(
      Rect.fromPoints(
1203 1204
        button.localToGlobal(offset, ancestor: overlay),
        button.localToGlobal(button.size.bottomRight(Offset.zero) + offset, ancestor: overlay),
1205 1206 1207
      ),
      Offset.zero & overlay.size,
    );
1208 1209 1210
    final List<PopupMenuEntry<T>> items = widget.itemBuilder(context);
    // Only show the menu if there is something to show
    if (items.isNotEmpty) {
1211
      widget.onOpened?.call();
1212
      showMenu<T?>(
1213
        context: context,
1214
        elevation: widget.elevation ?? popupMenuTheme.elevation,
1215 1216
        items: items,
        initialValue: widget.initialValue,
1217
        position: position,
1218 1219
        shape: widget.shape ?? popupMenuTheme.shape,
        color: widget.color ?? popupMenuTheme.color,
1220
        constraints: widget.constraints,
1221
        clipBehavior: widget.clipBehavior,
1222
      )
1223
      .then<void>((T? newValue) {
1224
        if (!mounted) {
1225
          return null;
1226
        }
1227
        if (newValue == null) {
1228
          widget.onCanceled?.call();
1229 1230
          return null;
        }
1231
        widget.onSelected?.call(newValue);
1232 1233
      });
    }
Hans Muller's avatar
Hans Muller committed
1234 1235
  }

1236
  bool get _canRequestFocus {
1237
    final NavigationMode mode = MediaQuery.maybeOf(context)?.navigationMode ?? NavigationMode.traditional;
1238 1239 1240 1241 1242 1243 1244 1245
    switch (mode) {
      case NavigationMode.traditional:
        return widget.enabled;
      case NavigationMode.directional:
        return true;
    }
  }

1246
  @override
Hans Muller's avatar
Hans Muller committed
1247
  Widget build(BuildContext context) {
1248 1249 1250 1251
    final bool enableFeedback = widget.enableFeedback
      ?? PopupMenuTheme.of(context).enableFeedback
      ?? true;

1252
    assert(debugCheckHasMaterialLocalizations(context));
1253

1254
    if (widget.child != null) {
1255
      return Tooltip(
1256
        message: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip,
1257
        child: InkWell(
1258
          onTap: widget.enabled ? showButtonMenu : null,
1259
          canRequestFocus: _canRequestFocus,
1260
          radius: widget.splashRadius,
1261
          enableFeedback: enableFeedback,
1262
          child: widget.child,
1263 1264
        ),
      );
1265
    }
1266

1267 1268 1269
    return IconButton(
      icon: widget.icon ?? Icon(Icons.adaptive.more),
      padding: widget.padding,
1270
      splashRadius: widget.splashRadius,
1271
      iconSize: widget.iconSize,
1272 1273 1274
      tooltip: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip,
      onPressed: widget.enabled ? showButtonMenu : null,
      enableFeedback: enableFeedback,
1275
    );
Hans Muller's avatar
Hans Muller committed
1276 1277
  }
}
1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297

// 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)';
}