popup_menu.dart 40.4 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 25
// Examples can assume:
// enum Commands { heroAndScholar, hurricaneCame }
// dynamic _heroAndScholar;
26 27 28
// dynamic _selection;
// BuildContext context;
// void setState(VoidCallback fn) { }
29

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

40 41 42 43 44 45
/// A base class for entries in a material design popup menu.
///
/// The popup menu widget uses this interface to interact with the menu items.
/// To show a popup menu, use the [showMenu] function. To create a button that
/// shows a popup menu, consider using [PopupMenuButton].
///
Ian Hickson's avatar
Ian Hickson committed
46 47 48 49 50 51
/// 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]).
52 53 54
///
/// See also:
///
Ian Hickson's avatar
Ian Hickson committed
55 56 57 58 59 60
///  * [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.
61
abstract class PopupMenuEntry<T> extends StatefulWidget {
62 63
  /// Abstract const constructor. This constructor enables subclasses to provide
  /// const constructors so that they can be used in const expressions.
64
  const PopupMenuEntry({ Key? key }) : super(key: key);
Hans Muller's avatar
Hans Muller committed
65

66 67
  /// The amount of vertical space occupied by this entry.
  ///
Ian Hickson's avatar
Ian Hickson committed
68 69 70 71
  /// 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
72
  double get height;
73

Ian Hickson's avatar
Ian Hickson committed
74 75 76 77 78 79 80 81 82 83 84 85
  /// 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.
86
  bool represents(T? value);
Hans Muller's avatar
Hans Muller committed
87 88
}

89 90
/// A horizontal divider in a material design popup menu.
///
Ian Hickson's avatar
Ian Hickson committed
91
/// This widget adapts the [Divider] for use in popup menus.
92 93 94
///
/// See also:
///
Ian Hickson's avatar
Ian Hickson committed
95 96 97 98
///  * [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.
99
class PopupMenuDivider extends PopupMenuEntry<Never> {
100 101
  /// Creates a horizontal divider for a popup menu.
  ///
Ian Hickson's avatar
Ian Hickson committed
102
  /// By default, the divider has a height of 16 logical pixels.
103
  const PopupMenuDivider({ Key? key, this.height = _kMenuDividerHeight }) : super(key: key);
Hans Muller's avatar
Hans Muller committed
104

Ian Hickson's avatar
Ian Hickson committed
105 106 107
  /// The height of the divider entry.
  ///
  /// Defaults to 16 pixels.
108
  @override
Hans Muller's avatar
Hans Muller committed
109 110
  final double height;

111
  @override
112
  bool represents(void value) => false;
113

114
  @override
115
  _PopupMenuDividerState createState() => _PopupMenuDividerState();
116 117 118
}

class _PopupMenuDividerState extends State<PopupMenuDivider> {
119
  @override
120
  Widget build(BuildContext context) => Divider(height: widget.height);
Hans Muller's avatar
Hans Muller committed
121 122
}

123 124 125 126 127 128
// 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({
129 130 131
    Key? key,
    required this.onLayout,
    required Widget? child,
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147
  }) : assert(onLayout != null), super(key: key, child: child);

  final ValueChanged<Size> onLayout;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return _RenderMenuItem(onLayout);
  }

  @override
  void updateRenderObject(BuildContext context, covariant _RenderMenuItem renderObject) {
    renderObject.onLayout = onLayout;
  }
}

class _RenderMenuItem extends RenderShiftedBox {
148
  _RenderMenuItem(this.onLayout, [RenderBox? child]) : assert(onLayout != null), super(child);
149 150 151 152 153 154 155 156

  ValueChanged<Size> onLayout;

  @override
  void performLayout() {
    if (child == null) {
      size = Size.zero;
    } else {
157 158 159 160
      child!.layout(constraints, parentUsesSize: true);
      size = constraints.constrain(child!.size);
      final BoxParentData childParentData = child!.parentData! as BoxParentData;
      childParentData.offset = Offset.zero;
161 162 163 164 165
    }
    onLayout(size);
  }
}

