Skip to content

Commit 275153c

Browse files
Add submenuIcon property to override the default SubmenuButton arrow icon (flutter#160086)
Fixes [https://github.com/flutter/flutter/issues/132898](https://github.com/flutter/flutter/issues/132898) ### Code sample <details> <summary>expand to view the code sample</summary> ```dart import 'package:flutter/material.dart'; void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { const MyApp({super.key}); @OverRide Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, home: Scaffold( body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ MenuBar( children: [ SubmenuButton( menuChildren: <Widget>[ SubmenuButton( menuChildren: <Widget>[ MenuItemButton( onPressed: () {}, child: const Text('Menu '), ), ], child: const Text('SubmenuButton with default arrow icon'), ), SubmenuButton( submenuIcon: const WidgetStateProperty<Widget?>.fromMap( <WidgetStatesConstraint, Widget?>{ WidgetState.disabled: Icon(Icons.close), WidgetState.hovered: Icon(Icons.favorite), WidgetState.focused: Icon(Icons.add), WidgetState.any: Icon(Icons.arrow_forward_ios), }, ), menuChildren: <Widget>[ MenuItemButton( onPressed: () {}, child: const Text('Menu '), ), ], child: const Text('SubmenuButton with custom Icon widget'), ), SubmenuButton( submenuIcon: WidgetStatePropertyAll(Image.network( 'https://i.imgur.com/SF3mSOY.png', width: 28, height: 28)), menuChildren: <Widget>[ MenuItemButton( onPressed: () {}, child: const Text('Menu '), ), ], child: const Text('SubmenuButton with network image icon'), ), ], child: const Text('Menu'), ), ], ) ], ), ), ), ); } } ``` </details> ### Preview <img width="803" alt="Screenshot 2024-12-11 at 14 04 57" src="https://github.com/user-attachments/assets/4b330020-28c6-4af9-967b-630c0d43b01a"> ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Greg Spencer <[email protected]>
1 parent a3eb526 commit 275153c

File tree

4 files changed

+275
-8
lines changed

4 files changed

+275
-8
lines changed

packages/flutter/lib/src/material/menu_anchor.dart

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1692,6 +1692,7 @@ class SubmenuButton extends StatefulWidget {
16921692
this.statesController,
16931693
this.leadingIcon,
16941694
this.trailingIcon,
1695+
this.submenuIcon,
16951696
required this.menuChildren,
16961697
required this.child,
16971698
});
@@ -1756,6 +1757,18 @@ class SubmenuButton extends StatefulWidget {
17561757
/// An optional icon to display before the [child].
17571758
final Widget? leadingIcon;
17581759

1760+
/// If provided, the widget replaces the default [SubmenuButton] arrow icon.
1761+
///
1762+
/// Resolves in the following states:
1763+
/// * [WidgetState.disabled].
1764+
/// * [WidgetState.hovered].
1765+
/// * [WidgetState.focused].
1766+
///
1767+
/// If this is null, then the value of [MenuThemeData.submenuIcon] is used.
1768+
/// If that is also null, then defaults to a right arrow icon with the size
1769+
/// of 24 pixels.
1770+
final MaterialStateProperty<Widget?>? submenuIcon;
1771+
17591772
/// An optional icon to display after the [child].
17601773
final Widget? trailingIcon;
17611774

@@ -1982,6 +1995,17 @@ class _SubmenuButtonState extends State<SubmenuButton> {
19821995
(Axis.vertical, TextDirection.rtl) => Offset(0, -menuPadding.top),
19831996
(Axis.vertical, TextDirection.ltr) => Offset(0, -menuPadding.top),
19841997
};
1998+
final Set<MaterialState> states = <MaterialState>{
1999+
if (!_enabled) MaterialState.disabled,
2000+
if (_isHovered) MaterialState.hovered,
2001+
if (_buttonFocusNode.hasFocus) MaterialState.focused,
2002+
};
2003+
final Widget submenuIcon = widget.submenuIcon?.resolve(states)
2004+
?? MenuTheme.of(context).submenuIcon?.resolve(states)
2005+
?? const Icon(
2006+
Icons.arrow_right, // Automatically switches with text direction.
2007+
size: _kDefaultSubmenuIconSize,
2008+
);
19852009

19862010
return Actions(
19872011
actions: actions,
@@ -2055,6 +2079,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
20552079
trailingIcon: widget.trailingIcon,
20562080
hasSubmenu: true,
20572081
showDecoration: (controller._anchor!._parent?._orientation ?? Axis.horizontal) == Axis.vertical,
2082+
submenuIcon: submenuIcon,
20582083
child: child,
20592084
),
20602085
),
@@ -3059,6 +3084,7 @@ class _MenuItemLabel extends StatelessWidget {
30593084
this.shortcut,
30603085
this.semanticsLabel,
30613086
this.overflowAxis = Axis.vertical,
3087+
this.submenuIcon,
30623088
this.child,
30633089
});
30643090

