drop_down.dart 19.5 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:math' as math;
6

7
import 'package:flutter/foundation.dart';
8
import 'package:flutter/scheduler.dart';
9 10
import 'package:flutter/widgets.dart';

11
import 'colors.dart';
12
import 'debug.dart';
13
import 'icon.dart';
14
import 'icons.dart';
15
import 'ink_well.dart';
16
import 'scrollbar.dart';
17 18
import 'shadows.dart';
import 'theme.dart';
19
import 'material.dart';
20

21
const Duration _kDropdownMenuDuration = const Duration(milliseconds: 300);
22
const double _kMenuItemHeight = 48.0;
23
const double _kDenseButtonHeight = 24.0;
Adam Barth's avatar
Adam Barth committed
24
const EdgeInsets _kMenuVerticalPadding = const EdgeInsets.symmetric(vertical: 8.0);
25
const EdgeInsets _kMenuHorizontalPadding = const EdgeInsets.symmetric(horizontal: 16.0);
26

27 28
class _DropdownMenuPainter extends CustomPainter {
  _DropdownMenuPainter({
29 30 31
    Color color,
    int elevation,
    this.selectedIndex,
32
    Animation<double> resize,
33 34 35 36
  }) : color = color,
       elevation = elevation,
       resize = resize,
       _painter = new BoxDecoration(
37 38 39
         // If you add a background image here, you must provide a real
         // configuration in the paint() function and you must provide some sort
         // of onChanged callback here.
40
         backgroundColor: color,
41
         borderRadius: new BorderRadius.circular(2.0),
42 43 44
         boxShadow: kElevationToShadow[elevation]
       ).createBoxPainter(),
       super(repaint: resize);
45 46 47

  final Color color;
  final int elevation;
48 49 50 51
  final int selectedIndex;
  final Animation<double> resize;

  final BoxPainter _painter;
52

53
  @override
54
  void paint(Canvas canvas, Size size) {
55
    final double selectedItemOffset = selectedIndex * _kMenuItemHeight + _kMenuVerticalPadding.top;
56
    final Tween<double> top = new Tween<double>(
57
      begin: selectedItemOffset.clamp(0.0, size.height - _kMenuItemHeight),
58
      end: 0.0,
59 60 61
    );

    final Tween<double> bottom = new Tween<double>(
62
      begin: (top.begin + _kMenuItemHeight).clamp(_kMenuItemHeight, size.height),
63
      end: size.height,
64 65
    );

66 67 68
    final Rect rect = new Rect.fromLTRB(0.0, top.evaluate(resize), size.width, bottom.evaluate(resize));

    _painter.paint(canvas, rect.topLeft.toOffset(), new ImageConfiguration(size: rect.size));
69 70
  }

71
  @override
72
  bool shouldRepaint(_DropdownMenuPainter oldPainter) {
73 74
    return oldPainter.color != color
        || oldPainter.elevation != elevation
75 76
        || oldPainter.selectedIndex != selectedIndex
        || oldPainter.resize != resize;
77 78 79
  }
}

80 81
// Do not use the platform-specific default scroll configuration.
// Dropdown menus should never overscroll or display an overscroll indicator.
82 83
class _DropdownScrollConfigurationDelegate extends ScrollConfigurationDelegate {
  const _DropdownScrollConfigurationDelegate(this._platform);
84 85 86 87 88 89 90

  @override
  TargetPlatform get platform => _platform;
  final TargetPlatform _platform;

  @override
  ExtentScrollBehavior createScrollBehavior() => new OverscrollWhenScrollableBehavior(platform: platform);
91 92

  @override
93 94 95
  Widget wrapScrollWidget(BuildContext context, Widget scrollWidget) {
    return new ClampOverscrolls(edge: ScrollableEdge.both, child: scrollWidget);
  }
96 97 98

  @override
  bool updateShouldNotify(ScrollConfigurationDelegate old) => platform != old.platform;
99 100
}

101 102
class _DropdownMenu<T> extends StatefulWidget {
  _DropdownMenu({
103
    Key key,
104
    _DropdownRoute<T> route,
105
  }) : route = route, super(key: key);
106

107
  final _DropdownRoute<T> route;
108

109
  @override
110
  _DropdownMenuState<T> createState() => new _DropdownMenuState<T>();
111 112
}

113
class _DropdownMenuState<T> extends State<_DropdownMenu<T>> {
114 115 116 117 118 119 120 121 122 123 124 125 126
  CurvedAnimation _fadeOpacity;
  CurvedAnimation _resize;

