diff --git a/examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart b/examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart new file mode 100644 index 00000000000..a9a59e65df9 --- /dev/null +++ b/examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart @@ -0,0 +1,74 @@ +// 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. + +/// Flutter code sample for [DropdownMenu]s. The first dropdown menu has an outlined border +/// which is the default configuration, and the second one has a filled input decoration. + +import 'package:flutter/material.dart'; + +void main() => runApp(const DropdownMenuExample()); + +class DropdownMenuExample extends StatelessWidget { + const DropdownMenuExample({super.key}); + + List getEntryList() { + final List entries = []; + + for (int index = 0; index < EntryLabel.values.length; index++) { + // Disabled item 1, 2 and 6. + final bool enabled = index != 1 && index != 2 && index != 6; + entries.add(DropdownMenuEntry(label: EntryLabel.values[index].label, enabled: enabled)); + } + return entries; + } + + @override + Widget build(BuildContext context) { + final List dropdownMenuEntries = getEntryList(); + + return MaterialApp( + theme: ThemeData( + useMaterial3: true, + colorSchemeSeed: Colors.green + ), + home: Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + DropdownMenu( + label: const Text('Label'), + dropdownMenuEntries: dropdownMenuEntries, + ), + const SizedBox(width: 20), + DropdownMenu( + enableFilter: true, + leadingIcon: const Icon(Icons.search), + label: const Text('Label'), + dropdownMenuEntries: dropdownMenuEntries, + inputDecorationTheme: const InputDecorationTheme(filled: true), + ) + ], + ), + ) + ), + ), + ); + } +} + +enum EntryLabel { + item0('Item 0'), + item1('Item 1'), + item2('Item 2'), + item3('Item 3'), + item4('Item 4'), + item5('Item 5'), + item6('Item 6'); + + const EntryLabel(this.label); + final String label; +} diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index adbcdc3fdde..99b9a67f03c 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -77,6 +77,8 @@ export 'src/material/drawer.dart'; export 'src/material/drawer_header.dart'; export 'src/material/drawer_theme.dart'; export 'src/material/dropdown.dart'; +export 'src/material/dropdown_menu.dart'; +export 'src/material/dropdown_menu_theme.dart'; export 'src/material/elevated_button.dart'; export 'src/material/elevated_button_theme.dart'; export 'src/material/elevation_overlay.dart'; diff --git a/packages/flutter/lib/src/material/dropdown_menu.dart b/packages/flutter/lib/src/material/dropdown_menu.dart new file mode 100644 index 00000000000..4bd6884f2f2 --- /dev/null +++ b/packages/flutter/lib/src/material/dropdown_menu.dart @@ -0,0 +1,751 @@ +// 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/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_state.dart'; +import 'menu_anchor.dart'; +import 'menu_style.dart'; +import 'text_field.dart'; +import 'theme.dart'; + + +// Navigation shortcuts to move the selected menu items up or down. +Map _kMenuTraversalShortcuts = { + LogicalKeySet(LogicalKeyboardKey.arrowUp): const _ArrowUpIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const _ArrowDownIntent(), +}; + +const double _kMinimumWidth = 112.0; + +const double _kDefaultHorizontalPadding = 12.0; + +/// Defines a [DropdownMenu] menu button that represents one item view in the menu. +/// +/// See also: +/// +/// * [DropdownMenu] +class DropdownMenuEntry { + /// Creates an entry that is used with [DropdownMenu.dropdownMenuEntries]. + /// + /// [label] must be non-null. + const DropdownMenuEntry({ + required this.label, + this.leadingIcon, + this.trailingIcon, + this.enabled = true, + this.style, + }); + + /// The label displayed in the center of the menu item. + final String label; + + /// 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; +} + + +/// A dropdown menu that can be opened from a [TextField]. The selected +/// menu item is displayed in that field. +/// +/// 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. +/// +/// The dropdown menu 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 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.label, + this.hintText, + this.selectedTrailingIcon, + this.enableFilter = false, + this.enableSearch = true, + this.textStyle, + this.inputDecorationTheme, + this.menuStyle, + required this.dropdownMenuEntries, + }); + + /// Determine if the [DropdownMenu] is enabled. + /// + /// Defaults to true. + 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]. + final Widget? trailingIcon; + + /// 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; + + /// 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 text style for the [TextField] of the [DropdownMenu]; + /// + /// Defaults to the overall theme's [TextTheme.labelLarge] + /// if the dropdown menu theme's value is null. + final TextStyle? textStyle; + + /// Defines the default appearance of [InputDecoration] to show around the text field. + /// + /// By default, shows a outlined text field. + final InputDecorationTheme? inputDecorationTheme; + + /// 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; + + /// 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 dropdownMenuEntries; + + @override + State createState() => _DropdownMenuState(); +} + +class _DropdownMenuState extends State { + final MenuController _controller = MenuController(); + final GlobalKey _anchorKey = GlobalKey(); + final GlobalKey _leadingKey = GlobalKey(); + final FocusNode _textFocusNode = FocusNode(); + final TextEditingController _textEditingController = TextEditingController(); + late bool _enableFilter; + late List filteredEntries; + List? _initialMenu; + int? currentHighlight; + double? leadingPadding; + bool _menuHasEnabledItem = false; + + @override + void initState() { + super.initState(); + _enableFilter = widget.enableFilter; + filteredEntries = widget.dropdownMenuEntries; + _menuHasEnabledItem = filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); + + refreshLeadingPadding(); + } + + @override + void didUpdateWidget(DropdownMenu oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.dropdownMenuEntries != widget.dropdownMenuEntries) { + _menuHasEnabledItem = filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); + } + if (oldWidget.leadingIcon != widget.leadingIcon) { + refreshLeadingPadding(); + } + } + + void refreshLeadingPadding() { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + leadingPadding = getWidth(_leadingKey); + }); + }); + } + + double? getWidth(GlobalKey key) { + final BuildContext? context = key.currentContext; + if (context != null) { + final RenderBox box = context.findRenderObject()! as RenderBox; + return box.size.width; + } + return null; + } + + List filter(List entries, TextEditingController textEditingController) { + final String filterText = textEditingController.text.toLowerCase(); + return entries + .where((DropdownMenuEntry entry) => entry.label.toLowerCase().contains(filterText)) + .toList(); + } + + int? search(List entries, TextEditingController textEditingController) { + final String searchText = textEditingController.value.text.toLowerCase(); + if (searchText.isEmpty) { + return null; + } + final int index = entries.indexWhere((DropdownMenuEntry entry) => entry.label.toLowerCase().contains(searchText)); + + return index != -1 ? index : null; + } + + List _buildButtons( + List filteredEntries, + TextEditingController textEditingController, + TextDirection textDirection, + { int? focusedIndex } + ) { + final List result = []; + final double padding = leadingPadding ?? _kDefaultHorizontalPadding; + final ButtonStyle defaultStyle; + switch (textDirection) { + case TextDirection.rtl: + defaultStyle = MenuItemButton.styleFrom( + padding: EdgeInsets.only(left: _kDefaultHorizontalPadding, right: padding), + ); + break; + case TextDirection.ltr: + defaultStyle = MenuItemButton.styleFrom( + padding: EdgeInsets.only(left: padding, right: _kDefaultHorizontalPadding), + ); + break; + } + + for (int i = 0; i < filteredEntries.length; i++) { + final DropdownMenuEntry entry = filteredEntries[i]; + ButtonStyle effectiveStyle = entry.style ?? defaultStyle; + final Color focusedBackgroundColor = effectiveStyle.foregroundColor?.resolve({MaterialState.focused}) + ?? Theme.of(context).colorScheme.onSurface; + + // Simulate the focused state because the text field should always be focused + // during traversal. If the menu item has a custom foreground color, the "focused" + // color will also change to foregroundColor.withOpacity(0.12). + effectiveStyle = entry.enabled && i == focusedIndex + ? effectiveStyle.copyWith( + backgroundColor: MaterialStatePropertyAll(focusedBackgroundColor.withOpacity(0.12)) + ) + : effectiveStyle; + + final MenuItemButton menuItemButton = MenuItemButton( + style: effectiveStyle, + leadingIcon: entry.leadingIcon, + trailingIcon: entry.trailingIcon, + onPressed: entry.enabled + ? () { + textEditingController.text = entry.label; + textEditingController.selection = + TextSelection.collapsed(offset: textEditingController.text.length); + currentHighlight = widget.enableSearch ? i : -1; + } + : null, + requestFocusOnHover: false, + child: Text(entry.label), + ); + result.add(menuItemButton); + } + + return result; + } + + void handleUpKeyInvoke(_) => setState(() { + if (!_menuHasEnabledItem || !_controller.isOpen) { + return; + } + _enableFilter = false; + currentHighlight ??= 0; + currentHighlight = (currentHighlight! - 1) % filteredEntries.length; + while (!filteredEntries[currentHighlight!].enabled) { + currentHighlight = (currentHighlight! - 1) % filteredEntries.length; + } + _textEditingController.text = filteredEntries[currentHighlight!].label; + _textEditingController.selection = + TextSelection.collapsed(offset: _textEditingController.text.length); + }); + + void handleDownKeyInvoke(_) => setState(() { + if (!_menuHasEnabledItem || !_controller.isOpen) { + return; + } + _enableFilter = false; + currentHighlight ??= -1; + currentHighlight = (currentHighlight! + 1) % filteredEntries.length; + while (!filteredEntries[currentHighlight!].enabled) { + currentHighlight = (currentHighlight! + 1) % filteredEntries.length; + } + _textEditingController.text = filteredEntries[currentHighlight!].label; + _textEditingController.selection = + TextSelection.collapsed(offset: _textEditingController.text.length); + }); + + void handlePressed(MenuController controller) { + if (controller.isOpen) { + currentHighlight = -1; + controller.close(); + } else { // close to open + if (_textEditingController.text.isNotEmpty) { + _enableFilter = false; + } + controller.open(); + } + setState(() {}); + } + + @override + void dispose() { + _textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final TextDirection textDirection = Directionality.of(context); + _initialMenu ??= _buildButtons(widget.dropdownMenuEntries, _textEditingController, textDirection); + final DropdownMenuThemeData theme = DropdownMenuTheme.of(context); + final DropdownMenuThemeData defaults = _DropdownMenuDefaultsM3(context); + + if (_enableFilter) { + filteredEntries = filter(widget.dropdownMenuEntries, _textEditingController); + } + + if (widget.enableSearch) { + currentHighlight = search(filteredEntries, _textEditingController); + } + + final List menu = _buildButtons(filteredEntries, _textEditingController, textDirection, focusedIndex: currentHighlight); + + final TextStyle? effectiveTextStyle = widget.textStyle ?? theme.textStyle ?? defaults.textStyle; + + MenuStyle? effectiveMenuStyle = widget.menuStyle + ?? theme.menuStyle + ?? defaults.menuStyle!; + + final double? anchorWidth = getWidth(_anchorKey); + if (widget.width != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith(minimumSize: MaterialStatePropertyAll(Size(widget.width!, 0.0))); + } else if (anchorWidth != null){ + effectiveMenuStyle = effectiveMenuStyle.copyWith(minimumSize: MaterialStatePropertyAll(Size(anchorWidth, 0.0))); + } + + if (widget.menuHeight != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith(maximumSize: MaterialStatePropertyAll(Size(double.infinity, widget.menuHeight!))); + } + final InputDecorationTheme effectiveInputDecorationTheme = widget.inputDecorationTheme + ?? theme.inputDecorationTheme + ?? defaults.inputDecorationTheme!; + + return Shortcuts( + shortcuts: _kMenuTraversalShortcuts, + child: Actions( + actions: >{ + _ArrowUpIntent: CallbackAction<_ArrowUpIntent>( + onInvoke: handleUpKeyInvoke, + ), + _ArrowDownIntent: CallbackAction<_ArrowDownIntent>( + onInvoke: handleDownKeyInvoke, + ), + }, + child: MenuAnchor( + style: effectiveMenuStyle, + controller: _controller, + menuChildren: menu, + crossAxisUnconstrained: false, + builder: (BuildContext context, MenuController controller, Widget? child) { + assert(_initialMenu != null); + final Widget trailingButton = Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: IconButton( + isSelected: controller.isOpen, + icon: widget.trailingIcon ?? const Icon(Icons.arrow_drop_down), + selectedIcon: widget.selectedTrailingIcon ?? const Icon(Icons.arrow_drop_up), + onPressed: () { + _textFocusNode.requestFocus(); + handlePressed(controller); + }, + ), + ); + + final Widget leadingButton = Padding( + padding: const EdgeInsets.all(8.0), + child: widget.leadingIcon ?? const SizedBox() + ); + + return _DropdownMenuBody( + key: _anchorKey, + width: widget.width, + children: [ + TextField( + focusNode: _textFocusNode, + style: effectiveTextStyle, + controller: _textEditingController, + onEditingComplete: () { + if (currentHighlight != null) { + _textEditingController.text = filteredEntries[currentHighlight!].label; + _textEditingController.selection = + TextSelection.collapsed(offset: _textEditingController.text.length); + } + controller.close(); + }, + onTap: () { + handlePressed(controller); + }, + onChanged: (_) { + controller.open(); + setState(() { + filteredEntries = widget.dropdownMenuEntries; + _enableFilter = widget.enableFilter; + }); + }, + decoration: InputDecoration( + enabled: widget.enabled, + label: widget.label, + hintText: widget.hintText, + prefixIcon: widget.leadingIcon != null ? Container( + key: _leadingKey, + child: widget.leadingIcon + ) : null, + suffixIcon: trailingButton, + ).applyDefaults(effectiveInputDecorationTheme) + ), + for (Widget c in _initialMenu!) c, + trailingButton, + leadingButton, + ], + ); + }, + ), + ), + ); + } +} + +class _ArrowUpIntent extends Intent { + const _ArrowUpIntent(); +} + +class _ArrowDownIntent extends Intent { + const _ArrowDownIntent(); +} + +class _DropdownMenuBody extends MultiChildRenderObjectWidget { + _DropdownMenuBody({ + super.key, + super.children, + this.width, + }); + + final double? width; + + @override + _RenderDropdownMenuBody createRenderObject(BuildContext context) { + return _RenderDropdownMenuBody( + width: width, + ); + } +} + +class _DropdownMenuBodyParentData extends ContainerBoxParentData { } + +class _RenderDropdownMenuBody extends RenderBox + with ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + + _RenderDropdownMenuBody({ + this.width, + }); + + final double? width; + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _DropdownMenuBodyParentData) { + child.parentData = _DropdownMenuBodyParentData(); + } + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + double maxWidth = 0.0; + double? maxHeight; + RenderBox? child = firstChild; + + final BoxConstraints innerConstraints = BoxConstraints( + maxWidth: width ?? computeMaxIntrinsicWidth(constraints.maxWidth), + maxHeight: computeMaxIntrinsicHeight(constraints.maxHeight), + ); + while (child != null) { + if (child == firstChild) { + child.layout(innerConstraints, parentUsesSize: true); + maxHeight ??= child.size.height; + final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + continue; + } + child.layout(innerConstraints, parentUsesSize: true); + final _DropdownMenuBodyParentData 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 _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; + context.paintChild(child, offset + childParentData.offset); + } + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final BoxConstraints constraints = this.constraints; + double maxWidth = 0.0; + double? maxHeight; + RenderBox? child = firstChild; + final BoxConstraints innerConstraints = BoxConstraints( + maxWidth: width ?? computeMaxIntrinsicWidth(constraints.maxWidth), + maxHeight: computeMaxIntrinsicHeight(constraints.maxHeight), + ); + + while (child != null) { + if (child == firstChild) { + final Size childSize = child.getDryLayout(innerConstraints); + maxHeight ??= childSize.height; + final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + continue; + } + final Size childSize = child.getDryLayout(innerConstraints); + final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; + childParentData.offset = Offset.zero; + maxWidth = math.max(maxWidth, childSize.width); + maxHeight ??= childSize.height; + assert(child.parentData == childParentData); + 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 _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + continue; + } + final double maxIntrinsicWidth = child.getMinIntrinsicWidth(height); + if (child == lastChild) { + width += maxIntrinsicWidth; + } + if (child == childBefore(lastChild!)) { + width += maxIntrinsicWidth; + } + width = math.max(width, maxIntrinsicWidth); + final _DropdownMenuBodyParentData 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 _DropdownMenuBodyParentData 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 _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + } + + return math.max(width, _kMinimumWidth); + } + + @override + double computeMinIntrinsicHeight(double height) { + final RenderBox? child = firstChild; + double width = 0; + if (child != null) { + width = math.max(width, child.getMinIntrinsicHeight(height)); + } + return width; + } + + @override + double computeMaxIntrinsicHeight(double height) { + final RenderBox? child = firstChild; + double width = 0; + if (child != null) { + width = math.max(width, child.getMaxIntrinsicHeight(height)); + } + return width; + } + + @override + bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { + final RenderBox? child = firstChild; + if (child != null) { + final _DropdownMenuBodyParentData 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; + } +} + +// Hand coded defaults. These will be updated once we have tokens/spec. +class _DropdownMenuDefaultsM3 extends DropdownMenuThemeData { + _DropdownMenuDefaultsM3(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + + @override + TextStyle? get textStyle => _theme.textTheme.labelLarge; + + @override + MenuStyle get menuStyle { + return const MenuStyle( + minimumSize: MaterialStatePropertyAll(Size(_kMinimumWidth, 0.0)), + maximumSize: MaterialStatePropertyAll(Size.infinite), + ); + } + + @override + InputDecorationTheme get inputDecorationTheme { + return const InputDecorationTheme(border: OutlineInputBorder()); + } +} diff --git a/packages/flutter/lib/src/material/dropdown_menu_theme.dart b/packages/flutter/lib/src/material/dropdown_menu_theme.dart new file mode 100644 index 00000000000..028c39509aa --- /dev/null +++ b/packages/flutter/lib/src/material/dropdown_menu_theme.dart @@ -0,0 +1,176 @@ +// 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 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; + +import 'input_decorator.dart'; +import 'menu_style.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Overrides the default values of visual properties for descendant [DropdownMenu] widgets. +/// +/// Descendant widgets obtain the current [DropdownMenuThemeData] object with +/// [DropdownMenuTheme.of]. Instances of [DropdownMenuTheme] can +/// be customized with [DropdownMenuThemeData.copyWith]. +/// +/// Typically a [DropdownMenuTheme] is specified as part of the overall [Theme] with +/// [ThemeData.dropdownMenuTheme]. +/// +/// All [DropdownMenuThemeData] properties are null by default. When null, the [DropdownMenu] +/// computes its own default values, typically based on the overall +/// theme's [ThemeData.colorScheme], [ThemeData.textTheme], and [ThemeData.iconTheme]. +@immutable +class DropdownMenuThemeData with Diagnosticable { + /// Creates a [DropdownMenuThemeData] that can be used to override default properties + /// in a [DropdownMenuTheme] widget. + const DropdownMenuThemeData({ + this.textStyle, + this.inputDecorationTheme, + this.menuStyle, + }); + + /// Overrides the default value for [DropdownMenu.textStyle]. + final TextStyle? textStyle; + + /// The input decoration theme for the [TextField]s in a [DropdownMenu]. + /// + /// If this is null, the [DropdownMenu] provides its own defaults. + final InputDecorationTheme? inputDecorationTheme; + + /// Overrides the menu's default style in a [DropdownMenu]. + /// + /// Any values not set in the [MenuStyle] will use the menu default for that + /// property. + final MenuStyle? menuStyle; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + DropdownMenuThemeData copyWith({ + TextStyle? textStyle, + InputDecorationTheme? inputDecorationTheme, + MenuStyle? menuStyle, + }) { + return DropdownMenuThemeData( + textStyle: textStyle ?? this.textStyle, + inputDecorationTheme: inputDecorationTheme ?? this.inputDecorationTheme, + menuStyle: menuStyle ?? this.menuStyle, + ); + } + + /// Linearly interpolates between two dropdown menu themes. + static DropdownMenuThemeData lerp(DropdownMenuThemeData? a, DropdownMenuThemeData? b, double t) { + return DropdownMenuThemeData( + textStyle: TextStyle.lerp(a?.textStyle, b?.textStyle, t), + inputDecorationTheme: t < 0.5 ? a?.inputDecorationTheme : b?.inputDecorationTheme, + menuStyle: MenuStyle.lerp(a?.menuStyle, b?.menuStyle, t), + ); + } + + @override + int get hashCode => Object.hash( + textStyle, + inputDecorationTheme, + menuStyle, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is DropdownMenuThemeData + && other.textStyle == textStyle + && other.inputDecorationTheme == inputDecorationTheme + && other.menuStyle == menuStyle; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('textStyle', textStyle, defaultValue: null)); + properties.add(DiagnosticsProperty('inputDecorationTheme', inputDecorationTheme, defaultValue: null)); + properties.add(DiagnosticsProperty('menuStyle', menuStyle, defaultValue: null)); + } +} + +/// An inherited widget that defines the visual properties for [DropdownMenu]s in this widget's subtree. +/// +/// Values specified here are used for [DropdownMenu] properties that are not +/// given an explicit non-null value. +class DropdownMenuTheme extends InheritedTheme { + /// Creates a [DropdownMenuTheme] that controls visual parameters for + /// descendant [DropdownMenu]s. + const DropdownMenuTheme({ + super.key, + required this.data, + required super.child, + }) : assert(data != null); + + /// Specifies the visual properties used by descendant [DropdownMenu] + /// widgets. + final DropdownMenuThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [DropdownMenuTheme] widget, then + /// [ThemeData.dropdownMenuTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// DropdownMenuThemeData theme = DropdownMenuTheme.of(context); + /// ``` + /// + /// See also: + /// + /// * [maybeOf], which returns null if it doesn't find a + /// [DropdownMenuTheme] ancestor. + static DropdownMenuThemeData of(BuildContext context) { + return maybeOf(context) ?? Theme.of(context).dropdownMenuTheme; + } + + /// The data from the closest instance of this class that encloses the given + /// context, if any. + /// + /// Use this function if you want to allow situations where no + /// [DropdownMenuTheme] is in scope. Prefer using [DropdownMenuTheme.of] + /// in situations where a [DropdownMenuThemeData] is expected to be + /// non-null. + /// + /// If there is no [DropdownMenuTheme] in scope, then this function will + /// return null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// DropdownMenuThemeData? theme = DropdownMenuTheme.maybeOf(context); + /// if (theme == null) { + /// // Do something else instead. + /// } + /// ``` + /// + /// See also: + /// + /// * [of], which will return [ThemeData.dropdownMenuTheme] if it doesn't + /// find a [DropdownMenuTheme] ancestor, instead of returning null. + static DropdownMenuThemeData? maybeOf(BuildContext context) { + assert(context != null); + return context.dependOnInheritedWidgetOfExactType()?.data; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return DropdownMenuTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(DropdownMenuTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/flutter/lib/src/material/menu_anchor.dart b/packages/flutter/lib/src/material/menu_anchor.dart index d551546c703..9eabe026556 100644 --- a/packages/flutter/lib/src/material/menu_anchor.dart +++ b/packages/flutter/lib/src/material/menu_anchor.dart @@ -131,6 +131,7 @@ class MenuAnchor extends StatefulWidget { this.anchorTapClosesMenu = false, this.onOpen, this.onClose, + this.crossAxisUnconstrained = true, required this.menuChildren, this.builder, this.child, @@ -213,6 +214,14 @@ class MenuAnchor extends StatefulWidget { /// A callback that is invoked when the menu is closed. final VoidCallback? onClose; + /// Determine if the menu panel can be wrapped by a [UnconstrainedBox] which allows + /// the panel to render at its "natural" size. + /// + /// Defaults to true as it allows developers to render the menu panel at the + /// size it should be. When it is set to false, it can be useful when the menu should + /// be constrained in both main axis and cross axis, such as a [DropdownMenu]. + final bool crossAxisUnconstrained; + /// A list of children containing the menu items that are the contents of the /// menu surrounded by this [MenuAnchor]. /// @@ -516,6 +525,7 @@ class _MenuAnchorState extends State { menuPosition: position, clipBehavior: widget.clipBehavior, menuChildren: widget.menuChildren, + crossAxisUnconstrained: widget.crossAxisUnconstrained, ), ), to: overlay.context, @@ -791,6 +801,7 @@ class MenuItemButton extends StatefulWidget { super.key, this.onPressed, this.onHover, + this.requestFocusOnHover = true, this.onFocusChange, this.focusNode, this.shortcut, @@ -817,6 +828,11 @@ class MenuItemButton extends StatefulWidget { /// area and false if a pointer has exited. final ValueChanged? onHover; + /// Determine if hovering can request focus. + /// + /// Defaults to true. + final bool requestFocusOnHover; + /// Handler called when the focus changes. /// /// Called with true if this widget's node gains focus, and false if it loses @@ -1064,7 +1080,7 @@ class _MenuItemButtonState extends State { void _handleHover(bool hovering) { widget.onHover?.call(hovering); - if (hovering) { + if (hovering && widget.requestFocusOnHover) { assert(_debugMenuInfo('Requesting focus for $_focusNode from hover')); _focusNode.requestFocus(); } @@ -2424,7 +2440,10 @@ class _MenuDirectionalFocusAction extends DirectionalFocusAction { // correct node. if (currentMenu.widget.childFocusNode != null) { final FocusTraversalPolicy? policy = FocusTraversalGroup.maybeOf(primaryFocus!.context!); - policy?.invalidateScopeData(currentMenu.widget.childFocusNode!.nearestScope!); + if (currentMenu.widget.childFocusNode!.nearestScope != null) { + policy?.invalidateScopeData(currentMenu.widget.childFocusNode!.nearestScope!); + } + return false; } return false; } @@ -2455,7 +2474,10 @@ class _MenuDirectionalFocusAction extends DirectionalFocusAction { // correct node. if (currentMenu.widget.childFocusNode != null) { final FocusTraversalPolicy? policy = FocusTraversalGroup.maybeOf(primaryFocus!.context!); - policy?.invalidateScopeData(currentMenu.widget.childFocusNode!.nearestScope!); + if (currentMenu.widget.childFocusNode!.nearestScope != null) { + policy?.invalidateScopeData(currentMenu.widget.childFocusNode!.nearestScope!); + } + return false; } return false; } @@ -3198,6 +3220,7 @@ class _MenuPanel extends StatefulWidget { required this.menuStyle, this.clipBehavior = Clip.none, required this.orientation, + this.crossAxisUnconstrained = true, required this.children, }); @@ -3209,6 +3232,13 @@ class _MenuPanel extends StatefulWidget { /// Defaults to [Clip.none]. final Clip clipBehavior; + /// Determine if a [UnconstrainedBox] can be applied to the menu panel to allow it to render + /// at its "natural" size. + /// + /// Defaults to true. When it is set to false, it can be useful when the menu should + /// be constrained in both main-axis and cross-axis, such as a [DropdownMenu]. + final bool crossAxisUnconstrained; + /// The layout orientation of this panel. final Axis orientation; @@ -3297,38 +3327,45 @@ class _MenuPanelState extends State<_MenuPanel> { ); } } - return ConstrainedBox( - constraints: effectiveConstraints, - child: UnconstrainedBox( - constrainedAxis: widget.orientation, + + Widget menuPanel = _intrinsicCrossSize( + child: Material( + elevation: elevation, + shape: shape, + color: backgroundColor, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + type: backgroundColor == null ? MaterialType.transparency : MaterialType.canvas, clipBehavior: Clip.hardEdge, - alignment: AlignmentDirectional.centerStart, - child: _intrinsicCrossSize( - child: Material( - elevation: elevation, - shape: shape, - color: backgroundColor, - shadowColor: shadowColor, - surfaceTintColor: surfaceTintColor, - type: backgroundColor == null ? MaterialType.transparency : MaterialType.canvas, - clipBehavior: Clip.hardEdge, - child: Padding( - padding: resolvedPadding, - child: SingleChildScrollView( - scrollDirection: widget.orientation, - child: Flex( - crossAxisAlignment: CrossAxisAlignment.start, - textDirection: Directionality.of(context), - direction: widget.orientation, - mainAxisSize: MainAxisSize.min, - children: widget.children, - ), - ), + child: Padding( + padding: resolvedPadding, + child: SingleChildScrollView( + scrollDirection: widget.orientation, + child: Flex( + crossAxisAlignment: CrossAxisAlignment.start, + textDirection: Directionality.of(context), + direction: widget.orientation, + mainAxisSize: MainAxisSize.min, + children: widget.children, ), ), ), ), ); + + if (widget.crossAxisUnconstrained) { + menuPanel = UnconstrainedBox( + constrainedAxis: widget.orientation, + clipBehavior: Clip.hardEdge, + alignment: AlignmentDirectional.centerStart, + child: menuPanel, + ); + } + + return ConstrainedBox( + constraints: effectiveConstraints, + child: menuPanel, + ); } Widget _intrinsicCrossSize({required Widget child}) { @@ -3349,6 +3386,7 @@ class _Submenu extends StatelessWidget { required this.menuPosition, required this.alignmentOffset, required this.clipBehavior, + this.crossAxisUnconstrained = true, required this.menuChildren, }); @@ -3357,6 +3395,7 @@ class _Submenu extends StatelessWidget { final Offset? menuPosition; final Offset alignmentOffset; final Clip clipBehavior; + final bool crossAxisUnconstrained; final List menuChildren; @override @@ -3454,6 +3493,7 @@ class _Submenu extends StatelessWidget { menuStyle: menuStyle, clipBehavior: clipBehavior, orientation: anchor._orientation, + crossAxisUnconstrained: crossAxisUnconstrained, children: menuChildren, ), ), diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 5da89826456..20a5ea78220 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -25,6 +25,7 @@ import 'data_table_theme.dart'; import 'dialog_theme.dart'; import 'divider_theme.dart'; import 'drawer_theme.dart'; +import 'dropdown_menu_theme.dart'; import 'elevated_button_theme.dart'; import 'expansion_tile_theme.dart'; import 'filled_button_theme.dart'; @@ -348,6 +349,7 @@ class ThemeData with Diagnosticable { DialogTheme? dialogTheme, DividerThemeData? dividerTheme, DrawerThemeData? drawerTheme, + DropdownMenuThemeData? dropdownMenuTheme, ElevatedButtonThemeData? elevatedButtonTheme, ExpansionTileThemeData? expansionTileTheme, FilledButtonThemeData? filledButtonTheme, @@ -602,6 +604,7 @@ class ThemeData with Diagnosticable { dialogTheme ??= const DialogTheme(); dividerTheme ??= const DividerThemeData(); drawerTheme ??= const DrawerThemeData(); + dropdownMenuTheme ??= const DropdownMenuThemeData(); elevatedButtonTheme ??= const ElevatedButtonThemeData(); expansionTileTheme ??= const ExpansionTileThemeData(); filledButtonTheme ??= const FilledButtonThemeData(); @@ -699,6 +702,7 @@ class ThemeData with Diagnosticable { dialogTheme: dialogTheme, dividerTheme: dividerTheme, drawerTheme: drawerTheme, + dropdownMenuTheme: dropdownMenuTheme, elevatedButtonTheme: elevatedButtonTheme, expansionTileTheme: expansionTileTheme, filledButtonTheme: filledButtonTheme, @@ -812,6 +816,7 @@ class ThemeData with Diagnosticable { required this.dialogTheme, required this.dividerTheme, required this.drawerTheme, + required this.dropdownMenuTheme, required this.elevatedButtonTheme, required this.expansionTileTheme, required this.filledButtonTheme, @@ -983,6 +988,7 @@ class ThemeData with Diagnosticable { assert(dialogTheme != null), assert(dividerTheme != null), assert(drawerTheme != null), + assert(dropdownMenuTheme != null), assert(elevatedButtonTheme != null), assert(expansionTileTheme != null), assert(filledButtonTheme != null), @@ -1554,6 +1560,9 @@ class ThemeData with Diagnosticable { /// A theme for customizing the appearance and layout of [Drawer] widgets. final DrawerThemeData drawerTheme; + /// A theme for customizing the appearance and layout of [DropdownMenu] widgets. + final DropdownMenuThemeData dropdownMenuTheme; + /// A theme for customizing the appearance and internal layout of /// [ElevatedButton]s. final ElevatedButtonThemeData elevatedButtonTheme; @@ -1816,6 +1825,13 @@ class ThemeData with Diagnosticable { Color get toggleableActiveColor => _toggleableActiveColor!; final Color? _toggleableActiveColor; + // The number 5 was chosen without any real science or research behind it. It + + // copies of ThemeData in memory comfortably) and not too small (most apps + // shouldn't have more than 5 theme/localization pairs). + static const int _localizedThemeDataCacheSize = 5; + /// Caches localized themes to speed up the [localize] method. + /// Creates a copy of this theme but with the given fields replaced with the new values. /// /// The [brightness] value is applied to the [colorScheme]. @@ -1883,6 +1899,7 @@ class ThemeData with Diagnosticable { DialogTheme? dialogTheme, DividerThemeData? dividerTheme, DrawerThemeData? drawerTheme, + DropdownMenuThemeData? dropdownMenuTheme, ElevatedButtonThemeData? elevatedButtonTheme, ExpansionTileThemeData? expansionTileTheme, FilledButtonThemeData? filledButtonTheme, @@ -2047,6 +2064,7 @@ class ThemeData with Diagnosticable { dialogTheme: dialogTheme ?? this.dialogTheme, dividerTheme: dividerTheme ?? this.dividerTheme, drawerTheme: drawerTheme ?? this.drawerTheme, + dropdownMenuTheme: dropdownMenuTheme ?? this.dropdownMenuTheme, elevatedButtonTheme: elevatedButtonTheme ?? this.elevatedButtonTheme, expansionTileTheme: expansionTileTheme ?? this.expansionTileTheme, filledButtonTheme: filledButtonTheme ?? this.filledButtonTheme, @@ -2089,14 +2107,7 @@ class ThemeData with Diagnosticable { bottomAppBarColor: bottomAppBarColor ?? _bottomAppBarColor, ); } - - // The number 5 was chosen without any real science or research behind it. It // just seemed like a number that's not too big (we should be able to fit 5 - // copies of ThemeData in memory comfortably) and not too small (most apps - // shouldn't have more than 5 theme/localization pairs). - static const int _localizedThemeDataCacheSize = 5; - - /// Caches localized themes to speed up the [localize] method. static final _FifoCache<_IdentityThemeDataCacheKey, ThemeData> _localizedThemeDataCache = _FifoCache<_IdentityThemeDataCacheKey, ThemeData>(_localizedThemeDataCacheSize); @@ -2253,6 +2264,7 @@ class ThemeData with Diagnosticable { dialogTheme: DialogTheme.lerp(a.dialogTheme, b.dialogTheme, t), dividerTheme: DividerThemeData.lerp(a.dividerTheme, b.dividerTheme, t), drawerTheme: DrawerThemeData.lerp(a.drawerTheme, b.drawerTheme, t)!, + dropdownMenuTheme: DropdownMenuThemeData.lerp(a.dropdownMenuTheme, b.dropdownMenuTheme, t), elevatedButtonTheme: ElevatedButtonThemeData.lerp(a.elevatedButtonTheme, b.elevatedButtonTheme, t)!, expansionTileTheme: ExpansionTileThemeData.lerp(a.expansionTileTheme, b.expansionTileTheme, t)!, filledButtonTheme: FilledButtonThemeData.lerp(a.filledButtonTheme, b.filledButtonTheme, t)!, @@ -2361,6 +2373,7 @@ class ThemeData with Diagnosticable { other.dialogTheme == dialogTheme && other.dividerTheme == dividerTheme && other.drawerTheme == drawerTheme && + other.dropdownMenuTheme == dropdownMenuTheme && other.elevatedButtonTheme == elevatedButtonTheme && other.expansionTileTheme == expansionTileTheme && other.filledButtonTheme == filledButtonTheme && @@ -2466,6 +2479,7 @@ class ThemeData with Diagnosticable { dialogTheme, dividerTheme, drawerTheme, + dropdownMenuTheme, elevatedButtonTheme, expansionTileTheme, filledButtonTheme, @@ -2573,6 +2587,7 @@ class ThemeData with Diagnosticable { properties.add(DiagnosticsProperty('dialogTheme', dialogTheme, defaultValue: defaultData.dialogTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('dividerTheme', dividerTheme, defaultValue: defaultData.dividerTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('drawerTheme', drawerTheme, defaultValue: defaultData.drawerTheme, level: DiagnosticLevel.debug)); + properties.add(DiagnosticsProperty('dropdownMenuTheme', dropdownMenuTheme, defaultValue: defaultData.dropdownMenuTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('elevatedButtonTheme', elevatedButtonTheme, defaultValue: defaultData.elevatedButtonTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('expansionTileTheme', expansionTileTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('filledButtonTheme', filledButtonTheme, defaultValue: defaultData.filledButtonTheme, level: DiagnosticLevel.debug)); diff --git a/packages/flutter/test/material/dropdown_menu_test.dart b/packages/flutter/test/material/dropdown_menu_test.dart new file mode 100644 index 00000000000..3e831df1581 --- /dev/null +++ b/packages/flutter/test/material/dropdown_menu_test.dart @@ -0,0 +1,762 @@ +// 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 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final List menuChildren = []; + + for (final TestMenu value in TestMenu.values) { + final DropdownMenuEntry entry = DropdownMenuEntry(label: value.label); + menuChildren.add(entry); + } + + Widget buildTest(ThemeData themeData, List entries, + {double? width, double? menuHeight, Widget? leadingIcon, Widget? label}) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + label: label, + leadingIcon: leadingIcon, + width: width, + menuHeight: menuHeight, + dropdownMenuEntries: entries, + ), + ), + ); + } + + testWidgets('DropdownMenu defaults', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(buildTest(themeData, menuChildren)); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, themeData.textTheme.labelLarge!.color); + expect(editableText.style.background, themeData.textTheme.labelLarge!.background); + expect(editableText.style.shadows, themeData.textTheme.labelLarge!.shadows); + expect(editableText.style.decoration, themeData.textTheme.labelLarge!.decoration); + expect(editableText.style.locale, themeData.textTheme.labelLarge!.locale); + expect(editableText.style.wordSpacing, themeData.textTheme.labelLarge!.wordSpacing); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.border, const OutlineInputBorder()); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first); + await tester.pump(); + expect(find.byType(MenuAnchor), findsOneWidget); + + final Finder menuMaterial = find.ancestor( + of: find.widgetWithText(TextButton, TestMenu.mainMenu0.label), + matching: find.byType(Material), + ).last; + Material material = tester.widget(menuMaterial); + expect(material.color, themeData.colorScheme.surface); + expect(material.shadowColor, themeData.colorScheme.shadow); + expect(material.surfaceTintColor, themeData.colorScheme.surfaceTint); + expect(material.elevation, 3.0); + expect(material.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))); + + final Finder buttonMaterial = find.descendant( + of: find.byType(TextButton), + matching: find.byType(Material), + ).last; + + material = tester.widget(buttonMaterial); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, themeData.colorScheme.onSurface); + }); + + testWidgets('DropdownMenu can be disabled', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: SafeArea( + child: DropdownMenu( + enabled: false, + dropdownMenuEntries: menuChildren, + ), + ), + ), + ), + ); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.enabled, false); + final Finder menuMaterial = find.ancestor( + of: find.byType(SingleChildScrollView), + matching: find.byType(Material), + ); + expect(menuMaterial, findsNothing); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + final Finder updatedMenuMaterial = find.ancestor( + of: find.byType(SingleChildScrollView), + matching: find.byType(Material), + ); + expect(updatedMenuMaterial, findsNothing); + }); + + testWidgets('The width of the text field should always be the same as the menu view', + (WidgetTester tester) async { + + final ThemeData themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: SafeArea( + child: DropdownMenu( + dropdownMenuEntries: menuChildren, + ), + ), + ), + ) + ); + + final Finder textField = find.byType(TextField); + final Size anchorSize = tester.getSize(textField); + expect(anchorSize, const Size(180.0, 54.0)); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + + final Finder menuMaterial = find.ancestor( + of: find.byType(SingleChildScrollView), + matching: find.byType(Material), + ); + final Size menuSize = tester.getSize(menuMaterial); + expect(menuSize, const Size(180.0, 304.0)); + + // The text field should have same width as the menu + // when the width property is not null. + await tester.pumpWidget(buildTest(themeData, menuChildren, width: 200.0)); + + final Finder anchor = find.byType(TextField); + final Size size = tester.getSize(anchor); + expect(size, const Size(200.0, 54.0)); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + + final Finder updatedMenu = find.ancestor( + of: find.byType(SingleChildScrollView), + matching: find.byType(Material), + ); + final Size updatedMenuSize = tester.getSize(updatedMenu); + expect(updatedMenuSize, const Size(200.0, 304.0)); + }); + + testWidgets('The width property can customize the width of the dropdown menu', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + final List shortMenuItems = []; + + for (final ShortMenu value in ShortMenu.values) { + final DropdownMenuEntry entry = DropdownMenuEntry(label: value.label); + shortMenuItems.add(entry); + } + + const double customBigWidth = 250.0; + await tester.pumpWidget(buildTest(themeData, shortMenuItems, width: customBigWidth)); + RenderBox box = tester.firstRenderObject(find.byType(DropdownMenu)); + expect(box.size.width, customBigWidth); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + expect(find.byType(MenuItemButton), findsNWidgets(6)); + Size buttonSize = tester.getSize(find.widgetWithText(MenuItemButton, 'I0').last); + expect(buttonSize.width, customBigWidth); + + // reset test + await tester.pumpWidget(Container()); + const double customSmallWidth = 100.0; + await tester.pumpWidget(buildTest(themeData, shortMenuItems, width: customSmallWidth)); + box = tester.firstRenderObject(find.byType(DropdownMenu)); + expect(box.size.width, customSmallWidth); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + expect(find.byType(MenuItemButton), findsNWidgets(6)); + buttonSize = tester.getSize(find.widgetWithText(MenuItemButton, 'I0').last); + expect(buttonSize.width, customSmallWidth); + }); + + testWidgets('The menuHeight property can be used to show a shorter scrollable menu list instead of the complete list', + (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(buildTest(themeData, menuChildren)); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + + final Element firstItem = tester.element(find.widgetWithText(MenuItemButton, 'Item 0').last); + final RenderBox firstBox = firstItem.renderObject! as RenderBox; + final Offset topLeft = firstBox.localToGlobal(firstBox.size.topLeft(Offset.zero)); + final Element lastItem = tester.element(find.widgetWithText(MenuItemButton, 'Item 5').last); + final RenderBox lastBox = lastItem.renderObject! as RenderBox; + final Offset bottomRight = lastBox.localToGlobal(lastBox.size.bottomRight(Offset.zero)); + // height = height of MenuItemButton * 6 = 48 * 6 + expect(bottomRight.dy - topLeft.dy, 288.0); + + final Finder menuView = find.ancestor( + of: find.byType(SingleChildScrollView), + matching: find.byType(Padding), + ).first; + final Size menuViewSize = tester.getSize(menuView); + expect(menuViewSize, const Size(180.0, 304.0)); // 304 = 288 + vertical padding(2 * 8) + + // Constrains the menu height. + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildTest(themeData, menuChildren, menuHeight: 100)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + + final Finder updatedMenu = find.ancestor( + of: find.byType(SingleChildScrollView), + matching: find.byType(Padding), + ).first; + + final Size updatedMenuSize = tester.getSize(updatedMenu); + expect(updatedMenuSize, const Size(180.0, 100.0)); + }); + + testWidgets('The text in the menu button should be aligned with the text of ' + 'the text field - LTR', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + // Default text field (without leading icon). + await tester.pumpWidget(buildTest(themeData, menuChildren, label: const Text('label'))); + + final Finder label = find.text('label'); + final Offset labelTopLeft = tester.getTopLeft(label); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + final Finder itemText = find.text('Item 0').last; + final Offset itemTextTopLeft = tester.getTopLeft(itemText); + + expect(labelTopLeft.dx, equals(itemTextTopLeft.dx)); + + // Test when the text field has a leading icon. + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildTest(themeData, menuChildren, + leadingIcon: const Icon(Icons.search), + label: const Text('label'), + )); + + final Finder leadingIcon = find.widgetWithIcon(Container, Icons.search); + final double iconWidth = tester.getSize(leadingIcon).width; + final Finder updatedLabel = find.text('label'); + final Offset updatedLabelTopLeft = tester.getTopLeft(updatedLabel); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + final Finder updatedItemText = find.text('Item 0').last; + final Offset updatedItemTextTopLeft = tester.getTopLeft(updatedItemText); + + + expect(updatedLabelTopLeft.dx, equals(updatedItemTextTopLeft.dx)); + expect(updatedLabelTopLeft.dx, equals(iconWidth)); + + // Test when then leading icon is a widget with a bigger size. + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildTest(themeData, menuChildren, + leadingIcon: const SizedBox( + width: 75.0, + child: Icon(Icons.search)), + label: const Text('label'), + )); + + final Finder largeLeadingIcon = find.widgetWithIcon(Container, Icons.search); + final double largeIconWidth = tester.getSize(largeLeadingIcon).width; + final Finder updatedLabel1 = find.text('label'); + final Offset updatedLabelTopLeft1 = tester.getTopLeft(updatedLabel1); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + final Finder updatedItemText1 = find.text('Item 0').last; + final Offset updatedItemTextTopLeft1 = tester.getTopLeft(updatedItemText1); + + + expect(updatedLabelTopLeft1.dx, equals(updatedItemTextTopLeft1.dx)); + expect(updatedLabelTopLeft1.dx, equals(largeIconWidth)); + }); + + testWidgets('The text in the menu button should be aligned with the text of ' + 'the text field - RTL', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + // Default text field (without leading icon). + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: Directionality( + textDirection: TextDirection.rtl, + child: DropdownMenu( + label: const Text('label'), + dropdownMenuEntries: menuChildren, + ), + ), + ), + )); + + final Finder label = find.text('label'); + final Offset labelTopRight = tester.getTopRight(label); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + final Finder itemText = find.text('Item 0').last; + final Offset itemTextTopRight = tester.getTopRight(itemText); + + expect(labelTopRight.dx, equals(itemTextTopRight.dx)); + + // Test when the text field has a leading icon. + await tester.pumpWidget(Container()); + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: Directionality( + textDirection: TextDirection.rtl, + child: DropdownMenu( + leadingIcon: const Icon(Icons.search), + label: const Text('label'), + dropdownMenuEntries: menuChildren, + ), + ), + ), + )); + await tester.pump(); + + final Finder leadingIcon = find.widgetWithIcon(Container, Icons.search); + final double iconWidth = tester.getSize(leadingIcon).width; + final Offset dropdownMenuTopRight = tester.getTopRight(find.byType(DropdownMenu)); + final Finder updatedLabel = find.text('label'); + final Offset updatedLabelTopRight = tester.getTopRight(updatedLabel); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + final Finder updatedItemText = find.text('Item 0').last; + final Offset updatedItemTextTopRight = tester.getTopRight(updatedItemText); + + + expect(updatedLabelTopRight.dx, equals(updatedItemTextTopRight.dx)); + expect(updatedLabelTopRight.dx, equals(dropdownMenuTopRight.dx - iconWidth)); + + // Test when then leading icon is a widget with a bigger size. + await tester.pumpWidget(Container()); + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: Directionality( + textDirection: TextDirection.rtl, + child: DropdownMenu( + leadingIcon: const SizedBox(width: 75.0, child: Icon(Icons.search)), + label: const Text('label'), + dropdownMenuEntries: menuChildren, + ), + ), + ), + )); + await tester.pump(); + + final Finder largeLeadingIcon = find.widgetWithIcon(Container, Icons.search); + final double largeIconWidth = tester.getSize(largeLeadingIcon).width; + final Offset updatedDropdownMenuTopRight = tester.getTopRight(find.byType(DropdownMenu)); + final Finder updatedLabel1 = find.text('label'); + final Offset updatedLabelTopRight1 = tester.getTopRight(updatedLabel1); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + final Finder updatedItemText1 = find.text('Item 0').last; + final Offset updatedItemTextTopRight1 = tester.getTopRight(updatedItemText1); + + + expect(updatedLabelTopRight1.dx, equals(updatedItemTextTopRight1.dx)); + expect(updatedLabelTopRight1.dx, equals(updatedDropdownMenuTopRight.dx - largeIconWidth)); + }); + + testWidgets('DropdownMenu has default trailing icon button', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(buildTest(themeData, menuChildren)); + await tester.pump(); + + final Finder iconButton = find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first; + expect(iconButton, findsOneWidget); + + await tester.tap(iconButton); + await tester.pump(); + + final Finder menuMaterial = find.ancestor( + of: find.widgetWithText(MenuItemButton, TestMenu.mainMenu0.label), + matching: find.byType(Material), + ).last; + expect(menuMaterial, findsOneWidget); + }); + + testWidgets('DropdownMenu can customize trailing icon button', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + trailingIcon: const Icon(Icons.ac_unit), + dropdownMenuEntries: menuChildren, + ), + ), + )); + await tester.pump(); + + final Finder iconButton = find.widgetWithIcon(IconButton, Icons.ac_unit).first; + expect(iconButton, findsOneWidget); + + await tester.tap(iconButton); + await tester.pump(); + + final Finder menuMaterial = find.ancestor( + of: find.widgetWithText(MenuItemButton, TestMenu.mainMenu0.label), + matching: find.byType(Material), + ).last; + expect(menuMaterial, findsOneWidget); + }); + + testWidgets('Down key can highlight the menu item', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + trailingIcon: const Icon(Icons.ac_unit), + dropdownMenuEntries: menuChildren, + ), + ), + )); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await simulateKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + Finder button0Material = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Item 0').last, + matching: find.byType(Material), + ); + + Material item0material = tester.widget(button0Material); + expect(item0material.color, themeData.colorScheme.onSurface.withOpacity(0.12)); + + // Press down key one more time, the highlight should move to the next item. + await simulateKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + final Finder button1Material = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Menu 1').last, + matching: find.byType(Material), + ); + final Material item1material = tester.widget(button1Material); + expect(item1material.color, themeData.colorScheme.onSurface.withOpacity(0.12)); + button0Material = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Item 0').last, + matching: find.byType(Material), + ); + item0material = tester.widget(button0Material); + expect(item0material.color, Colors.transparent); // the previous item should not be highlighted. + }); + + testWidgets('Up key can highlight the menu item', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + dropdownMenuEntries: menuChildren, + ), + ), + )); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await simulateKeyDownEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + Finder button5Material = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Item 5').last, + matching: find.byType(Material), + ); + + Material item5material = tester.widget(button5Material); + expect(item5material.color, themeData.colorScheme.onSurface.withOpacity(0.12)); + + // Press up key one more time, the highlight should move up to the item 4. + await simulateKeyDownEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + final Finder button4Material = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Item 4').last, + matching: find.byType(Material), + ); + final Material item4material = tester.widget(button4Material); + expect(item4material.color, themeData.colorScheme.onSurface.withOpacity(0.12)); + button5Material = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Item 5').last, + matching: find.byType(Material), + ); + + item5material = tester.widget(button5Material); + expect(item5material.color, Colors.transparent); // the previous item should not be highlighted. + }); + + testWidgets('The text input should match the label of the menu item while pressing down key', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + dropdownMenuEntries: menuChildren, + ), + ), + )); + + // Open the menu + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await simulateKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Item 0'), findsOneWidget); + + // Press down key one more time to the next item. + await simulateKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Menu 1'), findsOneWidget); + + // Press down to the next item. + await simulateKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Item 2'), findsOneWidget); + }); + + testWidgets('The text input should match the label of the menu item while pressing up key', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + dropdownMenuEntries: menuChildren, + ), + ), + )); + + // Open the menu + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await simulateKeyDownEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Item 5'), findsOneWidget); + + // Press up key one more time to the upper item. + await simulateKeyDownEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Item 4'), findsOneWidget); + + // Press up to the upper item. + await simulateKeyDownEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Item 3'), findsOneWidget); + }); + + testWidgets('Disabled button will be skipped while pressing up/down key', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + final List menuWithDisabledItems = [ + const DropdownMenuEntry(label: 'Item 0'), + const DropdownMenuEntry(label: 'Item 1', enabled: false), + const DropdownMenuEntry(label: 'Item 2', enabled: false), + const DropdownMenuEntry(label: 'Item 3'), + const DropdownMenuEntry(label: 'Item 4'), + const DropdownMenuEntry(label: 'Item 5', enabled: false), + ]; + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + dropdownMenuEntries: menuWithDisabledItems, + ), + ), + )); + await tester.pump(); + + // Open the menu + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + + await simulateKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + final Finder button0Material = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Item 0').last, + matching: find.byType(Material), + ); + final Material item0Material = tester.widget(button0Material); + expect(item0Material.color, themeData.colorScheme.onSurface.withOpacity(0.12)); // first item can be highlighted as it's enabled. + + // Continue to press down key. Item 3 should be highlighted as Menu 1 and Item 2 are both disabled. + await simulateKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + final Finder button3Material = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Item 3').last, + matching: find.byType(Material), + ); + final Material item3Material = tester.widget(button3Material); + expect(item3Material.color, themeData.colorScheme.onSurface.withOpacity(0.12)); + }); + + testWidgets('Searching is enabled by default', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + dropdownMenuEntries: menuChildren, + ), + ), + )); + + // Open the menu + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'Menu 1'); + await tester.pumpAndSettle(); + final Finder buttonMaterial = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Menu 1').last, + matching: find.byType(Material), + ); + final Material itemMaterial = tester.widget(buttonMaterial); + expect(itemMaterial.color, themeData.colorScheme.onSurface.withOpacity(0.12)); // Menu 1 button is highlighted. + }); + + testWidgets('Highlight can move up/down from the searching result', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + dropdownMenuEntries: menuChildren, + ), + ), + )); + + // Open the menu + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'Menu 1'); + await tester.pumpAndSettle(); + final Finder buttonMaterial = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Menu 1').last, + matching: find.byType(Material), + ); + final Material itemMaterial = tester.widget(buttonMaterial); + expect(itemMaterial.color, themeData.colorScheme.onSurface.withOpacity(0.12)); + + // Press up to the upper item (Item 0). + await simulateKeyDownEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(find.widgetWithText(TextField, 'Item 0'), findsOneWidget); + final Finder button0Material = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Item 0').last, + matching: find.byType(Material), + ); + final Material item0Material = tester.widget(button0Material); + expect(item0Material.color, themeData.colorScheme.onSurface.withOpacity(0.12)); // Move up, the 'Item 0' is highlighted. + + // Continue to move up to the last item (Item 5). + await simulateKeyDownEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(find.widgetWithText(TextField, 'Item 5'), findsOneWidget); + final Finder button5Material = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Item 5').last, + matching: find.byType(Material), + ); + final Material item5Material = tester.widget(button5Material); + expect(item5Material.color, themeData.colorScheme.onSurface.withOpacity(0.12)); + }); + + testWidgets('Filtering is disabled by default', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + dropdownMenuEntries: menuChildren, + ), + ), + )); + + // Open the menu + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await tester.enterText(find.byType(TextField).first, 'Menu 1'); + await tester.pumpAndSettle(); + for (final TestMenu menu in TestMenu.values) { + // One is layout for the _DropdownMenuBody, the other one is the real button item in the menu. + expect(find.widgetWithText(MenuItemButton, menu.label), findsNWidgets(2)); + } + }); + + testWidgets('Enable filtering', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + enableFilter: true, + dropdownMenuEntries: menuChildren, + ), + ), + )); + + // Open the menu + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await tester.enterText(find + .byType(TextField) + .first, 'Menu 1'); + await tester.pumpAndSettle(); + for (final TestMenu menu in TestMenu.values) { + // 'Menu 1' should be 2, other items should only find one. + if (menu.label == TestMenu.mainMenu1.label) { + expect(find.widgetWithText(MenuItemButton, menu.label), findsNWidgets(2)); + } else { + expect(find.widgetWithText(MenuItemButton, menu.label), findsOneWidget); + } + } + }); +} + +enum TestMenu { + mainMenu0('Item 0'), + mainMenu1('Menu 1'), + mainMenu2('Item 2'), + mainMenu3('Item 3'), + mainMenu4('Item 4'), + mainMenu5('Item 5'); + + const TestMenu(this.label); + final String label; +} + +enum ShortMenu { + item0('I0'), + item1('I1'), + item2('I2'); + + const ShortMenu(this.label); + final String label; +} diff --git a/packages/flutter/test/material/dropdown_menu_theme_test.dart b/packages/flutter/test/material/dropdown_menu_theme_test.dart new file mode 100644 index 00000000000..e733d51553e --- /dev/null +++ b/packages/flutter/test/material/dropdown_menu_theme_test.dart @@ -0,0 +1,398 @@ +// 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 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('DropdownMenuThemeData copyWith, ==, hashCode basics', () { + expect(const DropdownMenuThemeData(), const DropdownMenuThemeData().copyWith()); + expect(const DropdownMenuThemeData().hashCode, const DropdownMenuThemeData().copyWith().hashCode); + + const DropdownMenuThemeData custom = DropdownMenuThemeData( + menuStyle: MenuStyle(backgroundColor: MaterialStatePropertyAll(Colors.green)), + inputDecorationTheme: InputDecorationTheme(filled: true), + textStyle: TextStyle(fontSize: 25.0), + ); + final DropdownMenuThemeData copy = const DropdownMenuThemeData().copyWith( + menuStyle: custom.menuStyle, + inputDecorationTheme: custom.inputDecorationTheme, + textStyle: custom.textStyle, + ); + expect(copy, custom); + }); + + testWidgets('Default DropdownMenuThemeData debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const DropdownMenuThemeData().debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, []); + }); + + testWidgets('With no other configuration, defaults are used', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Scaffold( + body: Center( + child: DropdownMenu( + dropdownMenuEntries: [ + DropdownMenuEntry(label: 'Item 0'), + DropdownMenuEntry(label: 'Item 1'), + DropdownMenuEntry(label: 'Item 2'), + ], + ), + ), + ), + ) + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, themeData.textTheme.labelLarge!.color); + expect(editableText.style.background, themeData.textTheme.labelLarge!.background); + expect(editableText.style.shadows, themeData.textTheme.labelLarge!.shadows); + expect(editableText.style.decoration, themeData.textTheme.labelLarge!.decoration); + expect(editableText.style.locale, themeData.textTheme.labelLarge!.locale); + expect(editableText.style.wordSpacing, themeData.textTheme.labelLarge!.wordSpacing); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.border, const OutlineInputBorder()); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first); + await tester.pump(); + expect(find.byType(MenuAnchor), findsOneWidget); + + final Finder menuMaterial = find.ancestor( + of: find.widgetWithText(TextButton, 'Item 0'), + matching: find.byType(Material), + ).last; + Material material = tester.widget(menuMaterial); + expect(material.color, themeData.colorScheme.surface); + expect(material.shadowColor, themeData.colorScheme.shadow); + expect(material.surfaceTintColor, themeData.colorScheme.surfaceTint); + expect(material.elevation, 3.0); + expect(material.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))); + + final Finder buttonMaterial = find.descendant( + of: find.widgetWithText(TextButton, 'Item 0'), + matching: find.byType(Material), + ).last; + + material = tester.widget(buttonMaterial); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, themeData.colorScheme.onSurface); + }); + + testWidgets('ThemeData.dropdownMenuTheme overrides defaults', (WidgetTester tester) async { + final ThemeData theme = ThemeData( + dropdownMenuTheme: DropdownMenuThemeData( + textStyle: TextStyle( + color: Colors.orange, + backgroundColor: Colors.indigo, + fontSize: 30.0, + shadows: kElevationToShadow[1], + decoration: TextDecoration.underline, + wordSpacing: 2.0, + ), + menuStyle: const MenuStyle( + backgroundColor: MaterialStatePropertyAll(Colors.grey), + shadowColor: MaterialStatePropertyAll(Colors.brown), + surfaceTintColor: MaterialStatePropertyAll(Colors.amberAccent), + elevation: MaterialStatePropertyAll(10.0), + shape: MaterialStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10.0))), + ), + ), + inputDecorationTheme: const InputDecorationTheme(filled: true, fillColor: Colors.lightGreen), + ) + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: Center( + child: DropdownMenu( + dropdownMenuEntries: [ + DropdownMenuEntry(label: 'Item 0'), + DropdownMenuEntry(label: 'Item 1'), + DropdownMenuEntry(label: 'Item 2'), + ], + ), + ), + ) + ) + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, Colors.orange); + expect(editableText.style.backgroundColor, Colors.indigo); + expect(editableText.style.shadows, kElevationToShadow[1]); + expect(editableText.style.decoration, TextDecoration.underline); + expect(editableText.style.wordSpacing, 2.0); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.filled, isTrue); + expect(textField.decoration?.fillColor, Colors.lightGreen); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first); + await tester.pump(); + expect(find.byType(MenuAnchor), findsOneWidget); + + final Finder menuMaterial = find.ancestor( + of: find.widgetWithText(TextButton, 'Item 0'), + matching: find.byType(Material), + ).last; + Material material = tester.widget(menuMaterial); + expect(material.color, Colors.grey); + expect(material.shadowColor, Colors.brown); + expect(material.surfaceTintColor, Colors.amberAccent); + expect(material.elevation, 10.0); + expect(material.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10.0)))); + + final Finder buttonMaterial = find.descendant( + of: find.widgetWithText(TextButton, 'Item 0'), + matching: find.byType(Material), + ).last; + + material = tester.widget(buttonMaterial); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, theme.colorScheme.onSurface); + }); + + testWidgets('DropdownMenuTheme overrides ThemeData and defaults', (WidgetTester tester) async { + final DropdownMenuThemeData global = DropdownMenuThemeData( + textStyle: TextStyle( + color: Colors.orange, + backgroundColor: Colors.indigo, + fontSize: 30.0, + shadows: kElevationToShadow[1], + decoration: TextDecoration.underline, + wordSpacing: 2.0, + ), + menuStyle: const MenuStyle( + backgroundColor: MaterialStatePropertyAll(Colors.grey), + shadowColor: MaterialStatePropertyAll(Colors.brown), + surfaceTintColor: MaterialStatePropertyAll(Colors.amberAccent), + elevation: MaterialStatePropertyAll(10.0), + shape: MaterialStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10.0))), + ), + ), + inputDecorationTheme: const InputDecorationTheme(filled: true, fillColor: Colors.lightGreen), + ); + + final DropdownMenuThemeData dropdownMenuTheme = DropdownMenuThemeData( + textStyle: TextStyle( + color: Colors.red, + backgroundColor: Colors.orange, + fontSize: 27.0, + shadows: kElevationToShadow[2], + decoration: TextDecoration.lineThrough, + wordSpacing: 5.0, + ), + menuStyle: const MenuStyle( + backgroundColor: MaterialStatePropertyAll(Colors.yellow), + shadowColor: MaterialStatePropertyAll(Colors.green), + surfaceTintColor: MaterialStatePropertyAll(Colors.teal), + elevation: MaterialStatePropertyAll(15.0), + shape: MaterialStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + ), + ), + inputDecorationTheme: const InputDecorationTheme(filled: true, fillColor: Colors.blue), + ); + + final ThemeData theme = ThemeData(dropdownMenuTheme: global); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: DropdownMenuTheme( + data: dropdownMenuTheme, + child: const Scaffold( + body: Center( + child: DropdownMenu( + dropdownMenuEntries: [ + DropdownMenuEntry(label: 'Item 0'), + DropdownMenuEntry(label: 'Item 1'), + DropdownMenuEntry(label: 'Item 2'), + ], + ), + ), + ), + ) + ) + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, Colors.red); + expect(editableText.style.backgroundColor, Colors.orange); + expect(editableText.style.fontSize, 27.0); + expect(editableText.style.shadows, kElevationToShadow[2]); + expect(editableText.style.decoration, TextDecoration.lineThrough); + expect(editableText.style.wordSpacing, 5.0); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.filled, isTrue); + expect(textField.decoration?.fillColor, Colors.blue); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first); + await tester.pump(); + expect(find.byType(MenuAnchor), findsOneWidget); + + final Finder menuMaterial = find.ancestor( + of: find.widgetWithText(TextButton, 'Item 0'), + matching: find.byType(Material), + ).last; + Material material = tester.widget(menuMaterial); + expect(material.color, Colors.yellow); + expect(material.shadowColor, Colors.green); + expect(material.surfaceTintColor, Colors.teal); + expect(material.elevation, 15.0); + expect(material.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0)))); + + final Finder buttonMaterial = find.descendant( + of: find.widgetWithText(TextButton, 'Item 0'), + matching: find.byType(Material), + ).last; + + material = tester.widget(buttonMaterial); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, theme.colorScheme.onSurface); + }); + + testWidgets('Widget parameters overrides DropdownMenuTheme, ThemeData and defaults', (WidgetTester tester) async { + final DropdownMenuThemeData global = DropdownMenuThemeData( + textStyle: TextStyle( + color: Colors.orange, + backgroundColor: Colors.indigo, + fontSize: 30.0, + shadows: kElevationToShadow[1], + decoration: TextDecoration.underline, + wordSpacing: 2.0, + ), + menuStyle: const MenuStyle( + backgroundColor: MaterialStatePropertyAll(Colors.grey), + shadowColor: MaterialStatePropertyAll(Colors.brown), + surfaceTintColor: MaterialStatePropertyAll(Colors.amberAccent), + elevation: MaterialStatePropertyAll(10.0), + shape: MaterialStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10.0))), + ), + ), + inputDecorationTheme: const InputDecorationTheme(filled: true, fillColor: Colors.lightGreen), + ); + + final DropdownMenuThemeData dropdownMenuTheme = DropdownMenuThemeData( + textStyle: TextStyle( + color: Colors.red, + backgroundColor: Colors.orange, + fontSize: 27.0, + shadows: kElevationToShadow[2], + decoration: TextDecoration.lineThrough, + wordSpacing: 5.0, + ), + menuStyle: const MenuStyle( + backgroundColor: MaterialStatePropertyAll(Colors.yellow), + shadowColor: MaterialStatePropertyAll(Colors.green), + surfaceTintColor: MaterialStatePropertyAll(Colors.teal), + elevation: MaterialStatePropertyAll(15.0), + shape: MaterialStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + ), + ), + inputDecorationTheme: const InputDecorationTheme(filled: true, fillColor: Colors.blue), + ); + + final ThemeData theme = ThemeData(dropdownMenuTheme: global); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: DropdownMenuTheme( + data: dropdownMenuTheme, + child: Scaffold( + body: Center( + child: DropdownMenu( + textStyle: TextStyle( + color: Colors.pink, + backgroundColor: Colors.cyan, + fontSize: 32.0, + shadows: kElevationToShadow[3], + decoration: TextDecoration.overline, + wordSpacing: 3.0, + ), + menuStyle: const MenuStyle( + backgroundColor: MaterialStatePropertyAll(Colors.limeAccent), + shadowColor: MaterialStatePropertyAll(Colors.deepOrangeAccent), + surfaceTintColor: MaterialStatePropertyAll(Colors.lightBlue), + elevation: MaterialStatePropertyAll(21.0), + shape: MaterialStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15.0))), + ), + ), + inputDecorationTheme: const InputDecorationTheme(filled: true, fillColor: Colors.deepPurple), + dropdownMenuEntries: const [ + DropdownMenuEntry(label: 'Item 0'), + DropdownMenuEntry(label: 'Item 1'), + DropdownMenuEntry(label: 'Item 2'), + ], + ), + ), + ), + ) + ) + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, Colors.pink); + expect(editableText.style.backgroundColor, Colors.cyan); + expect(editableText.style.fontSize, 32.0); + expect(editableText.style.shadows, kElevationToShadow[3]); + expect(editableText.style.decoration, TextDecoration.overline); + expect(editableText.style.wordSpacing, 3.0); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.filled, isTrue); + expect(textField.decoration?.fillColor, Colors.deepPurple); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first); + await tester.pump(); + expect(find.byType(MenuAnchor), findsOneWidget); + + final Finder menuMaterial = find.ancestor( + of: find.widgetWithText(TextButton, 'Item 0'), + matching: find.byType(Material), + ).last; + Material material = tester.widget(menuMaterial); + expect(material.color, Colors.limeAccent); + expect(material.shadowColor, Colors.deepOrangeAccent); + expect(material.surfaceTintColor, Colors.lightBlue); + expect(material.elevation, 21.0); + expect(material.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15.0)))); + + final Finder buttonMaterial = find.descendant( + of: find.widgetWithText(TextButton, 'Item 0'), + matching: find.byType(Material), + ).last; + + material = tester.widget(buttonMaterial); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, theme.colorScheme.onSurface); + }); +} diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart index 0704e6fa0bb..dd2407b23b6 100644 --- a/packages/flutter/test/material/theme_data_test.dart +++ b/packages/flutter/test/material/theme_data_test.dart @@ -784,6 +784,7 @@ void main() { dialogTheme: const DialogTheme(backgroundColor: Colors.black), dividerTheme: const DividerThemeData(color: Colors.black), drawerTheme: const DrawerThemeData(), + dropdownMenuTheme: const DropdownMenuThemeData(), elevatedButtonTheme: ElevatedButtonThemeData(style: ElevatedButton.styleFrom(backgroundColor: Colors.green)), expansionTileTheme: const ExpansionTileThemeData(backgroundColor: Colors.black), filledButtonTheme: FilledButtonThemeData(style: FilledButton.styleFrom(foregroundColor: Colors.green)), @@ -904,6 +905,7 @@ void main() { dialogTheme: const DialogTheme(backgroundColor: Colors.white), dividerTheme: const DividerThemeData(color: Colors.white), drawerTheme: const DrawerThemeData(), + dropdownMenuTheme: const DropdownMenuThemeData(), elevatedButtonTheme: const ElevatedButtonThemeData(), expansionTileTheme: const ExpansionTileThemeData(backgroundColor: Colors.black), filledButtonTheme: const FilledButtonThemeData(), @@ -1253,6 +1255,7 @@ void main() { 'dialogTheme', 'dividerTheme', 'drawerTheme', + 'dropdownMenuTheme', 'elevatedButtonTheme', 'expansionTileTheme', 'filledButtonTheme',