popup_menu.dart 12.6 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

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

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

31
abstract class PopupMenuEntry<T> extends StatefulWidget {
Hans Muller's avatar
Hans Muller committed
32 33 34 35 36 37 38 39 40 41
  PopupMenuEntry({ Key key }) : super(key: key);

  double get height;
  T get value => null;
  bool get enabled => true;
}

class PopupMenuDivider extends PopupMenuEntry<dynamic> {
  PopupMenuDivider({ Key key, this.height: 16.0 }) : super(key: key);

42
  @override
Hans Muller's avatar
Hans Muller committed
43 44
  final double height;

45
  @override
46 47 48 49
  _PopupMenuDividerState createState() => new _PopupMenuDividerState();
}

class _PopupMenuDividerState extends State<PopupMenuDivider> {
50
  @override
51
  Widget build(BuildContext context) => new Divider(height: config.height);
Hans Muller's avatar
Hans Muller committed
52 53 54
}

class PopupMenuItem<T> extends PopupMenuEntry<T> {
55 56 57
  PopupMenuItem({
    Key key,
    this.value,
58
    this.enabled: true,
59 60 61
    this.child
  }) : super(key: key);

62
  @override
63
  final T value;
64 65

  @override
66
  final bool enabled;
67

Hans Muller's avatar
Hans Muller committed
68
  final Widget child;
69

70
  @override
Hans Muller's avatar
Hans Muller committed
71 72
  double get height => _kMenuItemHeight;

73
  @override
74 75 76
  _PopupMenuItemState<PopupMenuItem<T>> createState() => new _PopupMenuItemState<PopupMenuItem<T>>();
}

77
class _PopupMenuItemState<T extends PopupMenuItem<dynamic>> extends State<T> {
78 79 80 81 82 83 84
  // Override this to put something else in the menu entry.
  Widget buildChild() => config.child;

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

85
  @override
86
  Widget build(BuildContext context) {
Hans Muller's avatar
Hans Muller committed
87
    final ThemeData theme = Theme.of(context);
88
    TextStyle style = theme.textTheme.subhead;
89
    if (!config.enabled)
Hans Muller's avatar
Hans Muller committed
90 91
      style = style.copyWith(color: theme.disabledColor);

92 93 94
    Widget item = new DefaultTextStyle(
      style: style,
      child: new Baseline(
95 96
        baseline: config.height - _kBaselineOffsetFromBottom,
        child: buildChild()
97 98
      )
    );
99
    if (!config.enabled) {
100 101 102 103 104 105 106
      final bool isDark = theme.brightness == ThemeBrightness.dark;
      item = new IconTheme(
        data: new IconThemeData(opacity: isDark ? 0.5 : 0.38),
        child: item
      );
    }

107 108 109 110 111
    return new InkWell(
      onTap: config.enabled ? onTap : null,
      child: new MergeSemantics(
        child: new Container(
          height: config.height,
112
          padding: const EdgeInsets.symmetric(horizontal: _kMenuHorizontalPadding),
113 114
          child: item
        )
115 116 117 118
      )
    );
  }
}
119

120 121 122 123
class CheckedPopupMenuItem<T> extends PopupMenuItem<T> {
  CheckedPopupMenuItem({
    Key key,
    T value,
124
    this.checked: false,
125
    bool enabled: true,
126 127 128 129
    Widget child
  }) : super(
    key: key,
    value: value,
130
    enabled: enabled,
131
    child: child
132
  );
133 134 135

  final bool checked;

136
  @override
137 138 139 140 141 142 143 144
  _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;

145
  @override
146 147 148 149 150 151 152
  void initState() {
    super.initState();
    _controller = new AnimationController(duration: _kFadeDuration)
      ..value = config.checked ? 1.0 : 0.0
      ..addListener(() => setState(() { /* animation changed */ }));
  }

153
  @override
154 155 156 157 158 159 160 161 162
  void onTap() {
    // This fades the checkmark in or out when tapped.
    if (config.checked)
      _controller.reverse();
    else
      _controller.forward();
    super.onTap();
  }

163
  @override
164 165 166
  Widget buildChild() {
    return new ListItem(
      enabled: config.enabled,
167
      leading: new FadeTransition(
168 169 170
        opacity: _opacity,
        child: new Icon(icon: _controller.isDismissed ? null : Icons.done)
      ),
171
      title: config.child
172 173
    );
  }
174 175
}