166 167 168 169 170 171 172 173
/// An item in a material design popup menu.
///
/// 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
174 175
/// Typically the [child] of a [PopupMenuItem] is a [Text] widget. More
/// elaborate menus with icons can use a [ListTile]. By default, a
176
/// [PopupMenuItem] is [kMinInteractiveDimension] pixels high. If you use a widget
177
/// with a different height, it must be specified in the [height] property.
Ian Hickson's avatar
Ian Hickson committed
178
///
179
/// {@tool snippet}
Ian Hickson's avatar
Ian Hickson committed
180 181 182 183 184 185 186
///
/// Here, a [Text] widget is used with a popup menu item. The `WhyFarther` type
/// is an enum, not shown here.
///
/// ```dart
/// const PopupMenuItem<WhyFarther>(
///   value: WhyFarther.harder,
187
///   child: Text('Working a lot harder'),
188
/// )
Ian Hickson's avatar
Ian Hickson committed
189
/// ```
190
/// {@end-tool}
Ian Hickson's avatar
Ian Hickson committed
191 192 193 194 195 196 197
///
/// 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.
///
198 199
/// See also:
///
Ian Hickson's avatar
Ian Hickson committed
200 201 202 203 204
///  * [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
205
class PopupMenuItem<T> extends PopupMenuEntry<T> {
206 207
  /// Creates an item for a popup menu.
  ///
Ian Hickson's avatar
Ian Hickson committed
208 209
  /// By default, the item is [enabled].
  ///
210
  /// The `enabled` and `height` arguments must not be null.
211
  const PopupMenuItem({
212
    Key? key,
213
    this.value,
214
    this.enabled = true,
215
    this.height = kMinInteractiveDimension,
216
    this.textStyle,
217
    this.mouseCursor,
218
    required this.child,
Ian Hickson's avatar
Ian Hickson committed
219 220 221
  }) : assert(enabled != null),
       assert(height != null),
       super(key: key);
222

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

226
  /// Whether the user is permitted to select this item.
Ian Hickson's avatar
Ian Hickson committed
227 228 229
  ///
  /// Defaults to true. If this is false, then the item will not react to
  /// touches.
230
  final bool enabled;
231

giga10's avatar
giga10 committed
232
  /// The minimum height of the menu item.
Ian Hickson's avatar
Ian Hickson committed
233
  ///
234
  /// Defaults to [kMinInteractiveDimension] pixels.
Ian Hickson's avatar
Ian Hickson committed
235 236 237
  @override
  final double height;

238
  /// The text style of the popup menu item.
239 240
  ///
  /// If this property is null, then [PopupMenuThemeData.textStyle] is used.
241 242
  /// If [PopupMenuThemeData.textStyle] is also null, then [TextTheme.subtitle1]
  /// of [ThemeData.textTheme] is used.
243
  final TextStyle? textStyle;
244

245 246 247 248 249 250 251 252 253
  /// The cursor for a mouse pointer when it enters or is hovering over the
  /// widget.
  ///
  /// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>],
  /// [MaterialStateProperty.resolve] is used for the following [MaterialState]:
  ///
  ///  * [MaterialState.disabled].
  ///
  /// If this property is null, [MaterialStateMouseCursor.clickable] will be used.
254
  final MouseCursor? mouseCursor;
255

256
  /// The widget below this widget in the tree.
Ian Hickson's avatar
Ian Hickson committed
257 258 259 260
  ///
  /// 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.
261
  final Widget? child;
262

263
  @override
264
  bool represents(T? value) => value == this.value;
Hans Muller's avatar
Hans Muller committed
265

266
  @override
267
  PopupMenuItemState<T, PopupMenuItem<T>> createState() => PopupMenuItemState<T, PopupMenuItem<T>>();
268 269
}

270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293
/// 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
294
  Widget? buildChild() => widget.child;
295

296 297 298 299 300 301 302
  /// 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
303
  void handleTap() {
304
    Navigator.pop<T>(context, widget.value);
305 306
  }

307
  @override
308
  Widget build(BuildContext context) {
309
    final ThemeData theme = Theme.of(context)!;
310
    final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
311
    TextStyle style = widget.textStyle ?? popupMenuTheme.textStyle ?? theme.textTheme.subtitle1!;
312

313
    if (!widget.enabled)
Hans Muller's avatar
Hans Muller committed
314 315
      style = style.copyWith(color: theme.disabledColor);

316
    Widget item = AnimatedDefaultTextStyle(
317
      style: style,
318
      duration: kThemeChangeDuration,
319 320 321 322
      child: Container(
        alignment: AlignmentDirectional.centerStart,
        constraints: BoxConstraints(minHeight: widget.height),
        padding: const EdgeInsets.symmetric(horizontal: _kMenuHorizontalPadding),
Ian Hickson's avatar
Ian Hickson committed
323
        child: buildChild(),
324
      ),
325
    );
326

327
    if (!widget.enabled) {
328
      final bool isDark = theme.brightness == Brightness.dark;
329
      item = IconTheme.merge(
330
        data: IconThemeData(opacity: isDark ? 0.5 : 0.38),
Ian Hickson's avatar
Ian Hickson committed
331
        child: item,
332 333
      );
    }
334 335 336 337 338 339
    final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>(
      widget.mouseCursor ?? MaterialStateMouseCursor.clickable,
      <MaterialState>{
        if (!widget.enabled) MaterialState.disabled,
      },
    );
340

341 342 343 344 345 346 347 348 349 350 351
    return MergeSemantics(
      child: Semantics(
        enabled: widget.enabled,
        button: true,
        child: InkWell(
          onTap: widget.enabled ? handleTap : null,
          canRequestFocus: widget.enabled,
          mouseCursor: effectiveMouseCursor,
          child: item,
        ),
      )
352 353 354
    );
  }
}
355

