dropdown.dart 19.2 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 'material.dart';
17
import 'scrollbar.dart';
18 19 20
import 'shadows.dart';
import 'theme.dart';

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.
Adam Barth's avatar
Adam Barth committed
82
class _DropdownScrollBehavior extends ScrollBehavior {
83
  const _DropdownScrollBehavior();
84 85

  @override
86
  TargetPlatform getPlatform(BuildContext context) => Theme.of(context).platform;
87 88

  @override
89
  Widget buildViewportChrome(BuildContext context, Widget child, AxisDirection axisDirection) => child;
90 91

  @override
92
  ScrollPhysics getScrollPhysics(BuildContext context) => const ClampingScrollPhysics();
93 94
}

95 96
class _DropdownMenu<T> extends StatefulWidget {
  _DropdownMenu({
97
    Key key,
98
    _DropdownRoute<T> route,
99
  }) : route = route, super(key: key);
100

101
  final _DropdownRoute<T> route;
102

103
  @override
104
  _DropdownMenuState<T> createState() => new _DropdownMenuState<T>();
105 106
}

107
class _DropdownMenuState<T> extends State<_DropdownMenu<T>> {
108 109 110 111 112 113 114 115 116 117 118 119 120
  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),
121
      reverseCurve: const Interval(0.75, 1.0),
122 123 124 125
    );
    _resize = new CurvedAnimation(
      parent: config.route.animation,
      curve: const Interval(0.25, 0.5),
126
      reverseCurve: const Threshold(0.0),
127 128 129
    );
  }

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

167
    return new FadeTransition(
168
      opacity: _fadeOpacity,
169
      child: new CustomPaint(
170
        painter: new _DropdownMenuPainter(
171 172 173
          color: Theme.of(context).canvasColor,
          elevation: route.elevation,
          selectedIndex: route.selectedIndex,
174
          resize: _resize,
175 176 177
        ),
        child: new Material(
          type: MaterialType.transparency,
178
          textStyle: route.style,
Adam Barth's avatar
Adam Barth committed
179
          child: new ScrollConfiguration(
180
            behavior: const _DropdownScrollBehavior(),
181
            child: new Scrollbar(
182 183
              child: new ListView(
                controller: config.route.scrollController,
184 185
                padding: _kMenuVerticalPadding,
                itemExtent: _kMenuItemHeight,
186
                shrinkWrap: true,
187 188 189 190 191 192
                children: children,
              ),
            ),
          ),
        ),
      ),
193 194 195 196
    );
  }
}

197 198
class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate {
  _DropdownMenuRouteLayout({ this.route });
199

200
  final _DropdownRoute<T> route;
201 202 203

  Rect get buttonRect => route.buttonRect;
  int get selectedIndex => route.selectedIndex;
204
  ScrollController get scrollController => route.scrollController;
205 206 207

  @override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
208 209 210
    // 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.
211
    //   -- https://material.google.com/components/menus.html#menus-simple-menus
212
    final double maxHeight = math.max(0.0, constraints.maxHeight - 2 * _kMenuItemHeight);
213
    final double width = buttonRect.width + 8.0;
214
    return new BoxConstraints(
215 216
      minWidth: width,
      maxWidth: width,
217
      minHeight: 0.0,
218
      maxHeight: maxHeight,
219 220 221 222 223
    );
  }

  @override
  Offset getPositionForChild(Size size, Size childSize) {
224
    final double buttonTop = buttonRect.top;
225
    final double selectedItemOffset = selectedIndex * _kMenuItemHeight + _kMenuVerticalPadding.top;
226
    double top = (buttonTop - selectedItemOffset) - (_kMenuItemHeight - buttonRect.height) / 2.0;
227
    final double topPreferredLimit = _kMenuItemHeight;
228
    if (top < topPreferredLimit)
229
      top = math.min(buttonTop, topPreferredLimit);
230
    double bottom = top + childSize.height;
231
    final double bottomPreferredLimit = size.height - _kMenuItemHeight;
232
    if (bottom > bottomPreferredLimit) {
233
      bottom = math.max(buttonTop + _kMenuItemHeight, bottomPreferredLimit);
234 235
      top = bottom - childSize.height;
    }
236 237 238 239 240 241 242 243 244 245 246
    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;
    });
247 248 249 250

    if (route.initialLayout) {
      route.initialLayout = false;
      final double scrollOffset = selectedItemOffset - (buttonTop - top);
251 252 253
      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
254
        scrollController.jumpTo(scrollOffset);
255
      });
256 257
    }

258
    return new Offset(buttonRect.left, top);
259 260 261
  }

  @override
262
  bool shouldRelayout(_DropdownMenuRouteLayout<T> oldDelegate) => oldDelegate.route != route;
263 264
}

265 266 267
// 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.
268 269
class _DropdownRouteResult<T> {
  const _DropdownRouteResult(this.result);
270

271
  final T result;
272 273

  @override
274
  bool operator ==(dynamic other) {
275
    if (other is! _DropdownRouteResult<T>)
276
      return false;
277
    final _DropdownRouteResult<T> typedOther = other;
278 279
    return result == typedOther.result;
  }
280 281

  @override
282 283 284
  int get hashCode => result.hashCode;
}

285 286
class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
  _DropdownRoute({
287
    this.items,
288
    this.buttonRect,
289
    this.selectedIndex,
290
    this.elevation: 8,
291
    this.theme,
292 293
    this.style,
  }) {
294 295
    assert(style != null);
  }
296

297
  final ScrollController scrollController = new ScrollController();
298
  final List<DropdownMenuItem<T>> items;
299
  final Rect buttonRect;