  @override
  void initState() {
    super.initState();
    // We need to hold these animations as state because of their curve
    // direction. When the route's animation reverses, if we were to recreate
    // the CurvedAnimation objects in build, we'd lose
    // CurvedAnimation._curveDirection.
    _fadeOpacity = new CurvedAnimation(
      parent: config.route.animation,
      curve: const Interval(0.0, 0.25),
127
      reverseCurve: const Interval(0.75, 1.0),
128 129 130 131
    );
    _resize = new CurvedAnimation(
      parent: config.route.animation,
      curve: const Interval(0.25, 0.5),
132
      reverseCurve: const Threshold(0.0),
133 134 135
    );
  }

136
  @override
137 138
  Widget build(BuildContext context) {
    // The menu is shown in three stages (unit timing in brackets):
Hixie's avatar
Hixie committed
139 140
    // [0s - 0.25s] - Fade in a rect-sized menu container with the selected item.
    // [0.25s - 0.5s] - Grow the otherwise empty menu container from the center
141
    //   until it's big enough for as many items as we're going to show.
Hixie's avatar
Hixie committed
142
    // [0.5s - 1.0s] Fade in the remaining visible items from top to bottom.
143 144
    //
    // When the menu is dismissed we just fade the entire thing out
Hixie's avatar
Hixie committed
145
    // in the first 0.25s.
146
    final _DropdownRoute<T> route = config.route;
Adam Barth's avatar
Adam Barth committed
147
    final double unit = 0.5 / (route.items.length + 1.5);
148
    final List<Widget> children = <Widget>[];
Adam Barth's avatar
Adam Barth committed
149
    for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex) {
150
      CurvedAnimation opacity;
Adam Barth's avatar
Adam Barth committed
151
      if (itemIndex == route.selectedIndex) {
152
        opacity = new CurvedAnimation(parent: route.animation, curve: const Threshold(0.0));
153 154 155
      } else {
        final double start = (0.5 + (itemIndex + 1) * unit).clamp(0.0, 1.0);
        final double end = (start + 1.5 * unit).clamp(0.0, 1.0);
156
        opacity = new CurvedAnimation(parent: route.animation, curve: new Interval(start, end));
157
      }
158
      children.add(new FadeTransition(
159 160
        opacity: opacity,
        child: new InkWell(
161 162
          child: new Container(
            padding: _kMenuHorizontalPadding,
163
            child: route.items[itemIndex],
164
          ),
165 166
          onTap: () => Navigator.pop(
            context,
167 168 169
            new _DropdownRouteResult<T>(route.items[itemIndex].value),
          ),
        ),
170 171 172
      ));
    }

173
    return new FadeTransition(
174
      opacity: _fadeOpacity,
175
      child: new CustomPaint(
176
        painter: new _DropdownMenuPainter(
177 178 179
          color: Theme.of(context).canvasColor,
          elevation: route.elevation,
          selectedIndex: route.selectedIndex,
180
          resize: _resize,
181 182 183
        ),
        child: new Material(
          type: MaterialType.transparency,
184
          textStyle: route.style,
185
          child: new ScrollConfiguration(
186
            delegate: new _DropdownScrollConfigurationDelegate(Theme.of(context).platform),
187 188 189 190 191
            child: new Scrollbar(
              child: new ScrollableList(
                scrollableKey: config.route.scrollableKey,
                padding: _kMenuVerticalPadding,
                itemExtent: _kMenuItemHeight,
192 193 194 195 196 197
                children: children,
              ),
            ),
          ),
        ),
      ),
198 199 200 201
    );
  }
}

202 203
class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate {
  _DropdownMenuRouteLayout({ this.route });
204

205
  final _DropdownRoute<T> route;
206 207 208 209

  Rect get buttonRect => route.buttonRect;
  int get selectedIndex => route.selectedIndex;
  GlobalKey<ScrollableState> get scrollableKey => route.scrollableKey;
210 211 212