356 357 358 359 360
/// An item with a checkmark in a material design popup menu.
///
/// To show a popup menu, use the [showMenu] function. To create a button that
/// shows a popup menu, consider using [PopupMenuButton].
///
361
/// A [CheckedPopupMenuItem] is kMinInteractiveDimension pixels high, which
362 363
/// matches the default minimum height of a [PopupMenuItem]. The horizontal
/// layout uses [ListTile]; the checkmark is an [Icons.done] icon, shown in the
364
/// [ListTile.leading] position.
Ian Hickson's avatar
Ian Hickson committed
365
///
366
/// {@tool snippet}
Ian Hickson's avatar
Ian Hickson committed
367 368 369 370 371 372 373 374 375 376
///
/// 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
377
/// PopupMenuButton<Commands>(
Ian Hickson's avatar
Ian Hickson committed
378 379 380 381 382 383 384 385 386 387 388 389
///   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>>[
390
///     CheckedPopupMenuItem<Commands>(
Ian Hickson's avatar
Ian Hickson committed
391 392 393 394 395 396 397
///       checked: _heroAndScholar,
///       value: Commands.heroAndScholar,
///       child: const Text('Hero and scholar'),
///     ),
///     const PopupMenuDivider(),
///     const PopupMenuItem<Commands>(
///       value: Commands.hurricaneCame,
398
///       child: ListTile(leading: Icon(null), title: Text('Bring hurricane')),
Ian Hickson's avatar
Ian Hickson committed
399 400 401 402 403
///     ),
///     // ...other items listed here
///   ],
/// )
/// ```
404
/// {@end-tool}
Ian Hickson's avatar
Ian Hickson committed
405 406 407 408 409
///
/// 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.
///
410 411
/// See also:
///
Ian Hickson's avatar
Ian Hickson committed
412 413 414 415 416 417
///  * [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.
418
class CheckedPopupMenuItem<T> extends PopupMenuItem<T> {
419 420
  /// Creates a popup menu item with a checkmark.
  ///
Ian Hickson's avatar
Ian Hickson committed
421 422 423 424
  /// 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.
425
  const CheckedPopupMenuItem({
426 427
    Key? key,
    T? value,
428 429
    this.checked = false,
    bool enabled = true,
430
    Widget? child,
Ian Hickson's avatar
Ian Hickson committed
431 432
  }) : assert(checked != null),
       super(
433 434
    key: key,
    value: value,
435
    enabled: enabled,
Ian Hickson's avatar
Ian Hickson committed
436
    child: child,
437
  );
438

439
  /// Whether to display a checkmark next to the menu item.
Ian Hickson's avatar
Ian Hickson committed
440 441 442 443 444 445 446
  ///
  /// 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.
447 448
  final bool checked;

Ian Hickson's avatar
Ian Hickson committed
449 450 451 452 453 454 455 456
  /// 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
457
  Widget? get child => super.child;
Ian Hickson's avatar
Ian Hickson committed
458

459
  @override
460
  _CheckedPopupMenuItemState<T> createState() => _CheckedPopupMenuItemState<T>();
461 462
}

463
class _CheckedPopupMenuItemState<T> extends PopupMenuItemState<T, CheckedPopupMenuItem<T>> with SingleTickerProviderStateMixin {
464
  static const Duration _fadeDuration = Duration(milliseconds: 150);
465
  late AnimationController _controller;
466 467
  Animation<double> get _opacity => _controller.view;

468
  @override
469 470
  void initState() {
    super.initState();
471
    _controller = AnimationController(duration: _fadeDuration, vsync: this)
472
      ..value = widget.checked ? 1.0 : 0.0
473 474 475
      ..addListener(() => setState(() { /* animation changed */ }));
  }

476
  @override
Ian Hickson's avatar
Ian Hickson committed
477
  void handleTap() {
478
    // This fades the checkmark in or out when tapped.
479
    if (widget.checked)
480 481 482
      _controller.reverse();
    else
      _controller.forward();
Ian Hickson's avatar
Ian Hickson committed
483
    super.handleTap();
484 485
  }

486
  @override
487
  Widget buildChild() {
488
    return ListTile(
489
      enabled: widget.enabled,
490
      leading: FadeTransition(
491
        opacity: _opacity,
492
        child: Icon(_controller.isDismissed ? null : Icons.done),
493
      ),
Ian Hickson's avatar
Ian Hickson committed
494
      title: widget.child,
495 496
    );
  }
497 498
}

