popup_menu.dart 43.9 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
const double _kDefaultIconSize = 24.0;
41

42 43 44 45 46 47 48 49
/// 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,
}

50
/// A base class for entries in a Material Design popup menu.
51 52 53 54 55
///
/// 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
56 57 58 59 60 61
/// 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]).
62 63 64
///
/// See also:
///
Ian Hickson's avatar
Ian Hickson committed
65 66 67 68 69 70
///  * [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.
71
abstract class PopupMenuEntry<T> extends StatefulWidget {
72 73
  /// Abstract const constructor. This constructor enables subclasses to provide
  /// const constructors so that they can be used in const expressions.
74
  const PopupMenuEntry({ super.key });
Hans Muller's avatar
Hans Muller committed
75

76 77
  /// The amount of vertical space occupied by this entry.
  ///
Ian Hickson's avatar
Ian Hickson committed
78 79 80 81
  /// 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
82
  double get height;
83

Ian Hickson's avatar
Ian Hickson committed
84 85 86 87 88 89 90 91 92 93 94 95
  /// 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.
96
  bool represents(T? value);
Hans Muller's avatar
Hans Muller committed
97 98
}

99
/// A horizontal divider in a Material Design popup menu.
100
///
Ian Hickson's avatar
Ian Hickson committed
101
/// This widget adapts the [Divider] for use in popup menus.
102 103 104
///
/// See also:
///
Ian Hickson's avatar
Ian Hickson committed
105 106 107 108
///  * [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.
109
class PopupMenuDivider extends PopupMenuEntry<Never> {
110 111
  /// Creates a horizontal divider for a popup menu.
  ///
Ian Hickson's avatar
Ian Hickson committed
112
  /// By default, the divider has a height of 16 logical pixels.
113
  const PopupMenuDivider({ super.key, this.height = _kMenuDividerHeight });
Hans Muller's avatar
Hans Muller committed
114

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

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

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

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

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

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

  ValueChanged<Size> onLayout;

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

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

183
/// An item in a Material Design popup menu.
184 185 186 187 188 189 190
///
/// 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
191 192
/// Typically the [child] of a [PopupMenuItem] is a [Text] widget. More
/// elaborate menus with icons can use a [ListTile]. By default, a
193
/// [PopupMenuItem] is [kMinInteractiveDimension] pixels high. If you use a widget
194
/// with a different height, it must be specified in the [height] property.
Ian Hickson's avatar
Ian Hickson committed
195
///
196
/// {@tool snippet}
Ian Hickson's avatar
Ian Hickson committed
197
///
198
/// Here, a [Text] widget is used with a popup menu item. The `Menu` type
Ian Hickson's avatar
Ian Hickson committed
199 200 201
/// is an enum, not shown here.
///
/// ```dart
202 203 204
/// const PopupMenuItem<Menu>(
///   value: Menu.itemOne,
///   child: Text('Item 1'),
205
/// )
Ian Hickson's avatar
Ian Hickson committed
206
/// ```
207
/// {@end-tool}
Ian Hickson's avatar
Ian Hickson committed
208 209 210 211 212 213 214
///
/// 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.
///
215 216
/// See also:
///
Ian Hickson's avatar
Ian Hickson committed
217 218 219 220 221
///  * [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
222
class PopupMenuItem<T> extends PopupMenuEntry<T> {
223 224
  /// Creates an item for a popup menu.
  ///
Ian Hickson's avatar
Ian Hickson committed
225 226
  /// By default, the item is [enabled].
  ///
227
  /// The `enabled` and `height` arguments must not be null.
228
  const PopupMenuItem({
229
    super.key,
230
    this.value,
231
    this.onTap,
232
    this.enabled = true,
233
    this.height = kMinInteractiveDimension,
234
    this.padding,
235
    this.textStyle,
236
    this.mouseCursor,
237
    required this.child,
Ian Hickson's avatar
Ian Hickson committed
238
  }) : assert(enabled != null),
239
       assert(height != null);
240

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

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

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

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

259 260 261 262 263 264 265 266 267
  /// 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;

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

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

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

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

301
  @override
302
  PopupMenuItemState<T, PopupMenuItem<T>> createState() => PopupMenuItemState<T, PopupMenuItem<T>>();
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 328
/// 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
329
  Widget? buildChild() => widget.child;
330

331 332 333 334 335 336 337
  /// 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
338
  void handleTap() {
339 340
    widget.onTap?.call();

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

651 652
  // The padding of unsafe area.
  EdgeInsets padding;
653

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

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

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

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

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

Ian Hickson's avatar
Ian Hickson committed
688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707
    // 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;
      }
    }
708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723
    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
724

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

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

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

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

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

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

794
  @override
795
  Duration get transitionDuration => _kMenuDuration;
796 797

