blob: dc786318da801db010d2170e56c4bd0b7923ae33 [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.
/// @docImport 'text_theme.dart';
library;
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_style.dart';
import 'dropdown_menu_theme.dart';
import 'icon_button.dart';
import 'icons.dart';
import 'input_border.dart';
import 'input_decorator.dart';
import 'material_localizations.dart';
import 'material_state.dart';
import 'menu_anchor.dart';
import 'menu_button_theme.dart';
import 'menu_style.dart';
import 'text_field.dart';
import 'theme.dart';
import 'theme_data.dart';
// Examples can assume:
// late BuildContext context;
// late FocusNode myFocusNode;
/// A callback function that returns the list of the items that matches the
/// current applied filter.
///
/// Used by [DropdownMenu.filterCallback].
typedef FilterCallback<T> =
List<DropdownMenuEntry<T>> Function(List<DropdownMenuEntry<T>> entries, String filter);
/// A callback function that returns the index of the item that matches the
/// current contents of a text field.
///
/// If a match doesn't exist then null must be returned.
///
/// Used by [DropdownMenu.searchCallback].
typedef SearchCallback<T> = int? Function(List<DropdownMenuEntry<T>> entries, String query);
/// The type of builder function used by [DropdownMenu.decorationBuilder] to
/// build the [InputDecoration] passed to the inner text field.
///
/// The `context` is the context that the decoration is being built in.
///
/// The `controller` is the [MenuController] that can be used to open and close
/// the menu with and query the current state.
typedef DropdownMenuDecorationBuilder =
InputDecoration Function(BuildContext context, MenuController controller);
const double _kMinimumWidth = 112.0;
const double _kDefaultHorizontalPadding = 12.0;
const double _kInputStartGap = 4.0;
/// Defines a [DropdownMenu] menu button that represents one item view in the menu.
///
/// See also:
///
/// * [DropdownMenu]
class DropdownMenuEntry<T> {
/// Creates an entry that is used with [DropdownMenu.dropdownMenuEntries].
const DropdownMenuEntry({
required this.value,
required this.label,
this.labelWidget,
this.leadingIcon,
this.trailingIcon,
this.enabled = true,
this.style,
});
/// the value used to identify the entry.
///
/// This value must be unique across all entries in a [DropdownMenu].
final T value;
/// The label displayed in the center of the menu item.
final String label;
/// Overrides the default label widget which is `Text(label)`.
///
/// This widget is only displayed in the open dropdown menu. When an item is
/// selected, the menu closes and the text field displays the plain text of
/// the [label].
///
/// The dropdown menu's closed state is a text field or a read-only text field
/// on mobile, which can only display text.
/// While custom widgets like icons or images can be shown in [labelWidget]
/// when the menu is open, the text field will only show the [label] string upon selection.
///
/// To control the text that appears in the text field for a selected item,
/// set the [label] property to a descriptive string.
///
/// {@tool dartpad}
/// This sample shows how to override the default label [Text]
/// widget with one that forces the menu entry to appear on one line
/// by specifying [Text.maxLines] and [Text.overflow].
///
/// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu_entry_label_widget.0.dart **
/// {@end-tool}
final Widget? labelWidget;
/// An optional icon to display before the label.
final Widget? leadingIcon;
/// An optional icon to display after the label.
final Widget? trailingIcon;
/// Whether the menu item is enabled or disabled.
///
/// The default value is true. If true, the [DropdownMenuEntry.label] will be filled
/// out in the text field of the [DropdownMenu] when this entry is clicked; otherwise,
/// this entry is disabled.
final bool enabled;
/// Customizes this menu item's appearance.
///
/// Null by default.
final ButtonStyle? style;
}
/// Defines the behavior for closing the dropdown menu when an item is selected.
enum DropdownMenuCloseBehavior {
/// Closes all open menus in the widget tree.
all,
/// Closes only the current dropdown menu.
self,
/// Does not close any menus.
none,
}
/// A dropdown menu that can be opened from a [TextField]. The selected
/// menu item is displayed in that field.
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=giV9AbM2gd8}
///
/// This widget is used to help people make a choice from a menu and put the
/// selected item into the text input field. People can also filter the list based
/// on the text input or search one item in the menu list.
///
/// The menu is composed of a list of [DropdownMenuEntry]s. People can provide information,
/// such as: label, leading icon or trailing icon for each entry. The [TextField]
/// will be updated based on the selection from the menu entries. The text field
/// will stay empty if the selected entry is disabled.
///
/// When the dropdown menu has focus, it can be traversed by pressing the up or down key.
/// During the process, the corresponding item will be highlighted and
/// the text field will be updated. Disabled items will be skipped during traversal.
///
/// The menu can be scrollable if not all items in the list are displayed at once.
///
/// {@tool dartpad}
/// This sample shows how to display outlined [DropdownMenu] and filled [DropdownMenu].
///
/// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [MenuAnchor], which is a widget used to mark the "anchor" for a set of submenus.
/// The [DropdownMenu] uses a [TextField] as the "anchor".
/// * [TextField], which is a text input widget that uses an [InputDecoration].
/// * [DropdownMenuEntry], which is used to build the [MenuItemButton] in the [DropdownMenu] list.
class DropdownMenu<T> extends StatefulWidget {
/// Creates a const [DropdownMenu].
///
/// The leading and trailing icons in the text field can be customized by using
/// [leadingIcon], [trailingIcon] and [selectedTrailingIcon] properties. They are
/// passed down to the [InputDecoration] properties, and will override values
/// in the [InputDecoration.prefixIcon] and [InputDecoration.suffixIcon].
///
/// Except leading and trailing icons, the text field can be configured by the
/// [inputDecorationTheme] property. The menu can be configured by the [menuStyle].
const DropdownMenu({
super.key,
this.enabled = true,
this.width,
this.menuHeight,
this.leadingIcon,
this.trailingIcon,
this.showTrailingIcon = true,
this.trailingIconFocusNode,
this.label,
this.hintText,
this.helperText,
this.errorText,
this.selectedTrailingIcon,
this.enableFilter = false,
this.enableSearch = true,
this.keyboardType,
this.textStyle,
this.textAlign = TextAlign.start,
// TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized.
Object? inputDecorationTheme,
this.decorationBuilder,
this.menuStyle,
this.controller,
this.initialSelection,
this.onSelected,
this.focusNode,
this.requestFocusOnTap,
this.selectOnly = false,
this.expandedInsets,
this.filterCallback,
this.searchCallback,
this.alignmentOffset,
required this.dropdownMenuEntries,
this.inputFormatters,
this.closeBehavior = DropdownMenuCloseBehavior.all,
this.maxLines = 1,
this.textInputAction,
this.cursorHeight,
this.restorationId,
this.menuController,
}) : assert(filterCallback == null || enableFilter),
assert(
inputDecorationTheme == null ||
(inputDecorationTheme is InputDecorationTheme ||
inputDecorationTheme is InputDecorationThemeData),
),
assert(trailingIconFocusNode == null || showTrailingIcon),
assert(
decorationBuilder == null ||
(label == null && hintText == null && helperText == null && errorText == null),
),
_inputDecorationTheme = inputDecorationTheme;
/// Determine if the [DropdownMenu] is enabled.
///
/// Defaults to true.
///
/// {@tool dartpad}
/// This sample demonstrates how the [enabled] and [requestFocusOnTap] properties
/// affect the textfield's hover cursor.
///
/// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.2.dart **
/// {@end-tool}
final bool enabled;
/// Determine the width of the [DropdownMenu].
///
/// If this is null, the width of the [DropdownMenu] will be the same as the width of the widest
/// menu item plus the width of the leading/trailing icon.
final double? width;
/// Determine the height of the menu.
///
/// If this is null, the menu will display as many items as possible on the screen.
final double? menuHeight;
/// An optional Icon at the front of the text input field.
///
/// Defaults to null. If this is not null, the menu items will have extra paddings to be aligned
/// with the text in the text field.
final Widget? leadingIcon;
/// An optional icon at the end of the text field.
///
/// Defaults to an [Icon] with [Icons.arrow_drop_down].
///
/// If [showTrailingIcon] is false, the trailing icon will not be shown.
final Widget? trailingIcon;
/// Specifies if the [DropdownMenu] should show the [trailingIcon].
///
/// If [trailingIcon] is set, [DropdownMenu] will use that trailing icon,
/// otherwise a default trailing icon will be created.
///
/// If [showTrailingIcon] is false, [trailingIconFocusNode] must be null.
///
/// If a value is provided for [decorationBuilder] and the resulting [InputDecoration.suffixIcon]
/// is not null, [showTrailingIcon] has no effect.
///
/// Defaults to true.
final bool showTrailingIcon;
/// Defines the FocusNode for the trailing icon.
///
/// If [showTrailingIcon] is false, [trailingIconFocusNode] must be null.
///
/// The [focusNode] is a long-lived object that's typically managed by a
/// [StatefulWidget] parent. See [FocusNode] for more information.
///
/// To give the keyboard focus to this widget, provide a [focusNode] and then
/// use the current [FocusScope] to request the focus:
///
/// ```dart
/// FocusScope.of(context).requestFocus(myFocusNode);
/// ```
///
/// This happens automatically when the widget is tapped.
///
/// To be notified when the widget gains or loses the focus, add a listener
/// to the [focusNode]:
///
/// ```dart
/// myFocusNode.addListener(() { print(myFocusNode.hasFocus); });
/// ```
///
/// If null, this widget will create its own [FocusNode].
final FocusNode? trailingIconFocusNode;
/// Optional widget that describes the input field.
///
/// When the input field is empty and unfocused, the label is displayed on
/// top of the input field (i.e., at the same location on the screen where
/// text may be entered in the input field). When the input field receives
/// focus (or if the field is non-empty), the label moves above, either
/// vertically adjacent to, or to the center of the input field.
///
/// Defaults to null.
final Widget? label;
/// Text that suggests what sort of input the field accepts.
///
/// Defaults to null;
final String? hintText;
/// Text that provides context about the [DropdownMenu]'s value, such
/// as how the value will be used.
///
/// If non-null, the text is displayed below the input field, in
/// the same location as [errorText]. If a non-null [errorText] value is
/// specified then the helper text is not shown.
///
/// Defaults to null;
///
/// See also:
///
/// * [InputDecoration.helperText], which is the text that provides context about the [InputDecorator.child]'s value.
final String? helperText;
/// Text that appears below the input field and the border to show the error message.
///
/// If non-null, the border's color animates to red and the [helperText] is not shown.
///
/// Defaults to null;
///
/// See also:
///
/// * [InputDecoration.errorText], which is the text that appears below the [InputDecorator.child] and the border.
final String? errorText;
/// An optional icon at the end of the text field to indicate that the text
/// field is pressed.
///
/// Defaults to an [Icon] with [Icons.arrow_drop_up].
final Widget? selectedTrailingIcon;
/// Determine if the menu list can be filtered by the text input.
///
/// Defaults to false.
final bool enableFilter;
/// Determine if the first item that matches the text input can be highlighted.
///
/// Defaults to true as the search function could be commonly used.
final bool enableSearch;
/// The type of keyboard to use for editing the text.
///
/// Defaults to [TextInputType.text].
final TextInputType? keyboardType;
/// The text style for the [TextField] of the [DropdownMenu];
///
/// Defaults to the overall theme's [TextTheme.bodyLarge]
/// if the dropdown menu theme's value is null.
final TextStyle? textStyle;
/// The text align for the [TextField] of the [DropdownMenu].
///
/// Defaults to [TextAlign.start].
final TextAlign textAlign;
/// Defines the default appearance of [InputDecoration] to show around the text field.
///
/// By default, shows a outlined text field.
// TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized.
InputDecorationThemeData? get inputDecorationTheme {
if (_inputDecorationTheme == null) {
return null;
}
return _inputDecorationTheme is InputDecorationTheme
? _inputDecorationTheme.data
: _inputDecorationTheme as InputDecorationThemeData;
}
final Object? _inputDecorationTheme;
/// The builder function used to create the [InputDecoration] passed to the text field.
///
/// If a value is provided for this property and the resulting [InputDecoration.suffixIcon]
/// is null, a default [IconButton] is assigned as the suffix icon. This button's icon will
/// use [trailingIcon] and [selectedTrailingIcon] if those are explicitly defined; otherwise,
/// it defaults to [Icons.arrow_drop_down] for the collapsed state and [Icons.arrow_drop_up]
/// for the expanded state.
///
/// If null, the default builder creates a decoration where:
/// - [InputDecoration.label] is set to [label].
/// - [InputDecoration.hintText] is set to [hintText].
/// - [InputDecoration.helperText] is set to [helperText].
/// - [InputDecoration.errorText] is set to [errorText].
/// - [InputDecoration.prefixIcon] is set to [leadingIcon].
/// - [InputDecoration.suffixIcon] is set to an [IconButton] which uses [trailingIcon] and [selectedTrailingIcon] if defined, or [Icons.arrow_drop_down] and [Icons.arrow_drop_up] otherwise.
final DropdownMenuDecorationBuilder? decorationBuilder;
/// The [MenuStyle] that defines the visual attributes of the menu.
///
/// The default width of the menu is set to the width of the text field.
final MenuStyle? menuStyle;
/// Controls the text being edited or selected in the menu.
///
/// If null, this widget will create its own [TextEditingController].
final TextEditingController? controller;
/// The value used for an initial selection.
///
/// This property sets the initial value of the dropdown menu when the widget
/// is first created. If the value matches one of the [dropdownMenuEntries],
/// the corresponding label will be displayed in the text field.
///
/// Setting this to null does not clear the text field.
///
/// To programmatically clear the text field, use a [TextEditingController]
/// and call [TextEditingController.clear] on it.
///
/// Defaults to null.
///
/// See also:
///
/// * [controller], which is required to programmatically clear or modify
/// the text field content.
final T? initialSelection;
/// The callback is called when a selection is made.
///
/// The callback receives the selected entry's value of type `T` when the user
/// chooses an item. It may also be invoked with `null` to indicate that the
/// selection was cleared / that no item was chosen.
///
/// Defaults to null. If this callback itself is null, the widget still updates
/// the text field with the selected label.
final ValueChanged<T?>? onSelected;
/// Defines the keyboard focus for this widget.
///
/// The [focusNode] is a long-lived object that's typically managed by a
/// [StatefulWidget] parent. See [FocusNode] for more information.
///
/// To give the keyboard focus to this widget, provide a [focusNode] and then
/// use the current [FocusScope] to request the focus:
///
/// ```dart
/// FocusScope.of(context).requestFocus(myFocusNode);
/// ```
///
/// This happens automatically when the widget is tapped.
///
/// To be notified when the widget gains or loses the focus, add a listener
/// to the [focusNode]:
///
/// ```dart
/// myFocusNode.addListener(() { print(myFocusNode.hasFocus); });
/// ```
///
/// If null, this widget will create its own [FocusNode].
///
/// ## Keyboard
///
/// Requesting the focus will typically cause the keyboard to be shown
/// if it's not showing already.
///
/// On Android, the user can hide the keyboard - without changing the focus -
/// with the system back button. They can restore the keyboard's visibility
/// by tapping on a text field. The user might hide the keyboard and
/// switch to a physical keyboard, or they might just need to get it
/// out of the way for a moment, to expose something it's
/// obscuring. In this case requesting the focus again will not
/// cause the focus to change, and will not make the keyboard visible.
///
/// If this is non-null, the behaviour of [requestFocusOnTap] is overridden
/// by the [FocusNode.canRequestFocus] property.
final FocusNode? focusNode;
/// Determine if the dropdown menu requests focus and the on-screen virtual
/// keyboard is shown in response to a touch event.
///
/// Ignored if a [focusNode] is explicitly provided (in which case,
/// [FocusNode.canRequestFocus] controls the behavior).
///
/// Defaults to null, which enables platform-specific behavior:
///
/// * On mobile platforms, acts as if set to false; tapping on the text
/// field and opening the menu will not cause a focus request and the
/// virtual keyboard will not appear.
///
/// * On desktop platforms, acts as if set to true; the dropdown takes the
/// focus when activated.
///
/// Set this to true or false explicitly to override the default behavior.
///
/// {@tool dartpad}
/// This sample demonstrates how the [enabled] and [requestFocusOnTap] properties
/// affect the textfield's hover cursor.
///
/// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.2.dart **
/// {@end-tool}
final bool? requestFocusOnTap;
/// Determines if the dropdown menu behaves as a 'select' component.
///
/// This is useful for mobile platforms where a dropdown menu is commonly used as
/// a 'select' widget (i.e., the user can only select from the list, not edit
/// the text field to search or filter).
///
/// When true, the inner text field is read-only.
///
/// If the text field is also focusable (see [requestFocusOnTap]), the following
/// behaviors are also activated:
///
/// * Pressing Enter when the menu is closed opens it.
/// * The decoration reflects the focus state.
///
/// Defaults to false.
final bool selectOnly;
/// Descriptions of the menu items in the [DropdownMenu].
///
/// This is a required parameter. It is recommended that at least one [DropdownMenuEntry]
/// is provided. If this is an empty list, the menu will be empty and only
/// contain space for padding.
final List<DropdownMenuEntry<T>> dropdownMenuEntries;
/// Defines the menu text field's width to be equal to its parent's width
/// plus the horizontal width of the specified insets.
///
/// If this property is null, the width of the text field will be determined
/// by the width of menu items or [DropdownMenu.width]. If this property is not null,
/// the text field's width will match the parent's width plus the specified insets.
/// If the value of this property is [EdgeInsets.zero], the width of the text field will be the same
/// as its parent's width.
///
/// The [expandedInsets]' top and bottom are ignored, only its left and right
/// properties are used.
///
/// Defaults to null.
final EdgeInsetsGeometry? expandedInsets;
/// When [DropdownMenu.enableFilter] is true, this callback is used to
/// compute the list of filtered items.
///
/// {@tool snippet}
///
/// In this example the `filterCallback` returns the items that contains the
/// trimmed query.
///
/// ```dart
/// DropdownMenu<Text>(
/// enableFilter: true,
/// filterCallback: (List<DropdownMenuEntry<Text>> entries, String filter) {
/// final String trimmedFilter = filter.trim().toLowerCase();
/// if (trimmedFilter.isEmpty) {
/// return entries;
/// }
///
/// return entries
/// .where((DropdownMenuEntry<Text> entry) =>
/// entry.label.toLowerCase().contains(trimmedFilter),
/// )
/// .toList();
/// },
/// dropdownMenuEntries: const <DropdownMenuEntry<Text>>[],
/// )
/// ```
/// {@end-tool}
///
/// Defaults to null. If this parameter is null and the
/// [DropdownMenu.enableFilter] property is set to true, the default behavior
/// will return a filtered list. The filtered list will contain items
/// that match the text provided by the input field, with a case-insensitive
/// comparison. When this is not null, `enableFilter` must be set to true.
final FilterCallback<T>? filterCallback;
/// When [DropdownMenu.enableSearch] is true, this callback is used to compute
/// the index of the search result to be highlighted.
///
/// {@tool snippet}
///
/// In this example the `searchCallback` returns the index of the search result
/// that exactly matches the query.
///
/// ```dart
/// DropdownMenu<Text>(
/// searchCallback: (List<DropdownMenuEntry<Text>> entries, String query) {
/// if (query.isEmpty) {
/// return null;
/// }
/// final int index = entries.indexWhere((DropdownMenuEntry<Text> entry) => entry.label == query);
///
/// return index != -1 ? index : null;
/// },
/// dropdownMenuEntries: const <DropdownMenuEntry<Text>>[],
/// )
/// ```
/// {@end-tool}
///
/// Defaults to null. If this is null and [DropdownMenu.enableSearch] is true,
/// the default function will return the index of the first matching result
/// which contains the contents of the text input field.
final SearchCallback<T>? searchCallback;
/// Optional input validation and formatting overrides.
///
/// Formatters are run in the provided order when the user changes the text
/// this widget contains. When this parameter changes, the new formatters will
/// not be applied until the next time the user inserts or deletes text.
/// Formatters don't run when the text is changed
/// programmatically via [controller].
///
/// See also:
///
/// * [TextEditingController], which implements the [Listenable] interface
/// and notifies its listeners on [TextEditingValue] changes.
final List<TextInputFormatter>? inputFormatters;
/// {@macro flutter.material.MenuAnchor.alignmentOffset}
final Offset? alignmentOffset;
/// Defines the behavior for closing the dropdown menu when an item is selected.
///
/// The close behavior can be set to:
/// * [DropdownMenuCloseBehavior.all]: Closes all open menus in the widget tree.
/// * [DropdownMenuCloseBehavior.self]: Closes only the current dropdown menu.
/// * [DropdownMenuCloseBehavior.none]: Does not close any menus.
///
/// This property allows fine-grained control over the menu's closing behavior,
/// which can be useful for creating nested or complex menu structures.
///
/// Defaults to [DropdownMenuCloseBehavior.all].
final DropdownMenuCloseBehavior closeBehavior;
/// Specifies the maximum number of lines the selected value can display
/// in the [DropdownMenu].
///
/// If the provided value is 1, then the text will not wrap, but will scroll
/// horizontally instead. Defaults to 1.
///
/// If this is null, there is no limit to the number of lines, and the text
/// container will start with enough vertical space for one line and
/// automatically grow to accommodate additional lines as they are entered, up
/// to the height of its constraints.
///
/// If this is not null, the provided value must be greater than zero. The text
/// field will restrict the input to the given number of lines and take up enough
/// horizontal space to accommodate that number of lines.
///
/// See also:
/// * [TextField.maxLines], which specifies the maximum number of lines
/// the [TextField] can display.
final int? maxLines;
/// {@macro flutter.widgets.TextField.textInputAction}
final TextInputAction? textInputAction;
/// {@macro flutter.widgets.editableText.cursorHeight}
final double? cursorHeight;
/// {@macro flutter.material.textfield.restorationId}
final String? restorationId;
/// An optional controller that allows opening and closing of the menu from
/// other widgets.
final MenuController? menuController;
@override
State<DropdownMenu<T>> createState() => _DropdownMenuState<T>();
}
class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
static const Map<ShortcutActivator, Intent> _editableShortcuts = <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowLeft): ExtendSelectionByCharacterIntent(
forward: false,
collapseSelection: true,
),
SingleActivator(LogicalKeyboardKey.arrowRight): ExtendSelectionByCharacterIntent(
forward: true,
collapseSelection: true,
),
SingleActivator(LogicalKeyboardKey.arrowUp): _ArrowUpIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): _ArrowDownIntent(),
};
static const Map<ShortcutActivator, Intent> _selectOnlyShortcuts = <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowUp): _ArrowUpIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): _ArrowDownIntent(),
// When selectOnly is true, a shortcut for the enter key is needed because
// the text field won't provide one.
SingleActivator(LogicalKeyboardKey.enter): _EnterIntent(),
};
final GlobalKey _anchorKey = GlobalKey();
final GlobalKey _leadingKey = GlobalKey();
late List<GlobalKey> buttonItemKeys;
late MenuController _controller;
bool _enableFilter = false;
late bool _enableSearch;
late List<DropdownMenuEntry<T>> filteredEntries;
List<Widget>? _initialMenu;
int? currentHighlight;
double? leadingPadding;
bool _menuHasEnabledItem = false;
TextEditingController? _localTextEditingController;
TextEditingController get _effectiveTextEditingController =>
widget.controller ?? (_localTextEditingController ??= TextEditingController());
final FocusNode _internalFocusNode = FocusNode();
WidgetStatesController? _highlightedItemStatesController;
FocusNode? _localTrailingIconButtonFocusNode;
FocusNode get _trailingIconButtonFocusNode =>
widget.trailingIconFocusNode ?? (_localTrailingIconButtonFocusNode ??= FocusNode());
@override
void initState() {
super.initState();
_enableSearch = widget.enableSearch;
filteredEntries = widget.dropdownMenuEntries;
buttonItemKeys = List<GlobalKey>.generate(filteredEntries.length, (int index) => GlobalKey());
_menuHasEnabledItem = filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled);
final int index = filteredEntries.indexWhere(
(DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection,
);
if (index != -1) {
_effectiveTextEditingController.value = TextEditingValue(
text: filteredEntries[index].label,
selection: TextSelection.collapsed(offset: filteredEntries[index].label.length),
);
}
refreshLeadingPadding();
_controller = widget.menuController ?? MenuController();
}
@override
void dispose() {
_localTextEditingController?.dispose();
_localTextEditingController = null;
_internalFocusNode.dispose();
_localTrailingIconButtonFocusNode?.dispose();
_localTrailingIconButtonFocusNode = null;
_highlightedItemStatesController?.dispose();
super.dispose();
}
@override
void didUpdateWidget(DropdownMenu<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
_localTextEditingController?.dispose();
_localTextEditingController = null;
}
if (oldWidget.enableFilter != widget.enableFilter) {
if (!widget.enableFilter) {
_enableFilter = false;
}
}
if (oldWidget.enableSearch != widget.enableSearch) {
if (!widget.enableSearch) {
_enableSearch = widget.enableSearch;
currentHighlight = null;
}
}
if (oldWidget.dropdownMenuEntries != widget.dropdownMenuEntries) {
currentHighlight = null;
filteredEntries = widget.dropdownMenuEntries;
buttonItemKeys = List<GlobalKey>.generate(filteredEntries.length, (int index) => GlobalKey());
_menuHasEnabledItem = filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled);
}
if (oldWidget.leadingIcon != widget.leadingIcon) {
refreshLeadingPadding();
}
if (oldWidget.initialSelection != widget.initialSelection) {
final int index = filteredEntries.indexWhere(
(DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection,
);
if (index != -1) {
_effectiveTextEditingController.value = TextEditingValue(
text: filteredEntries[index].label,
selection: TextSelection.collapsed(offset: filteredEntries[index].label.length),
);
}
}
if (oldWidget.menuController != widget.menuController) {
_controller = widget.menuController ?? MenuController();
}
}
bool canRequestFocus() {
return widget.focusNode?.canRequestFocus ??
widget.requestFocusOnTap ??
switch (Theme.of(context).platform) {
TargetPlatform.iOS || TargetPlatform.android || TargetPlatform.fuchsia => false,
TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => true,
};
}
bool get selectOnly => widget.selectOnly;
bool get isButton => !canRequestFocus() || selectOnly;
void refreshLeadingPadding() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
setState(() {
leadingPadding = getWidth(_leadingKey);
});
}, debugLabel: 'DropdownMenu.refreshLeadingPadding');
}
void scrollToHighlight() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final BuildContext? highlightContext = buttonItemKeys[currentHighlight!].currentContext;
if (highlightContext != null) {
Scrollable.of(
highlightContext,
).position.ensureVisible(highlightContext.findRenderObject()!);
}
}, debugLabel: 'DropdownMenu.scrollToHighlight');
}
double? getWidth(GlobalKey key) {
final BuildContext? context = key.currentContext;
if (context != null) {
final box = context.findRenderObject()! as RenderBox;
return box.hasSize ? box.size.width : null;
}
return null;
}
List<DropdownMenuEntry<T>> filter(
List<DropdownMenuEntry<T>> entries,
TextEditingController textEditingController,
) {
final String filterText = textEditingController.text.toLowerCase();
return entries
.where((DropdownMenuEntry<T> entry) => entry.label.toLowerCase().contains(filterText))
.toList();
}
bool _shouldUpdateCurrentHighlight(List<DropdownMenuEntry<T>> entries) {
final String searchText = _effectiveTextEditingController.value.text.toLowerCase();
if (searchText.isEmpty) {
return true;
}
// When `entries` are filtered by filter algorithm, currentHighlight may exceed the valid range of `entries` and should be updated.
if (currentHighlight == null || currentHighlight! >= entries.length) {
return true;
}
if (entries[currentHighlight!].label.toLowerCase().contains(searchText)) {
return false;
}
return true;
}
int? search(List<DropdownMenuEntry<T>> entries, TextEditingController textEditingController) {
final String searchText = textEditingController.value.text.toLowerCase();
if (searchText.isEmpty) {
return null;
}
final int index = entries.indexWhere(
(DropdownMenuEntry<T> entry) => entry.label.toLowerCase().contains(searchText),
);
return index != -1 ? index : null;
}
List<Widget> _buildButtons(
List<DropdownMenuEntry<T>> filteredEntries,
TextDirection textDirection, {
int? focusedIndex,
bool enableScrollToHighlight = true,
bool excludeSemantics = false,
bool? useMaterial3,
}) {
final double effectiveInputStartGap = useMaterial3 ?? false ? _kInputStartGap : 0.0;
final result = <Widget>[];
for (var i = 0; i < filteredEntries.length; i++) {
final DropdownMenuEntry<T> entry = filteredEntries[i];
// By default, when the text field has a leading icon but a menu entry doesn't
// have one, the label of the entry should have extra padding to be aligned
// with the text in the text input field. When both the text field and the
// menu entry have leading icons, the menu entry should remove the extra
// paddings so its leading icon will be aligned with the leading icon of
// the text field.
final double padding = entry.leadingIcon == null
? (leadingPadding ?? _kDefaultHorizontalPadding)
: _kDefaultHorizontalPadding;
ButtonStyle effectiveStyle =
entry.style ??
MenuItemButton.styleFrom(
padding: EdgeInsetsDirectional.only(start: padding, end: _kDefaultHorizontalPadding),
);
final ButtonStyle? themeStyle = MenuButtonTheme.of(context).style;
final WidgetStateProperty<Color?>? effectiveForegroundColor =
entry.style?.foregroundColor ?? themeStyle?.foregroundColor;
final WidgetStateProperty<Color?>? effectiveIconColor =
entry.style?.iconColor ?? themeStyle?.iconColor;
final WidgetStateProperty<Color?>? effectiveOverlayColor =
entry.style?.overlayColor ?? themeStyle?.overlayColor;
final WidgetStateProperty<Color?>? effectiveBackgroundColor =
entry.style?.backgroundColor ?? themeStyle?.backgroundColor;
// Simulate the focused state because the text field should always be focused
// during traversal. Include potential MenuItemButton theme in the focus
// simulation for all colors in the theme.
final bool entryIsSelected = entry.enabled && i == focusedIndex;
if (entryIsSelected) {
_highlightedItemStatesController?.dispose();
_highlightedItemStatesController = WidgetStatesController(<WidgetState>{
WidgetState.focused,
});
// Query the Material 3 default style.
// TODO(bleroux): replace once a standard way for accessing defaults will be defined.
// See: https://github.com/flutter/flutter/issues/130135.
final ButtonStyle defaultStyle = const MenuItemButton().defaultStyleOf(context);
Color? resolveFocusedColor(WidgetStateProperty<Color?>? colorStateProperty) {
return colorStateProperty?.resolve(<WidgetState>{WidgetState.focused});
}
final Color focusedForegroundColor = resolveFocusedColor(
effectiveForegroundColor ?? defaultStyle.foregroundColor!,
)!;
final Color focusedIconColor = resolveFocusedColor(
effectiveIconColor ?? defaultStyle.iconColor!,
)!;
final Color focusedOverlayColor = resolveFocusedColor(
effectiveOverlayColor ?? defaultStyle.overlayColor!,
)!;
// For the background color we can't rely on the default style which is transparent.
// Defaults to onSurface.withOpacity(0.12).
final Color focusedBackgroundColor =
resolveFocusedColor(effectiveBackgroundColor) ??
Theme.of(context).colorScheme.onSurface.withOpacity(0.12);
effectiveStyle = effectiveStyle.copyWith(
backgroundColor: MaterialStatePropertyAll<Color>(focusedBackgroundColor),
foregroundColor: MaterialStatePropertyAll<Color>(focusedForegroundColor),
iconColor: MaterialStatePropertyAll<Color>(focusedIconColor),
overlayColor: MaterialStatePropertyAll<Color>(focusedOverlayColor),
);
} else {
effectiveStyle = effectiveStyle.copyWith(
backgroundColor: effectiveBackgroundColor,
foregroundColor: effectiveForegroundColor,
iconColor: effectiveIconColor,
overlayColor: effectiveOverlayColor,
);
}
Widget label = entry.labelWidget ?? Text(entry.label);
if (widget.width != null) {
final double horizontalPadding =
padding + _kDefaultHorizontalPadding + effectiveInputStartGap;
label = ConstrainedBox(
constraints: BoxConstraints(maxWidth: widget.width! - horizontalPadding),
child: label,
);
}
final Widget menuItemButton = ExcludeFocus(
child: ExcludeSemantics(
excluding: excludeSemantics,
child: MenuItemButton(
key: enableScrollToHighlight ? buttonItemKeys[i] : null,
statesController: entryIsSelected ? _highlightedItemStatesController : null,
style: effectiveStyle,
leadingIcon: entry.leadingIcon,
trailingIcon: entry.trailingIcon,
closeOnActivate: widget.closeBehavior == DropdownMenuCloseBehavior.all,
onPressed: entry.enabled && widget.enabled
? () {
if (!mounted) {
// In some cases (e.g., nested menus), calling onSelected from MenuAnchor inside a postFrameCallback
// can result in the MenuItemButton's onPressed callback being triggered after the state has been disposed.
// TODO(ahmedrasar): MenuAnchor should avoid calling onSelected inside a postFrameCallback.
widget.controller?.value = TextEditingValue(
text: entry.label,
selection: TextSelection.collapsed(offset: entry.label.length),
);
widget.onSelected?.call(entry.value);
return;
}
_effectiveTextEditingController.value = TextEditingValue(
text: entry.label,
selection: TextSelection.collapsed(offset: entry.label.length),
);
currentHighlight = widget.enableSearch ? i : null;
widget.onSelected?.call(entry.value);
_enableFilter = false;
if (widget.closeBehavior == DropdownMenuCloseBehavior.self) {
_controller.close();
}
}
: null,
requestFocusOnHover: false,
// MenuItemButton implementation is based on M3 spec for menu which specifies a
// horizontal padding of 12 pixels.
// In the context of DropdownMenu the M3 spec specifies that the menu item and the text
// field content should be aligned. The text field has a horizontal padding of 16 pixels.
// To conform with the 16 pixels padding, a 4 pixels padding is added in front of the item label.
child: Padding(
padding: EdgeInsetsDirectional.only(start: effectiveInputStartGap),
child: label,
),
),
),
);
result.add(menuItemButton);
}
return result;
}
void handleUpKey(_ArrowUpIntent _) {
setState(() {
if (!widget.enabled || !_menuHasEnabledItem || !_controller.isOpen) {
return;
}
_enableFilter = false;
_enableSearch = false;
currentHighlight ??= 0;
currentHighlight = (currentHighlight! - 1) % filteredEntries.length;
while (!filteredEntries[currentHighlight!].enabled) {
currentHighlight = (currentHighlight! - 1) % filteredEntries.length;
}
final String currentLabel = filteredEntries[currentHighlight!].label;
_effectiveTextEditingController.value = TextEditingValue(
text: currentLabel,
selection: TextSelection.collapsed(offset: currentLabel.length),
);
});
}
void handleDownKey(_ArrowDownIntent _) {
setState(() {
if (!widget.enabled || !_menuHasEnabledItem || !_controller.isOpen) {
return;
}
_enableFilter = false;
_enableSearch = false;
currentHighlight ??= -1;
currentHighlight = (currentHighlight! + 1) % filteredEntries.length;
while (!filteredEntries[currentHighlight!].enabled) {
currentHighlight = (currentHighlight! + 1) % filteredEntries.length;
}
final String currentLabel = filteredEntries[currentHighlight!].label;
_effectiveTextEditingController.value = TextEditingValue(
text: currentLabel,
selection: TextSelection.collapsed(offset: currentLabel.length),
);
});
}
void handleEnterKey(_EnterIntent _) {
if (selectOnly && !_controller.isOpen) {
_controller.open();
return;
}
_handleSubmitted();
}
void handlePressed(MenuController controller, {bool focusForKeyboard = true}) {
if (controller.isOpen) {
currentHighlight = null;
controller.close();
} else {
filteredEntries = widget.dropdownMenuEntries;
// close to open
if (_effectiveTextEditingController.text.isNotEmpty) {
_enableFilter = false;
}
controller.open();
if (focusForKeyboard) {
_internalFocusNode.requestFocus();
}
}
setState(() {});
}
void _handleSubmitted() {
if (currentHighlight != null) {
final DropdownMenuEntry<T> entry = filteredEntries[currentHighlight!];
if (entry.enabled) {
_effectiveTextEditingController.value = TextEditingValue(
text: entry.label,
selection: TextSelection.collapsed(offset: entry.label.length),
);
widget.onSelected?.call(entry.value);
}
} else {
if (_controller.isOpen) {
widget.onSelected?.call(null);
}
}
if (!widget.enableSearch) {
currentHighlight = null;
}
_controller.close();
}
@override
Widget build(BuildContext context) {
final bool useMaterial3 = Theme.of(context).useMaterial3;
final TextDirection textDirection = Directionality.of(context);
_initialMenu ??= _buildButtons(
widget.dropdownMenuEntries,
textDirection,
enableScrollToHighlight: false,
// The _initialMenu is invisible, we should not add semantics nodes to it
excludeSemantics: true,
useMaterial3: useMaterial3,
);
final DropdownMenuThemeData theme = DropdownMenuTheme.of(context);
final DropdownMenuThemeData defaults = _DropdownMenuDefaultsM3(context);
if (_enableFilter) {
filteredEntries =
widget.filterCallback?.call(filteredEntries, _effectiveTextEditingController.text) ??
filter(widget.dropdownMenuEntries, _effectiveTextEditingController);
}
_menuHasEnabledItem = filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled);
if (_enableSearch) {
if (widget.searchCallback != null) {
currentHighlight = widget.searchCallback!(
filteredEntries,
_effectiveTextEditingController.text,
);
} else {
final bool shouldUpdateCurrentHighlight = _shouldUpdateCurrentHighlight(filteredEntries);
if (shouldUpdateCurrentHighlight) {
currentHighlight = search(filteredEntries, _effectiveTextEditingController);
}
}
if (currentHighlight != null) {
scrollToHighlight();
}
}
final List<Widget> menu = _buildButtons(
filteredEntries,
textDirection,
focusedIndex: currentHighlight,
useMaterial3: useMaterial3,
);
final TextStyle? baseTextStyle = widget.textStyle ?? theme.textStyle ?? defaults.textStyle;
final Color? disabledColor = theme.disabledColor ?? defaults.disabledColor;
final TextStyle? effectiveTextStyle = widget.enabled
? baseTextStyle
: baseTextStyle?.copyWith(color: disabledColor) ?? TextStyle(color: disabledColor);
MenuStyle? effectiveMenuStyle = widget.menuStyle ?? theme.menuStyle ?? defaults.menuStyle!;
final double? anchorWidth = getWidth(_anchorKey);
if (widget.width != null) {
effectiveMenuStyle = effectiveMenuStyle.copyWith(
minimumSize: WidgetStateProperty.resolveWith<Size?>((Set<WidgetState> states) {
final double? effectiveMaximumWidth = effectiveMenuStyle!.maximumSize
?.resolve(states)
?.width;
return Size(math.min(widget.width!, effectiveMaximumWidth ?? widget.width!), 0.0);
}),
);
} else if (anchorWidth != null) {
effectiveMenuStyle = effectiveMenuStyle.copyWith(
minimumSize: WidgetStateProperty.resolveWith<Size?>((Set<WidgetState> states) {
final double? effectiveMaximumWidth = effectiveMenuStyle!.maximumSize
?.resolve(states)
?.width;
return Size(math.min(anchorWidth, effectiveMaximumWidth ?? anchorWidth), 0.0);
}),
);
}
if (widget.menuHeight != null) {
effectiveMenuStyle = effectiveMenuStyle.copyWith(
maximumSize: MaterialStatePropertyAll<Size>(Size(double.infinity, widget.menuHeight!)),
);
}
final InputDecorationThemeData effectiveInputDecorationTheme =
widget.inputDecorationTheme ?? theme.inputDecorationTheme ?? defaults.inputDecorationTheme!;
final MouseCursor? effectiveMouseCursor = switch (widget.enabled) {
true => isButton ? SystemMouseCursors.click : SystemMouseCursors.text,
false => null,
};
Widget menuAnchor = MenuAnchor(
style: effectiveMenuStyle,
alignmentOffset: widget.alignmentOffset,
reservedPadding: EdgeInsets.zero,
controller: _controller,
menuChildren: menu,
crossAxisUnconstrained: false,
builder: (BuildContext context, MenuController controller, Widget? child) {
assert(_initialMenu != null);
final DropdownMenuDecorationBuilder decorationBuilder =
widget.decorationBuilder ?? _buildDefaultDecoration;
InputDecoration decoration = decorationBuilder(context, controller);
// If no suffixIcon is provided, the default IconButton is used for convenience.
if (decoration.suffixIcon == null) {
decoration = decoration.copyWith(
suffixIcon: _buildDefaultSuffixIcon(context, controller),
);
}
final InputDecoration effectiveDecoration = decoration.applyDefaults(
effectiveInputDecorationTheme,
);
final InputDecoration textFieldDecoration = effectiveDecoration.prefixIcon == null
? effectiveDecoration
: effectiveDecoration.copyWith(
prefixIcon: SizedBox(
key: _leadingKey, // Used to query the width in refreshLeadingPadding.
child: effectiveDecoration.prefixIcon,
),
);
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final Widget textField = Semantics(
button: isButton,
// This is set specificly for iOS because iOS does not have any native
// APIs to show whether the menu is expanded or collapsed.
hint: Theme.of(context).platform == TargetPlatform.iOS
? _controller.isOpen
? localizations.collapsedHint
: localizations.expandedHint
: null,
expanded: _controller.isOpen,
onExpand: _controller.isOpen
? null
: () {
_controller.open();
},
onCollapse: !_controller.isOpen
? null
: () {
_controller.close();
},
child: ExcludeSemantics(
// When both `isTextField` and `isButton` are true, this widget will
// still be treated as a text field on web. So excluding the semantics
// of the `TextField` on web is needed.
excluding: isButton && kIsWeb,
child: TextField(
key: _anchorKey,
enabled: widget.enabled,
mouseCursor: effectiveMouseCursor,
focusNode: widget.focusNode,
canRequestFocus: canRequestFocus(),
enableInteractiveSelection: !isButton,
readOnly: isButton,
keyboardType: widget.keyboardType,
textAlign: widget.textAlign,
textAlignVertical: TextAlignVertical.center,
maxLines: widget.maxLines,
textInputAction: widget.textInputAction,
cursorHeight: widget.cursorHeight,
style: effectiveTextStyle,
controller: _effectiveTextEditingController,
onSubmitted: (_) => _handleSubmitted(),
onTap: !widget.enabled
? null
: () {
handlePressed(controller, focusForKeyboard: !canRequestFocus());
},
onChanged: (String text) {
controller.open();
setState(() {
filteredEntries = widget.dropdownMenuEntries;
_enableFilter = widget.enableFilter;
_enableSearch = widget.enableSearch;
});
},
inputFormatters: widget.inputFormatters,
decoration: textFieldDecoration,
restorationId: widget.restorationId,
),
),
);
// The label used in _DropdownMenuBody to compute the preferred width.
final Widget? effectiveLabel =
effectiveDecoration.label ??
(effectiveDecoration.labelText != null ? Text(effectiveDecoration.labelText!) : null);
// If [expandedInsets] is not null, the width of the text field should depend
// on its parent width. So we don't need to use `_DropdownMenuBody` to
// calculate the children's width.
final Widget body = widget.expandedInsets != null
? textField
: _DropdownMenuBody(
width: widget.width,
// The children, except the text field, are used to compute the preferred width,
// which is the width of the longest children, plus the width of trailingButton
// and leadingButton.
//
// See _RenderDropdownMenuBody layout logic.
//
// TODO(bleroux): find a more accurate way to measure the text field minimum width.
// The text field width computation is not accurate as it is based only on label,
// prefixIcon and suffixIcon. Other InputDecoration parameters can have an
// impact on the total width.
children: <Widget>[
textField,
..._initialMenu!,
if (effectiveLabel != null)
ExcludeSemantics(
child: Padding(
// See RenderEditable.floatingCursorAddedMargin for the default horizontal padding.
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: DefaultTextStyle(style: effectiveTextStyle!, child: effectiveLabel),
),
),
effectiveDecoration.suffixIcon ?? const SizedBox.shrink(),
Padding(
// TODO(bleroux): find a more accurate way to get the correct width.
// This padding is used to mimic default input decorator padding.
// It won't be correct if non default values are used.
padding: const EdgeInsets.all(8.0),
child: effectiveDecoration.prefixIcon ?? const SizedBox.shrink(),
),
],
);
return Shortcuts(
shortcuts: selectOnly ? _selectOnlyShortcuts : _editableShortcuts,
child: body,
);
},
);
if (widget.expandedInsets case final EdgeInsetsGeometry padding) {
menuAnchor = Padding(
// Clamp the top and bottom padding to 0.
padding: padding.clamp(
EdgeInsets.zero,
const EdgeInsets.only(
left: double.infinity,
right: double.infinity,
).add(const EdgeInsetsDirectional.only(end: double.infinity, start: double.infinity)),
),
child: menuAnchor,
);
}
// Wrap the menu anchor with an Align to narrow down the constraints.
// Without this Align, when tight constraints are applied to DropdownMenu,
// the menu will appear below these constraints instead of below the
// text field.
menuAnchor = Align(
alignment: AlignmentDirectional.topStart,
widthFactor: 1.0,
heightFactor: 1.0,
child: menuAnchor,
);
return Actions(
actions: <Type, Action<Intent>>{
_ArrowUpIntent: CallbackAction<_ArrowUpIntent>(onInvoke: handleUpKey),
_ArrowDownIntent: CallbackAction<_ArrowDownIntent>(onInvoke: handleDownKey),
_EnterIntent: CallbackAction<_EnterIntent>(onInvoke: handleEnterKey),
DismissIntent: DismissMenuAction(controller: _controller),
},
child: Stack(
children: <Widget>[
// Handling keyboard navigation when the Textfield has no focus.
Shortcuts(
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowUp): _ArrowUpIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): _ArrowDownIntent(),
SingleActivator(LogicalKeyboardKey.enter): _EnterIntent(),
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
},
child: Focus(
focusNode: _internalFocusNode,
skipTraversal: true,
child: const SizedBox.shrink(),
),
),
menuAnchor,
],
),
);
}
InputDecoration _buildDefaultDecoration(BuildContext context, MenuController controller) {
return InputDecoration(
label: widget.label,
hintText: widget.hintText,
helperText: widget.helperText,
errorText: widget.errorText,
prefixIcon: widget.leadingIcon,
suffixIcon: _buildDefaultSuffixIcon(context, controller),
);
}
Widget? _buildDefaultSuffixIcon(BuildContext context, MenuController controller) {
final bool isCollapsed = widget.inputDecorationTheme?.isCollapsed ?? false;
return widget.showTrailingIcon
? Padding(
padding: isCollapsed ? EdgeInsets.zero : const EdgeInsets.all(4.0),
child: ExcludeSemantics(
// When the text field is treated as a button (i.e., it can
// not be focused), the trailing button should become part of
// the text field button by excluding semantics. Otherwise,
// it will inappropriately announce whether this icon button
// is selected or not.
excluding: isButton,
child: IconButton(
focusNode: _trailingIconButtonFocusNode,
isSelected: controller.isOpen,
constraints: widget.inputDecorationTheme?.suffixIconConstraints,
padding: isCollapsed ? EdgeInsets.zero : null,
icon: widget.trailingIcon ?? const Icon(Icons.arrow_drop_down),
selectedIcon: widget.selectedTrailingIcon ?? const Icon(Icons.arrow_drop_up),
onPressed: !widget.enabled
? null
: () {
handlePressed(controller);
},
),
),
)
: null;
}
}
// `DropdownMenu` dispatches these private intents on arrow up/down keys.
// They are needed instead of the typical `DirectionalFocusIntent`s because
// `DropdownMenu` does not really navigate the focus tree upon arrow up/down
// keys: the focus stays on the text field and the menu items are given fake
// highlights as if they are focused. Using `DirectionalFocusIntent`s will cause
// the action to be processed by `EditableText`.
class _ArrowUpIntent extends Intent {
const _ArrowUpIntent();
}
class _ArrowDownIntent extends Intent {
const _ArrowDownIntent();
}
class _EnterIntent extends Intent {
const _EnterIntent();
}
class _DropdownMenuBody extends MultiChildRenderObjectWidget {
const _DropdownMenuBody({super.children, this.width});
final double? width;
@override
_RenderDropdownMenuBody createRenderObject(BuildContext context) {
return _RenderDropdownMenuBody(width: width);
}
@override
void updateRenderObject(BuildContext context, _RenderDropdownMenuBody renderObject) {
renderObject.width = width;
}
}
class _DropdownMenuBodyParentData extends ContainerBoxParentData<RenderBox> {}
class _RenderDropdownMenuBody extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, _DropdownMenuBodyParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _DropdownMenuBodyParentData> {
_RenderDropdownMenuBody({double? width}) : _width = width;
double? get width => _width;
double? _width;
set width(double? value) {
if (_width == value) {
return;
}
_width = value;
markNeedsLayout();
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _DropdownMenuBodyParentData) {
child.parentData = _DropdownMenuBodyParentData();
}
}
@override
void performLayout() {
final BoxConstraints constraints = this.constraints;
var maxWidth = 0.0;
double? maxHeight;
RenderBox? child = firstChild;
final double intrinsicWidth = width ?? getMaxIntrinsicWidth(constraints.maxHeight);
final double widthConstraint = math.min(intrinsicWidth, constraints.maxWidth);
final innerConstraints = BoxConstraints(
maxWidth: widthConstraint,
maxHeight: getMaxIntrinsicHeight(widthConstraint),
);
while (child != null) {
if (child == firstChild) {
child.layout(innerConstraints, parentUsesSize: true);
maxHeight ??= child.size.height;
final childParentData = child.parentData! as _DropdownMenuBodyParentData;
assert(child.parentData == childParentData);
child = childParentData.nextSibling;
continue;
}
child.layout(innerConstraints, parentUsesSize: true);
final childParentData = child.parentData! as _DropdownMenuBodyParentData;
childParentData.offset = Offset.zero;
maxWidth = math.max(maxWidth, child.size.width);
maxHeight ??= child.size.height;
assert(child.parentData == childParentData);
child = childParentData.nextSibling;
}
assert(maxHeight != null);
maxWidth = math.max(_kMinimumWidth, maxWidth);
size = constraints.constrain(Size(width ?? maxWidth, maxHeight!));
}
@override
void paint(PaintingContext context, Offset offset) {
final RenderBox? child = firstChild;
if (child != null) {
final childParentData = child.parentData! as _DropdownMenuBodyParentData;
context.paintChild(child, offset + childParentData.offset);
}
}
@override
Size computeDryLayout(BoxConstraints constraints) {
var maxWidth = 0.0;
double? maxHeight;
RenderBox? child = firstChild;
final double intrinsicWidth = width ?? getMaxIntrinsicWidth(constraints.maxHeight);
final double widthConstraint = math.min(intrinsicWidth, constraints.maxWidth);
final innerConstraints = BoxConstraints(
maxWidth: widthConstraint,
maxHeight: getMaxIntrinsicHeight(widthConstraint),
);
while (child != null) {
final Size childSize = child.getDryLayout(innerConstraints);
// The first child is the TextField, which doesn't contribute to the
// menu's width calculation.
if (child != firstChild) {
maxWidth = math.max(maxWidth, childSize.width);
}
final childParentData = child.parentData! as _DropdownMenuBodyParentData;
maxHeight ??= childSize.height;
child = childParentData.nextSibling;
}
assert(maxHeight != null);
maxWidth = math.max(_kMinimumWidth, maxWidth);
return constraints.constrain(Size(width ?? maxWidth, maxHeight!));
}
@override
double computeMinIntrinsicWidth(double height) {
RenderBox? child = firstChild;
double width = 0;
while (child != null) {
if (child == firstChild) {
final childParentData = child.parentData! as _DropdownMenuBodyParentData;
child = childParentData.nextSibling;
continue;
}
final double minIntrinsicWidth = child.getMinIntrinsicWidth(height);
// Add the width of leading icon.
if (child == lastChild) {
width += minIntrinsicWidth;
}
// Add the width of trailing icon.
if (child == childBefore(lastChild!)) {
width += minIntrinsicWidth;
}
width = math.max(width, minIntrinsicWidth);
final childParentData = child.parentData! as _DropdownMenuBodyParentData;
child = childParentData.nextSibling;
}
return math.max(width, _kMinimumWidth);
}
@override
double computeMaxIntrinsicWidth(double height) {
RenderBox? child = firstChild;
double width = 0;
while (child != null) {
if (child == firstChild) {
final childParentData = child.parentData! as _DropdownMenuBodyParentData;
child = childParentData.nextSibling;
continue;
}
final double maxIntrinsicWidth = child.getMaxIntrinsicWidth(height);
// Add the width of leading icon.
if (child == lastChild) {
width += maxIntrinsicWidth;
}
// Add the width of trailing icon.
if (child == childBefore(lastChild!)) {
width += maxIntrinsicWidth;
}
width = math.max(width, maxIntrinsicWidth);
final childParentData = child.parentData! as _DropdownMenuBodyParentData;
child = childParentData.nextSibling;
}
return math.max(width, _kMinimumWidth);
}
@override
double computeMinIntrinsicHeight(double width) {
final RenderBox? child = firstChild;
double width = 0;
if (child != null) {
width = math.max(width, child.getMinIntrinsicHeight(width));
}
return width;
}
@override
double computeMaxIntrinsicHeight(double width) {
final RenderBox? child = firstChild;
double width = 0;
if (child != null) {
width = math.max(width, child.getMaxIntrinsicHeight(width));
}
return width;
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
final RenderBox? child = firstChild;
if (child != null) {
final childParentData = child.parentData! as _DropdownMenuBodyParentData;
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - childParentData.offset);
return child.hitTest(result, position: transformed);
},
);
if (isHit) {
return true;
}
}
return false;
}
// Children except the text field (first child) are laid out for measurement purpose but not painted.
@override
void visitChildrenForSemantics(RenderObjectVisitor visitor) {
visitChildren((RenderObject renderObjectChild) {
final child = renderObjectChild as RenderBox;
if (child == firstChild) {
visitor(renderObjectChild);
}
});
}
}
// Hand coded defaults. These will be updated once we have tokens/spec.
class _DropdownMenuDefaultsM3 extends DropdownMenuThemeData {
_DropdownMenuDefaultsM3(this.context)
: super(disabledColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.38));
final BuildContext context;
late final ThemeData _theme = Theme.of(context);
@override
TextStyle? get textStyle => _theme.textTheme.bodyLarge;
@override
MenuStyle get menuStyle {
return const MenuStyle(
minimumSize: MaterialStatePropertyAll<Size>(Size(_kMinimumWidth, 0.0)),
maximumSize: MaterialStatePropertyAll<Size>(Size.infinite),
visualDensity: VisualDensity.standard,
);
}
@override
InputDecorationThemeData get inputDecorationTheme {
return const InputDecorationThemeData(border: OutlineInputBorder());
}
}