blob: a115b0982b1344a11efb6001e7a1103b6ec8e751 [file] [log] [blame]
// 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 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'button_theme.dart';
import 'colors.dart';
import 'constants.dart';
import 'debug.dart';
import 'icons.dart';
import 'ink_well.dart';
import 'input_decorator.dart';
import 'material.dart';
import 'material_localizations.dart';
import 'material_state.dart';
import 'scrollbar.dart';
import 'shadows.dart';
import 'theme.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);
/// A builder to customize dropdown buttons.
///
/// Used by [DropdownButton.selectedItemBuilder].
typedef DropdownButtonBuilder = List<Widget> Function(BuildContext context);
class _DropdownMenuPainter extends CustomPainter {
_DropdownMenuPainter({
this.color,
this.elevation,
this.selectedIndex,
this.borderRadius,
required this.resize,
required 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,
borderRadius: borderRadius ?? const BorderRadius.all(Radius.circular(2.0)),
boxShadow: kElevationToShadow[elevation],
).createBoxPainter(),
super(repaint: resize);
final Color? color;
final int? elevation;
final int? selectedIndex;
final BorderRadius? borderRadius;
final Animation<double> resize;
final ValueGetter<double> getSelectedItemOffset;
final BoxPainter _painter;
@override
void paint(Canvas canvas, Size size) {
final double selectedItemOffset = getSelectedItemOffset();
final Tween<double> top = Tween<double>(
begin: clampDouble(selectedItemOffset, 0.0, math.max(size.height - _kMenuItemHeight, 0.0)),
end: 0.0,
);
final Tween<double> bottom = Tween<double>(
begin: clampDouble(top.begin! + _kMenuItemHeight, math.min(_kMenuItemHeight, size.height), 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.borderRadius != borderRadius
|| oldPainter.resize != resize;
}
}
// The widget that is the button wrapping the menu items.
class _DropdownMenuItemButton<T> extends StatefulWidget {
const _DropdownMenuItemButton({
super.key,
this.padding,
required this.route,
required this.buttonRect,
required this.constraints,
required this.itemIndex,
required this.enableFeedback,
});
final _DropdownRoute<T> route;
final EdgeInsets? padding;
final Rect buttonRect;
final BoxConstraints constraints;
final int itemIndex;
final bool enableFeedback;
@override
_DropdownMenuItemButtonState<T> createState() => _DropdownMenuItemButtonState<T>();
}
class _DropdownMenuItemButtonState<T> extends State<_DropdownMenuItemButton<T>> {
void _handleFocusChange(bool focused) {
final bool inTraditionalMode;
switch (FocusManager.instance.highlightMode) {
case FocusHighlightMode.touch:
inTraditionalMode = false;
case FocusHighlightMode.traditional:
inTraditionalMode = true;
}
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<T> dropdownMenuItem = widget.route.items[widget.itemIndex].item!;
dropdownMenuItem.onTap?.call();
Navigator.pop(
context,
_DropdownRouteResult<T>(dropdownMenuItem.value),
);
}
static const Map<ShortcutActivator, Intent> _webShortcuts = <ShortcutActivator, Intent>{
// On the web, up/down don't change focus, *except* in a <select>
// element, which is what a dropdown emulates.
SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down),
SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up),
};
@override
Widget build(BuildContext context) {
final DropdownMenuItem<T> dropdownMenuItem = widget.route.items[widget.itemIndex].item!;
final 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 = clampDouble(0.5 + (widget.itemIndex + 1) * unit, 0.0, 1.0);
final double end = clampDouble(start + 1.5 * unit, 0.0, 1.0);
opacity = CurvedAnimation(parent: widget.route.animation!, curve: Interval(start, end));
}
Widget child = Container(
padding: widget.padding,
height: widget.route.itemHeight,
child: widget.route.items[widget.itemIndex],
);
// An [InkWell] is added to the item only if it is enabled
if (dropdownMenuItem.enabled) {
child = InkWell(
autofocus: widget.itemIndex == widget.route.selectedIndex,
enableFeedback: widget.enableFeedback,
onTap: _handleOnTap,
onFocusChange: _handleFocusChange,
child: child,
);
}
child = FadeTransition(opacity: opacity, child: child);
if (kIsWeb && dropdownMenuItem.enabled) {
child = Shortcuts(
shortcuts: _webShortcuts,
child: child,
);
}
return child;
}
}
class _DropdownMenu<T> extends StatefulWidget {
const _DropdownMenu({
super.key,
this.padding,
required this.route,
required this.buttonRect,
required this.constraints,
this.dropdownColor,
required this.enableFeedback,
this.borderRadius,
});
final _DropdownRoute<T> route;
final EdgeInsets? padding;
final Rect buttonRect;
final BoxConstraints constraints;
final Color? dropdownColor;
final bool enableFeedback;
final BorderRadius? borderRadius;
@override
_DropdownMenuState<T> createState() => _DropdownMenuState<T>();
}
class _DropdownMenuState<T> extends State<_DropdownMenu<T>> {
late CurvedAnimation _fadeOpacity;
late 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 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,
enableFeedback: widget.enableFeedback,
),
];
return FadeTransition(
opacity: _fadeOpacity,
child: CustomPaint(
painter: _DropdownMenuPainter(
color: widget.dropdownColor ?? Theme.of(context).canvasColor,
elevation: route.elevation,
selectedIndex: route.selectedIndex,
resize: _resize,
borderRadius: widget.borderRadius,
// 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: ClipRRect(
borderRadius: widget.borderRadius ?? BorderRadius.zero,
clipBehavior: widget.borderRadius != null
? Clip.antiAlias
: Clip.none,
child: Material(
type: MaterialType.transparency,
textStyle: route.style,
child: ScrollConfiguration(
// Dropdown menus should never overscroll or display an overscroll indicator.
// Scrollbars are built-in below.
// Platform must use Theme and ScrollPhysics must be Clamping.
behavior: ScrollConfiguration.of(context).copyWith(
scrollbars: false,
overscroll: false,
physics: const ClampingScrollPhysics(),
platform: Theme.of(context).platform,
),
child: PrimaryScrollController(
controller: widget.route.scrollController!,
child: Scrollbar(
thumbVisibility: true,
child: ListView(
// Ensure this always inherits the PrimaryScrollController
primary: true,
padding: kMaterialListPadding,
shrinkWrap: true,
children: children,
),
),
),
),
),
),
),
),
);
}
}
class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate {
_DropdownMenuRouteLayout({
required this.buttonRect,
required this.route,
required this.textDirection,
});
final Rect buttonRect;
final _DropdownRoute<T> route;
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
double maxHeight = math.max(0.0, constraints.maxHeight - 2 * _kMenuItemHeight);
if (route.menuMaxHeight != null && route.menuMaxHeight! <= maxHeight) {
maxHeight = route.menuMaxHeight!;
}
// 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,
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);
final double left;
switch (textDirection!) {
case TextDirection.rtl:
left = clampDouble(buttonRect.right, 0.0, size.width) - childSize.width;
case TextDirection.ltr:
left = clampDouble(buttonRect.left, 0.0, size.width - childSize.width);
}
return Offset(left, menuLimits.top);
}
@override
bool shouldRelayout(_DropdownMenuRouteLayout<T> 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.
@immutable
class _DropdownRouteResult<T> {
const _DropdownRouteResult(this.result);
final T? result;
@override
bool operator ==(Object other) {
return other is _DropdownRouteResult<T>
&& 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<T> extends PopupRoute<_DropdownRouteResult<T>> {
_DropdownRoute({
required this.items,
required this.padding,
required this.buttonRect,
required this.selectedIndex,
this.elevation = 8,
required this.capturedThemes,
required this.style,
this.barrierLabel,
this.itemHeight,
this.dropdownColor,
this.menuMaxHeight,
required this.enableFeedback,
this.borderRadius,
}) : itemHeights = List<double>.filled(items.length, itemHeight ?? kMinInteractiveDimension);
final List<_MenuItem<T>> items;
final EdgeInsetsGeometry padding;
final Rect buttonRect;
final int selectedIndex;
final int elevation;
final CapturedThemes capturedThemes;
final TextStyle style;
final double? itemHeight;
final Color? dropdownColor;
final double? menuMaxHeight;
final bool enableFeedback;
final BorderRadius? borderRadius;
final List<double> 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<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,
capturedThemes: capturedThemes,
style: style,
dropdownColor: dropdownColor,
enableFeedback: enableFeedback,
borderRadius: borderRadius,
);
},
);
}
void _dismiss() {
if (isActive) {
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) {
double computedMaxHeight = availableHeight - 2.0 * _kMenuItemHeight;
if (menuMaxHeight != null) {
computedMaxHeight = math.min(computedMaxHeight, menuMaxHeight!);
}
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 computedMaxHeight.
final double menuHeight = math.min(computedMaxHeight, 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);
menuBottom = menuTop + menuHeight;
}
if (menuBottom > bottomLimit) {
menuBottom = math.max(buttonBottom, bottomLimit);
menuTop = menuBottom - menuHeight;
}
if (menuBottom - itemHeights[selectedIndex] / 2.0 < buttonBottom - buttonRect.height / 2.0) {
menuBottom = buttonBottom - buttonRect.height / 2.0 + itemHeights[selectedIndex] / 2.0;
menuTop = menuBottom - menuHeight;
}
double scrollOffset = 0;
// 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).
if (preferredMenuHeight > computedMaxHeight) {
// The offset should be zero if the selected item is in view at the beginning
// of the menu. Otherwise, the scroll offset should center the item if possible.
scrollOffset = math.max(0.0, selectedItemOffset - (buttonTop - menuTop));
// If the selected item's scroll offset is greater than the maximum scroll offset,
// set it instead to the maximum allowed scroll offset.
scrollOffset = math.min(scrollOffset, preferredMenuHeight - menuHeight);
}
assert((menuBottom - menuTop - menuHeight).abs() < precisionErrorTolerance);
return _MenuLimits(menuTop, menuBottom, menuHeight, scrollOffset);
}
}
class _DropdownRoutePage<T> extends StatelessWidget {
const _DropdownRoutePage({
super.key,
required this.route,
required this.constraints,
this.items,
required this.padding,
required this.buttonRect,
required this.selectedIndex,
this.elevation = 8,
required this.capturedThemes,
this.style,
required this.dropdownColor,
required this.enableFeedback,
this.borderRadius,
});
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 CapturedThemes capturedThemes;
final TextStyle? style;
final Color? dropdownColor;
final bool enableFeedback;
final BorderRadius? borderRadius;
@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 kMinInteractiveDimension.
if (route.scrollController == null) {
final _MenuLimits menuLimits = route.getMenuLimits(buttonRect, constraints.maxHeight, selectedIndex);
route.scrollController = ScrollController(initialScrollOffset: menuLimits.scrollOffset);
}
final TextDirection? textDirection = Directionality.maybeOf(context);
final Widget menu = _DropdownMenu<T>(
route: route,
padding: padding.resolve(textDirection),
buttonRect: buttonRect,
constraints: constraints,
dropdownColor: dropdownColor,
enableFeedback: enableFeedback,
borderRadius: borderRadius,
);
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,
route: route,
textDirection: textDirection,
),
child: capturedThemes.wrap(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<T> extends SingleChildRenderObjectWidget {
const _MenuItem({
super.key,
required this.onLayout,
required this.item,
}) : super(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]) : super(child);
ValueChanged<Size> 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({
super.key,
this.alignment = AlignmentDirectional.centerStart,
required this.child,
});
/// The widget below this widget in the tree.
///
/// Typically a [Text] widget.
final Widget child;
/// Defines how the item is positioned within the container.
///
/// This property must not be null. It defaults to [AlignmentDirectional.centerStart].
///
/// See also:
///
/// * [Alignment], a class with convenient constants typically used to
/// specify an [AlignmentGeometry].
/// * [AlignmentDirectional], like [Alignment] for specifying alignments
/// relative to text direction.
final AlignmentGeometry alignment;
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(minHeight: _kMenuItemHeight),
alignment: alignment,
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<T> extends _DropdownMenuItemContainer {
/// Creates an item for a dropdown menu.
///
/// The [child] argument is required.
const DropdownMenuItem({
super.key,
this.onTap,
this.value,
this.enabled = true,
super.alignment,
required super.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;
/// Whether or not a user can select this menu item.
///
/// Defaults to `true`.
final bool enabled;
}
/// 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({
super.key,
required super.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.
///
/// ## Updating to [DropdownMenu]
///
/// There is a Material 3 version of this component,
/// [DropdownMenu] that is preferred for applications that are configured
/// for Material 3 (see [ThemeData.useMaterial3]).
/// The [DropdownMenu] widget's visuals
/// are a little bit different, see the Material 3 spec at
/// <https://m3.material.io/components/menus/guidelines> for
/// more details.
///
/// The [DropdownMenu] widget's API is also slightly different.
/// To update from [DropdownButton] to [DropdownMenu], you will
/// need to make the following changes:
///
/// 1. Instead of using [DropdownButton.items], which
/// takes a list of [DropdownMenuItem]s, use
/// [DropdownMenu.dropdownMenuEntries], which
/// takes a list of [DropdownMenuEntry]'s.
///
/// 2. Instead of using [DropdownButton.onChanged],
/// use [DropdownMenu.onSelected], which is also
/// a callback that is called when the user selects an entry.
///
/// 3. In [DropdownMenu] it is not required to track
/// the current selection in your app's state.
/// So, instead of tracking the current selection in
/// the [DropdownButton.value] property, you can set the
/// [DropdownMenu.initialSelection] property to the
/// item that should be selected before there is any user action.
///
/// 4. You may also need to make changes to the styling of the
/// [DropdownMenu], see the properties in the [DropdownMenu]
/// constructor for more details.
///
/// See the sample below for an example of migrating
/// from [DropdownButton] to [DropdownMenu].
///
/// ## Using [DropdownButton]
/// {@youtube 560 315 https://www.youtube.com/watch?v=ZzQ_PWrFihg}
///
/// One ancestor must be a [Material] widget and typically this is
/// provided by the app's [Scaffold].
///
/// 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}
/// 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)
///
/// ** See code in examples/api/lib/material/dropdown/dropdown_button.0.dart **
/// {@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.
///
/// {@tool dartpad}
/// This sample shows how you would rewrite the above [DropdownButton]
/// to use the [DropdownMenu].
///
/// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.1.dart **
/// {@end-tool}
///
///
/// See also:
///
/// * [DropdownButtonFormField], which integrates with the [Form] widget.
/// * [DropdownMenuItem], the class used to represent the [items].
/// * [DropdownButtonHideUnderline], which prevents its descendant dropdown buttons
/// from displaying their underlines.
/// * [ElevatedButton], [TextButton], 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.
///
/// If [value] is null and the button is enabled, [hint] will be displayed
/// if it is non-null.
///
/// If [value] is null and the button is disabled, [disabledHint] will be displayed
/// if it is non-null. If [disabledHint] is null, then [hint] will be displayed
/// if it is non-null.
///
/// 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 [autofocus] argument 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.
DropdownButton({
super.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.menuMaxHeight,
this.enableFeedback,
this.alignment = AlignmentDirectional.centerStart,
this.borderRadius,
this.padding,
// When adding new arguments, consider adding similar arguments to
// DropdownButtonFormField.
}) : 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',
),
assert(itemHeight == null || itemHeight >= kMinInteractiveDimension),
_inputDecoration = null,
_isEmpty = false,
_isFocused = false;
DropdownButton._formField({
super.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.menuMaxHeight,
this.enableFeedback,
this.alignment = AlignmentDirectional.centerStart,
this.borderRadius,
this.padding,
required InputDecoration inputDecoration,
required bool isEmpty,
required bool isFocused,
}) : 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 [DropdownButtonFormField]'s value: "
'$value. \n'
'Either zero or 2 or more [DropdownMenuItem]s were detected '
'with the same value',
),
assert(itemHeight == null || itemHeight >= kMinInteractiveDimension),
_inputDecoration = inputDecoration,
_isEmpty = isEmpty,
_isFocused = isFocused;
/// 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.
final List<DropdownMenuItem<T>>? items;
/// The value of the currently selected [DropdownMenuItem].
///
/// If [value] is null and the button is enabled, [hint] will be displayed
/// if it is non-null.
///
/// If [value] is null and the button is disabled, [disabledHint] will be displayed
/// if it is non-null. If [disabledHint] is null, then [hint] will be displayed
/// if it is non-null.
final T? value;
/// A placeholder widget that is displayed by the dropdown button.
///
/// If [value] is null and the dropdown is enabled ([items] and [onChanged] are non-null),
/// this widget is displayed as a placeholder for the dropdown button's value.
///
/// If [value] is null and the dropdown is disabled and [disabledHint] is null,
/// this widget is used as the placeholder.
final Widget? hint;
/// A preferred placeholder widget that is displayed when the dropdown is disabled.
///
/// If [value] is null, the dropdown is disabled ([items] or [onChanged] is null),
/// this widget is displayed as a placeholder for the dropdown button's value.
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 [DropdownButton.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 [DropdownButton.disabledHint] widget if it is non-null.
/// If [DropdownButton.disabledHint] is also null but [DropdownButton.hint] is
/// non-null, [DropdownButton.hint] will instead be displayed.
/// {@endtemplate}
final ValueChanged<T?>? 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}
/// This sample shows a `DropdownButton` with a button with [Text] that
/// corresponds to but is unique from [DropdownMenuItem].
///
/// ** See code in examples/api/lib/material/dropdown/dropdown_button.selected_item_builder.0.dart **
/// {@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}
/// This sample shows a `DropdownButton` with a dropdown button text style
/// that is different than its menu items.
///
/// ** See code in examples/api/lib/material/dropdown/dropdown_button.style.0.dart **
/// {@end-tool}
///
/// Defaults to the [TextTheme.titleMedium] 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 [MaterialColor.shade400] of [Colors.grey] 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 [MaterialColor.shade700] of [Colors.grey] 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;
/// Padding around the visible portion of the dropdown widget.
///
/// As the padding increases, the size of the [DropdownButton] will also
/// increase. The padding is included in the clickable area of the dropdown
/// widget, so this can make the widget easier to click.
///
/// Padding can be useful when used with a custom border. The clickable
/// area will stay flush with the border, as opposed to an external [Padding]
/// widget which will leave a non-clickable gap.
final EdgeInsetsGeometry? padding;
/// The maximum height of the menu.
///
/// The maximum height of the menu must be at least one row shorter than
/// the height of the app's view. This ensures that a tappable area
/// outside of the simple menu is present so the user can dismiss the menu.
///
/// If this property is set above the maximum allowable height threshold
/// mentioned above, then the menu defaults to being padded at the top
/// and bottom of the menu by at one menu item's height.
final double? menuMaxHeight;
/// Whether detected gestures should provide acoustic and/or haptic feedback.
///
/// For example, on Android a tap will produce a clicking sound and a
/// long-press will produce a short vibration, when feedback is enabled.
///
/// By default, platform-specific feedback is enabled.
///
/// See also:
///
/// * [Feedback] for providing platform-specific feedback to certain actions.
final bool? enableFeedback;
/// Defines how the hint or the selected item is positioned within the button.
///
/// This property must not be null. It defaults to [AlignmentDirectional.centerStart].
///
/// See also:
///
/// * [Alignment], a class with convenient constants typically used to
/// specify an [AlignmentGeometry].
/// * [AlignmentDirectional], like [Alignment] for specifying alignments
/// relative to text direction.
final AlignmentGeometry alignment;
/// Defines the corner radii of the menu's rounded rectangle shape.
final BorderRadius? borderRadius;
final InputDecoration? _inputDecoration;
final bool _isEmpty;
final bool _isFocused;
@override
State<DropdownButton<T>> createState() => _DropdownButtonState<T>();
}
class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindingObserver {
int? _selectedIndex;
_DropdownRoute<T>? _dropdownRoute;
Orientation? _lastOrientation;
FocusNode? _internalNode;
FocusNode? get focusNode => widget.focusNode ?? _internalNode;
late Map<Type, Action<Intent>> _actionMap;
// 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 = <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (ActivateIntent intent) => _handleTap(),
),
ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(
onInvoke: (ButtonActivateIntent intent) => _handleTap(),
),
};
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_removeDropdownRoute();
_internalNode?.dispose();
super.dispose();
}
void _removeDropdownRoute() {
_dropdownRoute?._dismiss();
_dropdownRoute = null;
_lastOrientation = null;
}
@override
void didUpdateWidget(DropdownButton<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.focusNode == null) {
_internalNode ??= _createFocusNode();
}
_updateSelectedIndex();
}
void _updateSelectedIndex() {
if (widget.items == null
|| widget.items!.isEmpty
|| (widget.value == null &&
widget.items!
.where((DropdownMenuItem<T> item) => item.enabled && item.value == widget.value)
.isEmpty)) {
_selectedIndex = null;
return;
}
assert(widget.items!.where((DropdownMenuItem<T> item) => item.value == widget.value).length == 1);
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.titleMedium;
void _handleTap() {
final TextDirection? textDirection = Directionality.maybeOf(context);
final EdgeInsetsGeometry menuMargin = ButtonTheme.of(context).alignedDropdown
? _kAlignedMenuMargin
: _kUnalignedMenuMargin;
final List<_MenuItem<T>> menuItems = <_MenuItem<T>>[
for (int index = 0; index < widget.items!.length; index += 1)
_MenuItem<T>(
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;
},
),
];
final NavigatorState navigator = Navigator.of(context);
assert(_dropdownRoute == null);
final RenderBox itemBox = context.findRenderObject()! as RenderBox;
final Rect itemRect = itemBox.localToGlobal(Offset.zero, ancestor: navigator.context.findRenderObject()) & itemBox.size;
_dropdownRoute = _DropdownRoute<T>(
items: menuItems,
buttonRect: menuMargin.resolve(textDirection).inflateRect(itemRect),
padding: _kMenuItemPadding.resolve(textDirection),
selectedIndex: _selectedIndex ?? 0,
elevation: widget.elevation,
capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
style: _textStyle!,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
itemHeight: widget.itemHeight,
dropdownColor: widget.dropdownColor,
menuMaxHeight: widget.menuMaxHeight,
enableFeedback: widget.enableFeedback ?? true,
borderRadius: widget.borderRadius,
);
focusNode?.requestFocus();
navigator.push(_dropdownRoute!).then<void>((_DropdownRouteResult<T>? newValue) {
_removeDropdownRoute();
if (!mounted || newValue == null) {
return;
}
widget.onChanged?.call(newValue.result);
});
widget.onTap?.call();
}
// 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 textScaleFactor = MediaQuery.textScaleFactorOf(context);
final double fontSize = _textStyle!.fontSize ?? Theme.of(context).textTheme.titleMedium!.fontSize!;
final double scaledFontSize = textScaleFactor * fontSize;
return math.max(scaledFontSize, 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;
}
}
}
bool get _enabled => widget.items != null && widget.items!.isNotEmpty && widget.onChanged != null;
Orientation _getOrientation(BuildContext context) {
Orientation? result = MediaQuery.maybeOrientationOf(context);
if (result == null) {
// If there's no MediaQuery, then use the view aspect to determine
// orientation.
final Size size = View.of(context).physicalSize;
result = size.width > size.height ? Orientation.landscape : Orientation.portrait;
}
return result;
}
@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.
// We should explicitly type the items list to be a list of <Widget>,
// otherwise, no explicit type adding items maybe trigger a crash/failure
// when hint and selectedItemBuilder are provided.
final List<Widget> items = widget.selectedItemBuilder == null
? (widget.items != null ? List<Widget>.of(widget.items!) : <Widget>[])
: List<Widget>.of(widget.selectedItemBuilder!(context));
int? hintIndex;
if (widget.hint != null || (!_enabled && widget.disabledHint != null)) {
final Widget displayedHint = _enabled ? widget.hint! : widget.disabledHint ?? widget.hint!;
hintIndex = items.length;
items.add(DefaultTextStyle(
style: _textStyle!.copyWith(color: Theme.of(context).hintColor),
child: IgnorePointer(
child: _DropdownMenuItemContainer(
alignment: widget.alignment,
child: displayedHint,
),
),
));
}
final EdgeInsetsGeometry padding = ButtonTheme.of(context).alignedDropdown
? _kAlignedButtonPadding
: _kUnalignedButtonPadding;
// If value is null (then _selectedIndex is null) then we
// display the hint or nothing at all.
final Widget innerItemsWidget;
if (items.isEmpty) {
innerItemsWidget = const SizedBox.shrink();
} else {
innerItemsWidget = IndexedStack(
index: _selectedIndex ?? hintIndex,
alignment: widget.alignment,
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(),
);
}
const Icon defaultIcon = Icon(Icons.arrow_drop_down);
Widget result = DefaultTextStyle(
style: _enabled ? _textStyle! : _textStyle!.copyWith(color: Theme.of(context).disabledColor),
child: Container(
padding: padding.resolve(Directionality.of(context)),
height: widget.isDense ? _denseButtonHeight : null,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
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: <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,
),
),
),
),
),
],
);
}
final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>(
MaterialStateMouseCursor.clickable,
<MaterialState>{
if (!_enabled) MaterialState.disabled,
},
);
if (widget._inputDecoration != null) {
result = InputDecorator(
decoration: widget._inputDecoration!,
isEmpty: widget._isEmpty,
isFocused: widget._isFocused,
child: result,
);
}
return Semantics(
button: true,
child: Actions(
actions: _actionMap,
child: InkWell(
mouseCursor: effectiveMouseCursor,
onTap: _enabled ? _handleTap : null,
canRequestFocus: _enabled,
borderRadius: widget.borderRadius,
focusNode: focusNode,
autofocus: widget.autofocus,
focusColor: widget.focusColor ?? Theme.of(context).focusColor,
enableFeedback: false,
child: widget.padding == null ? result : Padding(padding: widget.padding!, child: result),
),
),
);
}
}
/// A [FormField] that contains a [DropdownButton].
///
/// This is a convenience widget that wraps a [DropdownButton] widget in a
/// [FormField].
///
/// A [Form] ancestor is not required. The [Form] allows one to
/// save, reset, or validate multiple fields at once. To use without a [Form],
/// pass a [GlobalKey] to the constructor and use [GlobalKey.currentState] to
/// save or reset the form field.
///
/// See also:
///
/// * [DropdownButton], which is the underlying text field without the [Form]
/// integration.
class DropdownButtonFormField<T> extends FormField<T> {
/// Creates a [DropdownButton] widget that is a [FormField], wrapped in an
/// [InputDecorator].
///
/// For a description of the `onSaved`, `validator`, or `autovalidateMode`
/// parameters, see [FormField]. For the rest (other than [decoration]), see
/// [DropdownButton].
///
/// The `items`, `elevation`, `iconSize`, `isDense`, `isExpanded`,
/// `autofocus`, and `decoration` parameters must not be null.
DropdownButtonFormField({
super.key,
required List<DropdownMenuItem<T>>? items,
DropdownButtonBuilder? selectedItemBuilder,
T? value,
Widget? hint,
Widget? disabledHint,
required this.onChanged,
VoidCallback? onTap,
int elevation = 8,
TextStyle? style,
Widget? icon,
Color? iconDisabledColor,
Color? iconEnabledColor,
double iconSize = 24.0,
bool isDense = true,
bool isExpanded = false,
double? itemHeight,
Color? focusColor,
FocusNode? focusNode,
bool autofocus = false,
Color? dropdownColor,
InputDecoration? decoration,
super.onSaved,
super.validator,
AutovalidateMode? autovalidateMode,
double? menuMaxHeight,
bool? enableFeedback,
AlignmentGeometry alignment = AlignmentDirectional.centerStart,
BorderRadius? borderRadius,
EdgeInsetsGeometry? padding,
// When adding new arguments, consider adding similar arguments to
// DropdownButton.
}) : 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',
),
assert(itemHeight == null || itemHeight >= kMinInteractiveDimension),
decoration = decoration ?? InputDecoration(focusColor: focusColor),
super(
initialValue: value,
autovalidateMode: autovalidateMode ?? AutovalidateMode.disabled,
builder: (FormFieldState<T> field) {
final _DropdownButtonFormFieldState<T> state = field as _DropdownButtonFormFieldState<T>;
final InputDecoration decorationArg = decoration ?? InputDecoration(focusColor: focusColor);
final InputDecoration effectiveDecoration = decorationArg.applyDefaults(
Theme.of(field.context).inputDecorationTheme,
);
final bool showSelectedItem = items != null && items.where((DropdownMenuItem<T> item) => item.value == state.value).isNotEmpty;
bool isHintOrDisabledHintAvailable() {
final bool isDropdownDisabled = onChanged == null || (items == null || items.isEmpty);
if (isDropdownDisabled) {
return hint != null || disabledHint != null;
} else {
return hint != null;
}
}
final bool isEmpty = !showSelectedItem && !isHintOrDisabledHintAvailable();
// An unfocusable Focus widget so that this widget can detect if its
// descendants have focus or not.
return Focus(
canRequestFocus: false,
skipTraversal: true,
child: Builder(builder: (BuildContext context) {
return DropdownButtonHideUnderline(
child: DropdownButton<T>._formField(
items: items,
selectedItemBuilder: selectedItemBuilder,
value: state.value,
hint: hint,
disabledHint: disabledHint,
onChanged: onChanged == null ? null : state.didChange,
onTap: onTap,
elevation: elevation,
style: style,
icon: icon,
iconDisabledColor: iconDisabledColor,
iconEnabledColor: iconEnabledColor,
iconSize: iconSize,
isDense: isDense,
isExpanded: isExpanded,
itemHeight: itemHeight,
focusColor: focusColor,
focusNode: focusNode,
autofocus: autofocus,
dropdownColor: dropdownColor,
menuMaxHeight: menuMaxHeight,
enableFeedback: enableFeedback,
alignment: alignment,
borderRadius: borderRadius,
inputDecoration: effectiveDecoration.copyWith(errorText: field.errorText),
isEmpty: isEmpty,
isFocused: Focus.of(context).hasFocus,
padding: padding,
),
);
}),
);
},
);
/// {@macro flutter.material.dropdownButton.onChanged}
final ValueChanged<T?>? 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.
///
/// If not specified, an [InputDecorator] with the `focusColor` set to the
/// supplied `focusColor` (if any) will be used.
final InputDecoration decoration;
@override
FormFieldState<T> createState() => _DropdownButtonFormFieldState<T>();
}
class _DropdownButtonFormFieldState<T> extends FormFieldState<T> {
@override
void didChange(T? value) {
super.didChange(value);
final DropdownButtonFormField<T> dropdownButtonFormField = widget as DropdownButtonFormField<T>;
assert(dropdownButtonFormField.onChanged != null);
dropdownButtonFormField.onChanged!(value);
}
@override
void didUpdateWidget(DropdownButtonFormField<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialValue != widget.initialValue) {
setValue(widget.initialValue);
}
}
}