dropdown.dart 19.1 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 'constants.dart';
13
import 'debug.dart';
14
import 'icon.dart';
15
import 'icons.dart';
16
import 'ink_well.dart';
17
import 'material.dart';
18
import 'scrollbar.dart';
19 20 21
import 'shadows.dart';
import 'theme.dart';

22
const Duration _kDropdownMenuDuration = const Duration(milliseconds: 300);
23
const double _kMenuItemHeight = 48.0;
24
const double _kDenseButtonHeight = 24.0;
25
const EdgeInsets _kMenuHorizontalPadding = const EdgeInsets.symmetric(horizontal: 16.0);
26

27 28
class _DropdownMenuPainter extends CustomPainter {
  _DropdownMenuPainter({
29 30
    this.color,
    this.elevation,
31
    this.selectedIndex,
32 33
    this.resize,
  }) : _painter = new BoxDecoration(
34 35 36
         // 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.
37
         backgroundColor: color,
38
         borderRadius: new BorderRadius.circular(2.0),
39 40 41
         boxShadow: kElevationToShadow[elevation]
       ).createBoxPainter(),
       super(repaint: resize);
42 43 44

  final Color color;
  final int elevation;
45 46 47 48
  final int selectedIndex;
  final Animation<double> resize;

  final BoxPainter _painter;
49

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

    final Tween<double> bottom = new Tween<double>(
59
      begin: (top.begin + _kMenuItemHeight).clamp(_kMenuItemHeight, size.height),
60
      end: size.height,
61 62
    );

63 64 65
    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));
66 67
  }

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

77 78
// 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
79
class _DropdownScrollBehavior extends ScrollBehavior {
80
  const _DropdownScrollBehavior();
81 82

  @override
83
  TargetPlatform getPlatform(BuildContext context) => Theme.of(context).platform;
84 85

  @override
86
  Widget buildViewportChrome(BuildContext context, Widget child, AxisDirection axisDirection) => child;
87 88

  @override
89
  ScrollPhysics getScrollPhysics(BuildContext context) => const ClampingScrollPhysics();
90 91
}

92 93
class _DropdownMenu<T> extends StatefulWidget {
  _DropdownMenu({
94
    Key key,
95 96
    this.route,
  }) : super(key: key);
97

98
  final _DropdownRoute<T> route;
99

100
  @override
101
  _DropdownMenuState<T> createState() => new _DropdownMenuState<T>();
102 103
}

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

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

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

194 195
class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate {
  _DropdownMenuRouteLayout({ this.route });
196

197
  final _DropdownRoute<T> route;
198 199 200

  Rect get buttonRect => route.buttonRect;
  int get selectedIndex => route.selectedIndex;
201
  ScrollController get scrollController => route.scrollController;
202 203 204

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

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

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

255
    return new Offset(buttonRect.left, top);
256 257 258
  }

  @override
259
  bool shouldRelayout(_DropdownMenuRouteLayout<T> oldDelegate) => oldDelegate.route != route;
260 261
}

262 263 264
// 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.
265 266
class _DropdownRouteResult<T> {
  const _DropdownRouteResult(this.result);
267

268
  final T result;
269 270

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

  @override
279 280 281
  int get hashCode => result.hashCode;
}

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

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

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

306
  @override
307
  Duration get transitionDuration => _kDropdownMenuDuration;
308 309

  @override
310
  bool get barrierDismissible => true;
311 312

  @override
Hixie's avatar
Hixie committed
313
  Color get barrierColor => null;
314

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

321
    return new CustomSingleChildLayout(
322
      delegate: new _DropdownMenuRouteLayout<T>(route: this),
323
      child: menu,
324
    );
325
  }
326 327
}

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

344
  /// The widget below this widget in the tree.
345 346
  ///
  /// Typically a [Text] widget.
347
  final Widget child;
348 349 350

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

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

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

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

  @override
387
  bool updateShouldNotify(DropdownButtonHideUnderline old) => false;
388 389
}

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

427
  /// The list of possible items to select among.
428
  final List<DropdownMenuItem<T>> items;
429

430 431 432
  /// 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.
433
  final T value;
434

435 436 437
  /// Displayed if [value] is null.
  final Widget hint;

438
  /// Called when the user selects an item.
Hixie's avatar
Hixie committed
439
  final ValueChanged<T> onChanged;
440

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

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

460 461 462 463 464
  /// 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
465
  /// its own decorations, like [InputDecorator].
466 467
  final bool isDense;

468
  @override
469
  _DropdownButtonState<T> createState() => new _DropdownButtonState<T>();
470 471
}

472
class _DropdownButtonState<T> extends State<DropdownButton<T>> {
473 474
  int _selectedIndex;

475
  @override
476 477 478 479 480
  void initState() {
    super.initState();
    _updateSelectedIndex();
  }

481
 @override
482
  void didUpdateConfig(DropdownButton<T> oldConfig) {
483
    _updateSelectedIndex();
484 485 486
  }

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

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

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

518 519 520 521 522 523 524 525
  // 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));
  }

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

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

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

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

589
    return new GestureDetector(
590
      onTap: _handleTap,
591
      behavior: HitTestBehavior.opaque,
592
      child: result
593 594 595
    );
  }
}