diff --git a/packages/flutter/lib/src/material/dropdown_menu.dart b/packages/flutter/lib/src/material/dropdown_menu.dart index dc8bcb068c3..c39934edfd5 100644 --- a/packages/flutter/lib/src/material/dropdown_menu.dart +++ b/packages/flutter/lib/src/material/dropdown_menu.dart @@ -19,6 +19,7 @@ import 'input_border.dart'; import 'input_decorator.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'; @@ -107,7 +108,6 @@ class DropdownMenuEntry { final ButtonStyle? style; } - /// A dropdown menu that can be opened from a [TextField]. The selected /// menu item is displayed in that field. /// @@ -643,14 +643,53 @@ class _DropdownMenuState extends State> { // 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; - final ButtonStyle defaultStyle = switch (textDirection) { + ButtonStyle effectiveStyle = entry.style ?? switch (textDirection) { TextDirection.rtl => MenuItemButton.styleFrom(padding: EdgeInsets.only(left: _kDefaultHorizontalPadding, right: padding)), TextDirection.ltr => MenuItemButton.styleFrom(padding: EdgeInsets.only(left: padding, right: _kDefaultHorizontalPadding)), }; - ButtonStyle effectiveStyle = entry.style ?? defaultStyle; - final Color focusedBackgroundColor = effectiveStyle.foregroundColor?.resolve({MaterialState.focused}) - ?? Theme.of(context).colorScheme.onSurface; + final ButtonStyle? themeStyle = MenuButtonTheme.of(context).style; + + final WidgetStateProperty? effectiveForegroundColor = entry.style?.foregroundColor ?? themeStyle?.foregroundColor; + final WidgetStateProperty? effectiveIconColor = entry.style?.iconColor ?? themeStyle?.iconColor; + final WidgetStateProperty? effectiveOverlayColor = entry.style?.overlayColor ?? themeStyle?.overlayColor; + final WidgetStateProperty? 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. + if (entry.enabled && i == focusedIndex) { + // 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? colorStateProperty) { + return colorStateProperty?.resolve({MaterialState.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(focusedBackgroundColor), + foregroundColor: MaterialStatePropertyAll(focusedForegroundColor), + iconColor: MaterialStatePropertyAll(focusedIconColor), + overlayColor: MaterialStatePropertyAll(focusedOverlayColor), + ); + } else { + effectiveStyle = effectiveStyle.copyWith( + backgroundColor: effectiveBackgroundColor, + foregroundColor: effectiveForegroundColor, + iconColor: effectiveIconColor, + overlayColor: effectiveOverlayColor, + ); + } Widget label = entry.labelWidget ?? Text(entry.label); if (widget.width != null) { @@ -661,15 +700,6 @@ class _DropdownMenuState extends State> { ); } - // 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 Widget menuItemButton = MenuItemButton( key: enableScrollToHighlight ? buttonItemKeys[i] : null, style: effectiveStyle, diff --git a/packages/flutter/test/material/dropdown_menu_test.dart b/packages/flutter/test/material/dropdown_menu_test.dart index b30a9c6b81d..84de6835613 100644 --- a/packages/flutter/test/material/dropdown_menu_test.dart +++ b/packages/flutter/test/material/dropdown_menu_test.dart @@ -13,12 +13,26 @@ void main() { const String longText = 'one two three four five six seven eight nine ten eleven twelve'; final List> menuChildren = >[]; + final List> menuChildrenWithIcons = >[]; for (final TestMenu value in TestMenu.values) { final DropdownMenuEntry entry = DropdownMenuEntry(value: value, label: value.label); menuChildren.add(entry); } + ValueKey leadingIconKey(TestMenu menuEntry) => ValueKey('leading-${menuEntry.label}'); + ValueKey trailingIconKey(TestMenu menuEntry) => ValueKey('trailing-${menuEntry.label}'); + + for (final TestMenu value in TestMenu.values) { + final DropdownMenuEntry entry = DropdownMenuEntry( + value: value, + label: value.label, + leadingIcon: Icon(key: leadingIconKey(value), Icons.alarm), + trailingIcon: Icon(key: trailingIconKey(value), Icons.abc), + ); + menuChildrenWithIcons.add(entry); + } + Widget buildTest(ThemeData themeData, List> entries, {double? width, double? menuHeight, Widget? leadingIcon, Widget? label}) { return MaterialApp( @@ -35,6 +49,13 @@ void main() { ); } + Material getButtonMaterial(WidgetTester tester, String itemLabel) { + return tester.widget(find.descendant( + of: find.widgetWithText(MenuItemButton, itemLabel).last, + matching: find.byType(Material), + )); + } + testWidgets('DropdownMenu defaults', (WidgetTester tester) async { final ThemeData themeData = ThemeData(); await tester.pumpWidget(buildTest(themeData, menuChildren)); @@ -83,6 +104,356 @@ void main() { expect(material.textStyle?.height, 1.43); }); + group('Item style', () { + const Color focusedBackgroundColor = Color(0xffff0000); + const Color focusedForegroundColor = Color(0xff00ff00); + const Color focusedIconColor = Color(0xff0000ff); + const Color focusedOverlayColor = Color(0xffff00ff); + const Color defaultBackgroundColor = Color(0xff00ffff); + const Color defaultForegroundColor = Color(0xff000000); + const Color defaultIconColor = Color(0xffffffff); + const Color defaultOverlayColor = Color(0xffffff00); + + final ButtonStyle customButtonStyle = ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.focused)) { + return focusedBackgroundColor; + } + return defaultBackgroundColor; + }), + foregroundColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.focused)) { + return focusedForegroundColor; + } + return defaultForegroundColor; + }), + iconColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.focused)) { + return focusedIconColor; + } + return defaultIconColor; + }), + overlayColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.focused)) { + return focusedOverlayColor; + } + return defaultOverlayColor; + }), + ); + + final List> styledMenuEntries = >[]; + for (final DropdownMenuEntry entryWithIcons in menuChildrenWithIcons) { + styledMenuEntries.add(DropdownMenuEntry( + value: entryWithIcons.value, + label: entryWithIcons.label, + leadingIcon: entryWithIcons.leadingIcon, + trailingIcon: entryWithIcons.trailingIcon, + style: customButtonStyle, + )); + } + + TextStyle? iconStyle(WidgetTester tester, Key key) { + final RichText iconRichText = tester.widget( + find.descendant(of: find.byKey(key), matching: find.byType(RichText)).last, + ); + return iconRichText.text.style; + } + + RenderObject overlayPainter(WidgetTester tester, TestMenu menuItem) { + return tester.renderObject(find.descendant( + of: find.widgetWithText(MenuItemButton, menuItem.label).last, + matching: find.byElementPredicate( + (Element element) => element.renderObject.runtimeType.toString() == '_RenderInkFeatures', + ), + ).last); + } + + testWidgets('defaults are correct', (WidgetTester tester) async { + const TestMenu selectedItem = TestMenu.mainMenu3; + const TestMenu nonSelectedItem = TestMenu.mainMenu2; + + final ThemeData themeData = ThemeData(); + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu( + initialSelection: selectedItem, + dropdownMenuEntries: menuChildrenWithIcons, + ), + ), + )); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + final Material selectedButtonMaterial = getButtonMaterial(tester, selectedItem.label); + expect(selectedButtonMaterial.color, themeData.colorScheme.onSurface.withOpacity(0.12)); + expect(selectedButtonMaterial.textStyle?.color, themeData.colorScheme.onSurface); + expect(iconStyle(tester, leadingIconKey(selectedItem))?.color, themeData.colorScheme.onSurfaceVariant); + + final Material nonSelectedButtonMaterial = getButtonMaterial(tester, nonSelectedItem.label); + expect(nonSelectedButtonMaterial.color, Colors.transparent); + expect(nonSelectedButtonMaterial.textStyle?.color, themeData.colorScheme.onSurface); + expect(iconStyle(tester, leadingIconKey(nonSelectedItem))?.color, themeData.colorScheme.onSurfaceVariant); + + // Hover the selected item. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.widgetWithText(MenuItemButton, selectedItem.label).last)); + await tester.pump(); + + expect( + overlayPainter(tester, selectedItem), + paints..rect(color: themeData.colorScheme.onSurface.withOpacity(0.1).withAlpha(0)), + ); + + // Hover a non-selected item. + await gesture.moveTo(tester.getCenter(find.widgetWithText(MenuItemButton, nonSelectedItem.label).last)); + await tester.pump(); + + expect( + overlayPainter(tester, nonSelectedItem), + paints..rect(color: themeData.colorScheme.onSurface.withOpacity(0.08).withAlpha(0)), + ); + }); + + testWidgets('can be overridden at application theme level', (WidgetTester tester) async { + const TestMenu selectedItem = TestMenu.mainMenu3; + const TestMenu nonSelectedItem = TestMenu.mainMenu2; + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(menuButtonTheme: MenuButtonThemeData(style: customButtonStyle)), + home: Scaffold( + body: DropdownMenu( + initialSelection: selectedItem, + dropdownMenuEntries: menuChildrenWithIcons, + ), + ), + )); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + final Material selectedButtonMaterial = getButtonMaterial(tester, selectedItem.label); + expect(selectedButtonMaterial.color, focusedBackgroundColor); + expect(selectedButtonMaterial.textStyle?.color, focusedForegroundColor); + expect(iconStyle(tester, leadingIconKey(selectedItem))?.color, focusedIconColor); + + final Material nonSelectedButtonMaterial = getButtonMaterial(tester, nonSelectedItem.label); + expect(nonSelectedButtonMaterial.color, defaultBackgroundColor); + expect(nonSelectedButtonMaterial.textStyle?.color, defaultForegroundColor); + expect(iconStyle(tester, leadingIconKey(nonSelectedItem))?.color, defaultIconColor); + + // Hover the selected item. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.widgetWithText(MenuItemButton, selectedItem.label).last)); + await tester.pump(); + + expect( + overlayPainter(tester, selectedItem), + paints..rect(color: focusedOverlayColor.withAlpha(0)), + ); + + // Hover a non-selected item. + await gesture.moveTo(tester.getCenter(find.widgetWithText(MenuItemButton, nonSelectedItem.label).last)); + await tester.pump(); + + expect( + overlayPainter(tester, nonSelectedItem), + paints..rect(color: defaultOverlayColor.withAlpha(0)), + ); + }); + + testWidgets('can be overridden at menu entry level', (WidgetTester tester) async { + const TestMenu selectedItem = TestMenu.mainMenu3; + const TestMenu nonSelectedItem = TestMenu.mainMenu2; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: DropdownMenu( + initialSelection: selectedItem, + dropdownMenuEntries: styledMenuEntries, + ), + ), + )); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + final Material selectedButtonMaterial = getButtonMaterial(tester, selectedItem.label); + expect(selectedButtonMaterial.color, focusedBackgroundColor); + expect(selectedButtonMaterial.textStyle?.color, focusedForegroundColor); + expect(iconStyle(tester, leadingIconKey(selectedItem))?.color, focusedIconColor); + + final Material nonSelectedButtonMaterial = getButtonMaterial(tester, nonSelectedItem.label); + expect(nonSelectedButtonMaterial.color, defaultBackgroundColor); + expect(nonSelectedButtonMaterial.textStyle?.color, defaultForegroundColor); + expect(iconStyle(tester, leadingIconKey(nonSelectedItem))?.color, defaultIconColor); + + // Hover the selected item. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.widgetWithText(MenuItemButton, selectedItem.label).last)); + await tester.pump(); + + expect( + overlayPainter(tester, selectedItem), + paints..rect(color: focusedOverlayColor.withAlpha(0)), + ); + + // Hover a non-selected item. + await gesture.moveTo(tester.getCenter(find.widgetWithText(MenuItemButton, nonSelectedItem.label).last)); + await tester.pump(); + + expect( + overlayPainter(tester, nonSelectedItem), + paints..rect(color: defaultOverlayColor.withAlpha(0)), + ); + }); + + testWidgets('defined at menu entry level takes precedence', (WidgetTester tester) async { + const TestMenu selectedItem = TestMenu.mainMenu3; + const TestMenu nonSelectedItem = TestMenu.mainMenu2; + + const Color luckyColor = Color(0xff777777); + final ButtonStyle singleColorButtonStyle = ButtonStyle( + backgroundColor: MaterialStateProperty.all(luckyColor), + foregroundColor: MaterialStateProperty.all(luckyColor), + iconColor: MaterialStateProperty.all(luckyColor), + overlayColor: MaterialStateProperty.all(luckyColor), + ); + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(menuButtonTheme: MenuButtonThemeData(style: singleColorButtonStyle)), + home: Scaffold( + body: DropdownMenu( + initialSelection: selectedItem, + dropdownMenuEntries: styledMenuEntries, + ), + ), + )); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + final Material selectedButtonMaterial = getButtonMaterial(tester, selectedItem.label); + expect(selectedButtonMaterial.color, focusedBackgroundColor); + expect(selectedButtonMaterial.textStyle?.color, focusedForegroundColor); + expect(iconStyle(tester, leadingIconKey(selectedItem))?.color, focusedIconColor); + + final Material nonSelectedButtonMaterial = getButtonMaterial(tester, nonSelectedItem.label); + expect(nonSelectedButtonMaterial.color, defaultBackgroundColor); + expect(nonSelectedButtonMaterial.textStyle?.color, defaultForegroundColor); + expect(iconStyle(tester, leadingIconKey(nonSelectedItem))?.color, defaultIconColor); + + // Hover the selected item. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.widgetWithText(MenuItemButton, selectedItem.label).last)); + await tester.pump(); + + expect( + overlayPainter(tester, selectedItem), + paints..rect(color: focusedOverlayColor.withAlpha(0)), + ); + + // Hover a non-selected item. + await gesture.moveTo(tester.getCenter(find.widgetWithText(MenuItemButton, nonSelectedItem.label).last)); + await tester.pump(); + + expect( + overlayPainter(tester, nonSelectedItem), + paints..rect(color: defaultOverlayColor.withAlpha(0)), + ); + }); + + testWidgets('defined at menu entry level and application level are merged', (WidgetTester tester) async { + const TestMenu selectedItem = TestMenu.mainMenu3; + const TestMenu nonSelectedItem = TestMenu.mainMenu2; + + const Color luckyColor = Color(0xff777777); + final ButtonStyle partialButtonStyle = ButtonStyle( + backgroundColor: MaterialStateProperty.all(luckyColor), + foregroundColor: MaterialStateProperty.all(luckyColor), + ); + + final List> partiallyStyledMenuEntries = >[]; + for (final DropdownMenuEntry entryWithIcons in menuChildrenWithIcons) { + partiallyStyledMenuEntries.add(DropdownMenuEntry( + value: entryWithIcons.value, + label: entryWithIcons.label, + leadingIcon: entryWithIcons.leadingIcon, + trailingIcon: entryWithIcons.trailingIcon, + style: partialButtonStyle, + )); + } + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(menuButtonTheme: MenuButtonThemeData(style: customButtonStyle)), + home: Scaffold( + body: DropdownMenu( + initialSelection: selectedItem, + dropdownMenuEntries: partiallyStyledMenuEntries, + ), + ), + )); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + final Material selectedButtonMaterial = getButtonMaterial(tester, selectedItem.label); + expect(selectedButtonMaterial.color, luckyColor); + expect(selectedButtonMaterial.textStyle?.color, luckyColor); + expect(iconStyle(tester, leadingIconKey(selectedItem))?.color, focusedIconColor); + + final Material nonSelectedButtonMaterial = getButtonMaterial(tester, nonSelectedItem.label); + expect(nonSelectedButtonMaterial.color, luckyColor); + expect(nonSelectedButtonMaterial.textStyle?.color, luckyColor); + expect(iconStyle(tester, leadingIconKey(nonSelectedItem))?.color, defaultIconColor); + + // Hover the selected item. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.widgetWithText(MenuItemButton, selectedItem.label).last)); + await tester.pump(); + + expect( + overlayPainter(tester, selectedItem), + paints..rect(color: focusedOverlayColor.withAlpha(0)), + ); + + // Hover a non-selected item. + await gesture.moveTo(tester.getCenter(find.widgetWithText(MenuItemButton, nonSelectedItem.label).last)); + await tester.pump(); + + expect( + overlayPainter(tester, nonSelectedItem), + paints..rect(color: defaultOverlayColor.withAlpha(0)), + ); + }); + }); + testWidgets('Inner TextField is disabled when DropdownMenu is disabled', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -344,7 +715,6 @@ void main() { final Rect dropdownMenuRect = tester.getRect(find.byType(TextField)); expect(dropdownMenuRect.top, containerRect.top); - await tester.tap(find.byType(TextField)); await tester.pumpAndSettle();