dropdown.dart 49.3 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// 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
import 'dart:ui' show window;
7

8
import 'package:flutter/foundation.dart';
9
import 'package:flutter/rendering.dart';
10
import 'package:flutter/services.dart';
11 12
import 'package:flutter/widgets.dart';

13
import 'button_theme.dart';
14
import 'colors.dart';
15
import 'constants.dart';
16
import 'debug.dart';
17
import 'icons.dart';
18
import 'ink_well.dart';
19
import 'input_decorator.dart';
20
import 'material.dart';
21
import 'material_localizations.dart';
22
import 'scrollbar.dart';
23 24 25
import 'shadows.dart';
import 'theme.dart';

26
const Duration _kDropdownMenuDuration = Duration(milliseconds: 300);
27
const double _kMenuItemHeight = kMinInteractiveDimension;
28
const double _kDenseButtonHeight = 24.0;
29 30
const EdgeInsets _kMenuItemPadding = EdgeInsets.symmetric(horizontal: 16.0);
const EdgeInsetsGeometry _kAlignedButtonPadding = EdgeInsetsDirectional.only(start: 16.0, end: 4.0);
31 32
const EdgeInsets _kUnalignedButtonPadding = EdgeInsets.zero;
const EdgeInsets _kAlignedMenuMargin = EdgeInsets.zero;
33
const EdgeInsetsGeometry _kUnalignedMenuMargin = EdgeInsetsDirectional.only(start: 16.0, end: 24.0);
34

35 36
typedef DropdownButtonBuilder = List<Widget> Function(BuildContext context);

37 38
class _DropdownMenuPainter extends CustomPainter {
  _DropdownMenuPainter({
39 40
    this.color,
    this.elevation,
41
    this.selectedIndex,
42
    this.resize,
43
    this.getSelectedItemOffset,
44
  }) : _painter = BoxDecoration(
45
         // If you add an image here, you must provide a real
46 47
         // configuration in the paint() function and you must provide some sort
         // of onChanged callback here.
48
         color: color,
49
         borderRadius: BorderRadius.circular(2.0),
50
         boxShadow: kElevationToShadow[elevation],
51 52
       ).createBoxPainter(),
       super(repaint: resize);
53 54 55

  final Color color;
  final int elevation;
56 57
  final int selectedIndex;
  final Animation<double> resize;
58
  final ValueGetter<double> getSelectedItemOffset;
59
  final BoxPainter _painter;
60

61
  @override
62
  void paint(Canvas canvas, Size size) {
63
    final double selectedItemOffset = getSelectedItemOffset();
64
    final Tween<double> top = Tween<double>(
65
      begin: selectedItemOffset.clamp(0.0, size.height - _kMenuItemHeight),
66
      end: 0.0,
67 68
    );

69
    final Tween<double> bottom = Tween<double>(
70
      begin: (top.begin + _kMenuItemHeight).clamp(_kMenuItemHeight, size.height),
71
      end: size.height,
72 73
    );

74
    final Rect rect = Rect.fromLTRB(0.0, top.evaluate(resize), size.width, bottom.evaluate(resize));
75

76
    _painter.paint(canvas, rect.topLeft, ImageConfiguration(size: rect.size));
77 78
  }

79
  @override
80
  bool shouldRepaint(_DropdownMenuPainter oldPainter) {
81 82
    return oldPainter.color != color
        || oldPainter.elevation != elevation
83 84
        || oldPainter.selectedIndex != selectedIndex
        || oldPainter.resize != resize;
85 86 87
  }
}

88 89
// 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
90
class _DropdownScrollBehavior extends ScrollBehavior {
91
  const _DropdownScrollBehavior();
92 93

  @override
94
  TargetPlatform getPlatform(BuildContext context) => Theme.of(context).platform;
95 96

  @override
97
  Widget buildViewportChrome(BuildContext context, Widget child, AxisDirection axisDirection) => child;
98 99

  @override
100
  ScrollPhysics getScrollPhysics(BuildContext context) => const ClampingScrollPhysics();
101 102
}

103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192
// The widget that is the button wrapping the menu items.
class _DropdownMenuItemButton<T> extends StatefulWidget {
  const _DropdownMenuItemButton({
    Key key,
    @required this.padding,
    @required this.route,
    @required this.buttonRect,
    @required this.constraints,
    @required this.itemIndex,
  }) : super(key: key);

  final _DropdownRoute<T> route;
  final EdgeInsets padding;
  final Rect buttonRect;
  final BoxConstraints constraints;
  final int itemIndex;

  @override
  _DropdownMenuItemButtonState<T> createState() => _DropdownMenuItemButtonState<T>();
}

class _DropdownMenuItemButtonState<T> extends State<_DropdownMenuItemButton<T>> {
  void _handleFocusChange(bool focused) {
    bool inTraditionalMode;
    switch (FocusManager.instance.highlightMode) {
      case FocusHighlightMode.touch:
        inTraditionalMode = false;
        break;
      case FocusHighlightMode.traditional:
        inTraditionalMode = true;
        break;
    }

    if (focused && inTraditionalMode) {
      final _MenuLimits menuLimits = widget.route.getMenuLimits(
          widget.buttonRect, widget.constraints.maxHeight, widget.itemIndex);
      widget.route.scrollController.animateTo(
        menuLimits.scrollOffset,
        curve: Curves.easeInOut,
        duration: const Duration(milliseconds: 100),
      );
    }
  }

  void _handleOnTap() {
    Navigator.pop(
      context,
      _DropdownRouteResult<T>(widget.route.items[widget.itemIndex].item.value),
    );
  }

  static final Map<LogicalKeySet, Intent> _webShortcuts =<LogicalKeySet, Intent>{
    LogicalKeySet(LogicalKeyboardKey.enter): const Intent(SelectAction.key),
  };

  @override
  Widget build(BuildContext context) {
    CurvedAnimation opacity;
    final double unit = 0.5 / (widget.route.items.length + 1.5);
    if (widget.itemIndex == widget.route.selectedIndex) {
      opacity = CurvedAnimation(parent: widget.route.animation, curve: const Threshold(0.0));
    } else {
      final double start = (0.5 + (widget.itemIndex + 1) * unit).clamp(0.0, 1.0);
      final double end = (start + 1.5 * unit).clamp(0.0, 1.0);
      opacity = CurvedAnimation(parent: widget.route.animation, curve: Interval(start, end));
    }
    Widget child = FadeTransition(
      opacity: opacity,
      child: InkWell(
        autofocus: widget.itemIndex == widget.route.selectedIndex,
        child: Container(
          padding: widget.padding,
          child: widget.route.items[widget.itemIndex],
        ),
        onTap: _handleOnTap,
        onFocusChange: _handleFocusChange,
      ),
    );
    if (kIsWeb) {
      // On the web, enter doesn't select things, *except* in a <select>
      // element, which is what a dropdown emulates.
      child = Shortcuts(
        shortcuts: _webShortcuts,
        child: child,
      );
    }
    return child;
  }
}

