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:
parent
a3eb526ce4
commit
275153c234
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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({
|
||||
|
@ -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({
|
||||
|
Loading…
x
Reference in New Issue
Block a user