popup_menu.dart 16 KB
Newer Older
1 2 3 4
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'dart:async';
6

7
import 'package:flutter/widgets.dart';
8
import 'package:meta/meta.dart';
9

10
import 'constants.dart';
Hans Muller's avatar
Hans Muller committed
11
import 'divider.dart';
12
import 'icon.dart';
13
import 'icons.dart';
Hans Muller's avatar
Hans Muller committed
14
import 'icon_button.dart';
15 16
import 'icon_theme.dart';
import 'icon_theme_data.dart';
17
import 'ink_well.dart';
18
import 'list_item.dart';
19
import 'material.dart';
20
import 'theme.dart';
21 22

const Duration _kMenuDuration = const Duration(milliseconds: 300);
23
const double _kBaselineOffsetFromBottom = 20.0;
24
const double _kMenuCloseIntervalEnd = 2.0 / 3.0;
25
const double _kMenuHorizontalPadding = 16.0;
26 27 28
const double _kMenuItemHeight = 48.0;
const double _kMenuMaxWidth = 5.0 * _kMenuWidthStep;
const double _kMenuMinWidth = 2.0 * _kMenuWidthStep;
29
const double _kMenuVerticalPadding = 8.0;
30
const double _kMenuWidthStep = 56.0;
Hans Muller's avatar
Hans Muller committed
31
const double _kMenuScreenPadding = 8.0;
32

33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
/// 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].
///
/// The type `T` is the type of the value the entry represents. All the entries
/// in a given menu must represent values with consistent types.
///
/// See also:
///
///  * [PopupMenuItem]
///  * [PopupMenuDivider]
///  * [CheckedPopupMenuItem]
///  * [showMenu]
///  * [PopupMenuButton]
49
abstract class PopupMenuEntry<T> extends StatefulWidget {
50 51 52
  /// Abstract const constructor. This constructor enables subclasses to provide
  /// const constructors so that they can be used in const expressions.
  const PopupMenuEntry({ Key key }) : super(key: key);
Hans Muller's avatar
Hans Muller committed
53

54 55 56
  /// The amount of vertical space occupied by this entry.
  ///
  /// This value must remain constant for a given instance.
Hans Muller's avatar
Hans Muller committed
57
  double get height;
58 59

  /// The value that should be returned by [showMenu] when the user selects this entry.
Hans Muller's avatar
Hans Muller committed
60
  T get value => null;
61 62 63

  /// Whether the user is permitted to select this entry.
  bool get enabled;
Hans Muller's avatar
Hans Muller committed
64 65
}

66 67 68 69 70 71 72 73 74
/// A horizontal divider in a material design popup menu.
///
/// This widget adatps the [Divider] for use in popup menus.
///
/// See also:
///
///  * [PopupMenuItem]
///  * [showMenu]
///  * [PopupMenuButton]
Hans Muller's avatar
Hans Muller committed
75
class PopupMenuDivider extends PopupMenuEntry<dynamic> {
76 77 78
  /// Creates a horizontal divider for a popup menu.
  ///
  /// By default, the divider has a height of 16.0 logical pixels.
Hans Muller's avatar
Hans Muller committed
79 80
  PopupMenuDivider({ Key key, this.height: 16.0 }) : super(key: key);

81
  @override
Hans Muller's avatar
Hans Muller committed
82 83
  final double height;

84 85 86
  @override
  bool get enabled => false;

87
  @override
88 89 90 91
  _PopupMenuDividerState createState() => new _PopupMenuDividerState();
}

class _PopupMenuDividerState extends State<PopupMenuDivider> {
92
  @override
93
  Widget build(BuildContext context) => new Divider(height: config.height);
Hans Muller's avatar
Hans Muller committed
94 95
}

96 97 98 99 100 101 102 103 104 105 106 107 108 109
/// 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].
///
/// See also:
///
///  * [PopupMenuDivider]
///  * [CheckedPopupMenuItem]
///  * [showMenu]
///  * [PopupMenuButton]
Hans Muller's avatar
Hans Muller committed
110
class PopupMenuItem<T> extends PopupMenuEntry<T> {
111 112 113
  /// Creates an item for a popup menu.
  ///
  /// By default, the item is enabled.
114 115 116
  PopupMenuItem({
    Key key,
    this.value,
117
    this.enabled: true,
118 119 120
    this.child
  }) : super(key: key);

121
  @override
122
  final T value;
123 124

