Created
September 2, 2019 10:15
-
-
Save tudor07/9f886102f3cb2f69314e159ea10572e1 to your computer and use it in GitHub Desktop.
DropdownButton with custom height for list of options
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// DropdownButton from material.dart does not allow to set a height | |
/// for the list of options inside it. | |
/// This is a copy of the Flutter's code which allows setting height also. | |
/// Once Flutter adds this in the framework this should be removed. | |
// Copyright 2015 The Chromium Authors. All rights reserved. | |
// Use of this source code is governed by a BSD-style license that can be | |
// found in the LICENSE file. | |
import 'dart:math' as math; | |
import 'package:flutter/material.dart'; | |
const Duration _kDropdownMenuDuration = Duration(milliseconds: 300); | |
const double _kMenuItemHeight = 48.0; | |
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); | |
class _DropdownMenuPainter extends CustomPainter { | |
_DropdownMenuPainter({ | |
this.color, | |
this.elevation, | |
this.selectedIndex, | |
this.resize, | |
}) : _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, | |
borderRadius: BorderRadius.circular(2.0), | |
boxShadow: kElevationToShadow[elevation], | |
).createBoxPainter(), | |
super(repaint: resize); | |
final Color color; | |
final int elevation; | |
final int selectedIndex; | |
final Animation<double> resize; | |
final BoxPainter _painter; | |
@override | |
void paint(Canvas canvas, Size size) { | |
final double selectedItemOffset = | |
selectedIndex * _kMenuItemHeight + kMaterialListPadding.top; | |
final Tween<double> top = Tween<double>( | |
begin: selectedItemOffset.clamp(0.0, size.height - _kMenuItemHeight), | |
end: 0.0, | |
); | |
final Tween<double> bottom = Tween<double>( | |
begin: | |
(top.begin + _kMenuItemHeight).clamp(_kMenuItemHeight, size.height), | |
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(); | |
} | |
class _DropdownMenu<T> extends StatefulWidget { | |
const _DropdownMenu({ | |
Key key, | |
this.padding, | |
this.route, | |
this.height, | |
}) : super(key: key); | |
final _DropdownRoute<T> route; | |
final EdgeInsets padding; | |
final double height; | |
@override | |
_DropdownMenuState<T> createState() => _DropdownMenuState<T>(); | |
} | |
class _DropdownMenuState<T> extends State<_DropdownMenu<T>> { | |
CurvedAnimation _fadeOpacity; | |
CurvedAnimation _resize; | |
@override | |
void initState() { | |
super.initState(); | |
// We need to hold these animations as state because of their curve | |
// direction. When the route's animation reverses, if we were to recreate | |
// the CurvedAnimation objects in build, we'd lose | |
// CurvedAnimation._curveDirection. | |
_fadeOpacity = 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<T> route = widget.route; | |
final double unit = 0.5 / (route.items.length + 1.5); | |
final List<Widget> children = <Widget>[]; | |
for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex) { | |
CurvedAnimation opacity; | |
if (itemIndex == route.selectedIndex) { | |
opacity = CurvedAnimation( | |
parent: route.animation, curve: const Threshold(0.0)); | |
} else { | |
final double start = (0.5 + (itemIndex + 1) * unit).clamp(0.0, 1.0); | |
final double end = (start + 1.5 * unit).clamp(0.0, 1.0); | |
opacity = CurvedAnimation( | |
parent: route.animation, curve: Interval(start, end)); | |
} | |
children.add(FadeTransition( | |
opacity: opacity, | |
child: InkWell( | |
child: Container( | |
padding: widget.padding, | |
child: route.items[itemIndex], | |
), | |
onTap: () => Navigator.pop( | |
context, | |
_DropdownRouteResult<T>(route.items[itemIndex].value), | |
), | |
), | |
)); | |
} | |
return FadeTransition( | |
opacity: _fadeOpacity, | |
child: CustomPaint( | |
painter: _DropdownMenuPainter( | |
color: Theme.of(context).canvasColor, | |
elevation: route.elevation, | |
selectedIndex: route.selectedIndex, | |
resize: _resize, | |
), | |
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: widget.height != null | |
? SizedBox( | |
height: widget.height, | |
child: ListView( | |
controller: widget.route.scrollController, | |
padding: kMaterialListPadding, | |
itemExtent: _kMenuItemHeight, | |
shrinkWrap: true, | |
children: children, | |
), | |
) | |
: ListView( | |
controller: widget.route.scrollController, | |
padding: kMaterialListPadding, | |
itemExtent: _kMenuItemHeight, | |
shrinkWrap: true, | |
children: children, | |
), | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
} | |
class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate { | |
_DropdownMenuRouteLayout({ | |
@required this.buttonRect, | |
@required this.menuTop, | |
@required this.menuHeight, | |
@required this.textDirection, | |
}); | |
final Rect buttonRect; | |
final double menuTop; | |
final double menuHeight; | |
final TextDirection textDirection; | |
@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 = | |
math.max(0.0, 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) { | |
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(menuTop >= 0.0); | |
assert(menuTop + menuHeight <= size.height); | |
} | |
return true; | |
}()); | |
assert(textDirection != null); | |
double left; | |
switch (textDirection) { | |
case TextDirection.rtl: | |
left = buttonRect.right.clamp(0.0, size.width) - childSize.width; | |
break; | |
case TextDirection.ltr: | |
left = buttonRect.left.clamp(0.0, size.width - childSize.width); | |
break; | |
} | |
return Offset(left, menuTop); | |
} | |
@override | |
bool shouldRelayout(_DropdownMenuRouteLayout<T> oldDelegate) { | |
return buttonRect != oldDelegate.buttonRect || | |
menuTop != oldDelegate.menuTop || | |
menuHeight != oldDelegate.menuHeight || | |
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<T> { | |
const _DropdownRouteResult(this.result); | |
final T result; | |
@override | |
bool operator ==(dynamic other) { | |
if (other is! _DropdownRouteResult<T>) return false; | |
final _DropdownRouteResult<T> typedOther = other; | |
return result == typedOther.result; | |
} | |
@override | |
int get hashCode => result.hashCode; | |
} | |
class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> { | |
_DropdownRoute({ | |
this.items, | |
this.padding, | |
this.buttonRect, | |
this.selectedIndex, | |
this.elevation = 8, | |
this.theme, | |
this.height, | |
@required this.style, | |
this.barrierLabel, | |
}) : assert(style != null); | |
final double height; | |
final List<DropdownMenuItem<T>> items; | |
final EdgeInsetsGeometry padding; | |
final Rect buttonRect; | |
final int selectedIndex; | |
final int elevation; | |
final ThemeData theme; | |
final TextStyle style; | |
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<double> animation, | |
Animation<double> secondaryAnimation) { | |
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, | |
height: height, | |
); | |
}); | |
} | |
void _dismiss() { | |
navigator?.removeRoute(this); | |
} | |
} | |
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, | |
this.height, | |
}) : super(key: key); | |
final _DropdownRoute<T> route; | |
final BoxConstraints constraints; | |
final List<DropdownMenuItem<T>> items; | |
final EdgeInsetsGeometry padding; | |
final Rect buttonRect; | |
final int selectedIndex; | |
final int elevation; | |
final ThemeData theme; | |
final TextStyle style; | |
final double height; | |
@override | |
Widget build(BuildContext context) { | |
assert(debugCheckHasDirectionality(context)); | |
final double availableHeight = constraints.maxHeight; | |
final double maxMenuHeight = availableHeight - 2.0 * _kMenuItemHeight; | |
final double buttonTop = buttonRect.top; | |
final double buttonBottom = math.min(buttonRect.bottom, availableHeight); | |
// 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); | |
final double selectedItemOffset = | |
selectedIndex * _kMenuItemHeight + kMaterialListPadding.top; | |
double menuTop = (buttonTop - selectedItemOffset) - | |
(_kMenuItemHeight - buttonRect.height) / 2.0; | |
final double preferredMenuHeight = | |
(items.length * _kMenuItemHeight) + kMaterialListPadding.vertical; | |
// 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 (route.scrollController == null) { | |
// The limit is asymmetrical because we do not care how far positive the | |
// limit goes. We are only concerned about the case where the value of | |
// [buttonTop - menuTop] is larger than selectedItemOffset, ie. when | |
// the button is close to the bottom of the screen and the selected item | |
// is close to 0. | |
final double scrollOffset = preferredMenuHeight > maxMenuHeight | |
? math.max(0.0, selectedItemOffset - (buttonTop - menuTop)) | |
: 0.0; | |
route.scrollController = | |
ScrollController(initialScrollOffset: scrollOffset); | |
} | |
final TextDirection textDirection = Directionality.of(context); | |
Widget menu = _DropdownMenu<T>( | |
route: route, | |
height: height, | |
padding: padding.resolve(textDirection), | |
); | |
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<T>( | |
buttonRect: buttonRect, | |
menuTop: menuTop, | |
menuHeight: menuHeight, | |
textDirection: textDirection, | |
), | |
child: menu, | |
); | |
}, | |
), | |
); | |
} | |
} | |
/// 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 StatelessWidget { | |
/// Creates an item for a dropdown menu. | |
/// | |
/// The [child] argument is required. | |
const DropdownMenuItem({ | |
Key key, | |
this.value, | |
@required this.child, | |
}) : assert(child != null), | |
super(key: key); | |
/// The widget below this widget in the tree. | |
/// | |
/// Typically a [Text] widget. | |
final Widget child; | |
/// The value to return if the user selects this menu item. | |
/// | |
/// Eventually returned in a call to [DropdownButton.onChanged]. | |
final T value; | |
@override | |
Widget build(BuildContext context) { | |
return Container( | |
height: _kMenuItemHeight, | |
alignment: AlignmentDirectional.centerStart, | |
child: child, | |
); | |
} | |
} | |
/// 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.inheritFromWidgetOfExactType(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 snippet --template=stateful_widget_scaffold} | |
/// | |
/// This sample shows a `DropdownButton` whose value is one of | |
/// "One", "Two", "Free", or "Four". | |
/// | |
/// ```dart | |
/// String dropdownValue = 'One'; | |
/// | |
/// @override | |
/// Widget build(BuildContext context) { | |
/// return Scaffold( | |
/// body: Center( | |
/// child: DropdownButton<String>( | |
/// value: dropdownValue, | |
/// 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(), | |
/// ), | |
/// ), | |
/// ); | |
/// } | |
/// ``` | |
/// {@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. | |
/// | |
/// 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. | |
/// * <https://material.io/design/components/menus.html#dropdown-menu> | |
class DropdownButton<T> 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). | |
/// | |
/// 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. | |
DropdownButton({ | |
Key key, | |
@required this.items, | |
this.value, | |
this.hint, | |
this.disabledHint, | |
@required this.onChanged, | |
this.elevation = 8, | |
this.style, | |
this.underline, | |
this.icon, | |
this.iconDisabledColor, | |
this.iconEnabledColor, | |
this.iconSize = 24.0, | |
this.isDense = false, | |
this.isExpanded = false, | |
this.height, | |
}) : assert(items == null || | |
items.isEmpty || | |
value == null || | |
items | |
.where((DropdownMenuItem<T> item) => item.value == value) | |
.length == | |
1), | |
assert(elevation != null), | |
assert(iconSize != null), | |
assert(isDense != null), | |
assert(isExpanded != null), | |
super(key: key); | |
final double height; | |
/// 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. | |
final List<DropdownMenuItem<T>> items; | |
/// The value of the currently selected [DropdownMenuItem], or null if no | |
/// item has been selected. If `value` is null then the menu is popped up as | |
/// if the first item were selected. | |
final T value; | |
/// Displayed if [value] is null. | |
final Widget hint; | |
/// A message to show when the dropdown is disabled. | |
/// | |
/// Displayed if [items] or [onChanged] is null. | |
final Widget disabledHint; | |
/// 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. | |
final ValueChanged<T> onChanged; | |
/// 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. | |
/// | |
/// Defaults to the [TextTheme.subhead] 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; | |
@override | |
_DropdownButtonState<T> createState() => _DropdownButtonState<T>(); | |
} | |
class _DropdownButtonState<T> extends State<DropdownButton<T>> | |
with WidgetsBindingObserver { | |
int _selectedIndex; | |
_DropdownRoute<T> _dropdownRoute; | |
@override | |
void initState() { | |
super.initState(); | |
_updateSelectedIndex(); | |
WidgetsBinding.instance.addObserver(this); | |
} | |
@override | |
void dispose() { | |
WidgetsBinding.instance.removeObserver(this); | |
_removeDropdownRoute(); | |
super.dispose(); | |
} | |
// Typically called because the device's orientation has changed. | |
// Defined by WidgetsBindingObserver | |
@override | |
void didChangeMetrics() { | |
_removeDropdownRoute(); | |
} | |
void _removeDropdownRoute() { | |
_dropdownRoute?._dismiss(); | |
_dropdownRoute = null; | |
} | |
@override | |
void didUpdateWidget(DropdownButton<T> oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
_updateSelectedIndex(); | |
} | |
void _updateSelectedIndex() { | |
if (!_enabled) { | |
return; | |
} | |
assert(widget.value == null || | |
widget.items | |
.where((DropdownMenuItem<T> 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.subhead; | |
void _handleTap() { | |
final RenderBox itemBox = context.findRenderObject(); | |
final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size; | |
final TextDirection textDirection = Directionality.of(context); | |
final EdgeInsetsGeometry menuMargin = | |
ButtonTheme.of(context).alignedDropdown | |
? _kAlignedMenuMargin | |
: _kUnalignedMenuMargin; | |
assert(_dropdownRoute == null); | |
_dropdownRoute = _DropdownRoute<T>( | |
items: widget.items, | |
height: widget.height, | |
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, | |
); | |
Navigator.push(context, _dropdownRoute) | |
.then<void>((_DropdownRouteResult<T> newValue) { | |
_dropdownRoute = null; | |
if (!mounted || newValue == null) return; | |
if (widget.onChanged != null) widget.onChanged(newValue.result); | |
}); | |
} | |
// 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.subhead.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; | |
@override | |
Widget build(BuildContext context) { | |
assert(debugCheckHasMaterial(context)); | |
assert(debugCheckHasMaterialLocalizations(context)); | |
// The width of the button and the menu are defined by the widest | |
// item and the width of the hint. | |
final List<Widget> items = | |
_enabled ? List<Widget>.from(widget.items) : <Widget>[]; | |
int hintIndex; | |
if (widget.hint != null || (!_enabled && widget.disabledHint != null)) { | |
final Widget emplacedHint = _enabled | |
? widget.hint | |
: DropdownMenuItem<Widget>(child: widget.disabledHint ?? widget.hint); | |
hintIndex = items.length; | |
items.add(DefaultTextStyle( | |
style: _textStyle.copyWith(color: Theme.of(context).hintColor), | |
child: IgnorePointer( | |
child: emplacedHint, | |
ignoringSemantics: false, | |
), | |
)); | |
} | |
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: items, | |
); | |
} | |
const Icon defaultIcon = Icon(Icons.arrow_drop_down); | |
Widget result = DefaultTextStyle( | |
style: _textStyle, | |
child: Container( | |
padding: padding.resolve(Directionality.of(context)), | |
height: widget.isDense ? _denseButtonHeight : null, | |
child: Row( | |
mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
mainAxisSize: MainAxisSize.min, | |
children: <Widget>[ | |
widget.isExpanded | |
? Expanded(child: innerItemsWidget) | |
: innerItemsWidget, | |
IconTheme( | |
data: IconThemeData( | |
color: _iconColor, | |
size: widget.iconSize, | |
), | |
child: widget.icon ?? defaultIcon, | |
), | |
], | |
), | |
), | |
); | |
if (!DropdownButtonHideUnderline.at(context)) { | |
final double bottom = widget.isDense ? 0.0 : 8.0; | |
result = Stack( | |
children: <Widget>[ | |
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: GestureDetector( | |
onTap: _enabled ? _handleTap : null, | |
behavior: HitTestBehavior.opaque, | |
child: result, | |
), | |
); | |
} | |
} | |
/// 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, | |
this.onChanged, | |
InputDecoration decoration = const InputDecoration(), | |
FormFieldSetter<T> onSaved, | |
FormFieldValidator<T> validator, | |
Widget hint, | |
}) : assert(decoration != null), | |
super( | |
key: key, | |
onSaved: onSaved, | |
initialValue: value, | |
validator: validator, | |
builder: (FormFieldState<T> field) { | |
final InputDecoration effectiveDecoration = decoration | |
.applyDefaults(Theme.of(field.context).inputDecorationTheme); | |
return InputDecorator( | |
decoration: | |
effectiveDecoration.copyWith(errorText: field.errorText), | |
isEmpty: value == null, | |
child: DropdownButtonHideUnderline( | |
child: DropdownButton<T>( | |
isDense: true, | |
value: value, | |
items: items, | |
hint: hint, | |
onChanged: field.didChange, | |
), | |
), | |
); | |
}); | |
/// Called when the user selects an item. | |
final ValueChanged<T> onChanged; | |
@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); | |
if (widget.onChanged != null) widget.onChanged(value); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Did you find a solution for this?