193
class _DropdownMenu<T> extends StatefulWidget {
194
  const _DropdownMenu({
195
    Key key,
196
    this.padding,
197
    this.route,
198 199
    this.buttonRect,
    this.constraints,
200
  }) : super(key: key);
201

202
  final _DropdownRoute<T> route;
203
  final EdgeInsets padding;
204 205
  final Rect buttonRect;
  final BoxConstraints constraints;
206

207
  @override
208
  _DropdownMenuState<T> createState() => _DropdownMenuState<T>();
209 210
}

211
class _DropdownMenuState<T> extends State<_DropdownMenu<T>> {
212 213 214 215 216 217 218 219 220 221
  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.
222
    _fadeOpacity = CurvedAnimation(
223
      parent: widget.route.animation,
224
      curve: const Interval(0.0, 0.25),
225
      reverseCurve: const Interval(0.75, 1.0),
226
    );
227
    _resize = CurvedAnimation(
228
      parent: widget.route.animation,
229
      curve: const Interval(0.25, 0.5),
230
      reverseCurve: const Threshold(0.0),
231 232 233
    );
  }

234
  @override
235 236
  Widget build(BuildContext context) {
    // The menu is shown in three stages (unit timing in brackets):
Hixie's avatar
Hixie committed
237 238
    // [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
239
    //   until it's big enough for as many items as we're going to show.
Hixie's avatar
Hixie committed
240
    // [0.5s - 1.0s] Fade in the remaining visible items from top to bottom.
241 242
    //
    // When the menu is dismissed we just fade the entire thing out
Hixie's avatar
Hixie committed
243
    // in the first 0.25s.
244
    assert(debugCheckHasMaterialLocalizations(context));
245
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
246
    final _DropdownRoute<T> route = widget.route;
247 248 249 250 251 252 253 254
    final List<Widget> children = <Widget>[
      for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex)
        _DropdownMenuItemButton<T>(
          route: widget.route,
          padding: widget.padding,
          buttonRect: widget.buttonRect,
          constraints: widget.constraints,
          itemIndex: itemIndex,
255
        ),
256
      ];
257

258
    return FadeTransition(
259
      opacity: _fadeOpacity,
260 261
      child: CustomPaint(
        painter: _DropdownMenuPainter(
262 263 264
          color: Theme.of(context).canvasColor,
          elevation: route.elevation,
          selectedIndex: route.selectedIndex,
265
          resize: _resize,
266 267
          // This offset is passed as a callback, not a value, because it must
          // be retrieved at paint time (after layout), not at build time.
268
          getSelectedItemOffset: () => route.getItemOffset(route.selectedIndex),
269
        ),
270
        child: Semantics(
271 272 273 274
          scopesRoute: true,
          namesRoute: true,
          explicitChildNodes: true,
          label: localizations.popupMenuLabel,
275
          child: Material(
276 277 278 279 280 281 282 283 284 285
            type: MaterialType.transparency,
            textStyle: route.style,
            child: ScrollConfiguration(
              behavior: const _DropdownScrollBehavior(),
              child: Scrollbar(
                child: ListView(
                  controller: widget.route.scrollController,
                  padding: kMaterialListPadding,
                  shrinkWrap: true,
                  children: children,
286
                ),
287 288 289 290
              ),
            ),
          ),
        ),
291 292
      ),
    );
293 294 295
  }
}

296
class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate {
297 298
  _DropdownMenuRouteLayout({
    @required this.buttonRect,
299
    @required this.route,
300 301
    @required this.textDirection,
  });
302

303
  final Rect buttonRect;
304
  final _DropdownRoute<T> route;
305
  final TextDirection textDirection;
306 307 308

  @override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
309 310 311
    // 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.
312
    //   -- https://material.io/design/components/menus.html#usage
313
    final double maxHeight = math.max(0.0, constraints.maxHeight - 2 * _kMenuItemHeight);
314 315
    // The width of a menu should be at most the view width. This ensures that
    // the menu does not extend past the left and right edges of the screen.
316
    final double width = math.min(constraints.maxWidth, buttonRect.width);
317
    return BoxConstraints(
318 319
      minWidth: width,
      maxWidth: width,
320
      minHeight: 0.0,
321
      maxHeight: maxHeight,
322 323 324 325 326
    );
  }

  @override
  Offset getPositionForChild(Size size, Size childSize) {
327
    final _MenuLimits menuLimits = route.getMenuLimits(buttonRect, size.height, route.selectedIndex);
328

329
    assert(() {
330
      final Rect container = Offset.zero & size;
331 332 333 334
      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.
335 336
        assert(menuLimits.top >= 0.0);
        assert(menuLimits.top + menuLimits.height <= size.height);
337 338
      }
      return true;
339
    }());
340 341 342 343
    assert(textDirection != null);
    double left;
    switch (textDirection) {
      case TextDirection.rtl:
344
        left = buttonRect.right.clamp(0.0, size.width) - childSize.width;
345 346 347 348 349
        break;
      case TextDirection.ltr:
        left = buttonRect.left.clamp(0.0, size.width - childSize.width);
        break;
    }
350 351

    return Offset(left, menuLimits.top);
352 353 354
  }

  @override
355
  bool shouldRelayout(_DropdownMenuRouteLayout<T> oldDelegate) {
356
    return buttonRect != oldDelegate.buttonRect || textDirection != oldDelegate.textDirection;
357
  }
358 359
}

360 361 362
// 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.
363 364
class _DropdownRouteResult<T> {
  const _DropdownRouteResult(this.result);
365

366
  final T result;
367 368

  @override
369
  bool operator ==(dynamic other) {
370
    if (other is! _DropdownRouteResult<T>)
371
      return false;
372
    final _DropdownRouteResult<T> typedOther = other;
373 374
    return result == typedOther.result;
  }
375 376

  @override
377 378 379
  int get hashCode => result.hashCode;
}

380 381 382 383 384 385 386 387
class _MenuLimits {
  const _MenuLimits(this.top, this.bottom, this.height, this.scrollOffset);
  final double top;
  final double bottom;
  final double height;
  final double scrollOffset;
}

388 389
class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
  _DropdownRoute({
390
    this.items,
391
    this.padding,
392
    this.buttonRect,
393
    this.selectedIndex,
394
    this.elevation = 8,
395
    this.theme,
396
    @required this.style,
397
    this.barrierLabel,
398 399 400
    this.itemHeight,
  }) : assert(style != null),
       itemHeights = List<double>.filled(items.length, itemHeight ?? kMinInteractiveDimension);