499
class _PopupMenu<T> extends StatelessWidget {
500
  const _PopupMenu({
501 502 503
    Key? key,
    required this.route,
    required this.semanticLabel,
Adam Barth's avatar
Adam Barth committed
504
  }) : super(key: key);
505

Hixie's avatar
Hixie committed
506
  final _PopupMenuRoute<T> route;
507
  final String? semanticLabel;
508

509
  @override
510
  Widget build(BuildContext context) {
511 512
    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>[];
513
    final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
514

Ian Hickson's avatar
Ian Hickson committed
515
    for (int i = 0; i < route.items.length; i += 1) {
Hans Muller's avatar
Hans Muller committed
516
      final double start = (i + 1) * unit;
517
      final double end = (start + 1.5 * unit).clamp(0.0, 1.0);
518
      final CurvedAnimation opacity = CurvedAnimation(
519
        parent: route.animation!,
520
        curve: Interval(start, end),
521
      );
Hans Muller's avatar
Hans Muller committed
522
      Widget item = route.items[i];
Ian Hickson's avatar
Ian Hickson committed
523
      if (route.initialValue != null && route.items[i].represents(route.initialValue)) {
524
        item = Container(
525
          color: Theme.of(context)!.highlightColor,
Ian Hickson's avatar
Ian Hickson committed
526
          child: item,
Hans Muller's avatar
Hans Muller committed
527 528
        );
      }
529 530 531 532 533 534 535 536 537 538 539
      children.add(
        _MenuItem(
          onLayout: (Size size) {
            route.itemSizes[i] = size;
          },
          child: FadeTransition(
            opacity: opacity,
            child: item,
          ),
        ),
      );
540
    }
541

542 543 544
    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));
545

546
    final Widget child = ConstrainedBox(
547
      constraints: const BoxConstraints(
548
        minWidth: _kMenuMinWidth,
549
        maxWidth: _kMenuMaxWidth,
550
      ),
551
      child: IntrinsicWidth(
552
        stepWidth: _kMenuWidthStep,
553
        child: Semantics(
554 555 556 557
          scopesRoute: true,
          namesRoute: true,
          explicitChildNodes: true,
          label: semanticLabel,
558
          child: SingleChildScrollView(
559 560 561
            padding: const EdgeInsets.symmetric(
              vertical: _kMenuVerticalPadding
            ),
562
            child: ListBody(children: children),
563
          ),
564 565
        ),
      ),
566
    );
Adam Barth's avatar
Adam Barth committed
567

568
    return AnimatedBuilder(
569 570
      animation: route.animation!,
      builder: (BuildContext context, Widget? child) {
571
        return Opacity(
572
          opacity: opacity.evaluate(route.animation!),
573
          child: Material(
574 575
            shape: route.shape ?? popupMenuTheme.shape,
            color: route.color ?? popupMenuTheme.color,
576
            type: MaterialType.card,
577
            elevation: route.elevation ?? popupMenuTheme.elevation ?? 8.0,
578
            child: Align(
Ian Hickson's avatar
Ian Hickson committed
579
              alignment: AlignmentDirectional.topEnd,
580 581
              widthFactor: width.evaluate(route.animation!),
              heightFactor: height.evaluate(route.animation!),
582
              child: child,
583 584
            ),
          ),
585
        );
586
      },
Ian Hickson's avatar
Ian Hickson committed
587
      child: child,
588 589 590
    );
  }
}
591

Ian Hickson's avatar
Ian Hickson committed
592
// Positioning of the menu on the screen.
593
class _PopupMenuRouteLayout extends SingleChildLayoutDelegate {
594
  _PopupMenuRouteLayout(this.position, this.itemSizes, this.selectedItemIndex, this.textDirection);
Hans Muller's avatar
Hans Muller committed
595

Ian Hickson's avatar
Ian Hickson committed
596
  // Rectangle of underlying button, relative to the overlay's dimensions.
597
  final RelativeRect position;
Ian Hickson's avatar
Ian Hickson committed
598

599 600
  // The sizes of each item are computed when the menu is laid out, and before
  // the route is laid out.
601
  List<Size?> itemSizes;
602 603 604

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

Ian Hickson's avatar
Ian Hickson committed
607 608 609 610 611 612 613
  // Whether to prefer going to the left or to the right.
  final TextDirection textDirection;

