Add submenuIcon property to override the default SubmenuButton arrow icon (#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 <gspencergoog@users.noreply.github.com>
This commit is contained in:
Taha Tesser 2024-12-11 21:10:07 +02:00 committed by GitHub
parent a3eb526ce4
commit 275153c234
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 275 additions and 8 deletions

View File

@ -1692,6 +1692,7 @@ class SubmenuButton extends StatefulWidget {
this.statesController,
this.leadingIcon,
this.trailingIcon,
this.submenuIcon,
required this.menuChildren,
required this.child,
});
@ -1756,6 +1757,18 @@ class SubmenuButton extends StatefulWidget {
/// An optional icon to display before the [child].
final Widget? leadingIcon;
/// If provided, the widget replaces the default [SubmenuButton] arrow icon.
///
/// Resolves in the following states:
/// * [WidgetState.disabled].
/// * [WidgetState.hovered].
/// * [WidgetState.focused].
///
/// If this is null, then the value of [MenuThemeData.submenuIcon] is used.
/// If that is also null, then defaults to a right arrow icon with the size
/// of 24 pixels.
final MaterialStateProperty<Widget?>? submenuIcon;
/// An optional icon to display after the [child].
final Widget? trailingIcon;
@ -1982,6 +1995,17 @@ class _SubmenuButtonState extends State<SubmenuButton> {
(Axis.vertical, TextDirection.rtl) => Offset(0, -menuPadding.top),
(Axis.vertical, TextDirection.ltr) => Offset(0, -menuPadding.top),
};
final Set<MaterialState> states = <MaterialState>{
if (!_enabled) MaterialState.disabled,
if (_isHovered) MaterialState.hovered,
if (_buttonFocusNode.hasFocus) MaterialState.focused,
};
final Widget submenuIcon = widget.submenuIcon?.resolve(states)
?? MenuTheme.of(context).submenuIcon?.resolve(states)
?? const Icon(
Icons.arrow_right, // Automatically switches with text direction.
size: _kDefaultSubmenuIconSize,
);
return Actions(
actions: actions,
@ -2055,6 +2079,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
trailingIcon: widget.trailingIcon,
hasSubmenu: true,
showDecoration: (controller._anchor!._parent?._orientation ?? Axis.horizontal) == Axis.vertical,
submenuIcon: submenuIcon,
child: child,
),
),
@ -3059,6 +3084,7 @@ class _MenuItemLabel extends StatelessWidget {
this.shortcut,
this.semanticsLabel,
this.overflowAxis = Axis.vertical,
this.submenuIcon,
this.child,
});
@ -3089,6 +3115,9 @@ class _MenuItemLabel extends StatelessWidget {
/// The direction in which the menu item expands.
final Axis overflowAxis;
/// The submenu icon that is displayed when [showDecoration] and [hasSubmenu] are true.
final Widget? submenuIcon;
/// An optional child widget that is displayed in the label.
final Widget? child;
@ -3156,10 +3185,7 @@ class _MenuItemLabel extends StatelessWidget {
if (showDecoration && hasSubmenu)
Padding(
padding: EdgeInsetsDirectional.only(start: horizontalPadding),
child: const Icon(
Icons.arrow_right, // Automatically switches with text direction.
size: _kDefaultSubmenuIconSize,
),
child: submenuIcon,
),
],
);

View File

