Fix DropdownMenu focused item styles (#153159)

## Description

This PR fixes the style resolution for selected dropdown menu items (make it possible to provide a custom style and avoid hardcoded values when possible).

For the moment, I kept the default selected background which was previously set (`onSurface.withOpacity(0.12)`) to keep this PR focused on its goal which is to make it possible to overrides the defaults item style by providing a custom button style at the theme level or at the menu entry level.

## Related Issue

Fixes https://github.com/flutter/flutter/issues/123736.

## Tests

Adds 4 tests.
This commit is contained in:
Bruno Leroux 2024-09-06 20:50:06 +02:00 committed by GitHub
parent 0eaeb0d1c5
commit 2e221e7308
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 415 additions and 15 deletions

View File

@ -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<T> {
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<T> extends State<DropdownMenu<T>> {
// 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>{MaterialState.focused})
?? Theme.of(context).colorScheme.onSurface;
final ButtonStyle? themeStyle = MenuButtonTheme.of(context).style;
final WidgetStateProperty<Color?>? effectiveForegroundColor = entry.style?.foregroundColor ?? themeStyle?.foregroundColor;
final WidgetStateProperty<Color?>? effectiveIconColor = entry.style?.iconColor ?? themeStyle?.iconColor;
final WidgetStateProperty<Color?>? effectiveOverlayColor = entry.style?.overlayColor ?? themeStyle?.overlayColor;
final WidgetStateProperty<Color?>? effectiveBackgroundColor = entry.style?.backgroundColor ?? themeStyle?.backgroundColor;
// Simulate the focused state because the text field should always be focused
// during traversal. Include potential MenuItemButton theme in the focus
// simulation for all colors in the theme.
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<Color?>? colorStateProperty) {
return colorStateProperty?.resolve(<MaterialState>{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<Color>(focusedBackgroundColor),
foregroundColor: MaterialStatePropertyAll<Color>(focusedForegroundColor),
iconColor: MaterialStatePropertyAll<Color>(focusedIconColor),
overlayColor: MaterialStatePropertyAll<Color>(focusedOverlayColor),
);
} else {
effectiveStyle = effectiveStyle.copyWith(
backgroundColor: effectiveBackgroundColor,
foregroundColor: effectiveForegroundColor,
iconColor: effectiveIconColor,
overlayColor: effectiveOverlayColor,
);
}
Widget label = entry.labelWidget ?? Text(entry.label);
if (widget.width != null) {
@ -661,15 +700,6 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
);
}
// 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<Color>(focusedBackgroundColor.withOpacity(0.12))
)
: effectiveStyle;
final Widget menuItemButton = MenuItemButton(
key: enableScrollToHighlight ? buttonItemKeys[i] : null,
style: effectiveStyle,

View File

@ -13,12 +13,26 @@ void main() {
const String longText = 'one two three four five six seven eight nine ten eleven twelve';
final List<DropdownMenuEntry<TestMenu>> menuChildren = <DropdownMenuEntry<TestMenu>>[];
final List<DropdownMenuEntry<TestMenu>> menuChildrenWithIcons = <DropdownMenuEntry<TestMenu>>[];
for (final TestMenu value in TestMenu.values) {
final DropdownMenuEntry<TestMenu> entry = DropdownMenuEntry<TestMenu>(value: value, label: value.label);
menuChildren.add(entry);
}
ValueKey<String> leadingIconKey(TestMenu menuEntry) => ValueKey<String>('leading-${menuEntry.label}');
ValueKey<String> trailingIconKey(TestMenu menuEntry) => ValueKey<String>('trailing-${menuEntry.label}');
for (final TestMenu value in TestMenu.values) {
final DropdownMenuEntry<TestMenu> entry = DropdownMenuEntry<TestMenu>(
value: value,
label: value.label,
leadingIcon: Icon(key: leadingIconKey(value), Icons.alarm),
trailingIcon: Icon(key: trailingIconKey(value), Icons.abc),
);
menuChildrenWithIcons.add(entry);
}
Widget buildTest<T extends Enum>(ThemeData themeData, List<DropdownMenuEntry<T>> 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<Material>(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<MaterialState> states) {
if (states.contains(MaterialState.focused)) {
return focusedBackgroundColor;
}
return defaultBackgroundColor;
}),
foregroundColor: MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.focused)) {
return focusedForegroundColor;
}
return defaultForegroundColor;
}),
iconColor: MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.focused)) {
return focusedIconColor;
}
return defaultIconColor;
}),
overlayColor: MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.focused)) {
return focusedOverlayColor;
}
return defaultOverlayColor;
}),
);
final List<DropdownMenuEntry<TestMenu>> styledMenuEntries = <DropdownMenuEntry<TestMenu>>[];
for (final DropdownMenuEntry<TestMenu> entryWithIcons in menuChildrenWithIcons) {
styledMenuEntries.add(DropdownMenuEntry<TestMenu>(
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<RichText>(
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<TestMenu>(
initialSelection: selectedItem,
dropdownMenuEntries: menuChildrenWithIcons,
),
),
));
// Open the menu.
await tester.tap(find.byType(DropdownMenu<TestMenu>));
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<TestMenu>(
initialSelection: selectedItem,
dropdownMenuEntries: menuChildrenWithIcons,
),
),
));
// Open the menu.
await tester.tap(find.byType(DropdownMenu<TestMenu>));
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<TestMenu>(
initialSelection: selectedItem,
dropdownMenuEntries: styledMenuEntries,
),
),
));
// Open the menu.
await tester.tap(find.byType(DropdownMenu<TestMenu>));
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<TestMenu>(
initialSelection: selectedItem,
dropdownMenuEntries: styledMenuEntries,
),
),
));
// Open the menu.
await tester.tap(find.byType(DropdownMenu<TestMenu>));
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<DropdownMenuEntry<TestMenu>> partiallyStyledMenuEntries = <DropdownMenuEntry<TestMenu>>[];
for (final DropdownMenuEntry<TestMenu> entryWithIcons in menuChildrenWithIcons) {
partiallyStyledMenuEntries.add(DropdownMenuEntry<TestMenu>(
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<TestMenu>(
initialSelection: selectedItem,
dropdownMenuEntries: partiallyStyledMenuEntries,
),
),
));
// Open the menu.
await tester.tap(find.byType(DropdownMenu<TestMenu>));
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();