  @override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
213 214 215
    // The maximum height of a simple menu should be one or more rows less than
    // the view height. This ensures a tappable area outside of the simple menu
    // with which to dismiss the menu.
216
    //   -- https://material.google.com/components/menus.html#menus-simple-menus
217
    final double maxHeight = math.max(0.0, constraints.maxHeight - 2 * _kMenuItemHeight);
218
    final double width = buttonRect.width + 8.0;
219
    return new BoxConstraints(
220 221
      minWidth: width,
      maxWidth: width,
222
      minHeight: 0.0,
223
      maxHeight: maxHeight,
224 225 226 227 228
    );
  }

  @override
  Offset getPositionForChild(Size size, Size childSize) {
229
    final double buttonTop = buttonRect.top;
230
    final double selectedItemOffset = selectedIndex * _kMenuItemHeight + _kMenuVerticalPadding.top;
231
    double top = (buttonTop - selectedItemOffset) - (_kMenuItemHeight - buttonRect.height) / 2.0;
232
    final double topPreferredLimit = _kMenuItemHeight;
233
    if (top < topPreferredLimit)
234
      top = math.min(buttonTop, topPreferredLimit);
235
    double bottom = top + childSize.height;
236
    final double bottomPreferredLimit = size.height - _kMenuItemHeight;
237
    if (bottom > bottomPreferredLimit) {
238
      bottom = math.max(buttonTop + _kMenuItemHeight, bottomPreferredLimit);
239 240
      top = bottom - childSize.height;
    }
241 242 243 244 245 246 247 248 249 250 251
    assert(() {
      final Rect container = Point.origin & size;
      if (container.intersect(buttonRect) == buttonRect) {
        // If the button was entirely on-screen, then verify
        // that the menu is also on-screen.
        // If the button was a bit off-screen, then, oh well.
        assert(top >= 0.0);
        assert(top + childSize.height <= size.height);
      }
      return true;
    });
252 253 254 255

    if (route.initialLayout) {
      route.initialLayout = false;
      final double scrollOffset = selectedItemOffset - (buttonTop - top);
256 257 258 259 260
      SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
        // TODO(ianh): Compute and set this during layout instead of being
        // lagged by one frame. https://github.com/flutter/flutter/issues/5751
        scrollableKey.currentState.scrollTo(scrollOffset);
      });
261 262
    }

263
    return new Offset(buttonRect.left, top);
264 265 266
  }

  @override
267
  bool shouldRelayout(_DropdownMenuRouteLayout<T> oldDelegate) => oldDelegate.route != route;
268 269
}

270 271 272
// We box the return value so that the return value can be null. Otherwise,
// canceling the route (which returns null) would get confused with actually
// returning a real null value.
273 274
class _DropdownRouteResult<T> {
  const _DropdownRouteResult(this.result);
275

276
  final T result;
277 278

  @override
279
  bool operator ==(dynamic other) {
280
    if (other is! _DropdownRouteResult<T>)
281
      return false;
282
    final _DropdownRouteResult<T> typedOther = other;
283 284
    return result == typedOther.result;
  }
285 286

  @override
287 288 289
  int get hashCode => result.hashCode;
}

290 291
class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
  _DropdownRoute({
292
    this.items,
293
    this.buttonRect,
294
    this.selectedIndex,
295
    this.elevation: 8,
296
    this.theme,
297 298
    this.style,
  }) {
299 300
    assert(style != null);
  }
301

302 303
  final GlobalKey<ScrollableState> scrollableKey = new GlobalKey<ScrollableState>(debugLabel: '_DropdownMenu');
  final List<DropdownMenuItem<T>> items;
304
  final Rect buttonRect;
Hixie's avatar
Hixie committed
305
  final int selectedIndex;
Hans Muller's avatar
Hans Muller committed
306
  final int elevation;
307
  final ThemeData theme;
308 309
  final TextStyle style;

310 311 312
  // The layout gets this route's scrollableKey so that it can scroll the
  /// selected item into position, but only on the initial layout.
  bool initialLayout = true;
313

314
  @override
315
  Duration get transitionDuration => _kDropdownMenuDuration;
316 317

  @override
Hixie's avatar
Hixie committed
318
  bool get barrierDismissable => true;
319 320

  @override
Hixie's avatar
Hixie committed
321
  Color get barrierColor => null;
322

323
  @override
324
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> forwardAnimation) {
325 326 327 328
    Widget menu = new _DropdownMenu<T>(route: this);
    if (theme != null)
      menu = new Theme(data: theme, child: menu);

329
    return new CustomSingleChildLayout(
330
      delegate: new _DropdownMenuRouteLayout<T>(route: this),
331
      child: menu,
332
    );
333
  }
334 335
}

336
/// An item in a menu created by a [DropdownButton].
337 338 339
///
/// 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.
340
class DropdownMenuItem<T> extends StatelessWidget {
341
  /// Creates an item for a dropdown menu.
342 343
  ///
  /// The [child] argument is required.
344
  DropdownMenuItem({
345 346
    Key key,
    this.value,
347
    this.child,
348 349 350
  }) : super(key: key) {
    assert(child != null);
  }
351

352
  /// The widget below this widget in the tree.
353 354
  ///
  /// Typically a [Text] widget.
355
  final Widget child;
356 357 358