  // We put the child wherever position specifies, so long as it will fit within
  // the specified parent size padded (inset) by 8. If necessary, we adjust the
  // child's position so that it fits.

614
  @override
Hans Muller's avatar
Hans Muller committed
615
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
Ian Hickson's avatar
Ian Hickson committed
616 617
    // The menu can be at most the size of the overlay minus 8.0 pixels in each
    // direction.
618 619 620
    return BoxConstraints.loose(
      constraints.biggest - const Offset(_kMenuScreenPadding * 2.0, _kMenuScreenPadding * 2.0) as Size,
    );
Hans Muller's avatar
Hans Muller committed
621 622
  }

623
  @override
Hans Muller's avatar
Hans Muller committed
624
  Offset getPositionForChild(Size size, Size childSize) {
Ian Hickson's avatar
Ian Hickson committed
625 626 627 628 629
    // size: The size of the overlay.
    // childSize: The size of the menu, when fully open, as determined by
    // getConstraintsForChild.

    // Find the ideal vertical position.
630 631 632
    double y = position.top;
    if (selectedItemIndex != null && itemSizes != null) {
      double selectedItemOffset = _kMenuVerticalPadding;
633 634 635
      for (int index = 0; index < selectedItemIndex!; index += 1)
        selectedItemOffset += itemSizes[index]!.height;
      selectedItemOffset += itemSizes[selectedItemIndex!]!.height / 2;
Ian Hickson's avatar
Ian Hickson committed
636 637
      y = position.top + (size.height - position.top - position.bottom) / 2.0 - selectedItemOffset;
    }
Hans Muller's avatar
Hans Muller committed
638

Ian Hickson's avatar
Ian Hickson committed
639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658
    // 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;
      }
    }
Hans Muller's avatar
Hans Muller committed
659

Ian Hickson's avatar
Ian Hickson committed
660 661
    // Avoid going outside an area defined as the rectangle 8.0 pixels from the
    // edge of the screen in every direction.
Hans Muller's avatar
Hans Muller committed
662 663
    if (x < _kMenuScreenPadding)
      x = _kMenuScreenPadding;
Ian Hickson's avatar
Ian Hickson committed
664
    else if (x + childSize.width > size.width - _kMenuScreenPadding)
Hans Muller's avatar
Hans Muller committed
665 666 667
      x = size.width - childSize.width - _kMenuScreenPadding;
    if (y < _kMenuScreenPadding)
      y = _kMenuScreenPadding;
Ian Hickson's avatar
Ian Hickson committed
668
    else if (y + childSize.height > size.height - _kMenuScreenPadding)
Hans Muller's avatar
Hans Muller committed
669
      y = size.height - childSize.height - _kMenuScreenPadding;
670
    return Offset(x, y);
Hans Muller's avatar
Hans Muller committed
671 672
  }

673
  @override
Hans Muller's avatar
Hans Muller committed
674
  bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) {
675 676 677 678 679 680 681 682 683
    // If called when the old and new itemSizes have been initialized then
    // we expect them to have the same length because there's no practical
    // way to change length of the items list once the menu has been shown.
    assert(itemSizes.length == oldDelegate.itemSizes.length);

    return position != oldDelegate.position
        || selectedItemIndex != oldDelegate.selectedItemIndex
        || textDirection != oldDelegate.textDirection
        || !listEquals(itemSizes, oldDelegate.itemSizes);
Hans Muller's avatar
Hans Muller committed
684 685 686
  }
}

Hixie's avatar
Hixie committed
687 688
class _PopupMenuRoute<T> extends PopupRoute<T> {
  _PopupMenuRoute({
689 690
    required this.position,
    required this.items,
Hans Muller's avatar
Hans Muller committed
691
    this.initialValue,
692
    this.elevation,
693
    this.theme,
694 695
    required this.popupMenuTheme,
    required this.barrierLabel,
696
    this.semanticLabel,
697 698
    this.shape,
    this.color,
699 700 701
    required this.showMenuContext,
    required this.captureInheritedThemes,
  }) : itemSizes = List<Size?>.filled(items.length, null);
702

703
  final RelativeRect position;
Hans Muller's avatar
Hans Muller committed
704
  final List<PopupMenuEntry<T>> items;
705 706 707 708 709 710 711
  final List<Size?> itemSizes;
  final T? initialValue;
  final double? elevation;
  final ThemeData? theme;
  final String? semanticLabel;
  final ShapeBorder? shape;
  final Color? color;
712
  final PopupMenuThemeData popupMenuTheme;
713 714
  final BuildContext showMenuContext;
  final bool captureInheritedThemes;
715

716
  @override
717
  Animation<double> createAnimation() {
718
    return CurvedAnimation(
719
      parent: super.createAnimation(),
720
      curve: Curves.linear,
721
      reverseCurve: const Interval(0.0, _kMenuCloseIntervalEnd),
722
    );
723 724
  }

725
  @override
726
  Duration get transitionDuration => _kMenuDuration;
727 728

