drop_down.dart 17.3 KB
Newer Older
1 2 3 4 5
// 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.

import 'dart:async';
6
import 'dart:math' as math;
7 8

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

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

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

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

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

  final BoxPainter _painter;
51

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

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

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

69
  @override
Hixie's avatar
Hixie committed
70
  bool shouldRepaint(_DropDownMenuPainter oldPainter) {
71 72
    return oldPainter.color != color
        || oldPainter.elevation != elevation
73 74
        || oldPainter.selectedIndex != selectedIndex
        || oldPainter.resize != resize;
75 76 77
  }
}

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

  @override
  Widget wrapScrollWidget(Widget scrollWidget) => new ClampOverscrolls(value: true, child: scrollWidget);
}
final ScrollConfigurationDelegate _dropDownScroll = const _DropDownScrollConfigurationDelegate();

88
class _DropDownMenu<T> extends StatefulWidget {
Hixie's avatar
Hixie committed
89
  _DropDownMenu({
90
    Key key,
Hixie's avatar
Hixie committed
91
    _DropDownRoute<T> route
92
  }) : route = route, super(key: key);
93

Hixie's avatar
Hixie committed
94
  final _DropDownRoute<T> route;
95

96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122
  @override
  _DropDownMenuState<T> createState() => new _DropDownMenuState<T>();
}

class _DropDownMenuState<T> extends State<_DropDownMenu<T>> {
  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),
      reverseCurve: const Interval(0.75, 1.0)
    );
    _resize = new CurvedAnimation(
      parent: config.route.animation,
      curve: const Interval(0.25, 0.5),
      reverseCurve: const Step(0.0)
    );
  }

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

160
    return new FadeTransition(
161
      opacity: _fadeOpacity,
162 163 164 165 166
      child: new CustomPaint(
        painter: new _DropDownMenuPainter(
          color: Theme.of(context).canvasColor,
          elevation: route.elevation,
          selectedIndex: route.selectedIndex,
167
          resize: _resize
168 169 170
        ),
        child: new Material(
          type: MaterialType.transparency,
171
          textStyle: route.style,
172 173 174 175 176 177 178 179 180
          child: new ScrollConfiguration(
            delegate: _dropDownScroll,
            child: new Scrollbar(
              child: new ScrollableList(
                scrollableKey: config.route.scrollableKey,
                padding: _kMenuVerticalPadding,
                itemExtent: _kMenuItemHeight,
                children: children
              )
181
            )
182 183
          )
        )
184 185 186 187 188
      )
    );
  }
}

189 190
class _DropDownMenuRouteLayout<T> extends SingleChildLayoutDelegate {
  _DropDownMenuRouteLayout({ this.route });
191

192 193 194 195 196
  final _DropDownRoute<T> route;

  Rect get buttonRect => route.buttonRect;
  int get selectedIndex => route.selectedIndex;
  GlobalKey<ScrollableState> get scrollableKey => route.scrollableKey;
197 198 199

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

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

    if (route.initialLayout) {
      route.initialLayout = false;
      final double scrollOffset = selectedItemOffset - (buttonTop - top);
      scrollableKey.currentState.scrollTo(scrollOffset);
    }

246
    return new Offset(buttonRect.left, top);
247 248 249
  }

  @override
250
  bool shouldRelayout(_DropDownMenuRouteLayout<T> oldDelegate) => oldDelegate.route != route;
251 252
}

253 254 255 256 257
// 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.
class _DropDownRouteResult<T> {
  const _DropDownRouteResult(this.result);
258

259
  final T result;
260 261

  @override
262
  bool operator ==(dynamic other) {
263
    if (other is! _DropDownRouteResult<T>)
264 265 266 267
      return false;
    final _DropDownRouteResult<T> typedOther = other;
    return result == typedOther.result;
  }
268 269

  @override
270 271 272 273
  int get hashCode => result.hashCode;
}