  @override
125
  final bool enabled;
126

127
  /// The widget below this widget in the tree.
Hans Muller's avatar
Hans Muller committed
128
  final Widget child;
129

130
  @override
Hans Muller's avatar
Hans Muller committed
131 132
  double get height => _kMenuItemHeight;

133
  @override
134 135 136
  _PopupMenuItemState<PopupMenuItem<T>> createState() => new _PopupMenuItemState<PopupMenuItem<T>>();
}

137
class _PopupMenuItemState<T extends PopupMenuItem<dynamic>> extends State<T> {
138 139 140 141 142 143 144
  // Override this to put something else in the menu entry.
  Widget buildChild() => config.child;

  void onTap() {
    Navigator.pop(context, config.value);
  }

145
  @override
146
  Widget build(BuildContext context) {
Hans Muller's avatar
Hans Muller committed
147
    final ThemeData theme = Theme.of(context);
148
    TextStyle style = theme.textTheme.subhead;
149
    if (!config.enabled)
Hans Muller's avatar
Hans Muller committed
150 151
      style = style.copyWith(color: theme.disabledColor);

152
    Widget item = new AnimatedDefaultTextStyle(
153
      style: style,
154
      duration: kThemeChangeDuration,
155
      child: new Baseline(
156
        baseline: config.height - _kBaselineOffsetFromBottom,
157
        baselineType: TextBaseline.alphabetic,
158
        child: buildChild()
159 160
      )
    );
161
    if (!config.enabled) {
162 163 164 165 166 167 168
      final bool isDark = theme.brightness == ThemeBrightness.dark;
      item = new IconTheme(
        data: new IconThemeData(opacity: isDark ? 0.5 : 0.38),
        child: item
      );
    }

169 170 171 172 173
    return new InkWell(
      onTap: config.enabled ? onTap : null,
      child: new MergeSemantics(
        child: new Container(
          height: config.height,
174
          padding: const EdgeInsets.symmetric(horizontal: _kMenuHorizontalPadding),
175 176
          child: item
        )
177 178 179 180
      )
    );
  }
}
181

182 183 184 185 186 187 188 189 190 191 192 193
/// 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].
///
/// See also:
///
///  * [PopupMenuItem]
///  * [PopupMenuDivider]
///  * [CheckedPopupMenuItem]
///  * [showMenu]
///  * [PopupMenuButton]
194
class CheckedPopupMenuItem<T> extends PopupMenuItem<T> {
195 196 197
  /// Creates a popup menu item with a checkmark.
  ///
  /// By default, the menu item is enabled but unchecked.
198 199 200
  CheckedPopupMenuItem({
    Key key,
    T value,
201
    this.checked: false,
202
    bool enabled: true,
203 204 205 206
    Widget child
  }) : super(
    key: key,
    value: value,
207
    enabled: enabled,
208
    child: child
209
  );
210

211
  /// Whether to display a checkmark next to the menu item.
212 213
  final bool checked;

214
  @override
215 216 217 218 219 220 221 222
  _CheckedPopupMenuItemState<T> createState() => new _CheckedPopupMenuItemState<T>();
}

class _CheckedPopupMenuItemState<T> extends _PopupMenuItemState<CheckedPopupMenuItem<T>> {
  static const Duration _kFadeDuration = const Duration(milliseconds: 150);
  AnimationController _controller;
  Animation<double> get _opacity => _controller.view;

223
  @override
224 225 226 227 228 229 230
  void initState() {
    super.initState();
    _controller = new AnimationController(duration: _kFadeDuration)
      ..value = config.checked ? 1.0 : 0.0
      ..addListener(() => setState(() { /* animation changed */ }));
  }

231
  @override
232 233 234 235 236 237 238 239 240
  void onTap() {
    // This fades the checkmark in or out when tapped.
    if (config.checked)
      _controller.reverse();
    else
      _controller.forward();
    super.onTap();
  }

241
  @override
242 243 244
  Widget buildChild() {
    return new ListItem(
      enabled: config.enabled,
245
      leading: new FadeTransition(
246 247 248
        opacity: _opacity,
        child: new Icon(icon: _controller.isDismissed ? null : Icons.done)
      ),
249
      title: config.child
250 251
    );
  }
252 253
}