  @override
798
  bool get barrierDismissible => true;
799 800

  @override
801
  Color? get barrierColor => null;
802

803 804 805
  @override
  final String barrierLabel;

806
  @override
807
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
808

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

818 819 820 821 822
    final Widget menu = _PopupMenu<T>(
      route: this,
      semanticLabel: semanticLabel,
      constraints: constraints,
    );
823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838
    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,
839
              _avoidBounds(mediaQuery),
840 841 842 843 844
            ),
            child: capturedThemes.wrap(menu),
          );
        },
      ),
Hans Muller's avatar
Hans Muller committed
845
    );
846
  }
847 848 849 850

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

Ian Hickson's avatar
Ian Hickson committed
853
/// Show a popup menu that contains the `items` at `position`.
854
///
855 856
/// `items` should be non-null and not empty.
///
Ian Hickson's avatar
Ian Hickson committed
857 858 859 860
/// 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).
861
///
Ian Hickson's avatar
Ian Hickson committed
862 863 864 865 866 867 868 869 870 871 872 873 874
/// 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
875 876 877 878 879 880 881
///
/// 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
882 883 884 885 886 887 888
/// 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.
889
///
890 891 892 893
/// 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.
///
894 895 896
/// 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
897
/// [MaterialLocalizations.popupMenuLabel].
898
///
Ian Hickson's avatar
Ian Hickson committed
899 900 901 902 903 904 905
/// 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.
906 907
///  * [SemanticsConfiguration.namesRoute], for a description of edge triggered
///    semantics.
908
Future<T?> showMenu<T>({
909 910 911 912 913 914 915 916
  required BuildContext context,
  required RelativeRect position,
  required List<PopupMenuEntry<T>> items,
  T? initialValue,
  double? elevation,
  String? semanticLabel,
  ShapeBorder? shape,
  Color? color,
917
  bool useRootNavigator = false,
918
  BoxConstraints? constraints,
Hans Muller's avatar
Hans Muller committed
919 920
}) {
  assert(context != null);
921
  assert(position != null);
922
  assert(useRootNavigator != null);
923
  assert(items != null && items.isNotEmpty);
924
  assert(debugCheckHasMaterialLocalizations(context));
925

926
  switch (Theme.of(context).platform) {
927
    case TargetPlatform.iOS:
928
    case TargetPlatform.macOS:
929 930 931
      break;
    case TargetPlatform.android:
    case TargetPlatform.fuchsia:
932 933
    case TargetPlatform.linux:
    case TargetPlatform.windows:
934
      semanticLabel ??= MaterialLocalizations.of(context).popupMenuLabel;
935 936
  }

937
  final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator);
938
  return navigator.push(_PopupMenuRoute<T>(
939
    position: position,
Adam Barth's avatar
Adam Barth committed
940
    items: items,
Hans Muller's avatar
Hans Muller committed
941
    initialValue: initialValue,
942
    elevation: elevation,
943 944
    semanticLabel: semanticLabel,
    barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
945 946
    shape: shape,
    color: color,
947
    capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
948
    constraints: constraints,
949 950
  ));
}
Hans Muller's avatar
Hans Muller committed
951

952 953 954 955 956
/// 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].
957
typedef PopupMenuItemSelected<T> = void Function(T value);
Hans Muller's avatar
Hans Muller committed
958

959 960 961 962
/// Signature for the callback invoked when a [PopupMenuButton] is dismissed
/// without selecting an item.
///
/// Used by [PopupMenuButton.onCanceled].
963
typedef PopupMenuCanceled = void Function();
964