401

402
  final List<_MenuItem<T>> items;
403
  final EdgeInsetsGeometry padding;
404
  final Rect buttonRect;
Hixie's avatar
Hixie committed
405
  final int selectedIndex;
Hans Muller's avatar
Hans Muller committed
406
  final int elevation;
407
  final ThemeData theme;
408
  final TextStyle style;
409
  final double itemHeight;
410

411
  final List<double> itemHeights;
412
  ScrollController scrollController;
413

414
  @override
415
  Duration get transitionDuration => _kDropdownMenuDuration;
416 417

  @override
418
  bool get barrierDismissible => true;
419 420

  @override
Hixie's avatar
Hixie committed
421
  Color get barrierColor => null;
422

423 424 425
  @override
  final String barrierLabel;

426
  @override
427
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        return _DropdownRoutePage<T>(
          route: this,
          constraints: constraints,
          items: items,
          padding: padding,
          buttonRect: buttonRect,
          selectedIndex: selectedIndex,
          elevation: elevation,
          theme: theme,
          style: style,
        );
      }
    );
  }

  void _dismiss() {
    navigator?.removeRoute(this);
  }

449
  double getItemOffset(int index) {
450
    double offset = kMaterialListPadding.top;
451
    if (items.isNotEmpty && index > 0) {
452 453
      assert(items.length == itemHeights?.length);
      offset += itemHeights
454
        .sublist(0, index)
455 456 457 458
        .reduce((double total, double height) => total + height);
    }
    return offset;
  }
459

460 461 462 463
  // Returns the vertical extent of the menu and the initial scrollOffset
  // for the ListView that contains the menu items. The vertical center of the
  // selected item is aligned with the button's vertical center, as far as
  // that's possible given availableHeight.
464
  _MenuLimits getMenuLimits(Rect buttonRect, double availableHeight, int index) {
465
    final double maxMenuHeight = availableHeight - 2.0 * _kMenuItemHeight;
466
    final double buttonTop = buttonRect.top;
467
    final double buttonBottom = math.min(buttonRect.bottom, availableHeight);
468
    final double selectedItemOffset = getItemOffset(index);
469 470 471 472 473 474

    // If the button is placed on the bottom or top of the screen, its top or
    // bottom may be less than [_kMenuItemHeight] from the edge of the screen.
    // In this case, we want to change the menu limits to align with the top
    // or bottom edge of the button.
    final double topLimit = math.min(_kMenuItemHeight, buttonTop);
475
    final double bottomLimit = math.max(availableHeight - _kMenuItemHeight, buttonBottom);
476

477 478 479 480
    double menuTop = (buttonTop - selectedItemOffset) - (itemHeights[selectedIndex] - buttonRect.height) / 2.0;
    double preferredMenuHeight = kMaterialListPadding.vertical;
    if (items.isNotEmpty)
      preferredMenuHeight += itemHeights.reduce((double total, double height) => total + height);
481 482 483 484 485 486 487 488 489 490 491 492 493

    // If there are too many elements in the menu, we need to shrink it down
    // so it is at most the maxMenuHeight.
    final double menuHeight = math.min(maxMenuHeight, preferredMenuHeight);
    double menuBottom = menuTop + menuHeight;

    // If the computed top or bottom of the menu are outside of the range
    // specified, we need to bring them into range. If the item height is larger
    // than the button height and the button is at the very bottom or top of the
    // screen, the menu will be aligned with the bottom or top of the button
    // respectively.
    if (menuTop < topLimit)
      menuTop = math.min(buttonTop, topLimit);
494

495 496 497
    if (menuBottom > bottomLimit) {
      menuBottom = math.max(buttonBottom, bottomLimit);
      menuTop = menuBottom - menuHeight;
498 499
    }

500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546
    // If all of the menu items will not fit within availableHeight then
    // compute the scroll offset that will line the selected menu item up
    // with the select item. This is only done when the menu is first
    // shown - subsequently we leave the scroll offset where the user left
    // it. This scroll offset is only accurate for fixed height menu items
    // (the default).
    final double scrollOffset = preferredMenuHeight <= maxMenuHeight ? 0 :
      math.max(0.0, selectedItemOffset - (buttonTop - menuTop));

    return _MenuLimits(menuTop, menuBottom, menuHeight, scrollOffset);
  }
}

class _DropdownRoutePage<T> extends StatelessWidget {
  const _DropdownRoutePage({
    Key key,
    this.route,
    this.constraints,
    this.items,
    this.padding,
    this.buttonRect,
    this.selectedIndex,
    this.elevation = 8,
    this.theme,
    this.style,
  }) : super(key: key);

  final _DropdownRoute<T> route;
  final BoxConstraints constraints;
  final List<_MenuItem<T>> items;
  final EdgeInsetsGeometry padding;
  final Rect buttonRect;
  final int selectedIndex;
  final int elevation;
  final ThemeData theme;
  final TextStyle style;

  @override
  Widget build(BuildContext context) {
    assert(debugCheckHasDirectionality(context));

    // Computing the initialScrollOffset now, before the items have been laid
    // out. This only works if the item heights are effectively fixed, i.e. either
    // DropdownButton.itemHeight is specified or DropdownButton.itemHeight is null
    // and all of the items' intrinsic heights are less than kMinInteractiveDimension.
    // Otherwise the initialScrollOffset is just a rough approximation based on
    // treating the items as if their heights were all equal to kMinInteractveDimension.
547
    if (route.scrollController == null) {
548
      final _MenuLimits menuLimits = route.getMenuLimits(buttonRect, constraints.maxHeight, selectedIndex);
549
      route.scrollController = ScrollController(initialScrollOffset: menuLimits.scrollOffset);
550 551
    }

552
    final TextDirection textDirection = Directionality.of(context);
553
    Widget menu = _DropdownMenu<T>(
554
      route: route,
555
      padding: padding.resolve(textDirection),
556 557
      buttonRect: buttonRect,
      constraints: constraints,
558 559
    );

560
    if (theme != null)
561
      menu = Theme(data: theme, child: menu);
562

563
    return MediaQuery.removePadding(
564 565 566 567 568
      context: context,
      removeTop: true,
      removeBottom: true,
      removeLeft: true,
      removeRight: true,
569
      child: Builder(
570
        builder: (BuildContext context) {
571 572
          return CustomSingleChildLayout(
            delegate: _DropdownMenuRouteLayout<T>(
573
              buttonRect: buttonRect,
574
              route: route,
575
              textDirection: textDirection,
576 577 578 579
            ),
            child: menu,
          );
        },
580
      ),
581
    );
582
  }
583 584
}