176
class _PopupMenu<T> extends StatelessWidget {
Adam Barth's avatar
Adam Barth committed
177
  _PopupMenu({
178
    Key key,
Adam Barth's avatar
Adam Barth committed
179 180
    this.route
  }) : super(key: key);
181

Hixie's avatar
Hixie committed
182
  final _PopupMenuRoute<T> route;
183

184
  @override
185
  Widget build(BuildContext context) {
Adam Barth's avatar
Adam Barth committed
186
    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
187
    List<Widget> children = <Widget>[];
188

Adam Barth's avatar
Adam Barth committed
189
    for (int i = 0; i < route.items.length; ++i) {
Hans Muller's avatar
Hans Muller committed
190 191
      final double start = (i + 1) * unit;
      final double end = (start + 1.5 * unit).clamp(0.0, 1.0);
192 193 194 195
      CurvedAnimation opacity = new CurvedAnimation(
        parent: route.animation,
        curve: new Interval(start, end)
      );
Hans Muller's avatar
Hans Muller committed
196 197 198 199 200 201 202
      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
        );
      }
203 204
      children.add(new FadeTransition(
        opacity: opacity,
205
        child: item
Hans Muller's avatar
Hans Muller committed
206
      ));
207
    }
208

209 210 211 212 213 214 215 216 217 218 219 220
    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(
221
          children: children,
222
          padding: const EdgeInsets.symmetric(
223 224 225 226 227
            vertical: _kMenuVerticalPadding
          )
        )
      )
    );
Adam Barth's avatar
Adam Barth committed
228

229 230
    return new AnimatedBuilder(
      animation: route.animation,
231
      builder: (BuildContext context, Widget child) {
232
        return new Opacity(
233
          opacity: opacity.evaluate(route.animation),
234 235 236 237 238
          child: new Material(
            type: MaterialType.card,
            elevation: route.elevation,
            child: new Align(
              alignment: const FractionalOffset(1.0, 0.0),
239 240 241
              widthFactor: width.evaluate(route.animation),
              heightFactor: height.evaluate(route.animation),
              child: child
242
            )
Adam Barth's avatar
Adam Barth committed
243
          )
244
        );
245 246
      },
      child: child
247 248 249
    );
  }
}
250

251
class _PopupMenuRouteLayout extends SingleChildLayoutDelegate {
Hans Muller's avatar
Hans Muller committed
252
  _PopupMenuRouteLayout(this.position, this.selectedItemOffset);
Hans Muller's avatar
Hans Muller committed
253 254

  final ModalPosition position;
Hans Muller's avatar
Hans Muller committed
255
  final double selectedItemOffset;
Hans Muller's avatar
Hans Muller committed
256

257
  @override
Hans Muller's avatar
Hans Muller committed
258 259 260 261 262 263 264 265 266 267 268 269
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
    return new BoxConstraints(
      minWidth: 0.0,
      maxWidth: constraints.maxWidth,
      minHeight: 0.0,
      maxHeight: constraints.maxHeight
    );
  }

  // 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.
270
  @override
Hans Muller's avatar
Hans Muller committed
271 272 273 274 275 276
  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
277 278
    if (selectedItemOffset != null)
      y -= selectedItemOffset + _kMenuVerticalPadding + _kMenuItemHeight / 2.0;
Hans Muller's avatar
Hans Muller committed
279 280 281 282 283 284 285 286 287 288 289 290

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

291
  @override
Hans Muller's avatar
Hans Muller committed
292 293 294 295 296
  bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) {
    return position != oldDelegate.position;
  }
}

Hixie's avatar
Hixie committed
297 298 299 300 301
class _PopupMenuRoute<T> extends PopupRoute<T> {
  _PopupMenuRoute({
    Completer<T> completer,
    this.position,
    this.items,
Hans Muller's avatar
Hans Muller committed
302
    this.initialValue,
Hixie's avatar
Hixie committed
303 304
    this.elevation
  }) : super(completer: completer);
305

306
  final ModalPosition position;
Hans Muller's avatar
Hans Muller committed
307
  final List<PopupMenuEntry<T>> items;
Hans Muller's avatar
Hans Muller committed
308
  final dynamic initialValue;
Hans Muller's avatar
Hans Muller committed
309
  final int elevation;
310

311
  @override
Hans Muller's avatar
Hans Muller committed
312
  ModalPosition getPosition(BuildContext context) => null;
313

314
  @override
315
  Animation<double> createAnimation() {
316 317
    return new CurvedAnimation(
      parent: super.createAnimation(),
318
      reverseCurve: new Interval(0.0, _kMenuCloseIntervalEnd)
319
    );
320 321
  }

322
  @override
323
  Duration get transitionDuration => _kMenuDuration;
324 325