class _DropDownRoute<T> extends PopupRoute<_DropDownRouteResult<T>> {
Hixie's avatar
Hixie committed
274
  _DropDownRoute({
275
    Completer<_DropDownRouteResult<T>> completer,
276
    this.items,
277
    this.buttonRect,
278
    this.selectedIndex,
279 280 281 282 283
    this.elevation: 8,
    TextStyle style
  }) : _style = style, super(completer: completer) {
    assert(style != null);
  }
284

285
  final GlobalKey<ScrollableState> scrollableKey = new GlobalKey<ScrollableState>(debugLabel: '_DropDownMenu');
Hixie's avatar
Hixie committed
286
  final List<DropDownMenuItem<T>> items;
287
  final Rect buttonRect;
Hixie's avatar
Hixie committed
288
  final int selectedIndex;
Hans Muller's avatar
Hans Muller committed
289
  final int elevation;
290 291 292
  // 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;
293

294 295 296 297 298 299 300 301 302 303 304
  TextStyle get style => _style;
  TextStyle _style;
  set style (TextStyle value) {
    assert(value != null);
    if (_style == value)
      return;
    setState(() {
      _style = value;
    });
  }

305
  @override
Hixie's avatar
Hixie committed
306
  Duration get transitionDuration => _kDropDownMenuDuration;
307 308

  @override
Hixie's avatar
Hixie committed
309
  bool get barrierDismissable => true;
310 311

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

314
  @override
315
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> forwardAnimation) {
316
    return new CustomSingleChildLayout(
317
      delegate: new _DropDownMenuRouteLayout<T>(route: this),
318 319
      child: new _DropDownMenu<T>(route: this)
    );
320
  }
321 322
}

323 324 325 326
/// An item in a menu created by a [DropDownButton].
///
/// 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.
327
class DropDownMenuItem<T> extends StatelessWidget {
328 329 330
  /// Creates an item for a drop down menu.
  ///
  /// The [child] argument is required.
Hixie's avatar
Hixie committed
331
  DropDownMenuItem({
332 333 334
    Key key,
    this.value,
    this.child
335 336 337
  }) : super(key: key) {
    assert(child != null);
  }
338

339
  /// The widget below this widget in the tree.
340 341
  ///
  /// Typically a [Text] widget.
342
  final Widget child;
343 344 345 346

  /// The value to return if the user selects this menu item.
  ///
  /// Eventually returned in a call to [DropDownButton.onChanged].
347
  final T value;
348

349
  @override
350 351 352
  Widget build(BuildContext context) {
    return new Container(
      height: _kMenuItemHeight,
353 354
      align: FractionalOffset.centerLeft,
      child: child
355 356 357 358
    );
  }
}

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

  /// Returns whether the underline of [DropDownButton] widgets should
  /// be hidden.
  static bool at(BuildContext context) {
    return context.inheritFromWidgetOfExactType(DropDownButtonHideUnderline) != null;
  }

  @override
  bool updateShouldNotify(DropDownButtonHideUnderline old) => false;
}

385 386 387 388 389 390 391 392 393
/// 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:
394
///
395 396 397
///  * [RaisedButton]
///  * [FlatButton]
///  * <https://www.google.com/design/spec/components/buttons.html#buttons-dropdown-buttons>
398
class DropDownButton<T> extends StatefulWidget {
399 400 401
  /// Creates a drop down button.
  ///
  /// The [items] must have distinct values and [value] must be among them.
402 403 404
  ///
  /// The [elevation] and [iconSize] arguments must not be null (they both have
  /// defaults, so do not need to be specified).
Hixie's avatar
Hixie committed
405
  DropDownButton({
406
    Key key,
407 408 409
    @required this.items,
    @required this.value,
    @required this.onChanged,
410 411
    this.elevation: 8,
    this.style,
412
    this.iconSize: 24.0
413
  }) : super(key: key) {
414
    assert(items != null);
415 416
    assert(items.where((DropDownMenuItem<T> item) => item.value == value).length == 1);
  }
417

418
  /// The list of possible items to select among.
Hixie's avatar
Hixie committed
419
  final List<DropDownMenuItem<T>> items;
420 421