585 586 587 588
// This widget enables _DropdownRoute to look up the sizes of
// each menu item. These sizes are used to compute the offset of the selected
// item so that _DropdownRoutePage can align the vertical center of the
// selected item lines up with the vertical center of the dropdown button,
589
// as closely as possible.
590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622
class _MenuItem<T> extends SingleChildRenderObjectWidget {
  const _MenuItem({
    Key key,
    @required this.onLayout,
    @required this.item,
  }) : assert(onLayout != null), super(key: key, child: item);

  final ValueChanged<Size> onLayout;
  final DropdownMenuItem<T> item;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return _RenderMenuItem(onLayout);
  }

  @override
  void updateRenderObject(BuildContext context, covariant _RenderMenuItem renderObject) {
    renderObject.onLayout = onLayout;
  }
}

class _RenderMenuItem extends RenderProxyBox {
  _RenderMenuItem(this.onLayout, [RenderBox child]) : assert(onLayout != null), super(child);

  ValueChanged<Size> onLayout;

  @override
  void performLayout() {
    super.performLayout();
    onLayout(size);
  }
}

623 624 625 626
// The container widget for a menu item created by a [DropdownButton]. It
// provides the default configuration for [DropdownMenuItem]s, as well as a
// [DropdownButton]'s hint and disabledHint widgets.
class _DropdownMenuItemContainer extends StatelessWidget {
627
  /// Creates an item for a dropdown menu.
628 629
  ///
  /// The [child] argument is required.
630
  const _DropdownMenuItemContainer({
631
    Key key,
632
    @required this.child,
633 634
  }) : assert(child != null),
       super(key: key);
635

636
  /// The widget below this widget in the tree.
637 638
  ///
  /// Typically a [Text] widget.
639
  final Widget child;
640

641
  @override
642
  Widget build(BuildContext context) {
643
    return Container(
644
      constraints: const BoxConstraints(minHeight: _kMenuItemHeight),
645 646
      alignment: AlignmentDirectional.centerStart,
      child: child,
647 648 649 650
    );
  }
}

651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671
/// 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.
class DropdownMenuItem<T> extends _DropdownMenuItemContainer {
  /// Creates an item for a dropdown menu.
  ///
  /// The [child] argument is required.
  const DropdownMenuItem({
    Key key,
    this.value,
    @required Widget child,
  }) : assert(child != null),
       super(key: key, child: child);

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

672
/// An inherited widget that causes any descendant [DropdownButton]
673 674 675
/// widgets to not include their regular underline.
///
/// This is used by [DataTable] to remove the underline from any
676
/// [DropdownButton] widgets placed within material data tables, as
677
/// required by the material design specification.
678 679
class DropdownButtonHideUnderline extends InheritedWidget {
  /// Creates a [DropdownButtonHideUnderline]. A non-null [child] must
680
  /// be given.
681
  const DropdownButtonHideUnderline({
682
    Key key,
683
    @required Widget child,
684 685
  }) : assert(child != null),
       super(key: key, child: child);
686

687
  /// Returns whether the underline of [DropdownButton] widgets should
688 689
  /// be hidden.
  static bool at(BuildContext context) {
690
    return context.dependOnInheritedWidgetOfExactType<DropdownButtonHideUnderline>() != null;
691 692 693
  }