254
class _PopupMenu<T> extends StatelessWidget {
Adam Barth's avatar
Adam Barth committed
255
  _PopupMenu({
256
    Key key,
Adam Barth's avatar
Adam Barth committed
257 258
    this.route
  }) : super(key: key);
259

Hixie's avatar
Hixie committed
260
  final _PopupMenuRoute<T> route;
261

262
  @override
263
  Widget build(BuildContext context) {
Adam Barth's avatar
Adam Barth committed
264
    double unit = 1.0 / (route.items.length + 1.5); // 1.0 for the width and 0.5 for the last item's fade.
Hixie's avatar
Hixie committed
265
    List<Widget> children = <Widget>[];
266

Adam Barth's avatar
Adam Barth committed
267
    for (int i = 0; i < route.items.length; ++i) {
Hans Muller's avatar
Hans Muller committed
268 269
      final double start = (i + 1) * unit;
      final double end = (start + 1.5 * unit).clamp(0.0, 1.0);
270 271 272 273
      CurvedAnimation opacity = new CurvedAnimation(
        parent: route.animation,
        curve: new Interval(start, end)
      );
Hans Muller's avatar
Hans Muller committed
274 275 276 277 278 279 280
      Widget item = route.items[i];
      if (route.initialValue != null && route.initialValue == route.items[i].value) {
        item = new Container(
          decoration: new BoxDecoration(backgroundColor: Theme.of(context).highlightColor),
          child: item
        );
      }
281 282
      children.add(new FadeTransition(
        opacity: opacity,
283
        child: item
Hans Muller's avatar
Hans Muller committed
284
      ));
285
    }
286

287 288 289 290 291 292 293 294 295 296 297 298
    final CurveTween opacity = new CurveTween(curve: new Interval(0.0, 1.0 / 3.0));
    final CurveTween width = new CurveTween(curve: new Interval(0.0, unit));
    final CurveTween height = new CurveTween(curve: new Interval(0.0, unit * route.items.length));

    Widget child = new ConstrainedBox(
      constraints: new BoxConstraints(
        minWidth: _kMenuMinWidth,
        maxWidth: _kMenuMaxWidth
      ),
      child: new IntrinsicWidth(
        stepWidth: _kMenuWidthStep,
        child: new Block(
299
          children: children,
300
          padding: const EdgeInsets.symmetric(
301 302 303 304 305
            vertical: _kMenuVerticalPadding
          )
        )
      )
    );
Adam Barth's avatar
Adam Barth committed
306

307 308
    return new AnimatedBuilder(
      animation: route.animation,
309
      builder: (BuildContext context, Widget child) {
310
        return new Opacity(
311
          opacity: opacity.evaluate(route.animation),
312 313 314 315
          child: new Material(
            type: MaterialType.card,
            elevation: route.elevation,
            child: new Align(
316
              alignment: FractionalOffset.topRight,
317 318 319
              widthFactor: width.evaluate(route.animation),
              heightFactor: height.evaluate(route.animation),
              child: child
320
            )
Adam Barth's avatar
Adam Barth committed
321
          )
322
        );
323 324
      },
      child: child
325 326 327
    );
  }
}
328

329
class _PopupMenuRouteLayout extends SingleChildLayoutDelegate {
Hans Muller's avatar
Hans Muller committed
330
  _PopupMenuRouteLayout(this.position, this.selectedItemOffset);
Hans Muller's avatar
Hans Muller committed
331

332
  final RelativeRect position;
Hans Muller's avatar
Hans Muller committed
333
  final double selectedItemOffset;
Hans Muller's avatar
Hans Muller committed
334

335
  @override
Hans Muller's avatar
Hans Muller committed
336
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
337
    return constraints.loosen();
Hans Muller's avatar
Hans Muller committed
338 339 340 341 342
  }

  // Put the child wherever position specifies, so long as it will fit within the
  // specified parent size padded (inset) by 8. If necessary, adjust the child's
  // position so that it fits.
343
  @override