  /// The value to return if the user selects this menu item.
  ///
359
  /// Eventually returned in a call to [DropdownButton.onChanged].
360
  final T value;
361

362
  @override
363 364 365
  Widget build(BuildContext context) {
    return new Container(
      height: _kMenuItemHeight,
366
      alignment: FractionalOffset.centerLeft,
367
      child: child,
368 369 370 371
    );
  }
}

372
/// An inherited widget that causes any descendant [DropdownButton]
373 374 375
/// widgets to not include their regular underline.
///
/// This is used by [DataTable] to remove the underline from any
376
/// [DropdownButton] widgets placed within material data tables, as
377
/// required by the material design specification.
378 379
class DropdownButtonHideUnderline extends InheritedWidget {
  /// Creates a [DropdownButtonHideUnderline]. A non-null [child] must
380
  /// be given.
381
  DropdownButtonHideUnderline({
382
    Key key,
383
    Widget child,
384 385 386 387
  }) : super(key: key, child: child) {
    assert(child != null);
  }

388
  /// Returns whether the underline of [DropdownButton] widgets should
389 390
  /// be hidden.
  static bool at(BuildContext context) {
391
    return context.inheritFromWidgetOfExactType(DropdownButtonHideUnderline) != null;
392 393 394
  }

  @override
395
  bool updateShouldNotify(DropdownButtonHideUnderline old) => false;
396 397
}

398 399 400 401 402 403 404 405 406
/// A material design button for selecting from a list of items.
///
/// A dropdown button lets the user select from a number of items. The button
/// shows the currently selected item as well as an arrow that opens a menu for
/// selecting another item.
///
/// Requires one of its ancestors to be a [Material] widget.
///
/// See also:
407
///
408 409 410
///  * [DropdownButtonHideUnderline], which prevents its descendant drop down buttons
///    from displaying their underlines.
///  * [RaisedButton], [FlatButton], ordinary buttons that trigger a single action.
411
///  * <https://material.google.com/components/buttons.html#buttons-dropdown-buttons>
412
class DropdownButton<T> extends StatefulWidget {
413
  /// Creates a dropdown button.
414
  ///
415
  /// The [items] must have distinct values and if [value] isn't null it must be among them.
416 417 418
  ///
  /// The [elevation] and [iconSize] arguments must not be null (they both have
  /// defaults, so do not need to be specified).
419
  DropdownButton({
420
    Key key,
421
    @required this.items,
422
    this.value,
423
    this.hint,
424
    @required this.onChanged,
425 426
    this.elevation: 8,
    this.style,
427 428
    this.iconSize: 24.0,
    this.isDense: false,
429
  }) : super(key: key) {
430
    assert(items != null);
431 432
    assert(value == null ||
      items.where((DropdownMenuItem<T> item) => item.value == value).length == 1);
433
  }
434

435
  /// The list of possible items to select among.
436
  final List<DropdownMenuItem<T>> items;
437

438 439 440
  /// The currently selected item, or null if no item has been selected. If
  /// value is null then the menu is popped up as if the first item was
  /// selected.
441
  final T value;
442

443 444 445
  /// Displayed if [value] is null.
  final Widget hint;

446
  /// Called when the user selects an item.
Hixie's avatar
Hixie committed
447
  final ValueChanged<T> onChanged;
448

449
  /// The z-coordinate at which to place the menu when open.
450 451
  ///
  /// The following elevations have defined shadows: 1, 2, 3, 4, 6, 8, 9, 12, 16, 24
452 453
  ///
  /// Defaults to 8, the appropriate elevation for dropdown buttons.
Hans Muller's avatar
Hans Muller committed
454
  final int elevation;
455

456
  /// The text style to use for text in the dropdown button and the dropdown
457 458 459 460 461 462 463 464
  /// menu that appears when you tap the button.
  ///
  /// Defaults to the [TextTheme.subhead] value of the current
  /// [ThemeData.textTheme] of the current [Theme].
  final TextStyle style;

  /// The size to use for the drop-down button's down arrow icon button.
  ///
465
  /// Defaults to 24.0.
466 467
  final double iconSize;

468 469 470 471 472 473 474 475
  /// Reduce the button's height.
  ///
  /// By default this button's height is the same as its menu items' heights.
  /// If isDense is true, the button's height is reduced by about half. This
  /// can be useful when the button is embedded in a container that adds
  /// its own decorations, like [InputContainer].
  final bool isDense;

476
  @override
477
  _DropdownButtonState<T> createState() => new _DropdownButtonState<T>();
478 479
}