  @override
694
  bool updateShouldNotify(DropdownButtonHideUnderline oldWidget) => false;
695 696
}

697 698 699 700 701 702
/// 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.
///
703 704
/// The type `T` is the type of the [value] that each dropdown item represents.
/// All the entries in a given menu must represent values with consistent types.
705 706 707
/// Typically, an enum is used. Each [DropdownMenuItem] in [items] must be
/// specialized with that same type argument.
///
708 709 710 711
/// The [onChanged] callback should update a state variable that defines the
/// dropdown's value. It should also call [State.setState] to rebuild the
/// dropdown with the new value.
///
712
/// {@tool snippet --template=stateful_widget_scaffold_center}
713
///
714 715 716
/// This sample shows a `DropdownButton` with a large arrow icon,
/// purple text style, and bold purple underline, whose value is one of "One",
/// "Two", "Free", or "Four".
717
///
718
/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/dropdown_button.png)
719
///
720
/// ```dart
721 722
/// String dropdownValue = 'One';
///
723
/// @override
724
/// Widget build(BuildContext context) {
725 726 727 728 729 730 731
///   return DropdownButton<String>(
///     value: dropdownValue,
///     icon: Icon(Icons.arrow_downward),
///     iconSize: 24,
///     elevation: 16,
///     style: TextStyle(
///       color: Colors.deepPurple
732
///     ),
733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749
///     underline: Container(
///       height: 2,
///       color: Colors.deepPurpleAccent,
///     ),
///     onChanged: (String newValue) {
///       setState(() {
///         dropdownValue = newValue;
///       });
///     },
///     items: <String>['One', 'Two', 'Free', 'Four']
///       .map<DropdownMenuItem<String>>((String value) {
///         return DropdownMenuItem<String>(
///           value: value,
///           child: Text(value),
///         );
///       })
///       .toList(),
750 751 752 753 754 755 756 757
///   );
/// }
/// ```
/// {@end-tool}
///
/// If the [onChanged] callback is null or the list of [items] is null
/// then the dropdown button will be disabled, i.e. its arrow will be
/// displayed in grey and it will not respond to input. A disabled button
758 759 760
/// will display the [disabledHint] widget if it is non-null. However, if
/// [disabledHint] is null and [hint] is non-null, the [hint] widget will
/// instead be displayed.
761
///
762 763 764
/// Requires one of its ancestors to be a [Material] widget.
///
/// See also:
765
///
766
///  * [DropdownMenuItem], the class used to represent the [items].
767
///  * [DropdownButtonHideUnderline], which prevents its descendant dropdown buttons
768 769
///    from displaying their underlines.
///  * [RaisedButton], [FlatButton], ordinary buttons that trigger a single action.
770
///  * <https://material.io/design/components/menus.html#dropdown-menu>
771
class DropdownButton<T> extends StatefulWidget {
772
  /// Creates a dropdown button.
773
  ///
774 775 776 777
  /// The [items] must have distinct values. If [value] isn't null then it
  /// must be equal to one of the [DropDownMenuItem] values. If [items] or
  /// [onChanged] is null, the button will be disabled, the down arrow
  /// will be greyed out, and the [disabledHint] will be shown (if provided).
778 779
  /// If [disabledHint] is null and [hint] is non-null, [hint] will instead be
  /// shown.
780 781
  ///
  /// The [elevation] and [iconSize] arguments must not be null (they both have
782 783
  /// defaults, so do not need to be specified). The boolean [isDense] and
  /// [isExpanded] arguments must not be null.
784
  DropdownButton({
785
    Key key,
786
    @required this.items,
787
    this.selectedItemBuilder,
788
    this.value,
789
    this.hint,
790
    this.disabledHint,
791
    @required this.onChanged,
792
    this.elevation = 8,
793
    this.style,
794
    this.underline,
795 796 797
    this.icon,
    this.iconDisabledColor,
    this.iconEnabledColor,
798 799
    this.iconSize = 24.0,
    this.isDense = false,
800
    this.isExpanded = false,
801
    this.itemHeight = kMinInteractiveDimension,
802 803 804
    this.focusColor,
    this.focusNode,
    this.autofocus = false,
805 806 807 808 809 810 811 812 813
  }) : assert(items == null || items.isEmpty || value == null ||
              items.where((DropdownMenuItem<T> item) {
                return item.value == value;
              }).length == 1,
                'There should be exactly one item with [DropdownButton]\'s value: '
                '$value. \n'
                'Either zero or 2 or more [DropdownMenuItem]s were detected '
                'with the same value',
              ),
814 815 816 817
       assert(elevation != null),
       assert(iconSize != null),
       assert(isDense != null),
       assert(isExpanded != null),
818
       assert(autofocus != null),
819
       assert(itemHeight == null || itemHeight >=  kMinInteractiveDimension),
820
       super(key: key);
821

822 823 824 825 826
  /// The list of items the user can select.
  ///
  /// If the [onChanged] callback is null or the list of items is null
  /// then the dropdown button will be disabled, i.e. its arrow will be
  /// displayed in grey and it will not respond to input. A disabled button
827 828 829
  /// will display the [disabledHint] widget if it is non-null. If
  /// [disabledHint] is also null but [hint] is non-null, [hint] will instead
  /// be displayed.
830
  final List<DropdownMenuItem<T>> items;
831

832 833 834 835
  /// The value of the currently selected [DropdownMenuItem].
  ///
  /// If [value] is null and [hint] is non-null, the [hint] widget is
  /// displayed as a placeholder for the dropdown button's value.
836
  final T value;
837

838 839 840 841 842
  /// A placeholder widget that is displayed by the dropdown button.
  ///
  /// If [value] is null, this widget is displayed as a placeholder for
  /// the dropdown button's value. This widget is also displayed if the button
  /// is disabled ([items] or [onChanged] is null) and [disabledHint] is null.
843 844
  final Widget hint;

845 846
  /// A message to show when the dropdown is disabled.
  ///
847 848
  /// Displayed if [items] or [onChanged] is null. If [hint] is non-null and
  /// [disabledHint] is null, the [hint] widget will be displayed instead.
849 850
  final Widget disabledHint;

851
  /// {@template flutter.material.dropdownButton.onChanged}
852
  /// Called when the user selects an item.
853 854 855 856
  ///
  /// If the [onChanged] callback is null or the list of [items] is null
  /// then the dropdown button will be disabled, i.e. its arrow will be
  /// displayed in grey and it will not respond to input. A disabled button
857 858 859
  /// will display the [disabledHint] widget if it is non-null. If
  /// [disabledHint] is also null but [hint] is non-null, [hint] will instead
  /// be displayed.
860
  /// {@endtemplate}
Hixie's avatar
Hixie committed
861
  final ValueChanged<T> onChanged;
862

863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886
  /// A builder to customize the dropdown buttons corresponding to the
  /// [DropdownMenuItem]s in [items].
  ///
  /// When a [DropdownMenuItem] is selected, the widget that will be displayed
  /// from the list corresponds to the [DropdownMenuItem] of the same index
  /// in [items].
  ///
  /// {@tool snippet --template=stateful_widget_scaffold}
  ///
  /// This sample shows a `DropdownButton` with a button with [Text] that
  /// corresponds to but is unique from [DropdownMenuItem].
  ///
  /// ```dart
  /// final List<String> items = <String>['1','2','3'];
  /// String selectedItem = '1';
  ///
  /// @override
  /// Widget build(BuildContext context) {
  ///   return Padding(
  ///     padding: const EdgeInsets.symmetric(horizontal: 12.0),
  ///     child: DropdownButton<String>(
  ///       value: selectedItem,
  ///       onChanged: (String string) => setState(() => selectedItem = string),
  ///       selectedItemBuilder: (BuildContext context) {
887
  ///         return items.map<Widget>((String item) {
888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906
  ///           return Text(item);
  ///         }).toList();
  ///       },
  ///       items: items.map((String item) {
  ///         return DropdownMenuItem<String>(
  ///           child: Text('Log $item'),
  ///           value: item,
  ///         );
  ///       }).toList(),
  ///     ),
  ///   );
  /// }
  /// ```
  /// {@end-tool}
  ///
  /// If this callback is null, the [DropdownMenuItem] from [items]
  /// that matches [value] will be displayed.
  final DropdownButtonBuilder selectedItemBuilder;

907
  /// The z-coordinate at which to place the menu when open.
908
  ///
909 910
  /// The following elevations have defined shadows: 1, 2, 3, 4, 6, 8, 9, 12,
  /// 16, and 24. See [kElevationToShadow].
911 912
  ///
  /// Defaults to 8, the appropriate elevation for dropdown buttons.
Hans Muller's avatar
Hans Muller committed
913
  final int elevation;
914

915
  /// The text style to use for text in the dropdown button and the dropdown
916 917
  /// menu that appears when you tap the button.
  ///
918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962
  /// To use a separate text style for selected item when it's displayed within
  /// the dropdown button,, consider using [selectedItemBuilder].
  ///
  /// {@tool snippet --template=stateful_widget_scaffold}
  ///
  /// This sample shows a `DropdownButton` with a dropdown button text style
  /// that is different than its menu items.
  ///
  /// ```dart
  /// List<String> options = <String>['One', 'Two', 'Free', 'Four'];
  /// String dropdownValue = 'One';
  ///
  /// @override
  /// Widget build(BuildContext context) {
  ///   return Container(
  ///     alignment: Alignment.center,
  ///     color: Colors.blue,
  ///     child: DropdownButton<String>(
  ///       value: dropdownValue,
  ///       onChanged: (String newValue) {
  ///         setState(() {
  ///           dropdownValue = newValue;
  ///         });
  ///       },
  ///       style: TextStyle(color: Colors.blue),
  ///       selectedItemBuilder: (BuildContext context) {
  ///         return options.map((String value) {
  ///           return Text(
  ///             dropdownValue,
  ///             style: TextStyle(color: Colors.white),
  ///           );
  ///         }).toList();
  ///       },
  ///       items: options.map<DropdownMenuItem<String>>((String value) {
  ///         return DropdownMenuItem<String>(
  ///           value: value,
  ///           child: Text(value),
  ///         );
  ///       }).toList(),
  ///     ),
  ///   );
  /// }
  /// ```
  /// {@end-tool}
  ///
963 964 965 966
  /// Defaults to the [TextTheme.subhead] value of the current
  /// [ThemeData.textTheme] of the current [Theme].
  final TextStyle style;

967 968 969 970 971
  /// The widget to use for drawing the drop-down button's underline.
  ///
  /// Defaults to a 0.0 width bottom border with color 0xFFBDBDBD.
  final Widget underline;

972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992
  /// The widget to use for the drop-down button's icon.
  ///
  /// Defaults to an [Icon] with the [Icons.arrow_drop_down] glyph.
  final Widget icon;