Hans Muller's avatar
Hans Muller committed
344 345 346 347 348 349
  Offset getPositionForChild(Size size, Size childSize) {
    double x = position?.left
      ?? (position?.right != null ? size.width - (position.right + childSize.width) : _kMenuScreenPadding);
    double y = position?.top
      ?? (position?.bottom != null ? size.height - (position.bottom - childSize.height) : _kMenuScreenPadding);

Hans Muller's avatar
Hans Muller committed
350 351
    if (selectedItemOffset != null)
      y -= selectedItemOffset + _kMenuVerticalPadding + _kMenuItemHeight / 2.0;
Hans Muller's avatar
Hans Muller committed
352 353 354 355 356 357 358 359 360 361 362 363

    if (x < _kMenuScreenPadding)
      x = _kMenuScreenPadding;
    else if (x + childSize.width > size.width - 2 * _kMenuScreenPadding)
      x = size.width - childSize.width - _kMenuScreenPadding;
    if (y < _kMenuScreenPadding)
      y = _kMenuScreenPadding;
    else if (y + childSize.height > size.height - 2 * _kMenuScreenPadding)
      y = size.height - childSize.height - _kMenuScreenPadding;
    return new Offset(x, y);
  }

364
  @override
Hans Muller's avatar
Hans Muller committed
365 366 367 368 369
  bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) {
    return position != oldDelegate.position;
  }
}

Hixie's avatar
Hixie committed
370 371 372 373 374
class _PopupMenuRoute<T> extends PopupRoute<T> {
  _PopupMenuRoute({
    Completer<T> completer,
    this.position,
    this.items,
Hans Muller's avatar
Hans Muller committed
375
    this.initialValue,
Hixie's avatar
Hixie committed
376 377
    this.elevation
  }) : super(completer: completer);
378

379
  final RelativeRect position;
Hans Muller's avatar
Hans Muller committed
380
  final List<PopupMenuEntry<T>> items;
Hans Muller's avatar
Hans Muller committed
381
  final dynamic initialValue;
Hans Muller's avatar
Hans Muller committed
382
  final int elevation;
383

384
  @override
385
  Animation<double> createAnimation() {
386 387
    return new CurvedAnimation(
      parent: super.createAnimation(),
388
      curve: Curves.linear,
389
      reverseCurve: new Interval(0.0, _kMenuCloseIntervalEnd)
390
    );
391 392
  }

393
  @override
394
  Duration get transitionDuration => _kMenuDuration;
395 396

  @override
Hixie's avatar
Hixie committed
397
  bool get barrierDismissable => true;
398 399

  @override
Hixie's avatar
Hixie committed
400
  Color get barrierColor => null;
401

402
  @override
403
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> forwardAnimation) {
Ian Hickson's avatar
Ian Hickson committed
404
    double selectedItemOffset;
Hans Muller's avatar
Hans Muller committed
405
    if (initialValue != null) {
Hans Muller's avatar
Hans Muller committed
406 407 408
      selectedItemOffset = 0.0;
      for (int i = 0; i < items.length; i++) {
        if (initialValue == items[i].value)
Hans Muller's avatar
Hans Muller committed
409
          break;
Hans Muller's avatar
Hans Muller committed
410 411
        selectedItemOffset += items[i].height;
      }
Hans Muller's avatar
Hans Muller committed
412
    }
413 414 415
    return new CustomSingleChildLayout(
      delegate: new _PopupMenuRouteLayout(position, selectedItemOffset),
      child: new _PopupMenu<T>(route: this)
Hans Muller's avatar
Hans Muller committed
416
    );
417
  }
418 419
}

Hans Muller's avatar
Hans Muller committed
420 421 422 423 424
/// Show a popup menu that contains the [items] at [position]. If [initialValue]
/// is specified then the first item with a matching value will be highlighted
/// and the value of [position] implies where the left, center point of the
/// highlighted item should appear. If [initialValue] is not specified then position
/// implies the menu's origin.
425
Future<dynamic/*=T*/> showMenu/*<T>*/({
Hans Muller's avatar
Hans Muller committed
426
  BuildContext context,
427
  RelativeRect position,
428
  List<PopupMenuEntry<dynamic/*=T*/>> items,
Hans Muller's avatar
Hans Muller committed
429 430 431 432 433
  dynamic/*=T*/ initialValue,
  int elevation: 8
}) {
  assert(context != null);
  assert(items != null && items.length > 0);
434 435
  Completer<dynamic/*=T*/> completer = new Completer<dynamic/*=T*/>();
  Navigator.push(context, new _PopupMenuRoute<dynamic/*=T*/>(
436 437
    completer: completer,
    position: position,
Adam Barth's avatar
Adam Barth committed
438
    items: items,
Hans Muller's avatar
Hans Muller committed
439
    initialValue: initialValue,
Hans Muller's avatar
Hans Muller committed
440
    elevation: elevation
441 442 443
  ));
  return completer.future;
}
Hans Muller's avatar
Hans Muller committed
444 445 446 447 448