  @override
729
  bool get barrierDismissible => true;
730 731

  @override
732
  Color? get barrierColor => null;
733

734 735 736
  @override
  final String barrierLabel;

737
  @override
738
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
739

740
    int? selectedItemIndex;
Hans Muller's avatar
Hans Muller committed
741
    if (initialValue != null) {
742 743 744
      for (int index = 0; selectedItemIndex == null && index < items.length; index += 1) {
        if (items[index].represents(initialValue))
          selectedItemIndex = index;
Hans Muller's avatar
Hans Muller committed
745
      }
Hans Muller's avatar
Hans Muller committed
746
    }
747

748
    Widget menu = _PopupMenu<T>(route: this, semanticLabel: semanticLabel);
749 750 751 752 753
    if (captureInheritedThemes) {
      menu = InheritedTheme.captureAll(showMenuContext, menu);
    } else {
      // For the sake of backwards compatibility. An (unlikely) app that relied
      // on having menus only inherit from the material Theme could set
754
      // captureInheritedThemes to false and get the original behavior.
755
      if (theme != null)
756
        menu = Theme(data: theme!, child: menu);
757
    }
758

759
    return SafeArea(
760
      child: Builder(
761
        builder: (BuildContext context) {
762 763
          return CustomSingleChildLayout(
            delegate: _PopupMenuRouteLayout(
764
              position,
765 766
              itemSizes,
              selectedItemIndex,
767
              Directionality.of(context)!,
768 769 770 771 772
            ),
            child: menu,
          );
        },
      ),
Hans Muller's avatar
Hans Muller committed
773
    );
774
  }
775 776
}

Ian Hickson's avatar
Ian Hickson committed
777
/// Show a popup menu that contains the `items` at `position`.
778
///
779 780
/// `items` should be non-null and not empty.
///
Ian Hickson's avatar
Ian Hickson committed
781 782 783 784
/// 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).
785
///
Ian Hickson's avatar
Ian Hickson committed
786 787 788 789 790 791 792 793 794 795 796 797 798
/// 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
799 800 801 802 803 804 805
///
/// 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
806 807 808 809 810 811 812
/// 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.
813
///
814 815 816 817
/// 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.
///
818 819 820
/// 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
821
/// [MaterialLocalizations.popupMenuLabel].
822
///
Ian Hickson's avatar
Ian Hickson committed
823 824 825 826 827 828 829
/// 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.
830 831
///  * [SemanticsConfiguration.namesRoute], for a description of edge triggered
///    semantics.
832
Future<T> showMenu<T>({
833 834 835 836 837 838 839 840
  required BuildContext context,
  required RelativeRect position,
  required List<PopupMenuEntry<T>> items,
  T? initialValue,
  double? elevation,
  String? semanticLabel,
  ShapeBorder? shape,
  Color? color,
841
  bool captureInheritedThemes = true,
842
  bool useRootNavigator = false,
Hans Muller's avatar
Hans Muller committed
843 844
}) {
  assert(context != null);
845
  assert(position != null);
846
  assert(useRootNavigator != null);
847
  assert(items != null && items.isNotEmpty);
848
  assert(captureInheritedThemes != null);
849
  assert(debugCheckHasMaterialLocalizations(context));
850

851 852
  String? label;
  switch (Theme.of(context)!.platform) {
853
    case TargetPlatform.iOS:
854
    case TargetPlatform.macOS:
855 856 857 858
      label = semanticLabel;
      break;
    case TargetPlatform.android:
    case TargetPlatform.fuchsia:
859 860
    case TargetPlatform.linux:
    case TargetPlatform.windows:
861 862 863
      label = semanticLabel ?? MaterialLocalizations.of(context)?.popupMenuLabel;
  }

864
  return Navigator.of(context, rootNavigator: useRootNavigator)!.push(_PopupMenuRoute<T>(
865
    position: position,
Adam Barth's avatar
Adam Barth committed
866
    items: items,
Hans Muller's avatar
Hans Muller committed
867
    initialValue: initialValue,
868
    elevation: elevation,
869
    semanticLabel: label,
870
    theme: Theme.of(context, shadowThemeOnly: true),
871
    popupMenuTheme: PopupMenuTheme.of(context),
872
    barrierLabel: MaterialLocalizations.of(context)!.modalBarrierDismissLabel,
873 874
    shape: shape,
    color: color,
875 876
    showMenuContext: context,
    captureInheritedThemes: captureInheritedThemes,
877 878
  ));
}
Hans Muller's avatar
Hans Muller committed
879

880 881 882 883 884
/// 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].
885
typedef PopupMenuItemSelected<T> = void Function(T value);
Hans Muller's avatar
Hans Muller committed
886