965 966 967 968
/// Signature used by [PopupMenuButton] to lazily construct the items shown when
/// the button is pressed.
///
/// Used by [PopupMenuButton.itemBuilder].
969
typedef PopupMenuItemBuilder<T> = List<PopupMenuEntry<T>> Function(BuildContext context);
970

Hans Muller's avatar
Hans Muller committed
971 972
/// 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
973 974 975 976 977 978 979
/// 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
980
///
981
/// {@tool dartpad}
Ian Hickson's avatar
Ian Hickson committed
982
/// This example shows a menu with four items, selecting between an enum's
983
/// values and setting a `_selectedMenu` field based on the selection
Ian Hickson's avatar
Ian Hickson committed
984
///
985
/// ** See code in examples/api/lib/material/popupmenu/popupmenu.0.dart **
986
/// {@end-tool}
Ian Hickson's avatar
Ian Hickson committed
987 988 989 990 991 992 993
///
/// 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.
994
class PopupMenuButton<T> extends StatefulWidget {
995 996 997
  /// Creates a button that shows a popup menu.
  ///
  /// The [itemBuilder] argument must not be null.
998
  const PopupMenuButton({
999
    super.key,
1000
    required this.itemBuilder,
Hans Muller's avatar
Hans Muller committed
1001 1002
    this.initialValue,
    this.onSelected,
1003
    this.onCanceled,
1004
    this.tooltip,
1005
    this.elevation,
1006
    this.padding = const EdgeInsets.all(8.0),
1007
    this.child,
1008
    this.splashRadius,
1009
    this.icon,
1010
    this.iconSize,
1011
    this.offset = Offset.zero,
1012
    this.enabled = true,
1013 1014
    this.shape,
    this.color,
1015
    this.enableFeedback,
1016
    this.constraints,
1017
    this.position = PopupMenuPosition.over,
1018
  }) : assert(itemBuilder != null),
1019
       assert(enabled != null),
1020 1021 1022
       assert(
         !(child != null && icon != null),
         'You can only pass [child] or [icon], not both.',
1023
       );
Hans Muller's avatar
Hans Muller committed
1024

1025 1026
  /// Called when the button is pressed to create the items to show in the menu.
  final PopupMenuItemBuilder<T> itemBuilder;
1027

1028
  /// The value of the menu item, if any, that should be highlighted when the menu opens.
1029
  final T? initialValue;
1030

1031
  /// Called when the user selects a value from the popup menu created by this button.
1032 1033 1034
  ///
  /// If the popup menu is dismissed without selecting a value, [onCanceled] is
  /// called instead.
1035
  final PopupMenuItemSelected<T>? onSelected;
1036

1037 1038 1039
  /// Called when the user dismisses the popup menu without selecting an item.
  ///
  /// If the user selects a value, [onSelected] is called instead.
1040
  final PopupMenuCanceled? onCanceled;
1041

1042 1043 1044 1045
  /// 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.
1046
  final String? tooltip;
1047

1048 1049 1050 1051
  /// 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.
1052
  final double? elevation;
1053

1054 1055 1056
  /// 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.
1057
  final EdgeInsetsGeometry padding;
1058

1059 1060 1061 1062 1063
  /// The splash radius.
  ///
  /// If null, default splash radius of [InkWell] or [IconButton] is used.
  final double? splashRadius;

1064 1065
  /// If provided, [child] is the widget used for this button
  /// and the button will utilize an [InkWell] for taps.
1066
  final Widget? child;
Hans Muller's avatar
Hans Muller committed
1067

1068 1069
  /// If provided, the [icon] is used for this button
  /// and the button will behave like an [IconButton].
1070
  final Widget? icon;
1071

1072 1073
  /// The offset is applied relative to the initial position
  /// set by the [position].
1074
  ///
1075
  /// When not set, the offset defaults to [Offset.zero].
1076 1077
  final Offset offset;

1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091
  /// 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;

1092 1093 1094 1095 1096 1097
  /// 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).
1098
  final ShapeBorder? shape;
1099 1100 1101 1102 1103 1104