  /// The color of any [Icon] descendant of [icon] if this button is disabled,
  /// i.e. if [onChanged] is null.
  ///
  /// Defaults to [Colors.grey.shade400] when the theme's
  /// [ThemeData.brightness] is [Brightness.light] and to
  /// [Colors.white10] when it is [Brightness.dark]
  final Color iconDisabledColor;

  /// The color of any [Icon] descendant of [icon] if this button is enabled,
  /// i.e. if [onChanged] is defined.
  ///
  /// Defaults to [Colors.grey.shade700] when the theme's
  /// [ThemeData.brightness] is [Brightness.light] and to
  /// [Colors.white70] when it is [Brightness.dark]
  final Color iconEnabledColor;

993 994
  /// The size to use for the drop-down button's down arrow icon button.
  ///
995
  /// Defaults to 24.0.
996 997
  final double iconSize;

998 999 1000 1001 1002
  /// 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
1003
  /// its own decorations, like [InputDecorator].
1004 1005
  final bool isDense;

1006 1007 1008 1009 1010 1011 1012
  /// Set the dropdown's inner contents to horizontally fill its parent.
  ///
  /// By default this button's inner width is the minimum size of its contents.
  /// If [isExpanded] is true, the inner width is expanded to fill its
  /// surrounding container.
  final bool isExpanded;

1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025
  /// If null, then the menu item heights will vary according to each menu item's
  /// intrinsic height.
  ///
  /// The default value is [kMinInteractiveDimension], which is also the minimum
  /// height for menu items.
  ///
  /// If this value is null and there isn't enough vertical room for the menu,
  /// then the menu's initial scroll offset may not align the selected item with
  /// the dropdown button. That's because, in this case, the initial scroll
  /// offset is computed as if all of the menu item heights were
  /// [kMinInteractiveDimension].
  final double itemHeight;

1026 1027 1028 1029 1030 1031 1032 1033 1034
  /// The color for the button's [Material] when it has the input focus.
  final Color focusColor;

  /// {@macro flutter.widgets.Focus.focusNode}
  final FocusNode focusNode;

  /// {@macro flutter.widgets.Focus.autofocus}
  final bool autofocus;

1035
  @override
1036
  _DropdownButtonState<T> createState() => _DropdownButtonState<T>();
1037 1038
}

1039
class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindingObserver {
1040
  int _selectedIndex;
1041
  _DropdownRoute<T> _dropdownRoute;
1042
  Orientation _lastOrientation;
1043 1044 1045 1046
  FocusNode _internalNode;
  FocusNode get focusNode => widget.focusNode ?? _internalNode;
  bool _hasPrimaryFocus = false;
  Map<LocalKey, ActionFactory> _actionMap;
1047
  FocusHighlightMode _focusHighlightMode;
1048 1049 1050 1051 1052

  // Only used if needed to create _internalNode.
  FocusNode _createFocusNode() {
    return FocusNode(debugLabel: '${widget.runtimeType}');
  }
1053

1054
  @override
1055 1056 1057
  void initState() {
    super.initState();
    _updateSelectedIndex();
1058 1059 1060
    if (widget.focusNode == null) {
      _internalNode ??= _createFocusNode();
    }
1061 1062 1063 1064
    _actionMap = <LocalKey, ActionFactory>{
      SelectAction.key: _createAction,
      if (!kIsWeb) ActivateAction.key: _createAction,
    };
1065
    focusNode.addListener(_handleFocusChanged);
1066 1067 1068
    final FocusManager focusManager = WidgetsBinding.instance.focusManager;
    _focusHighlightMode = focusManager.highlightMode;
    focusManager.addHighlightModeListener(_handleFocusHighlightModeChange);
1069 1070
  }

1071 1072 1073
  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
1074
    _removeDropdownRoute();
1075
    WidgetsBinding.instance.focusManager.removeHighlightModeListener(_handleFocusHighlightModeChange);
1076 1077
    focusNode.removeListener(_handleFocusChanged);
    _internalNode?.dispose();
1078 1079 1080
    super.dispose();
  }

1081 1082
  void _removeDropdownRoute() {
    _dropdownRoute?._dismiss();
1083
    _dropdownRoute = null;
1084
    _lastOrientation = null;
1085 1086
  }

1087 1088 1089 1090 1091 1092 1093 1094
  void _handleFocusChanged() {
    if (_hasPrimaryFocus != focusNode.hasPrimaryFocus) {
      setState(() {
        _hasPrimaryFocus = focusNode.hasPrimaryFocus;
      });
    }
  }

1095 1096 1097 1098 1099 1100 1101 1102 1103
  void _handleFocusHighlightModeChange(FocusHighlightMode mode) {
    if (!mounted) {
      return;
    }
    setState(() {
      _focusHighlightMode = mode;
    });
  }

1104
  @override
1105
  void didUpdateWidget(DropdownButton<T> oldWidget) {
1106
    super.didUpdateWidget(oldWidget);
1107 1108 1109 1110 1111 1112 1113 1114
    if (widget.focusNode != oldWidget.focusNode) {
      oldWidget.focusNode?.removeListener(_handleFocusChanged);
      if (widget.focusNode == null) {
        _internalNode ??= _createFocusNode();
      }
      _hasPrimaryFocus = focusNode.hasPrimaryFocus;
      focusNode.addListener(_handleFocusChanged);
    }
1115
    _updateSelectedIndex();
1116 1117 1118
  }

  void _updateSelectedIndex() {
1119 1120 1121 1122
    if (!_enabled) {
      return;
    }

1123 1124
    assert(widget.value == null ||
      widget.items.where((DropdownMenuItem<T> item) => item.value == widget.value).length == 1);
1125
    _selectedIndex = null;
1126 1127
    for (int itemIndex = 0; itemIndex < widget.items.length; itemIndex++) {
      if (widget.items[itemIndex].value == widget.value) {
1128 1129 1130 1131 1132 1133
        _selectedIndex = itemIndex;
        return;
      }
    }
  }