  /// The currently selected item.
422
  final T value;
423 424

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

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

432 433 434 435 436 437 438 439 440
  /// The text style to use for text in the drop down button and the drop down
  /// 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.
  ///
441
  /// Defaults to 24.0.
442 443
  final double iconSize;

444
  @override
445 446 447 448
  _DropDownButtonState<T> createState() => new _DropDownButtonState<T>();
}

class _DropDownButtonState<T> extends State<DropDownButton<T>> {
449
  @override
450 451 452
  void initState() {
    super.initState();
    _updateSelectedIndex();
453
    assert(_selectedIndex != null);
454 455
  }

456
  @override
457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472
  void didUpdateConfig(DropDownButton<T> oldConfig) {
    if (config.items[_selectedIndex].value != config.value)
      _updateSelectedIndex();
  }

  int _selectedIndex;

  void _updateSelectedIndex() {
    for (int itemIndex = 0; itemIndex < config.items.length; itemIndex++) {
      if (config.items[itemIndex].value == config.value) {
        _selectedIndex = itemIndex;
        return;
      }
    }
  }

473 474 475 476
  TextStyle get _textStyle => config.style ?? Theme.of(context).textTheme.subhead;

  _DropDownRoute<T> _currentRoute;

477
  void _handleTap() {
478
    assert(_currentRoute == null);
479
    final RenderBox itemBox = context.findRenderObject();
480
    final Rect itemRect = itemBox.localToGlobal(Point.origin) & itemBox.size;
481
    final Completer<_DropDownRouteResult<T>> completer = new Completer<_DropDownRouteResult<T>>();
482
    _currentRoute = new _DropDownRoute<T>(
483
      completer: completer,
484
      items: config.items,
485
      buttonRect: _kMenuHorizontalPadding.inflateRect(itemRect),
486
      selectedIndex: _selectedIndex,
487 488 489 490
      elevation: config.elevation,
      style: _textStyle
    );
    Navigator.push(context, _currentRoute);
491
    completer.future.then((_DropDownRouteResult<T> newValue) {
492
      _currentRoute = null;
493
      if (!mounted || newValue == null)
494 495
        return;
      if (config.onChanged != null)
496
        config.onChanged(newValue.result);
497 498 499
    });
  }

500
  @override
501
  Widget build(BuildContext context) {
502
    assert(debugCheckHasMaterial(context));
503
    final TextStyle style = _textStyle;
504
    _currentRoute?.style = style;
505 506 507
    Widget result = new DefaultTextStyle(
      style: style,
      child: new Row(
508
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
509
        mainAxisSize: MainAxisSize.min,
510 511 512 513 514 515 516 517
        children: <Widget>[
          // We use an IndexedStack to make sure we have enough width to show any
          // possible item as the selected item without changing size.
          new IndexedStack(
            index: _selectedIndex,
            alignment: FractionalOffset.centerLeft,
            children: config.items
          ),
518 519 520 521 522
          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
          )
523 524
        ]
      )
525
    );
526

527
    if (!DropDownButtonHideUnderline.at(context)) {
528 529 530 531 532 533 534 535 536 537 538 539 540 541 542
      result = new Stack(
        children: <Widget>[
          result,
          new Positioned(
            left: 0.0,
            right: 0.0,
            bottom: 8.0,
            child: new Container(
              height: 1.0,
              decoration: const BoxDecoration(
                border: const Border(bottom: const BorderSide(color: const Color(0xFFBDBDBD), width: 0.0))
              )
            )
          )
        ]
543 544
      );
    }
545

546
    return new GestureDetector(
547
      onTap: _handleTap,
548
      behavior: HitTestBehavior.opaque,
549
      child: result
550 551 552
    );
  }
}