@@ -3089,6 +3115,9 @@ class _MenuItemLabel extends StatelessWidget {
30893115
/// The direction in which the menu item expands.
30903116
final Axis overflowAxis;
30913117

3118+
/// The submenu icon that is displayed when [showDecoration] and [hasSubmenu] are true.
3119+
final Widget? submenuIcon;
3120+
30923121
/// An optional child widget that is displayed in the label.
30933122
final Widget? child;
30943123

@@ -3156,10 +3185,7 @@ class _MenuItemLabel extends StatelessWidget {
31563185
if (showDecoration && hasSubmenu)
31573186
Padding(
31583187
padding: EdgeInsetsDirectional.only(start: horizontalPadding),
3159-
child: const Icon(
3160-
Icons.arrow_right, // Automatically switches with text direction.
3161-
size: _kDefaultSubmenuIconSize,
3162-
),
3188+
child: submenuIcon,
31633189
),
31643190
],
31653191
);

packages/flutter/lib/src/material/menu_theme.dart

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ library;
88
import 'package:flutter/foundation.dart';
99
import 'package:flutter/widgets.dart';
1010

11+
import 'material_state.dart';
1112
import 'menu_anchor.dart';
1213
import 'menu_style.dart';
1314
import 'theme.dart';
@@ -36,24 +37,41 @@ import 'theme.dart';
3637
@immutable
3738
class MenuThemeData with Diagnosticable {
3839
/// Creates a const set of properties used to configure [MenuTheme].
39-
const MenuThemeData({this.style});
40+
const MenuThemeData({
41+
this.style,
42+
this.submenuIcon,
43+
});
4044

4145
/// The [MenuStyle] of a [SubmenuButton] menu.
4246
///
4347
/// Any values not set in the [MenuStyle] will use the menu default for that
4448
/// property.
4549
final MenuStyle? style;
4650

51+
/// If provided, the widget replaces the default [SubmenuButton] arrow icon.
52+
///
53+
/// Resolves in the following states:
54+
/// * [WidgetState.disabled].
55+
/// * [WidgetState.hovered].
56+
/// * [WidgetState.focused].
57+
final MaterialStateProperty<Widget?>? submenuIcon;
58+
4759
/// Linearly interpolate between two menu button themes.
4860
static MenuThemeData? lerp(MenuThemeData? a, MenuThemeData? b, double t) {
4961
if (identical(a, b)) {
5062
return a;
5163
}
52-
return MenuThemeData(style: MenuStyle.lerp(a?.style, b?.style, t));
64+
return MenuThemeData(
65+
style: MenuStyle.lerp(a?.style, b?.style, t),
66+
submenuIcon: t < 0.5 ? a?.submenuIcon : b?.submenuIcon,
67+
);
5368
}
5469

5570
@override
56-
int get hashCode => style.hashCode;
71+
int get hashCode => Object.hash(
72+
style,
73+
submenuIcon,
74+
);
5775

5876
@override
5977
bool operator ==(Object other) {
@@ -63,13 +81,16 @@ class MenuThemeData with Diagnosticable {
6381
if (other.runtimeType != runtimeType) {
6482
return false;
6583
}
66-
return other is MenuThemeData && other.style == style;
84+
return other is MenuThemeData
85+
&& other.style == style
86+
&& other.submenuIcon == submenuIcon;
6787
}
6888

6989
@override
7090
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
7191
super.debugFillProperties(properties);
7292
properties.add(DiagnosticsProperty<MenuStyle>('style', style, defaultValue: null));
93+
properties.add(DiagnosticsProperty<MaterialStateProperty<Widget?>>('submenuIcon', submenuIcon, defaultValue: null));
7394
}
7495
}
7596

