From 275153c234bb460efd12d5b31726e1c06d8bb591 Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Wed, 11 Dec 2024 21:10:07 +0200 Subject: [PATCH] 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
expand to view the code sample ```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: [ SubmenuButton( menuChildren: [ MenuItemButton( onPressed: () {}, child: const Text('Menu '), ), ], child: const Text('SubmenuButton with default arrow icon'), ), SubmenuButton( submenuIcon: const WidgetStateProperty.fromMap( { WidgetState.disabled: Icon(Icons.close), WidgetState.hovered: Icon(Icons.favorite), WidgetState.focused: Icon(Icons.add), WidgetState.any: Icon(Icons.arrow_forward_ios), }, ), menuChildren: [ 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: [ MenuItemButton( onPressed: () {}, child: const Text('Menu '), ), ], child: const Text('SubmenuButton with network image icon'), ), ], child: const Text('Menu'), ), ], ) ], ), ), ), ); } } ```
### Preview Screenshot 2024-12-11 at 14 04 57 ## 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]. [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 --- .../flutter/lib/src/material/menu_anchor.dart | 34 ++++- .../flutter/lib/src/material/menu_theme.dart | 29 +++- .../test/material/menu_anchor_test.dart | 86 +++++++++++ .../test/material/menu_theme_test.dart | 134 ++++++++++++++++++ 4 files changed, 275 insertions(+), 8 deletions(-) diff --git a/packages/flutter/lib/src/material/menu_anchor.dart b/packages/flutter/lib/src/material/menu_anchor.dart index 568b812f53..b575761954 100644 --- a/packages/flutter/lib/src/material/menu_anchor.dart +++ b/packages/flutter/lib/src/material/menu_anchor.dart @@ -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? submenuIcon; + /// An optional icon to display after the [child]. final Widget? trailingIcon; @@ -1982,6 +1995,17 @@ class _SubmenuButtonState extends State { (Axis.vertical, TextDirection.rtl) => Offset(0, -menuPadding.top), (Axis.vertical, TextDirection.ltr) => Offset(0, -menuPadding.top), }; + final Set states = { + 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 { 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, ), ], ); diff --git a/packages/flutter/lib/src/material/menu_theme.dart b/packages/flutter/lib/src/material/menu_theme.dart index be6ee9ad49..38e62c3642 100644 --- a/packages/flutter/lib/src/material/menu_theme.dart +++ b/packages/flutter/lib/src/material/menu_theme.dart @@ -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? 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('style', style, defaultValue: null)); + properties.add(DiagnosticsProperty>('submenuIcon', submenuIcon, defaultValue: null)); } } diff --git a/packages/flutter/test/material/menu_anchor_test.dart b/packages/flutter/test/material/menu_anchor_test.dart index 83e312ae14..a04ce7ac6e 100644 --- a/packages/flutter/test/material/menu_anchor_test.dart +++ b/packages/flutter/test/material/menu_anchor_test.dart @@ -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 submenuIcon = WidgetStateProperty.resolveWith( + (Set 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? icon, + bool enabled = true, + }) { + return MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: [ + SubmenuButton( + menuChildren: [ + SubmenuButton( + submenuIcon: icon, + menuChildren: enabled + ? [ + MenuItemButton( + child: Text(TestMenu.mainMenu0.label), + ), + ] + : [], + 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 createTestMenus({ diff --git a/packages/flutter/test/material/menu_theme_test.dart b/packages/flutter/test/material/menu_theme_test.dart index 7a0142e1aa..772c81e70f 100644 --- a/packages/flutter/test/material/menu_theme_test.dart +++ b/packages/flutter/test/material/menu_theme_test.dart @@ -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 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 description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, []); + }); + + testWidgets('MenuThemeData debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const MenuThemeData( + style: MenuStyle(backgroundColor: WidgetStatePropertyAll(Color(0xfffffff1))), + submenuIcon: WidgetStatePropertyAll(Icon(Icons.add)), + ).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, equalsIgnoringHashCodes([ + '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.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 submenuIcon = WidgetStateProperty.resolveWith( + (Set 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? icon, + bool enabled = true, + }) { + return MaterialApp( + theme: ThemeData(menuTheme: MenuThemeData(submenuIcon: icon)), + home: Material( + child: MenuBar( + controller: controller, + children: [ + SubmenuButton( + menuChildren: [ + SubmenuButton( + menuChildren: enabled + ? [ + MenuItemButton( + child: Text(TestMenu.mainMenu0.label), + ), + ] + : [], + 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 createTestMenus({