1134
  TextStyle get _textStyle => widget.style ?? Theme.of(context).textTheme.subhead;
1135

1136
  void _handleTap() {
1137
    final RenderBox itemBox = context.findRenderObject();
1138
    final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size;
1139 1140
    final TextDirection textDirection = Directionality.of(context);
    final EdgeInsetsGeometry menuMargin = ButtonTheme.of(context).alignedDropdown
1141
      ? _kAlignedMenuMargin
1142
      : _kUnalignedMenuMargin;
1143

1144 1145 1146 1147 1148 1149 1150 1151 1152 1153
    final List<_MenuItem<T>> menuItems = List<_MenuItem<T>>(widget.items.length);
    for (int index = 0; index < widget.items.length; index += 1) {
      menuItems[index] = _MenuItem<T>(
        item: widget.items[index],
        onLayout: (Size size) {
          _dropdownRoute.itemHeights[index] = size.height;
        },
      );
    }

1154
    assert(_dropdownRoute == null);
1155
    _dropdownRoute = _DropdownRoute<T>(
1156
      items: menuItems,
1157 1158
      buttonRect: menuMargin.resolve(textDirection).inflateRect(itemRect),
      padding: _kMenuItemPadding.resolve(textDirection),
1159
      selectedIndex: _selectedIndex ?? 0,
1160
      elevation: widget.elevation,
1161 1162
      theme: Theme.of(context, shadowThemeOnly: true),
      style: _textStyle,
1163
      barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
1164
      itemHeight: widget.itemHeight,
1165 1166
    );

1167
    Navigator.push(context, _dropdownRoute).then<void>((_DropdownRouteResult<T> newValue) {
1168
      _dropdownRoute = null;
1169
      if (!mounted || newValue == null)
1170
        return;
1171 1172
      if (widget.onChanged != null)
        widget.onChanged(newValue.result);
1173 1174 1175
    });
  }

1176 1177 1178 1179 1180 1181 1182 1183 1184
  Action _createAction() {
    return CallbackAction(
      ActivateAction.key,
      onInvoke: (FocusNode node, Intent intent) {
        _handleTap();
      },
    );
  }

1185 1186 1187 1188 1189
  // 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 {
1190 1191
    final double fontSize = _textStyle.fontSize ?? Theme.of(context).textTheme.subhead.fontSize;
    return math.max(fontSize, math.max(widget.iconSize, _kDenseButtonHeight));
1192 1193
  }

1194
  Color get _iconColor {
1195 1196
    // These colors are not defined in the Material Design spec.
    if (_enabled) {
1197
      if (widget.iconEnabledColor != null)
1198 1199
        return widget.iconEnabledColor;

1200
      switch (Theme.of(context).brightness) {
1201 1202 1203 1204
        case Brightness.light:
          return Colors.grey.shade700;
        case Brightness.dark:
          return Colors.white70;
1205 1206
      }
    } else {
1207
      if (widget.iconDisabledColor != null)
1208 1209
        return widget.iconDisabledColor;

1210
      switch (Theme.of(context).brightness) {
1211 1212 1213 1214
        case Brightness.light:
          return Colors.grey.shade400;
        case Brightness.dark:
          return Colors.white10;
1215 1216
      }
    }
1217 1218 1219

    assert(false);
    return null;
1220 1221 1222 1223
  }

  bool get _enabled => widget.items != null && widget.items.isNotEmpty && widget.onChanged != null;

1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234
  Orientation _getOrientation(BuildContext context) {
    Orientation result = MediaQuery.of(context, nullOk: true)?.orientation;
    if (result == null) {
      // If there's no MediaQuery, then use the window aspect to determine
      // orientation.
      final Size size = window.physicalSize;
      result = size.width > size.height ? Orientation.landscape : Orientation.portrait;
    }
    return result;
  }

1235 1236 1237 1238 1239 1240 1241 1242 1243 1244
  bool get _showHighlight {
    switch (_focusHighlightMode) {
      case FocusHighlightMode.touch:
        return false;
      case FocusHighlightMode.traditional:
        return _hasPrimaryFocus;
    }
    return null;
  }

1245
  @override
1246
  Widget build(BuildContext context) {
1247
    assert(debugCheckHasMaterial(context));
1248
    assert(debugCheckHasMaterialLocalizations(context));
1249 1250 1251 1252 1253 1254
    final Orientation newOrientation = _getOrientation(context);
    _lastOrientation ??= newOrientation;
    if (newOrientation != _lastOrientation) {
      _removeDropdownRoute();
      _lastOrientation = newOrientation;
    }
1255 1256 1257

    // The width of the button and the menu are defined by the widest
    // item and the width of the hint.
1258 1259 1260 1261
    List<Widget> items;
    if (_enabled) {
      items = widget.selectedItemBuilder == null
        ? List<Widget>.from(widget.items)
1262
        : widget.selectedItemBuilder(context);
1263
    } else {
1264 1265 1266
      items = widget.selectedItemBuilder == null
        ? <Widget>[]
        : widget.selectedItemBuilder(context);
1267 1268
    }

1269
    int hintIndex;
1270
    if (widget.hint != null || (!_enabled && widget.disabledHint != null)) {
1271 1272 1273 1274
      Widget displayedHint = _enabled ? widget.hint : widget.disabledHint ?? widget.hint;
      if (widget.selectedItemBuilder == null)
        displayedHint = _DropdownMenuItemContainer(child: displayedHint);

1275
      hintIndex = items.length;
1276
      items.add(DefaultTextStyle(
1277
        style: _textStyle.copyWith(color: Theme.of(context).hintColor),
1278
        child: IgnorePointer(
1279
          ignoringSemantics: false,
1280
          child: displayedHint,
1281 1282 1283 1284
        ),
      ));
    }

1285 1286 1287 1288
    final EdgeInsetsGeometry padding = ButtonTheme.of(context).alignedDropdown
      ? _kAlignedButtonPadding
      : _kUnalignedButtonPadding;

1289 1290
    // If value is null (then _selectedIndex is null) or if disabled then we
    // display the hint or nothing at all.
1291 1292 1293 1294 1295 1296 1297 1298
    final int index = _enabled ? (_selectedIndex ?? hintIndex) : hintIndex;
    Widget innerItemsWidget;
    if (items.isEmpty) {
      innerItemsWidget = Container();
    } else {
      innerItemsWidget = IndexedStack(
        index: index,
        alignment: AlignmentDirectional.centerStart,
1299 1300 1301 1302 1303
        children: widget.isDense ? items : items.map((Widget item) {
          return widget.itemHeight != null
            ? SizedBox(height: widget.itemHeight, child: item)
            : Column(mainAxisSize: MainAxisSize.min, children: <Widget>[item]);
        }).toList(),
1304 1305
      );
    }
1306

1307 1308
    const Icon defaultIcon = Icon(Icons.arrow_drop_down);

1309
    Widget result = DefaultTextStyle(
1310
      style: _textStyle,
1311
      child: Container(
1312 1313 1314 1315 1316 1317
        decoration: _showHighlight
            ? BoxDecoration(
                color: widget.focusColor ?? Theme.of(context).focusColor,
                borderRadius: const BorderRadius.all(Radius.circular(4.0)),
              )
            : null,
1318
        padding: padding.resolve(Directionality.of(context)),
1319
        height: widget.isDense ? _denseButtonHeight : null,
1320
        child: Row(
1321 1322 1323
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
1324 1325 1326 1327
            if (widget.isExpanded)
              Expanded(child: innerItemsWidget)
            else
              innerItemsWidget,
1328 1329 1330 1331 1332 1333
            IconTheme(
              data: IconThemeData(
                color: _iconColor,
                size: widget.iconSize,
              ),
              child: widget.icon ?? defaultIcon,
1334 1335 1336 1337
            ),
          ],
        ),
      ),
1338
    );