@ -8,6 +8,7 @@ library;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'material_state.dart';
import 'menu_anchor.dart';
import 'menu_style.dart';
import 'theme.dart';
@ -36,7 +37,10 @@ import 'theme.dart';
@immutable
class MenuThemeData with Diagnosticable {
/// Creates a const set of properties used to configure [MenuTheme].
const MenuThemeData({this.style});
const MenuThemeData({
this.style,
this.submenuIcon,
});
/// The [MenuStyle] of a [SubmenuButton] menu.
///
@ -44,16 +48,30 @@ class MenuThemeData with Diagnosticable {
/// property.
final MenuStyle? style;
/// If provided, the widget replaces the default [SubmenuButton] arrow icon.
///
/// Resolves in the following states:
/// * [WidgetState.disabled].
/// * [WidgetState.hovered].
/// * [WidgetState.focused].
final MaterialStateProperty<Widget?>? submenuIcon;
/// Linearly interpolate between two menu button themes.
static MenuThemeData? lerp(MenuThemeData? a, MenuThemeData? b, double t) {
if (identical(a, b)) {
return a;
}
return MenuThemeData(style: MenuStyle.lerp(a?.style, b?.style, t));
return MenuThemeData(
style: MenuStyle.lerp(a?.style, b?.style, t),
submenuIcon: t < 0.5 ? a?.submenuIcon : b?.submenuIcon,
);
}
@override
int get hashCode => style.hashCode;
int get hashCode => Object.hash(
style,
submenuIcon,
);
@override
bool operator ==(Object other) {
@ -63,13 +81,16 @@ class MenuThemeData with Diagnosticable {
if (other.runtimeType != runtimeType) {
return false;
}
return other is MenuThemeData && other.style == style;
return other is MenuThemeData
&& other.style == style
&& other.submenuIcon == submenuIcon;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<MenuStyle>('style', style, defaultValue: null));
properties.add(DiagnosticsProperty<MaterialStateProperty<Widget?>>('submenuIcon', submenuIcon, defaultValue: null));
}
}

View File

@ -4757,6 +4757,92 @@ void main() {
expect(openCount, 1);
expect(closeCount, 1);
});
testWidgets('SubmenuButton.submenuIcon updates default arrow icon', (WidgetTester tester) async {
const IconData disabledIcon = Icons.close;
const IconData hoveredIcon = Icons.bolt;
const IconData focusedIcon = Icons.favorite;
const IconData defaultIcon = Icons.add;
final WidgetStateProperty<Widget?> submenuIcon = WidgetStateProperty.resolveWith<Widget?>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.disabled)) {
return const Icon(disabledIcon);
}
if (states.contains(WidgetState.hovered)) {
return const Icon(hoveredIcon);
}
if (states.contains(WidgetState.focused)) {
return const Icon(focusedIcon);
}
return const Icon(defaultIcon);
});
Widget buildMenu({
WidgetStateProperty<Widget?>? icon,
bool enabled = true,
}) {
return MaterialApp(
home: Material(
child: MenuBar(
controller: controller,
children: <Widget>[
SubmenuButton(
menuChildren: <Widget>[
SubmenuButton(
submenuIcon: icon,
menuChildren: enabled
? <Widget>[
MenuItemButton(
child: Text(TestMenu.mainMenu0.label),
),
]
: <Widget>[],
child: Text(TestMenu.subSubMenu110.label),
),
],
child: Text(TestMenu.subMenu00.label),
),
],
),
),
);
}
await tester.pumpWidget(buildMenu());
await tester.tap(find.text(TestMenu.subMenu00.label));
await tester.pump();
expect(find.byIcon(Icons.arrow_right), findsOneWidget);
controller.close();
await tester.pump();
await tester.pumpWidget(buildMenu(icon: submenuIcon));
await tester.tap(find.text(TestMenu.subMenu00.label));
await tester.pump();
expect(find.byIcon(defaultIcon), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(find.byIcon(focusedIcon), findsOneWidget);
controller.close();
await tester.pump();
await tester.tap(find.text(TestMenu.subMenu00.label));
await tester.pump();
await hoverOver(tester, find.text(TestMenu.subSubMenu110.label));
await tester.pump();
expect(find.byIcon(hoveredIcon), findsOneWidget);
controller.close();
await tester.pump();
await tester.pumpWidget(buildMenu(icon: submenuIcon, enabled: false));
await tester.tap(find.text(TestMenu.subMenu00.label));
await tester.pump();
expect(find.byIcon(disabledIcon), findsOneWidget);
});
}
List<Widget> createTestMenus({

View File

@ -2,7 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@ -46,6 +48,49 @@ void main() {
);
}
Future<TestGesture> hoverOver(WidgetTester tester, Finder finder) async {
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.moveTo(tester.getCenter(finder));
await tester.pumpAndSettle();
return gesture;
}
test('MenuThemeData defaults', () {
const MenuThemeData menuThemeData = MenuThemeData();
expect(menuThemeData.style, isNull);
expect(menuThemeData.submenuIcon, isNull);
});
testWidgets('Default MenuThemeData debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
const MenuThemeData().debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description, <String>[]);
});
testWidgets('MenuThemeData debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
const MenuThemeData(
style: MenuStyle(backgroundColor: WidgetStatePropertyAll<Color?>(Color(0xfffffff1))),
submenuIcon: WidgetStatePropertyAll<Widget?>(Icon(Icons.add)),
).debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description, equalsIgnoringHashCodes(<String>[
'style: MenuStyle#c6d29(backgroundColor: WidgetStatePropertyAll(Color(alpha: 1.0000, red: 1.0000, green: 1.0000, blue: 0.9451, colorSpace: ColorSpace.sRGB)))',
'submenuIcon: WidgetStatePropertyAll(Icon(IconData(U+0E047)))'
]));
});
test('MenuThemeData lerp special cases', () {
expect(MenuThemeData.lerp(null, null, 0), null);
const MenuThemeData data = MenuThemeData();
@ -198,6 +243,95 @@ void main() {
.style;
expect(textButtonStyle?.overlayColor?.resolve(<MaterialState>{MaterialState.hovered}), equals(Colors.blueGrey));
});
testWidgets('SubmenuButton.submenuIcon updates default arrow icon', (WidgetTester tester) async {
final MenuController controller = MenuController();
const IconData disabledIcon = Icons.close_fullscreen;
const IconData hoveredIcon = Icons.ac_unit;
const IconData focusedIcon = Icons.zoom_out;
const IconData defaultIcon = Icons.minimize;
final WidgetStateProperty<Widget?> submenuIcon = WidgetStateProperty.resolveWith<Widget?>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.disabled)) {
return const Icon(disabledIcon);
}
if (states.contains(WidgetState.hovered)) {
return const Icon(hoveredIcon);
}
if (states.contains(WidgetState.focused)) {
return const Icon(focusedIcon);
}
return const Icon(defaultIcon);
});
Widget buildMenu({
WidgetStateProperty<Widget?>? icon,
bool enabled = true,
}) {
return MaterialApp(
theme: ThemeData(menuTheme: MenuThemeData(submenuIcon: icon)),
home: Material(
child: MenuBar(
controller: controller,
children: <Widget>[
SubmenuButton(
menuChildren: <Widget>[
SubmenuButton(
menuChildren: enabled
? <Widget>[
MenuItemButton(
child: Text(TestMenu.mainMenu0.label),
),
]
: <Widget>[],
child: Text(TestMenu.subSubMenu110.label),
),
],
child: Text(TestMenu.subMenu00.label),
),
],
),
),
);
}
await tester.pumpWidget(buildMenu());
await tester.tap(find.text(TestMenu.subMenu00.label));
await tester.pump();
expect(find.byIcon(Icons.arrow_right), findsOneWidget);
controller.close();
await tester.pump();
await tester.pumpWidget(buildMenu(icon: submenuIcon));
await tester.pumpAndSettle();
await tester.tap(find.text(TestMenu.subMenu00.label));
await tester.pump();
expect(find.byIcon(defaultIcon), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(find.byIcon(focusedIcon), findsOneWidget);
controller.close();
await tester.pump();
await tester.tap(find.text(TestMenu.subMenu00.label));
await tester.pump();
await hoverOver(tester, find.text(TestMenu.subSubMenu110.label));
await tester.pump();
expect(find.byIcon(hoveredIcon), findsOneWidget);
controller.close();
await tester.pump();
await tester.pumpWidget(buildMenu(icon: submenuIcon, enabled: false));
await tester.tap(find.text(TestMenu.subMenu00.label));
await tester.pump();
expect(find.byIcon(disabledIcon), findsOneWidget);
});
}
List<Widget> createTestMenus({