  @override
Hixie's avatar
Hixie committed
326
  bool get barrierDismissable => true;
327 328

  @override
Hixie's avatar
Hixie committed
329
  Color get barrierColor => null;
330

331
  @override
332
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> forwardAnimation) {
Ian Hickson's avatar
Ian Hickson committed
333
    double selectedItemOffset;
Hans Muller's avatar
Hans Muller committed
334
    if (initialValue != null) {
Hans Muller's avatar
Hans Muller committed
335 336 337
      selectedItemOffset = 0.0;
      for (int i = 0; i < items.length; i++) {
        if (initialValue == items[i].value)
Hans Muller's avatar
Hans Muller committed
338
          break;
Hans Muller's avatar
Hans Muller committed
339 340
        selectedItemOffset += items[i].height;
      }
Hans Muller's avatar
Hans Muller committed
341 342 343 344
    }
    final Size screenSize = MediaQuery.of(context).size;
    return new ConstrainedBox(
      constraints: new BoxConstraints(maxWidth: screenSize.width, maxHeight: screenSize.height),
345
      child: new CustomSingleChildLayout(
Hans Muller's avatar
Hans Muller committed
346
        delegate: new _PopupMenuRouteLayout(position, selectedItemOffset),
347
        child: new _PopupMenu<T>(route: this)
Hans Muller's avatar
Hans Muller committed
348 349
      )
    );
350
  }
351 352
}

Hans Muller's avatar
Hans Muller committed
353 354 355 356 357
/// 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.
358
Future<dynamic/*=T*/> showMenu/*<T>*/({
Hans Muller's avatar
Hans Muller committed
359 360
  BuildContext context,
  ModalPosition position,
361
  List<PopupMenuEntry<dynamic/*=T*/>> items,
Hans Muller's avatar
Hans Muller committed
362 363 364 365 366
  dynamic/*=T*/ initialValue,
  int elevation: 8
}) {
  assert(context != null);
  assert(items != null && items.length > 0);
367 368
  Completer<dynamic/*=T*/> completer = new Completer<dynamic/*=T*/>();
  Navigator.push(context, new _PopupMenuRoute<dynamic/*=T*/>(
369 370
    completer: completer,
    position: position,
Adam Barth's avatar
Adam Barth committed
371
    items: items,
Hans Muller's avatar
Hans Muller committed
372
    initialValue: initialValue,
Hans Muller's avatar
Hans Muller committed
373
    elevation: elevation
374 375 376
  ));
  return completer.future;
}
Hans Muller's avatar
Hans Muller committed
377 378 379 380 381 382 383 384 385

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

/// 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.
386
class PopupMenuButton<T> extends StatefulWidget {
Hans Muller's avatar
Hans Muller committed
387 388 389 390 391 392 393 394 395 396
  PopupMenuButton({
    Key key,
    this.items,
    this.initialValue,
    this.onSelected,
    this.tooltip: 'Show menu',
    this.elevation: 8,
    this.child
  }) : super(key: key);

Hans Muller's avatar
Hans Muller committed
397
  final List<PopupMenuEntry<T>> items;
Hans Muller's avatar
Hans Muller committed
398 399 400 401 402 403
  final T initialValue;
  final PopupMenuItemSelected<T> onSelected;
  final String tooltip;
  final int elevation;
  final Widget child;

404
  @override
Hans Muller's avatar
Hans Muller committed
405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422
  _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,
      items: config.items,
      initialValue: config.initialValue,
      position: new ModalPosition(
        left: topLeft.x,
        top: topLeft.y + (config.initialValue != null ? renderBox.size.height / 2.0 : 0.0)
      )
    )
    .then((T value) {
423
      if (value != null && config.onSelected != null)
Hans Muller's avatar
Hans Muller committed
424 425 426 427
        config.onSelected(value);
    });
  }

428
  @override
Hans Muller's avatar
Hans Muller committed
429 430 431
  Widget build(BuildContext context) {
    if (config.child == null) {
      return new IconButton(
432
        icon: Icons.more_vert,
Hans Muller's avatar
Hans Muller committed
433 434 435 436 437 438 439 440 441 442
        tooltip: config.tooltip,
        onPressed: () { showButtonMenu(context); }
      );
    }
    return new InkWell(
      onTap: () { showButtonMenu(context); },
      child: config.child
    );
  }
}