// Copyright 2014 The Flutter 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:math' as math; import 'dart:ui' show window; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; const Duration _kDropdownMenuDuration = Duration(milliseconds: 300); const double _kMenuItemHeight = kMinInteractiveDimension; const double _kDenseButtonHeight = 24.0; const EdgeInsets _kMenuItemPadding = EdgeInsets.symmetric(horizontal: 16.0); const EdgeInsetsGeometry _kAlignedButtonPadding = EdgeInsetsDirectional.only(start: 16.0, end: 4.0); const EdgeInsets _kUnalignedButtonPadding = EdgeInsets.zero; const EdgeInsets _kAlignedMenuMargin = EdgeInsets.zero; const EdgeInsetsGeometry _kUnalignedMenuMargin = EdgeInsetsDirectional.only(start: 16.0, end: 24.0); typedef DropdownButtonBuilder = List Function(BuildContext context); class _DropdownMenuPainter extends CustomPainter { _DropdownMenuPainter({ this.color, this.elevation, this.selectedIndex, this.resize, this.getSelectedItemOffset, }) : _painter = BoxDecoration( // If you add an image here, you must provide a real // configuration in the paint() function and you must provide some sort // of onChanged callback here. color: color, //Change default 2.0 to 10.0 borderRadius: BorderRadius.circular(10.0), boxShadow: kElevationToShadow[elevation], ).createBoxPainter(), super(repaint: resize); final Color color; final int elevation; final int selectedIndex; final Animation resize; final ValueGetter getSelectedItemOffset; final BoxPainter _painter; @override void paint(Canvas canvas, Size size) { final double selectedItemOffset = getSelectedItemOffset(); final Tween top = Tween( begin: selectedItemOffset.clamp(0.0, size.height - _kMenuItemHeight) as double, end: 0.0, ); final Tween bottom = Tween( begin: (top.begin + _kMenuItemHeight).clamp(_kMenuItemHeight, size.height) as double, end: size.height, ); final Rect rect = Rect.fromLTRB( 0.0, top.evaluate(resize), size.width, bottom.evaluate(resize)); _painter.paint(canvas, rect.topLeft, ImageConfiguration(size: rect.size)); } @override bool shouldRepaint(_DropdownMenuPainter oldPainter) { return oldPainter.color != color || oldPainter.elevation != elevation || oldPainter.selectedIndex != selectedIndex || oldPainter.resize != resize; } } // Do not use the platform-specific default scroll configuration. // Dropdown menus should never overscroll or display an overscroll indicator. class _DropdownScrollBehavior extends ScrollBehavior { const _DropdownScrollBehavior(); @override TargetPlatform getPlatform(BuildContext context) => Theme.of(context).platform; @override Widget buildViewportChrome( BuildContext context, Widget child, AxisDirection axisDirection) => child; @override ScrollPhysics getScrollPhysics(BuildContext context) => const ClampingScrollPhysics(); } // The widget that is the button wrapping the menu items. class _DropdownMenuItemButton 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 route; final EdgeInsets padding; final Rect buttonRect; final BoxConstraints constraints; final int itemIndex; @override _DropdownMenuItemButtonState createState() => _DropdownMenuItemButtonState(); } class _DropdownMenuItemButtonState extends State<_DropdownMenuItemButton> { 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() { final DropdownMenuItem dropdownMenuItem = widget.route.items[widget.itemIndex].item; if (dropdownMenuItem.onTap != null) { dropdownMenuItem.onTap(); } Navigator.pop( context, _DropdownRouteResult(dropdownMenuItem.value), ); } @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) as double; final double end = (start + 1.5 * unit).clamp(0.0, 1.0) as double; 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, ), ); return child; } } class _DropdownMenu extends StatefulWidget { const _DropdownMenu({ Key key, this.padding, this.route, this.buttonRect, this.constraints, this.dropdownColor, this.displayItemCount, }) : super(key: key); final _DropdownRoute route; final EdgeInsets padding; final Rect buttonRect; final BoxConstraints constraints; final Color dropdownColor; final int displayItemCount; @override _DropdownMenuState createState() => _DropdownMenuState(); } class _DropdownMenuState extends State<_DropdownMenu> { 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 = CurvedAnimation( parent: widget.route.animation, curve: const Interval(0.0, 0.25), reverseCurve: const Interval(0.75, 1.0), ); _resize = CurvedAnimation( parent: widget.route.animation, curve: const Interval(0.25, 0.5), reverseCurve: const Threshold(0.0), ); } @override Widget build(BuildContext context) { // The menu is shown in three stages (unit timing in brackets): // [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 // until it's big enough for as many items as we're going to show. // [0.5s - 1.0s] Fade in the remaining visible items from top to bottom. // // When the menu is dismissed we just fade the entire thing out // in the first 0.25s. assert(debugCheckHasMaterialLocalizations(context)); final MaterialLocalizations localizations = MaterialLocalizations.of(context); final _DropdownRoute route = widget.route; final List children = [ for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex) _DropdownMenuItemButton( route: widget.route, padding: widget.padding, buttonRect: widget.buttonRect, constraints: widget.constraints, itemIndex: itemIndex, ), ]; return FadeTransition( opacity: _fadeOpacity, child: CustomPaint( painter: _DropdownMenuPainter( color: widget.dropdownColor ?? Theme.of(context).canvasColor, elevation: route.elevation, selectedIndex: route.selectedIndex, resize: _resize, // This offset is passed as a callback, not a value, because it must // be retrieved at paint time (after layout), not at build time. getSelectedItemOffset: () => route.getItemOffset(route.selectedIndex), ), child: Semantics( scopesRoute: true, namesRoute: true, explicitChildNodes: true, label: localizations.popupMenuLabel, child: Material( 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, ), ), ), ), ), ), ); } } class _DropdownMenuRouteLayout extends SingleChildLayoutDelegate { _DropdownMenuRouteLayout({ @required this.buttonRect, @required this.route, @required this.textDirection, this.displayItemCount, }); final Rect buttonRect; final _DropdownRoute route; final TextDirection textDirection; final int displayItemCount; @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { // 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://material.io/design/components/menus.html#usage final double maxHeight = displayItemCount == null ? math.max(0.0, constraints.maxHeight - 2 * _kMenuItemHeight) : math.min(_kMenuItemHeight * displayItemCount, constraints.maxHeight - 2 * _kMenuItemHeight); // 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. final double width = math.min(constraints.maxWidth, buttonRect.width); return BoxConstraints( minWidth: width, maxWidth: width, minHeight: 0.0, maxHeight: maxHeight, ); } @override Offset getPositionForChild(Size size, Size childSize) { final _MenuLimits menuLimits = route.getMenuLimits(buttonRect, size.height, route.selectedIndex); assert(() { final Rect container = Offset.zero & 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(menuLimits.top >= 0.0); assert(menuLimits.top + menuLimits.height <= size.height); } return true; }()); assert(textDirection != null); double left; switch (textDirection) { case TextDirection.rtl: left = (buttonRect.right.clamp(0.0, size.width) as double) - childSize.width; break; case TextDirection.ltr: left = buttonRect.left.clamp(0.0, size.width - childSize.width) as double; break; } return Offset(left, menuLimits.top); } @override bool shouldRelayout(_DropdownMenuRouteLayout oldDelegate) { return buttonRect != oldDelegate.buttonRect || textDirection != oldDelegate.textDirection; } } // 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 { const _DropdownRouteResult(this.result); final T result; @override bool operator ==(Object other) { return other is _DropdownRouteResult && other.result == result; } @override int get hashCode => result.hashCode; } class _MenuLimits { const _MenuLimits(this.top, this.bottom, this.height, this.scrollOffset); final double top; final double bottom; final double height; final double scrollOffset; } class _DropdownRoute extends PopupRoute<_DropdownRouteResult> { _DropdownRoute({ this.items, this.padding, this.buttonRect, this.selectedIndex, this.elevation = 8, this.theme, @required this.style, this.barrierLabel, this.itemHeight, this.dropdownColor, this.displayItemCount, }) : assert(style != null), itemHeights = List.filled( items.length, itemHeight ?? kMinInteractiveDimension); final List<_MenuItem> items; final EdgeInsetsGeometry padding; final Rect buttonRect; final int selectedIndex; final int elevation; final ThemeData theme; final TextStyle style; final double itemHeight; final Color dropdownColor; final int displayItemCount; final List itemHeights; ScrollController scrollController; @override Duration get transitionDuration => _kDropdownMenuDuration; @override bool get barrierDismissible => true; @override Color get barrierColor => null; @override final String barrierLabel; @override Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return _DropdownRoutePage( route: this, constraints: constraints, items: items, padding: padding, buttonRect: buttonRect, selectedIndex: selectedIndex, elevation: elevation, theme: theme, style: style, dropdownColor: dropdownColor, displayItemCount: displayItemCount, ); }); } void _dismiss() { navigator?.removeRoute(this); } double getItemOffset(int index) { double offset = kMaterialListPadding.top; if (items.isNotEmpty && index > 0) { assert(items.length == itemHeights?.length); offset += itemHeights .sublist(0, index) .reduce((double total, double height) => total + height); } return offset; } // 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. _MenuLimits getMenuLimits( Rect buttonRect, double availableHeight, int index) { final double maxMenuHeight = availableHeight - 2.0 * _kMenuItemHeight; final double buttonTop = buttonRect.top; final double buttonBottom = math.min(buttonRect.bottom, availableHeight); final double selectedItemOffset = getItemOffset(index); // 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); final double bottomLimit = math.max(availableHeight - _kMenuItemHeight, buttonBottom); 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); // 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); if (menuBottom > bottomLimit) { menuBottom = math.max(buttonBottom, bottomLimit); menuTop = menuBottom - menuHeight; } // 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 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, this.dropdownColor, this.displayItemCount, }) : super(key: key); final _DropdownRoute route; final BoxConstraints constraints; final List<_MenuItem> items; final EdgeInsetsGeometry padding; final Rect buttonRect; final int selectedIndex; final int elevation; final ThemeData theme; final TextStyle style; final Color dropdownColor; final int displayItemCount; @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. if (route.scrollController == null) { final _MenuLimits menuLimits = route.getMenuLimits(buttonRect, constraints.maxHeight, selectedIndex); route.scrollController = ScrollController(initialScrollOffset: menuLimits.scrollOffset); } final TextDirection textDirection = Directionality.of(context); Widget menu = _DropdownMenu( route: route, padding: padding.resolve(textDirection), buttonRect: buttonRect, constraints: constraints, dropdownColor: dropdownColor, ); if (theme != null) menu = Theme(data: theme, child: menu); return MediaQuery.removePadding( context: context, removeTop: true, removeBottom: true, removeLeft: true, removeRight: true, child: Builder( builder: (BuildContext context) { return CustomSingleChildLayout( delegate: _DropdownMenuRouteLayout( buttonRect: buttonRect, route: route, textDirection: textDirection, displayItemCount: displayItemCount, ), child: menu, ); }, ), ); } } // 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, // as closely as possible. class _MenuItem extends SingleChildRenderObjectWidget { const _MenuItem({ Key key, @required this.onLayout, @required this.item, }) : assert(onLayout != null), super(key: key, child: item); final ValueChanged onLayout; final DropdownMenuItem 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 onLayout; @override void performLayout() { super.performLayout(); onLayout(size); } } // 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 { /// Creates an item for a dropdown menu. /// /// The [child] argument is required. const _DropdownMenuItemContainer({ Key key, @required this.child, }) : assert(child != null), super(key: key); /// The widget below this widget in the tree. /// /// Typically a [Text] widget. final Widget child; @override Widget build(BuildContext context) { return Container( constraints: const BoxConstraints(minHeight: _kMenuItemHeight), alignment: AlignmentDirectional.centerStart, child: child, ); } } /// 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 extends _DropdownMenuItemContainer { // /// Creates an item for a dropdown menu. // /// // /// The [child] argument is required. // const DropdownMenuItem({ // Key key, // this.onTap, // this.value, // @required Widget child, // }) : assert(child != null), // super(key: key, child: child); // // /// Called when the dropdown menu item is tapped. // final VoidCallback onTap; // // /// The value to return if the user selects this menu item. // /// // /// Eventually returned in a call to [DropdownButton.onChanged]. // final T value; //} // /// 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. const DropdownButtonHideUnderline({ Key key, @required Widget child, }) : assert(child != null), super(key: key, child: child); /// Returns whether the underline of [DropdownButton] widgets should /// be hidden. static bool at(BuildContext context) { return context.dependOnInheritedWidgetOfExactType< DropdownButtonHideUnderline>() != null; } @override bool updateShouldNotify(DropdownButtonHideUnderline oldWidget) => false; } /// 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. /// /// 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. /// Typically, an enum is used. Each [DropdownMenuItem] in [items] must be /// specialized with that same type argument. /// /// 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. /// /// {@tool dartpad --template=stateful_widget_scaffold_center} /// /// 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". /// /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/dropdown_button.png) /// /// ```dart /// String dropdownValue = 'One'; /// /// @override /// Widget build(BuildContext context) { /// return DropdownButton( /// value: dropdownValue, /// icon: Icon(Icons.arrow_downward), /// iconSize: 24, /// elevation: 16, /// style: TextStyle( /// color: Colors.deepPurple /// ), /// underline: Container( /// height: 2, /// color: Colors.deepPurpleAccent, /// ), /// onChanged: (String newValue) { /// setState(() { /// dropdownValue = newValue; /// }); /// }, /// items: ['One', 'Two', 'Free', 'Four'] /// .map>((String value) { /// return DropdownMenuItem( /// value: value, /// child: Text(value), /// ); /// }) /// .toList(), /// ); /// } /// ``` /// {@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 /// 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. /// /// Requires one of its ancestors to be a [Material] widget. /// /// See also: /// /// * [DropdownMenuItem], the class used to represent the [items]. /// * [DropdownButtonHideUnderline], which prevents its descendant dropdown buttons /// from displaying their underlines. /// * [RaisedButton], [FlatButton], ordinary buttons that trigger a single action. /// * class MyDropdownButton extends StatefulWidget { /// Creates a dropdown button. /// /// 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). /// If [disabledHint] is null and [hint] is non-null, [hint] will instead be /// shown. /// /// The [elevation] and [iconSize] arguments must not be null (they both have /// defaults, so do not need to be specified). The boolean [isDense] and /// [isExpanded] arguments must not be null. /// /// The [dropdownColor] argument specifies the background color of the /// dropdown when it is open. If it is null, the current theme's /// [ThemeData.canvasColor] will be used instead. MyDropdownButton({ Key key, @required this.items, this.selectedItemBuilder, this.value, this.hint, this.disabledHint, @required this.onChanged, this.onTap, this.elevation = 8, this.style, this.underline, this.icon, this.iconDisabledColor, this.iconEnabledColor, this.iconSize = 24.0, this.isDense = false, this.isExpanded = false, this.itemHeight = kMinInteractiveDimension, this.focusColor, this.focusNode, this.autofocus = false, this.dropdownColor, this.displayItemCount, }) : assert( items == null || items.isEmpty || value == null || items.where((DropdownMenuItem 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', ), assert(elevation != null), assert(iconSize != null), assert(isDense != null), assert(isExpanded != null), assert(autofocus != null), assert(itemHeight == null || itemHeight >= kMinInteractiveDimension), assert(displayItemCount == null || displayItemCount > 0), super(key: key); /// 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 /// 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. final List> items; /// 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. final T value; /// 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. final Widget hint; /// A message to show when the dropdown is disabled. /// /// Displayed if [items] or [onChanged] is null. If [hint] is non-null and /// [disabledHint] is null, the [hint] widget will be displayed instead. final Widget disabledHint; /// {@template flutter.material.dropdownButton.onChanged} /// Called when the user selects an item. /// /// 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 /// 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. /// {@endtemplate} final ValueChanged onChanged; /// Called when the dropdown button is tapped. /// /// This is distinct from [onChanged], which is called when the user /// selects an item from the dropdown. /// /// The callback will not be invoked if the dropdown button is disabled. final VoidCallback onTap; /// 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 dartpad --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 items = ['1','2','3']; /// String selectedItem = '1'; /// /// @override /// Widget build(BuildContext context) { /// return Padding( /// padding: const EdgeInsets.symmetric(horizontal: 12.0), /// child: DropdownButton( /// value: selectedItem, /// onChanged: (String string) => setState(() => selectedItem = string), /// selectedItemBuilder: (BuildContext context) { /// return items.map((String item) { /// return Text(item); /// }).toList(); /// }, /// items: items.map((String item) { /// return DropdownMenuItem( /// 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; /// The z-coordinate at which to place the menu when open. /// /// The following elevations have defined shadows: 1, 2, 3, 4, 6, 8, 9, 12, /// 16, and 24. See [kElevationToShadow]. /// /// Defaults to 8, the appropriate elevation for dropdown buttons. final int elevation; /// The text style to use for text in the dropdown button and the dropdown /// menu that appears when you tap the button. /// /// To use a separate text style for selected item when it's displayed within /// the dropdown button,, consider using [selectedItemBuilder]. /// /// {@tool dartpad --template=stateful_widget_scaffold} /// /// This sample shows a `DropdownButton` with a dropdown button text style /// that is different than its menu items. /// /// ```dart /// List options = ['One', 'Two', 'Free', 'Four']; /// String dropdownValue = 'One'; /// /// @override /// Widget build(BuildContext context) { /// return Container( /// alignment: Alignment.center, /// color: Colors.blue, /// child: DropdownButton( /// 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>((String value) { /// return DropdownMenuItem( /// value: value, /// child: Text(value), /// ); /// }).toList(), /// ), /// ); /// } /// ``` /// {@end-tool} /// /// Defaults to the [TextTheme.subtitle1] value of the current /// [ThemeData.textTheme] of the current [Theme]. final TextStyle style; /// 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; /// 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; /// The size to use for the drop-down button's down arrow icon button. /// /// Defaults to 24.0. final double iconSize; /// Reduce the button's height. /// /// By default this button's height is the same as its menu items' heights. /// If isDense is true, the button's height is reduced by about half. This /// can be useful when the button is embedded in a container that adds /// its own decorations, like [InputDecorator]. final bool isDense; /// 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; /// 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; /// 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; /// The background color of the dropdown. /// /// If it is not provided, the theme's [ThemeData.canvasColor] will be used /// instead. final Color dropdownColor; final int displayItemCount; @override _MyDropdownButtonState createState() => _MyDropdownButtonState(); } class _MyDropdownButtonState extends State> with WidgetsBindingObserver { int _selectedIndex; _DropdownRoute _dropdownRoute; Orientation _lastOrientation; FocusNode _internalNode; FocusNode get focusNode => widget.focusNode ?? _internalNode; bool _hasPrimaryFocus = false; //Map _actionMap; FocusHighlightMode _focusHighlightMode; // Only used if needed to create _internalNode. FocusNode _createFocusNode() { return FocusNode(debugLabel: '${widget.runtimeType}'); } @override void initState() { super.initState(); _updateSelectedIndex(); if (widget.focusNode == null) { _internalNode ??= _createFocusNode(); } // _actionMap = { // ActivateAction.key: _createAction, // }; focusNode.addListener(_handleFocusChanged); final FocusManager focusManager = WidgetsBinding.instance.focusManager; _focusHighlightMode = focusManager.highlightMode; focusManager.addHighlightModeListener(_handleFocusHighlightModeChange); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _removeDropdownRoute(); WidgetsBinding.instance.focusManager .removeHighlightModeListener(_handleFocusHighlightModeChange); focusNode.removeListener(_handleFocusChanged); _internalNode?.dispose(); super.dispose(); } void _removeDropdownRoute() { _dropdownRoute?._dismiss(); _dropdownRoute = null; _lastOrientation = null; } void _handleFocusChanged() { if (_hasPrimaryFocus != focusNode.hasPrimaryFocus) { setState(() { _hasPrimaryFocus = focusNode.hasPrimaryFocus; }); } } void _handleFocusHighlightModeChange(FocusHighlightMode mode) { if (!mounted) { return; } setState(() { _focusHighlightMode = mode; }); } @override void didUpdateWidget(MyDropdownButton oldWidget) { super.didUpdateWidget(oldWidget); if (widget.focusNode != oldWidget.focusNode) { oldWidget.focusNode?.removeListener(_handleFocusChanged); if (widget.focusNode == null) { _internalNode ??= _createFocusNode(); } _hasPrimaryFocus = focusNode.hasPrimaryFocus; focusNode.addListener(_handleFocusChanged); } _updateSelectedIndex(); } void _updateSelectedIndex() { if (!_enabled) { return; } assert(widget.value == null || widget.items .where((DropdownMenuItem item) => item.value == widget.value) .length == 1); _selectedIndex = null; for (int itemIndex = 0; itemIndex < widget.items.length; itemIndex++) { if (widget.items[itemIndex].value == widget.value) { _selectedIndex = itemIndex; return; } } } TextStyle get _textStyle => widget.style ?? Theme.of(context).textTheme.subtitle1; void _handleTap() { final RenderBox itemBox = context.findRenderObject() as RenderBox; final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size; final TextDirection textDirection = Directionality.of(context); final EdgeInsetsGeometry menuMargin = ButtonTheme.of(context).alignedDropdown ? _kAlignedMenuMargin : _kUnalignedMenuMargin; final List<_MenuItem> menuItems = List<_MenuItem>(widget.items.length); for (int index = 0; index < widget.items.length; index += 1) { menuItems[index] = _MenuItem( item: widget.items[index], onLayout: (Size size) { // If [_dropdownRoute] is null and onLayout is called, this means // that performLayout was called on a _DropdownRoute that has not // left the widget tree but is already on its way out. // // Since onLayout is used primarily to collect the desired heights // of each menu item before laying them out, not having the _DropdownRoute // collect each item's height to lay out is fine since the route is // already on its way out. if (_dropdownRoute == null) return; _dropdownRoute.itemHeights[index] = size.height; }, ); } assert(_dropdownRoute == null); _dropdownRoute = _DropdownRoute( items: menuItems, buttonRect: menuMargin.resolve(textDirection).inflateRect(itemRect), padding: _kMenuItemPadding.resolve(textDirection), selectedIndex: _selectedIndex ?? 0, elevation: widget.elevation, theme: Theme.of(context, shadowThemeOnly: true), style: _textStyle, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, itemHeight: widget.itemHeight, dropdownColor: widget.dropdownColor, displayItemCount: widget.displayItemCount, ); Navigator.push(context, _dropdownRoute) .then((_DropdownRouteResult newValue) { _removeDropdownRoute(); if (!mounted || newValue == null) return; if (widget.onChanged != null) widget.onChanged(newValue.result); }); if (widget.onTap != null) { widget.onTap(); } } // Action _createAction() { // return CallbackAction( // ActivateAction.key, // onInvoke: (FocusNode node, Intent intent) { // _handleTap(); // }, // ); // } // 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 { final double fontSize = _textStyle.fontSize ?? Theme.of(context).textTheme.subtitle1.fontSize; return math.max(fontSize, math.max(widget.iconSize, _kDenseButtonHeight)); } Color get _iconColor { // These colors are not defined in the Material Design spec. if (_enabled) { if (widget.iconEnabledColor != null) return widget.iconEnabledColor; switch (Theme.of(context).brightness) { case Brightness.light: return Colors.grey.shade700; case Brightness.dark: return Colors.white70; } } else { if (widget.iconDisabledColor != null) return widget.iconDisabledColor; switch (Theme.of(context).brightness) { case Brightness.light: return Colors.grey.shade400; case Brightness.dark: return Colors.white10; } } assert(false); return null; } bool get _enabled => widget.items != null && widget.items.isNotEmpty && widget.onChanged != null; 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; } bool get _showHighlight { switch (_focusHighlightMode) { case FocusHighlightMode.touch: return false; case FocusHighlightMode.traditional: return _hasPrimaryFocus; } return null; } @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); assert(debugCheckHasMaterialLocalizations(context)); final Orientation newOrientation = _getOrientation(context); _lastOrientation ??= newOrientation; if (newOrientation != _lastOrientation) { _removeDropdownRoute(); _lastOrientation = newOrientation; } // The width of the button and the menu are defined by the widest // item and the width of the hint. List items; if (_enabled) { items = widget.selectedItemBuilder == null ? List.from(widget.items) : widget.selectedItemBuilder(context); } else { items = widget.selectedItemBuilder == null ? [] : widget.selectedItemBuilder(context); } int hintIndex; if (widget.hint != null || (!_enabled && widget.disabledHint != null)) { Widget displayedHint = _enabled ? widget.hint : widget.disabledHint ?? widget.hint; if (widget.selectedItemBuilder == null) displayedHint = _DropdownMenuItemContainer(child: displayedHint); hintIndex = items.length; items.add(DefaultTextStyle( style: _textStyle.copyWith(color: Theme.of(context).hintColor), child: IgnorePointer( ignoringSemantics: false, child: displayedHint, ), )); } final EdgeInsetsGeometry padding = ButtonTheme.of(context).alignedDropdown ? _kAlignedButtonPadding : _kUnalignedButtonPadding; // If value is null (then _selectedIndex is null) or if disabled then we // display the hint or nothing at all. final int index = _enabled ? (_selectedIndex ?? hintIndex) : hintIndex; Widget innerItemsWidget; if (items.isEmpty) { innerItemsWidget = Container(); } else { innerItemsWidget = IndexedStack( index: index, alignment: AlignmentDirectional.centerStart, children: widget.isDense ? items : items.map((Widget item) { return widget.itemHeight != null ? SizedBox(height: widget.itemHeight, child: item) : Column( mainAxisSize: MainAxisSize.min, children: [item]); }).toList(), ); } const Icon defaultIcon = Icon(Icons.arrow_drop_down); Widget result = DefaultTextStyle( style: _textStyle, child: Container( decoration: _showHighlight ? BoxDecoration( color: widget.focusColor ?? Theme.of(context).focusColor, borderRadius: const BorderRadius.all(Radius.circular(4.0)), ) : null, padding: padding.resolve(Directionality.of(context)), height: widget.isDense ? _denseButtonHeight : null, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.min, children: [ if (widget.isExpanded) Expanded(child: innerItemsWidget) else innerItemsWidget, IconTheme( data: IconThemeData( color: _iconColor, size: widget.iconSize, ), child: widget.icon ?? defaultIcon, ), ], ), ), ); if (!DropdownButtonHideUnderline.at(context)) { final double bottom = (widget.isDense || widget.itemHeight == null) ? 0.0 : 8.0; result = Stack( children: [ result, Positioned( left: 0.0, right: 0.0, bottom: bottom, child: widget.underline ?? Container( height: 1.0, decoration: const BoxDecoration( border: Border( bottom: BorderSide( color: Color(0xFFBDBDBD), width: 0.0, ), ), ), ), ), ], ); } return Semantics( button: true, child: Focus( canRequestFocus: _enabled, focusNode: focusNode, autofocus: widget.autofocus, child: GestureDetector( onTap: _enabled ? _handleTap : null, behavior: HitTestBehavior.opaque, child: result, ), ), ); } } /// A convenience widget that wraps a [DropdownButton] in a [FormField]. class DropdownButtonFormField extends FormField { /// 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> items, DropdownButtonBuilder selectedItemBuilder, Widget hint, @required this.onChanged, VoidCallback onTap, this.decoration = const InputDecoration(), FormFieldSetter onSaved, FormFieldValidator validator, bool autovalidate = false, Widget disabledHint, int elevation = 8, TextStyle style, Widget icon, Color iconDisabledColor, Color iconEnabledColor, double iconSize = 24.0, bool isDense = true, bool isExpanded = false, double itemHeight, }) : assert( items == null || items.isEmpty || value == null || items.where((DropdownMenuItem 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', ), assert(decoration != null), assert(elevation != null), assert(iconSize != null), assert(isDense != null), assert(isExpanded != null), assert(itemHeight == null || itemHeight > 0), super( key: key, onSaved: onSaved, initialValue: value, validator: validator, autovalidate: autovalidate, builder: (FormFieldState field) { final _DropdownButtonFormFieldState state = field as _DropdownButtonFormFieldState; final InputDecoration effectiveDecoration = decoration.applyDefaults( Theme.of(field.context).inputDecorationTheme, ); return InputDecorator( decoration: effectiveDecoration.copyWith(errorText: field.errorText), isEmpty: state.value == null, child: DropdownButtonHideUnderline( child: DropdownButton( value: state.value, items: items, selectedItemBuilder: selectedItemBuilder, hint: hint, onChanged: onChanged == null ? null : state.didChange, onTap: onTap, disabledHint: disabledHint, elevation: elevation, style: style, icon: icon, iconDisabledColor: iconDisabledColor, iconEnabledColor: iconEnabledColor, iconSize: iconSize, isDense: isDense, isExpanded: isExpanded, itemHeight: itemHeight, ), ), ); }, ); /// {@macro flutter.material.dropdownButton.onChanged} final ValueChanged onChanged; /// 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; @override FormFieldState createState() => _DropdownButtonFormFieldState(); } class _DropdownButtonFormFieldState extends FormFieldState { @override DropdownButtonFormField get widget => super.widget as DropdownButtonFormField; @override void didChange(T value) { super.didChange(value); assert(widget.onChanged != null); widget.onChanged(value); } @override void didUpdateWidget(DropdownButtonFormField oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.initialValue != widget.initialValue) { setValue(widget.initialValue); } } }