Hixie's avatar
Hixie committed
300
  final int selectedIndex;
Hans Muller's avatar
Hans Muller committed
301
  final int elevation;
302
  final ThemeData theme;
303 304
  final TextStyle style;

305 306
  // The layout gets this route's scrollController so that it can scroll the
  // selected item into position, but only on the initial layout.
307
  bool initialLayout = true;
308

309
  @override
310
  Duration get transitionDuration => _kDropdownMenuDuration;
311 312

  @override
Hixie's avatar
Hixie committed
313
  bool get barrierDismissable => true;
314 315

  @override
Hixie's avatar
Hixie committed
316
  Color get barrierColor => null;
317

318
  @override
319
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> forwardAnimation) {
320 321 322 323
    Widget menu = new _DropdownMenu<T>(route: this);
    if (theme != null)
      menu = new Theme(data: theme, child: menu);

324
    return new CustomSingleChildLayout(
325
      delegate: new _DropdownMenuRouteLayout<T>(route: this),
326
      child: menu,
327
    );
328
  }
329 330
}

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

347
  /// The widget below this widget in the tree.
348 349
  ///
  /// Typically a [Text] widget.
350
  final Widget child;
351 352 353

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

357
  @override
358 359 360
  Widget build(BuildContext context) {
    return new Container(
      height: _kMenuItemHeight,
361
      alignment: FractionalOffset.centerLeft,
362
      child: child,
363 364 365 366
    );
  }
}

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

383
  /// Returns whether the underline of [DropdownButton] widgets should
384 385
  /// be hidden.
  static bool at(BuildContext context) {
386
    return context.inheritFromWidgetOfExactType(DropdownButtonHideUnderline) != null;
387 388 389
  }

  @override
390
  bool updateShouldNotify(DropdownButtonHideUnderline old) => false;
391 392
}

393 394 395 396 397 398 399 400 401
/// 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:
402
///
403
///  * [DropdownButtonHideUnderline], which prevents its descendant dropdown buttons
404 405
///    from displaying their underlines.
///  * [RaisedButton], [FlatButton], ordinary buttons that trigger a single action.
406
///  * <https://material.google.com/components/buttons.html#buttons-dropdown-buttons>
407
class DropdownButton<T> extends StatefulWidget {
408
  /// Creates a dropdown button.
409
  ///
410
  /// The [items] must have distinct values and if [value] isn't null it must be among them.
411 412 413
  ///
  /// The [elevation] and [iconSize] arguments must not be null (they both have
  /// defaults, so do not need to be specified).
414
  DropdownButton({
415
    Key key,
416
    @required this.items,
417
    this.value,
418
    this.hint,
419
    @required this.onChanged,
420 421
    this.elevation: 8,
    this.style,
422 423
    this.iconSize: 24.0,
    this.isDense: false,
424
  }) : super(key: key) {
425
    assert(items != null);
426 427
    assert(value == null ||
      items.where((DropdownMenuItem<T> item) => item.value == value).length == 1);
428
  }
429

430
  /// The list of possible items to select among.
431
  final List<DropdownMenuItem<T>> items;
432

433 434 435
  /// 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.
436
  final T value;
437

438 439 440
  /// Displayed if [value] is null.
  final Widget hint;

441
  /// Called when the user selects an item.
Hixie's avatar
Hixie committed
442
  final ValueChanged<T> onChanged;
443

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

451
  /// The text style to use for text in the dropdown button and the dropdown
452 453 454 455 456 457 458 459
  /// 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.
  ///
460
  /// Defaults to 24.0.
461 462
  final double iconSize;

463 464 465 466 467 468 469 470
  /// 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;

471
  @override
472
  _DropdownButtonState<T> createState() => new _DropdownButtonState<T>();
473 474
}

475
class _DropdownButtonState<T> extends State<DropdownButton<T>> {
476 477
  int _selectedIndex;

478
  @override
479 480 481 482 483
  void initState() {
    super.initState();
    _updateSelectedIndex();
  }

484
 @override
485
  void didUpdateConfig(DropdownButton<T> oldConfig) {
486
    _updateSelectedIndex();
487 488 489
  }

  void _updateSelectedIndex() {
490 491 492
    assert(config.value == null ||
      config.items.where((DropdownMenuItem<T> item) => item.value == config.value).length == 1);
    _selectedIndex = null;
493 494 495 496 497 498 499 500
    for (int itemIndex = 0; itemIndex < config.items.length; itemIndex++) {
      if (config.items[itemIndex].value == config.value) {
        _selectedIndex = itemIndex;
        return;
      }
    }
  }

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

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

521 522 523 524 525 526 527 528
  // 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));
  }

529
  @override
530
  Widget build(BuildContext context) {
531
    assert(debugCheckHasMaterial(context));
532 533 534 535 536 537 538 539 540 541 542 543 544 545 546

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

547
    Widget result = new DefaultTextStyle(
548
      style: _textStyle,
549 550 551 552 553 554
      child: new SizedBox(
        height: config.isDense ? _denseButtonHeight : null,
        child: new Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
555 556
            // If value is null (then _selectedIndex is null) then we display
            // the hint or nothing at all.
557
            new IndexedStack(
558
              index: _selectedIndex ?? hintIndex,
559
              alignment: FractionalOffset.centerLeft,
560
              children: items,
561 562 563 564 565 566 567 568 569
            ),
            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
            ),
          ],
        ),
      ),
570
    );
571

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

592
    return new GestureDetector(
593
      onTap: _handleTap,
594
      behavior: HitTestBehavior.opaque,
595
      child: result
596 597 598
    );
  }
}