480
class _DropdownButtonState<T> extends State<DropdownButton<T>> {
481 482
  int _selectedIndex;

483
  @override
484 485 486 487 488
  void initState() {
    super.initState();
    _updateSelectedIndex();
  }

489
 @override
490
  void didUpdateConfig(DropdownButton<T> oldConfig) {
491
    _updateSelectedIndex();
492 493 494
  }

  void _updateSelectedIndex() {
495 496 497
    assert(config.value == null ||
      config.items.where((DropdownMenuItem<T> item) => item.value == config.value).length == 1);
    _selectedIndex = null;
498 499 500 501 502 503 504 505
    for (int itemIndex = 0; itemIndex < config.items.length; itemIndex++) {
      if (config.items[itemIndex].value == config.value) {
        _selectedIndex = itemIndex;
        return;
      }
    }
  }

506 507
  TextStyle get _textStyle => config.style ?? Theme.of(context).textTheme.subhead;

508
  void _handleTap() {
509
    final RenderBox itemBox = context.findRenderObject();
510
    final Rect itemRect = itemBox.localToGlobal(Point.origin) & itemBox.size;
511
    Navigator.push(context, new _DropdownRoute<T>(
512
      items: config.items,
513
      buttonRect: _kMenuHorizontalPadding.inflateRect(itemRect),
514
      selectedIndex: _selectedIndex ?? 0,
515
      elevation: config.elevation,
516 517
      theme: Theme.of(context, shadowThemeOnly: true),
      style: _textStyle,
518
    )).then<Null>((_DropdownRouteResult<T> newValue) {
519
      if (!mounted || newValue == null)
520
        return null;
521
      if (config.onChanged != null)
522
        config.onChanged(newValue.result);
523 524 525
    });
  }

526 527 528 529 530 531 532 533
  // When isDense is true, reduce the height of this button from _kMenuItemHeight to
  // _kDenseButtonHeight, but don't make it smaller than the text that it contains.
  // Similarly, we don't reduce the height of the button so much that its icon
  // would be clipped.
  double get _denseButtonHeight {
    return math.max(_textStyle.fontSize, math.max(config.iconSize, _kDenseButtonHeight));
  }

534
  @override
535
  Widget build(BuildContext context) {
536
    assert(debugCheckHasMaterial(context));
537 538 539 540 541 542 543 544 545 546 547 548 549 550 551

    // The width of the button and the menu are defined by the widest
    // item and the width of the hint.
    final List<Widget> items = new List<Widget>.from(config.items);
    int hintIndex;
    if (config.hint != null) {
      hintIndex = items.length;
      items.add(new DefaultTextStyle(
        style: _textStyle.copyWith(color: Theme.of(context).hintColor),
        child: new IgnorePointer(
          child: config.hint,
        ),
      ));
    }

552
    Widget result = new DefaultTextStyle(
553
      style: _textStyle,
554 555 556 557 558 559
      child: new SizedBox(
        height: config.isDense ? _denseButtonHeight : null,
        child: new Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
560 561
            // If value is null (then _selectedIndex is null) then we display
            // the hint or nothing at all.
562
            new IndexedStack(
563
              index: _selectedIndex ?? hintIndex,
564
              alignment: FractionalOffset.centerLeft,
565
              children: items,
566 567 568 569 570 571 572 573 574
            ),
            new Icon(Icons.arrow_drop_down,
              size: config.iconSize,
              // These colors are not defined in the Material Design spec.
              color: Theme.of(context).brightness == Brightness.light ? Colors.grey[700] : Colors.white70
            ),
          ],
        ),
      ),
575
    );
576

577
    if (!DropdownButtonHideUnderline.at(context)) {
578
      final double bottom = config.isDense ? 0.0 : 8.0;
579 580 581 582 583 584
      result = new Stack(
        children: <Widget>[
          result,
          new Positioned(
            left: 0.0,
            right: 0.0,
585
            bottom: bottom,
586 587 588 589
            child: new Container(
              height: 1.0,
              decoration: const BoxDecoration(
                border: const Border(bottom: const BorderSide(color: const Color(0xFFBDBDBD), width: 0.0))
590 591 592 593
              ),
            ),
          ),
        ],
594 595
      );
    }
596

597
    return new GestureDetector(
598
      onTap: _handleTap,
599
      behavior: HitTestBehavior.opaque,
600
      child: result
601 602 603
    );
  }
}