1339

1340
    if (!DropdownButtonHideUnderline.at(context)) {
1341
      final double bottom = (widget.isDense || widget.itemHeight == null) ? 0.0 : 8.0;
1342
      result = Stack(
1343 1344
        children: <Widget>[
          result,
1345
          Positioned(
1346 1347
            left: 0.0,
            right: 0.0,
1348
            bottom: bottom,
1349
            child: widget.underline ?? Container(
1350 1351
              height: 1.0,
              decoration: const BoxDecoration(
1352 1353 1354 1355 1356 1357
                border: Border(
                  bottom: BorderSide(
                    color: Color(0xFFBDBDBD),
                    width: 0.0,
                  ),
                ),
1358 1359 1360 1361
              ),
            ),
          ),
        ],
1362 1363
      );
    }
1364

1365
    return Semantics(
1366
      button: true,
1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378
      child: Actions(
        actions: _actionMap,
        child: Focus(
          canRequestFocus: _enabled,
          focusNode: focusNode,
          autofocus: widget.autofocus,
          child: GestureDetector(
            onTap: _enabled ? _handleTap : null,
            behavior: HitTestBehavior.opaque,
            child: result,
          ),
        ),
1379
      ),
1380 1381 1382
    );
  }
}
1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393

/// A convenience widget that wraps a [DropdownButton] in a [FormField].
class DropdownButtonFormField<T> extends FormField<T> {
  /// Creates a [DropdownButton] widget wrapped in an [InputDecorator] and
  /// [FormField].
  ///
  /// The [DropdownButton] [items] parameters must not be null.
  DropdownButtonFormField({
    Key key,
    T value,
    @required List<DropdownMenuItem<T>> items,
1394
    DropdownButtonBuilder selectedItemBuilder,
1395 1396 1397
    Widget hint,
    @required this.onChanged,
    this.decoration = const InputDecoration(),
1398 1399
    FormFieldSetter<T> onSaved,
    FormFieldValidator<T> validator,
1400 1401 1402 1403 1404 1405 1406 1407 1408 1409
    bool autovalidate = false,
    Widget disabledHint,
    int elevation = 8,
    TextStyle style,
    Widget icon,
    Color iconDisabledColor,
    Color iconEnabledColor,
    double iconSize = 24.0,
    bool isDense = false,
    bool isExpanded = false,
1410
    double itemHeight,
1411 1412 1413 1414 1415 1416 1417 1418 1419
  }) : assert(items == null || items.isEmpty || value == null ||
              items.where((DropdownMenuItem<T> item) {
                return item.value == value;
              }).length == 1,
                'There should be exactly one item with [DropdownButton]\'s value: '
                '$value. \n'
                'Either zero or 2 or more [DropdownMenuItem]s were detected '
                'with the same value',
              ),
1420 1421 1422 1423 1424
       assert(decoration != null),
       assert(elevation != null),
       assert(iconSize != null),
       assert(isDense != null),
       assert(isExpanded != null),
1425
       assert(itemHeight == null || itemHeight > 0),
1426 1427 1428 1429 1430
       super(
         key: key,
         onSaved: onSaved,
         initialValue: value,
         validator: validator,
1431
         autovalidate: autovalidate,
1432
         builder: (FormFieldState<T> field) {
1433 1434 1435
           final InputDecoration effectiveDecoration = decoration.applyDefaults(
             Theme.of(field.context).inputDecorationTheme,
           );
1436 1437 1438 1439 1440 1441 1442
           return InputDecorator(
             decoration: effectiveDecoration.copyWith(errorText: field.errorText),
             isEmpty: value == null,
             child: DropdownButtonHideUnderline(
               child: DropdownButton<T>(
                 value: value,
                 items: items,
1443
                 selectedItemBuilder: selectedItemBuilder,
1444
                 hint: hint,
1445 1446 1447 1448 1449 1450 1451 1452 1453 1454
                 onChanged: onChanged == null ? null : field.didChange,
                 disabledHint: disabledHint,
                 elevation: elevation,
                 style: style,
                 icon: icon,
                 iconDisabledColor: iconDisabledColor,
                 iconEnabledColor: iconEnabledColor,
                 iconSize: iconSize,
                 isDense: isDense,
                 isExpanded: isExpanded,
1455
                 itemHeight: itemHeight,
1456 1457 1458
               ),
             ),
           );
1459
         },
1460 1461
       );

1462
  /// {@macro flutter.material.dropdownButton.onChanged}
1463 1464
  final ValueChanged<T> onChanged;

1465 1466 1467 1468 1469 1470 1471 1472 1473
  /// The decoration to show around the dropdown button form field.
  ///
  /// By default, draws a horizontal line under the dropdown button field but can be
  /// configured to show an icon, label, hint text, and error text.
  ///
  /// Specify null to remove the decoration entirely (including the
  /// extra padding introduced by the decoration to save space for the labels).
  final InputDecoration decoration;

1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484
  @override
  FormFieldState<T> createState() => _DropdownButtonFormFieldState<T>();
}

class _DropdownButtonFormFieldState<T> extends FormFieldState<T> {
  @override
  DropdownButtonFormField<T> get widget => super.widget;

  @override
  void didChange(T value) {
    super.didChange(value);
1485 1486
    assert(widget.onChanged != null);
    widget.onChanged(value);
1487 1488
  }
}