  /// 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.
1105
  final Color? color;
1106

1107 1108 1109 1110 1111 1112 1113 1114 1115 1116
  /// 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;

1117 1118
  /// If provided, the size of the [Icon].
  ///
1119 1120 1121
  /// If this property is null, then [IconThemeData.size] is used.
  /// If [IconThemeData.size] is also null, then
  /// default size is 24.0 pixels.
1122 1123
  final double? iconSize;

1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134
  /// 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
1135
  /// recommended by the Material Design guidelines.
1136 1137 1138 1139
  /// Specifying this parameter enables creation of menu wider than
  /// the default maximum width.
  final BoxConstraints? constraints;

1140 1141 1142 1143 1144 1145 1146 1147 1148
  /// 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;

1149
  @override
1150
  PopupMenuButtonState<T> createState() => PopupMenuButtonState<T>();
Hans Muller's avatar
Hans Muller committed
1151 1152
}

1153 1154 1155 1156 1157 1158
/// 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
1159
  /// [PopupMenuButton.itemBuilder] at the position of your [PopupMenuButton].
1160 1161 1162 1163 1164 1165
  ///
  /// 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`.
1166
  void showButtonMenu() {
1167
    final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
1168 1169
    final RenderBox button = context.findRenderObject()! as RenderBox;
    final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox;
1170 1171 1172 1173 1174 1175 1176 1177 1178
    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;
    }
1179 1180
    final RelativeRect position = RelativeRect.fromRect(
      Rect.fromPoints(
1181 1182
        button.localToGlobal(offset, ancestor: overlay),
        button.localToGlobal(button.size.bottomRight(Offset.zero) + offset, ancestor: overlay),
1183 1184 1185
      ),
      Offset.zero & overlay.size,
    );
1186 1187 1188
    final List<PopupMenuEntry<T>> items = widget.itemBuilder(context);
    // Only show the menu if there is something to show
    if (items.isNotEmpty) {
1189
      showMenu<T?>(
1190
        context: context,
1191
        elevation: widget.elevation ?? popupMenuTheme.elevation,
1192 1193
        items: items,
        initialValue: widget.initialValue,
1194
        position: position,
1195 1196
        shape: widget.shape ?? popupMenuTheme.shape,
        color: widget.color ?? popupMenuTheme.color,
1197
        constraints: widget.constraints,
1198
      )
1199
      .then<void>((T? newValue) {
1200
        if (!mounted) {
1201
          return null;
1202
        }
1203
        if (newValue == null) {
1204
          widget.onCanceled?.call();
1205 1206
          return null;
        }
1207
        widget.onSelected?.call(newValue);
1208 1209
      });
    }
Hans Muller's avatar
Hans Muller committed
1210 1211
  }

1212
  bool get _canRequestFocus {
1213
    final NavigationMode mode = MediaQuery.maybeOf(context)?.navigationMode ?? NavigationMode.traditional;
1214 1215 1216 1217 1218 1219 1220 1221
    switch (mode) {
      case NavigationMode.traditional:
        return widget.enabled;
      case NavigationMode.directional:
        return true;
    }
  }

1222
  @override
Hans Muller's avatar
Hans Muller committed
1223
  Widget build(BuildContext context) {
1224
    final IconThemeData iconTheme = IconTheme.of(context);
1225 1226 1227 1228
    final bool enableFeedback = widget.enableFeedback
      ?? PopupMenuTheme.of(context).enableFeedback
      ?? true;

1229
    assert(debugCheckHasMaterialLocalizations(context));
1230

1231
    if (widget.child != null) {
1232
      return Tooltip(
1233
        message: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip,
1234
        child: InkWell(
1235
          onTap: widget.enabled ? showButtonMenu : null,
1236
          canRequestFocus: _canRequestFocus,
1237
          radius: widget.splashRadius,
1238
          enableFeedback: enableFeedback,
1239
          child: widget.child,
1240 1241
        ),
      );
1242
    }
1243

1244 1245 1246
    return IconButton(
      icon: widget.icon ?? Icon(Icons.adaptive.more),
      padding: widget.padding,
1247
      splashRadius: widget.splashRadius,
1248
      iconSize: widget.iconSize ?? iconTheme.size ?? _kDefaultIconSize,
1249 1250 1251
      tooltip: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip,
      onPressed: widget.enabled ? showButtonMenu : null,
      enableFeedback: enableFeedback,
1252
    );
Hans Muller's avatar
Hans Muller committed
1253 1254
  }
}
1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274

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