/// A callback that is passed the value of the PopupMenuItem that caused
/// its menu to be dismissed.
typedef void PopupMenuItemSelected<T>(T value);

449 450 451
/// Signature used by [PopupMenuButton] to lazily construct the items shown when the button is pressed.
typedef List<PopupMenuEntry<T>> PopupMenuItemBuilder<T>(BuildContext context);

Hans Muller's avatar
Hans Muller committed
452 453 454 455
/// Displays a menu when pressed and calls [onSelected] when the menu is dismissed
/// because an item was selected. The value passed to [onSelected] is the value of
/// the selected menu item. If child is null then a standard 'navigation/more_vert'
/// icon is created.
456
class PopupMenuButton<T> extends StatefulWidget {
457 458 459
  /// Creates a button that shows a popup menu.
  ///
  /// The [itemBuilder] argument must not be null.
Hans Muller's avatar
Hans Muller committed
460 461
  PopupMenuButton({
    Key key,
462
    @required this.itemBuilder,
Hans Muller's avatar
Hans Muller committed
463 464 465 466
    this.initialValue,
    this.onSelected,
    this.tooltip: 'Show menu',
    this.elevation: 8,
467
    this.padding: const EdgeInsets.all(8.0),
Hans Muller's avatar
Hans Muller committed
468
    this.child
469 470 471
  }) : super(key: key) {
    assert(itemBuilder != null);
  }
Hans Muller's avatar
Hans Muller committed
472

473 474
  /// Called when the button is pressed to create the items to show in the menu.
  final PopupMenuItemBuilder<T> itemBuilder;
475

476
  /// The value of the menu item, if any, that should be highlighted when the menu opens.
Hans Muller's avatar
Hans Muller committed
477
  final T initialValue;
478

479
  /// Called when the user selects a value from the popup menu created by this button.
Hans Muller's avatar
Hans Muller committed
480
  final PopupMenuItemSelected<T> onSelected;
481

482 483 484 485
  /// 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.
Hans Muller's avatar
Hans Muller committed
486
  final String tooltip;
487

488
  /// The z-coordinate at which to place the menu when open.
489 490
  ///
  /// The following elevations have defined shadows: 1, 2, 3, 4, 6, 8, 9, 12, 16, 24
Hans Muller's avatar
Hans Muller committed
491
  final int elevation;
492

493 494 495 496 497
  /// Matches IconButton's 8 dps padding by default. In some cases, notably where
  /// this button appears as the trailing element of a list item, it's useful to be able
  /// to set the padding to zero.
  final EdgeInsets padding;

498
  /// The widget below this widget in the tree.
Hans Muller's avatar
Hans Muller committed
499 500
  final Widget child;

501
  @override
Hans Muller's avatar
Hans Muller committed
502 503 504 505 506 507 508 509 510 511
  _PopupMenuButtonState<T> createState() => new _PopupMenuButtonState<T>();
}

class _PopupMenuButtonState<T> extends State<PopupMenuButton<T>> {
  void showButtonMenu(BuildContext context) {
    final RenderBox renderBox = context.findRenderObject();
    final Point topLeft = renderBox.localToGlobal(Point.origin);
    showMenu/*<T>*/(
      context: context,
      elevation: config.elevation,
512
      items: config.itemBuilder(context),
Hans Muller's avatar
Hans Muller committed
513
      initialValue: config.initialValue,
514 515 516
      position: new RelativeRect.fromLTRB(
        topLeft.x, topLeft.y + (config.initialValue != null ? renderBox.size.height / 2.0 : 0.0),
        0.0, 0.0
Hans Muller's avatar
Hans Muller committed
517 518 519
      )
    )
    .then((T value) {
520
      if (value != null && config.onSelected != null)
Hans Muller's avatar
Hans Muller committed
521 522 523 524
        config.onSelected(value);
    });
  }

525
  @override
Hans Muller's avatar
Hans Muller committed
526 527 528
  Widget build(BuildContext context) {
    if (config.child == null) {
      return new IconButton(
529
        icon: Icons.more_vert,
530
        padding: config.padding,
Hans Muller's avatar
Hans Muller committed
531 532 533 534 535 536 537 538 539 540
        tooltip: config.tooltip,
        onPressed: () { showButtonMenu(context); }
      );
    }
    return new InkWell(
      onTap: () { showButtonMenu(context); },
      child: config.child
    );
  }
}