887 888 889 890
/// Signature for the callback invoked when a [PopupMenuButton] is dismissed
/// without selecting an item.
///
/// Used by [PopupMenuButton.onCanceled].
891
typedef PopupMenuCanceled = void Function();
892

893 894 895 896
/// Signature used by [PopupMenuButton] to lazily construct the items shown when
/// the button is pressed.
///
/// Used by [PopupMenuButton.itemBuilder].
897
typedef PopupMenuItemBuilder<T> = List<PopupMenuEntry<T>> Function(BuildContext context);
898

Hans Muller's avatar
Hans Muller committed
899 900
/// 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
901 902 903 904 905 906 907
/// 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
908
///
909
/// {@tool snippet}
910
///
Ian Hickson's avatar
Ian Hickson committed
911 912 913 914 915 916 917 918 919
/// This example shows a menu with four items, selecting between an enum's
/// values and setting a `_selection` field based on the selection.
///
/// ```dart
/// // This is the type used by the popup menu below.
/// enum WhyFarther { harder, smarter, selfStarter, tradingCharter }
///
/// // This menu button widget updates a _selection field (of type WhyFarther,
/// // not shown here).
920
/// PopupMenuButton<WhyFarther>(
Ian Hickson's avatar
Ian Hickson committed
921 922 923 924
///   onSelected: (WhyFarther result) { setState(() { _selection = result; }); },
///   itemBuilder: (BuildContext context) => <PopupMenuEntry<WhyFarther>>[
///     const PopupMenuItem<WhyFarther>(
///       value: WhyFarther.harder,
925
///       child: Text('Working a lot harder'),
Ian Hickson's avatar
Ian Hickson committed
926 927 928
///     ),
///     const PopupMenuItem<WhyFarther>(
///       value: WhyFarther.smarter,
929
///       child: Text('Being a lot smarter'),
Ian Hickson's avatar
Ian Hickson committed
930 931 932
///     ),
///     const PopupMenuItem<WhyFarther>(
///       value: WhyFarther.selfStarter,
933
///       child: Text('Being a self-starter'),
Ian Hickson's avatar
Ian Hickson committed
934 935 936
///     ),
///     const PopupMenuItem<WhyFarther>(
///       value: WhyFarther.tradingCharter,
937
///       child: Text('Placed in charge of trading charter'),
Ian Hickson's avatar
Ian Hickson committed
938 939 940 941
///     ),
///   ],
/// )
/// ```
942
/// {@end-tool}
Ian Hickson's avatar
Ian Hickson committed
943 944 945 946 947 948 949
///
/// 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.
950
class PopupMenuButton<T> extends StatefulWidget {
951 952 953
  /// Creates a button that shows a popup menu.
  ///
  /// The [itemBuilder] argument must not be null.
954
  const PopupMenuButton({
955 956
    Key? key,
    required this.itemBuilder,
Hans Muller's avatar
Hans Muller committed
957 958
    this.initialValue,
    this.onSelected,
959
    this.onCanceled,
960
    this.tooltip,
961
    this.elevation,
962
    this.padding = const EdgeInsets.all(8.0),
963 964
    this.child,
    this.icon,
965
    this.offset = Offset.zero,
966
    this.enabled = true,
967 968
    this.shape,
    this.color,
969
    this.captureInheritedThemes = true,
970
  }) : assert(itemBuilder != null),
971
       assert(offset != null),
972
       assert(enabled != null),
973
       assert(captureInheritedThemes != null),
974 975
       assert(!(child != null && icon != null),
           'You can only pass [child] or [icon], not both.'),
976
       super(key: key);
Hans Muller's avatar
Hans Muller committed
977

978 979
  /// Called when the button is pressed to create the items to show in the menu.
  final PopupMenuItemBuilder<T> itemBuilder;
980

981
  /// The value of the menu item, if any, that should be highlighted when the menu opens.
982
  final T? initialValue;
983

984
  /// Called when the user selects a value from the popup menu created by this button.
985 986 987
  ///
  /// If the popup menu is dismissed without selecting a value, [onCanceled] is
  /// called instead.
988
  final PopupMenuItemSelected<T>? onSelected;
989

990 991 992
  /// Called when the user dismisses the popup menu without selecting an item.
  ///
  /// If the user selects a value, [onSelected] is called instead.
993
  final PopupMenuCanceled? onCanceled;
994

995 996 997 998
  /// 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.
999
  final String? tooltip;
1000

1001 1002 1003 1004
  /// 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.
1005
  final double? elevation;
1006

1007 1008 1009
  /// 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.
1010
  final EdgeInsetsGeometry padding;
1011

1012 1013
  /// If provided, [child] is the widget used for this button
  /// and the button will utilize an [InkWell] for taps.
1014
  final Widget? child;
Hans Muller's avatar
Hans Muller committed
1015

1016 1017
  /// If provided, the [icon] is used for this button
  /// and the button will behave like an [IconButton].
1018
  final Widget? icon;
1019

1020 1021 1022 1023 1024 1025
  /// The offset applied to the Popup Menu Button.
  ///
  /// When not set, the Popup Menu Button will be positioned directly next to
  /// the button that was used to create it.
  final Offset offset;

1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039
  /// 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;

1040 1041 1042 1043 1044 1045
  /// 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).
1046
  final ShapeBorder? shape;
1047 1048 1049 1050 1051 1052