packages/flutter/test/material/menu_anchor_test.dart

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4757,6 +4757,92 @@ void main() {
47574757
expect(openCount, 1);
47584758
expect(closeCount, 1);
47594759
});
4760+
4761+
testWidgets('SubmenuButton.submenuIcon updates default arrow icon', (WidgetTester tester) async {
4762+
const IconData disabledIcon = Icons.close;
4763+
const IconData hoveredIcon = Icons.bolt;
4764+
const IconData focusedIcon = Icons.favorite;
4765+
const IconData defaultIcon = Icons.add;
4766+
final WidgetStateProperty<Widget?> submenuIcon = WidgetStateProperty.resolveWith<Widget?>(
4767+
(Set<WidgetState> states) {
4768+
if (states.contains(WidgetState.disabled)) {
4769+
return const Icon(disabledIcon);
4770+
}
4771+
if (states.contains(WidgetState.hovered)) {
4772+
return const Icon(hoveredIcon);
4773+
}
4774+
if (states.contains(WidgetState.focused)) {
4775+
return const Icon(focusedIcon);
4776+
}
4777+
return const Icon(defaultIcon);
4778+
});
4779+
4780+
Widget buildMenu({
4781+
WidgetStateProperty<Widget?>? icon,
4782+
bool enabled = true,
4783+
}) {
4784+
return MaterialApp(
4785+
home: Material(
4786+
child: MenuBar(
4787+
controller: controller,
4788+
children: <Widget>[
4789+
SubmenuButton(
4790+
menuChildren: <Widget>[
4791+
SubmenuButton(
4792+
submenuIcon: icon,
4793+
menuChildren: enabled
4794+
? <Widget>[
4795+
MenuItemButton(
4796+
child: Text(TestMenu.mainMenu0.label),
4797+
),
4798+
]
4799+
: <Widget>[],
4800+
child: Text(TestMenu.subSubMenu110.label),
4801+
),
4802+
],
4803+
child: Text(TestMenu.subMenu00.label),
4804+
),
4805+
],
4806+
),
4807+
),
4808+
);
4809+
}
4810+
4811+
await tester.pumpWidget(buildMenu());
4812+
await tester.tap(find.text(TestMenu.subMenu00.label));
4813+
await tester.pump();
4814+
4815+
expect(find.byIcon(Icons.arrow_right), findsOneWidget);
4816+
4817+
controller.close();
4818+
await tester.pump();
4819+
4820+
await tester.pumpWidget(buildMenu(icon: submenuIcon));
4821+
await tester.tap(find.text(TestMenu.subMenu00.label));
4822+
await tester.pump();
4823+
expect(find.byIcon(defaultIcon), findsOneWidget);
4824+
4825+
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
4826+
await tester.pump();
4827+
expect(find.byIcon(focusedIcon), findsOneWidget);
4828+
4829+
controller.close();
4830+
await tester.pump();
4831+
4832+
await tester.tap(find.text(TestMenu.subMenu00.label));
4833+
await tester.pump();
4834+
await hoverOver(tester, find.text(TestMenu.subSubMenu110.label));
4835+
await tester.pump();
4836+
expect(find.byIcon(hoveredIcon), findsOneWidget);
4837+
4838+
controller.close();
4839+
await tester.pump();
4840+
4841+
await tester.pumpWidget(buildMenu(icon: submenuIcon, enabled: false));
4842+
await tester.tap(find.text(TestMenu.subMenu00.label));
4843+
await tester.pump();
4844+
expect(find.byIcon(disabledIcon), findsOneWidget);
4845+
});
47604846
}
47614847

47624848
List<Widget> createTestMenus({

0 commit comments

Comments
 (0)