  /// 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.
1053
  final Color? color;
1054

1055
  /// If true (the default) then the menu will be wrapped with copies
1056
  /// of the [InheritedTheme]s, like [Theme] and [PopupMenuTheme], which
1057 1058 1059
  /// are defined above the [BuildContext] where the menu is shown.
  final bool captureInheritedThemes;

1060
  @override
1061
  PopupMenuButtonState<T> createState() => PopupMenuButtonState<T>();
Hans Muller's avatar
Hans Muller committed
1062 1063
}

1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076
/// 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
  /// [PopupMenuButton.itemBuilder] at the position of your [PopupMenuButton].
  ///
  /// 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`.
1077
  void showButtonMenu() {
1078
    final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
1079
    final RenderBox button = context.findRenderObject()! as RenderBox;
1080
    final RenderBox overlay = Navigator.of(context)!.overlay!.context.findRenderObject()! as RenderBox;
1081 1082
    final RelativeRect position = RelativeRect.fromRect(
      Rect.fromPoints(
1083
        button.localToGlobal(widget.offset, ancestor: overlay),
Ian Hickson's avatar
Ian Hickson committed
1084 1085 1086 1087
        button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay),
      ),
      Offset.zero & overlay.size,
    );
1088 1089 1090 1091 1092
    final List<PopupMenuEntry<T>> items = widget.itemBuilder(context);
    // Only show the menu if there is something to show
    if (items.isNotEmpty) {
      showMenu<T>(
        context: context,
1093
        elevation: widget.elevation ?? popupMenuTheme.elevation,
1094 1095 1096
        items: items,
        initialValue: widget.initialValue,
        position: position,
1097 1098
        shape: widget.shape ?? popupMenuTheme.shape,
        color: widget.color ?? popupMenuTheme.color,
1099
        captureInheritedThemes: widget.captureInheritedThemes,
1100 1101 1102 1103 1104 1105
      )
      .then<void>((T newValue) {
        if (!mounted)
          return null;
        if (newValue == null) {
          if (widget.onCanceled != null)
1106
            widget.onCanceled!();
1107 1108 1109
          return null;
        }
        if (widget.onSelected != null)
1110
          widget.onSelected!(newValue);
1111 1112
      });
    }
Hans Muller's avatar
Hans Muller committed
1113 1114
  }

1115 1116 1117 1118 1119
  Icon _getIcon(TargetPlatform platform) {
    assert(platform != null);
    switch (platform) {
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
1120 1121
      case TargetPlatform.linux:
      case TargetPlatform.windows:
1122 1123
        return const Icon(Icons.more_vert);
      case TargetPlatform.iOS:
1124
      case TargetPlatform.macOS:
1125 1126 1127 1128
        return const Icon(Icons.more_horiz);
    }
  }

1129 1130 1131 1132 1133 1134 1135 1136 1137 1138
  bool get _canRequestFocus {
    final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional;
    switch (mode) {
      case NavigationMode.traditional:
        return widget.enabled;
      case NavigationMode.directional:
        return true;
    }
  }

1139
  @override
Hans Muller's avatar
Hans Muller committed
1140
  Widget build(BuildContext context) {
1141
    assert(debugCheckHasMaterialLocalizations(context));
1142 1143 1144

    if (widget.child != null)
      return Tooltip(
1145
        message: widget.tooltip ?? MaterialLocalizations.of(context)!.showMenuTooltip,
1146
        child: InkWell(
1147
          onTap: widget.enabled ? showButtonMenu : null,
1148
          canRequestFocus: _canRequestFocus,
1149
          child: widget.child,
1150 1151 1152 1153
        ),
      );

    return IconButton(
1154
      icon: widget.icon ?? _getIcon(Theme.of(context)!.platform),
1155
      padding: widget.padding,
1156
      tooltip: widget.tooltip ?? MaterialLocalizations.of(context)!.showMenuTooltip,
1157 1158
      onPressed: widget.enabled ? showButtonMenu : null,
    );
Hans Muller's avatar
Hans Muller committed
1159 1160
  }
}