diff --git a/dev/manual_tests/lib/menu_anchor.dart b/dev/manual_tests/lib/menu_anchor.dart index 5d0c8decf4..ff297e737d 100644 --- a/dev/manual_tests/lib/menu_anchor.dart +++ b/dev/manual_tests/lib/menu_anchor.dart @@ -211,10 +211,7 @@ class _ControlsState extends State<_Controls> { alignmentOffset: const Offset(100, -8), menuChildren: [ MenuItemButton( - shortcut: const SingleActivator( - LogicalKeyboardKey.keyB, - control: true, - ), + shortcut: TestMenu.standaloneMenu1.shortcut, onPressed: () { _itemSelected(TestMenu.standaloneMenu1); }, @@ -426,6 +423,9 @@ class _TestMenus extends StatefulWidget { class _TestMenusState extends State<_TestMenus> { final TextEditingController textController = TextEditingController(); + bool? checkboxState = false; + TestMenu? radioValue; + ShortcutRegistryEntry? _shortcutsEntry; void _itemSelected(TestMenu item) { debugPrint('App: Selected item ${item.label}'); @@ -439,6 +439,79 @@ class _TestMenusState extends State<_TestMenus> { debugPrint('App: Closed item ${item.label}'); } + void _setRadio(TestMenu item) { + debugPrint('App: Set Radio item ${item.label}'); + setState(() { + radioValue = item; + }); + } + + void _setCheck(TestMenu item) { + debugPrint('App: Set Checkbox item ${item.label}'); + setState(() { + switch (checkboxState) { + case false: + checkboxState = true; + break; + case true: + checkboxState = null; + break; + case null: + checkboxState = false; + break; + } + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _shortcutsEntry?.dispose(); + final Map shortcuts = {}; + for (final TestMenu item in TestMenu.values) { + if (item.shortcut == null) { + continue; + } + switch (item) { + case TestMenu.radioMenu1: + case TestMenu.radioMenu2: + case TestMenu.radioMenu3: + shortcuts[item.shortcut!] = VoidCallbackIntent(() => _setRadio(item)); + break; + case TestMenu.subMenu1: + shortcuts[item.shortcut!] = VoidCallbackIntent(() => _setCheck(item)); + break; + case TestMenu.mainMenu1: + case TestMenu.mainMenu2: + case TestMenu.mainMenu3: + case TestMenu.mainMenu4: + case TestMenu.subMenu2: + case TestMenu.subMenu3: + case TestMenu.subMenu4: + case TestMenu.subMenu5: + case TestMenu.subMenu6: + case TestMenu.subMenu7: + case TestMenu.subMenu8: + case TestMenu.subSubMenu1: + case TestMenu.subSubMenu2: + case TestMenu.subSubMenu3: + case TestMenu.subSubSubMenu1: + case TestMenu.testButton: + case TestMenu.standaloneMenu1: + case TestMenu.standaloneMenu2: + shortcuts[item.shortcut!] = VoidCallbackIntent(() => _itemSelected(item)); + break; + } + } + _shortcutsEntry = ShortcutRegistry.of(context).addAll(shortcuts); + } + + @override + void dispose() { + _shortcutsEntry?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Row( @@ -455,19 +528,61 @@ class _TestMenusState extends State<_TestMenus> { _closeItem(TestMenu.mainMenu1); }, menuChildren: [ - MenuItemButton( - shortcut: const SingleActivator( - LogicalKeyboardKey.keyB, - control: true, - ), - leadingIcon: - widget.addItem ? const Icon(Icons.check_box) : const Icon(Icons.check_box_outline_blank), + CheckboxMenuButton( + value: checkboxState, + tristate: true, + shortcut: TestMenu.subMenu1.shortcut, trailingIcon: const Icon(Icons.assessment), - onPressed: () { + onChanged: (bool? value) { + setState(() { + checkboxState = value; + }); _itemSelected(TestMenu.subMenu1); }, child: Text(TestMenu.subMenu1.label), ), + RadioMenuButton( + value: TestMenu.radioMenu1, + groupValue: radioValue, + toggleable: true, + shortcut: TestMenu.radioMenu1.shortcut, + trailingIcon: const Icon(Icons.assessment), + onChanged: (TestMenu? value) { + setState(() { + radioValue = value; + }); + _itemSelected(TestMenu.radioMenu1); + }, + child: Text(TestMenu.radioMenu1.label), + ), + RadioMenuButton( + value: TestMenu.radioMenu2, + groupValue: radioValue, + toggleable: true, + shortcut: TestMenu.radioMenu2.shortcut, + trailingIcon: const Icon(Icons.assessment), + onChanged: (TestMenu? value) { + setState(() { + radioValue = value; + }); + _itemSelected(TestMenu.radioMenu2); + }, + child: Text(TestMenu.radioMenu2.label), + ), + RadioMenuButton( + value: TestMenu.radioMenu3, + groupValue: radioValue, + toggleable: true, + shortcut: TestMenu.radioMenu3.shortcut, + trailingIcon: const Icon(Icons.assessment), + onChanged: (TestMenu? value) { + setState(() { + radioValue = value; + }); + _itemSelected(TestMenu.radioMenu3); + }, + child: Text(TestMenu.radioMenu3.label), + ), MenuItemButton( leadingIcon: const Icon(Icons.send), trailingIcon: const Icon(Icons.mail), @@ -495,10 +610,7 @@ class _TestMenusState extends State<_TestMenus> { }, ), MenuItemButton( - shortcut: const SingleActivator( - LogicalKeyboardKey.enter, - control: true, - ), + shortcut: TestMenu.subMenu3.shortcut, onPressed: () { _itemSelected(TestMenu.subMenu3); }, @@ -542,10 +654,6 @@ class _TestMenusState extends State<_TestMenus> { ) }, child: MenuItemButton( - shortcut: const SingleActivator( - LogicalKeyboardKey.keyA, - control: true, - ), onPressed: () { debugPrint('Activated text input item with ${textController.text} as a value.'); }, @@ -569,15 +677,7 @@ class _TestMenusState extends State<_TestMenus> { }, menuChildren: [ MenuItemButton( - shortcut: widget.addItem - ? const SingleActivator( - LogicalKeyboardKey.f11, - control: true, - ) - : const SingleActivator( - LogicalKeyboardKey.f10, - control: true, - ), + shortcut: TestMenu.subSubMenu1.shortcut, onPressed: () { _itemSelected(TestMenu.subSubMenu1); }, @@ -593,10 +693,11 @@ class _TestMenusState extends State<_TestMenus> { SubmenuButton( menuChildren: [ MenuItemButton( - child: Text(TestMenu.subSubSubMenu1.label), + shortcut: TestMenu.subSubSubMenu1.shortcut, onPressed: () { _itemSelected(TestMenu.subSubSubMenu1); }, + child: Text(TestMenu.subSubSubMenu1.label), ), ], child: Text(TestMenu.subSubMenu3.label), @@ -606,10 +707,7 @@ class _TestMenusState extends State<_TestMenus> { ), MenuItemButton( // Disabled button - shortcut: const SingleActivator( - LogicalKeyboardKey.tab, - control: true, - ), + shortcut: TestMenu.subMenu6.shortcut, child: Text(TestMenu.subMenu6.label), ), MenuItemButton( @@ -646,22 +744,26 @@ enum TestMenu { mainMenu2('Menu 2'), mainMenu3('Menu 3'), mainMenu4('Menu 4'), - subMenu1('Sub Menu 1'), + radioMenu1('Radio Menu One', SingleActivator(LogicalKeyboardKey.digit1, control: true)), + radioMenu2('Radio Menu Two', SingleActivator(LogicalKeyboardKey.digit2, control: true)), + radioMenu3('Radio Menu Three', SingleActivator(LogicalKeyboardKey.digit3, control: true)), + subMenu1('Sub Menu 1', SingleActivator(LogicalKeyboardKey.keyB, control: true)), subMenu2('Sub Menu 2'), - subMenu3('Sub Menu 3'), + subMenu3('Sub Menu 3', SingleActivator(LogicalKeyboardKey.enter, control: true)), subMenu4('Sub Menu 4'), subMenu5('Sub Menu 5'), - subMenu6('Sub Menu 6'), + subMenu6('Sub Menu 6', SingleActivator(LogicalKeyboardKey.tab, control: true)), subMenu7('Sub Menu 7'), subMenu8('Sub Menu 8'), - subSubMenu1('Sub Sub Menu 1'), + subSubMenu1('Sub Sub Menu 1', SingleActivator(LogicalKeyboardKey.f10, control: true)), subSubMenu2('Sub Sub Menu 2'), subSubMenu3('Sub Sub Menu 3'), - subSubSubMenu1('Sub Sub Sub Menu 1'), + subSubSubMenu1('Sub Sub Sub Menu 1', SingleActivator(LogicalKeyboardKey.f11, control: true)), testButton('TEST button'), - standaloneMenu1('Standalone Menu 1'), + standaloneMenu1('Standalone Menu 1', SingleActivator(LogicalKeyboardKey.keyC, control: true)), standaloneMenu2('Standalone Menu 2'); - const TestMenu(this.label); + const TestMenu(this.label, [this.shortcut]); final String label; + final MenuSerializableShortcut? shortcut; } diff --git a/examples/api/lib/material/menu_anchor/checkbox_menu_button.0.dart b/examples/api/lib/material/menu_anchor/checkbox_menu_button.0.dart new file mode 100644 index 0000000000..1b51066b16 --- /dev/null +++ b/examples/api/lib/material/menu_anchor/checkbox_menu_button.0.dart @@ -0,0 +1,108 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Flutter code sample for [CheckboxMenuButton]. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const MenuApp()); + +class MyCheckboxMenu extends StatefulWidget { + const MyCheckboxMenu({super.key, required this.message}); + + final String message; + + @override + State createState() => _MyCheckboxMenuState(); +} + +class _MyCheckboxMenuState extends State { + final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button'); + static const SingleActivator _showShortcut = SingleActivator(LogicalKeyboardKey.keyS, control: true); + bool _showingMessage = false; + + @override + void dispose() { + _buttonFocusNode.dispose(); + super.dispose(); + } + + void _setMessageVisibility(bool visible) { + setState(() { + _showingMessage = visible; + }); + } + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + _showShortcut: () { + _setMessageVisibility(!_showingMessage); + }, + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MenuAnchor( + childFocusNode: _buttonFocusNode, + menuChildren: [ + CheckboxMenuButton( + value: _showingMessage, + onChanged: (bool? value) { + _setMessageVisibility(value!); + }, + child: const Text('Show Message'), + ), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return TextButton( + focusNode: _buttonFocusNode, + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('OPEN MENU'), + ); + }, + ), + Expanded( + child: Container( + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + _showingMessage ? widget.message : '', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class MenuApp extends StatelessWidget { + const MenuApp({super.key}); + + static const String kMessage = '"Talk less. Smile more." - A. Burr'; + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold(body: MyCheckboxMenu(message: kMessage)), + ); + } +} diff --git a/examples/api/lib/material/menu_anchor/radio_menu_button.0.dart b/examples/api/lib/material/menu_anchor/radio_menu_button.0.dart new file mode 100644 index 0000000000..4fe54e6c9e --- /dev/null +++ b/examples/api/lib/material/menu_anchor/radio_menu_button.0.dart @@ -0,0 +1,114 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Flutter code sample for [RadioMenuButton]. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const MenuApp()); + +class MyRadioMenu extends StatefulWidget { + const MyRadioMenu({super.key}); + + @override + State createState() => _MyRadioMenuState(); +} + +class _MyRadioMenuState extends State { + final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button'); + Color _backgroundColor = Colors.red; + late ShortcutRegistryEntry _entry; + + static const SingleActivator _redShortcut = SingleActivator(LogicalKeyboardKey.keyR, control: true); + static const SingleActivator _greenShortcut = SingleActivator(LogicalKeyboardKey.keyG, control: true); + static const SingleActivator _blueShortcut = SingleActivator(LogicalKeyboardKey.keyB, control: true); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _entry = ShortcutRegistry.of(context).addAll({ + _redShortcut: VoidCallbackIntent(() => _setBackgroundColor(Colors.red)), + _greenShortcut: VoidCallbackIntent(() => _setBackgroundColor(Colors.green)), + _blueShortcut: VoidCallbackIntent(() => _setBackgroundColor(Colors.blue)), + }); + } + + @override + void dispose() { + _buttonFocusNode.dispose(); + _entry.dispose(); + super.dispose(); + } + + void _setBackgroundColor(Color? color) { + setState(() { + _backgroundColor = color!; + }); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MenuAnchor( + childFocusNode: _buttonFocusNode, + menuChildren: [ + RadioMenuButton( + value: Colors.red, + shortcut: _redShortcut, + groupValue: _backgroundColor, + onChanged: _setBackgroundColor, + child: const Text('Red Background'), + ), + RadioMenuButton( + value: Colors.green, + shortcut: _greenShortcut, + groupValue: _backgroundColor, + onChanged: _setBackgroundColor, + child: const Text('Green Background'), + ), + RadioMenuButton( + value: Colors.blue, + shortcut: _blueShortcut, + groupValue: _backgroundColor, + onChanged: _setBackgroundColor, + child: const Text('Blue Background'), + ), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return TextButton( + focusNode: _buttonFocusNode, + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('OPEN MENU'), + ); + }, + ), + Expanded( + child: Container( + color: _backgroundColor, + ), + ), + ], + ); + } +} + +class MenuApp extends StatelessWidget { + const MenuApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold(body: MyRadioMenu()), + ); + } +} diff --git a/examples/api/test/material/menu_anchor/checkbox_menu_button.0_test.dart b/examples/api/test/material/menu_anchor/checkbox_menu_button.0_test.dart new file mode 100644 index 0000000000..278d19af9c --- /dev/null +++ b/examples/api/test/material/menu_anchor/checkbox_menu_button.0_test.dart @@ -0,0 +1,27 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/menu_anchor/checkbox_menu_button.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open menu and show message', (WidgetTester tester) async { + await tester.pumpWidget( + const example.MenuApp(), + ); + + await tester.tap(find.byType(TextButton)); + await tester.pump(); + + expect(find.text('Show Message'), findsOneWidget); + expect(find.text(example.MenuApp.kMessage), findsNothing); + + await tester.tap(find.text('Show Message')); + await tester.pump(); + + expect(find.text('Show Message'), findsNothing); + expect(find.text(example.MenuApp.kMessage), findsOneWidget); + }); +} diff --git a/examples/api/test/material/menu_anchor/radio_menu_button.0_test.dart b/examples/api/test/material/menu_anchor/radio_menu_button.0_test.dart new file mode 100644 index 0000000000..ee33556142 --- /dev/null +++ b/examples/api/test/material/menu_anchor/radio_menu_button.0_test.dart @@ -0,0 +1,77 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_api_samples/material/menu_anchor/radio_menu_button.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open menu', (WidgetTester tester) async { + await tester.pumpWidget( + const example.MenuApp(), + ); + + await tester.tap(find.byType(TextButton)); + await tester.pump(); + await tester.pump(); + + expect(find.text('Red Background'), findsOneWidget); + expect(find.text('Green Background'), findsOneWidget); + expect(find.text('Blue Background'), findsOneWidget); + expect(find.byType(Radio), findsNWidgets(3)); + expect(tester.widget(find.byType(Container)).color, equals(Colors.red)); + + await tester.tap(find.text('Green Background')); + await tester.pump(); + + expect(tester.widget(find.byType(Container)).color, equals(Colors.green)); + }); + + testWidgets('Shortcuts work', (WidgetTester tester) async { + await tester.pumpWidget( + const example.MenuApp(), + ); + + // Open the menu so we can watch state changes resulting from the shortcuts + // firing. + await tester.tap(find.byType(TextButton)); + await tester.pump(); + + expect(find.text('Red Background'), findsOneWidget); + expect(find.text('Green Background'), findsOneWidget); + expect(find.text('Blue Background'), findsOneWidget); + expect(find.byType(Radio), findsNWidgets(3)); + expect(tester.widget(find.byType(Container)).color, equals(Colors.red)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + // Need to pump twice because of the one frame delay in the notification to + // update the overlay entry. + await tester.pump(); + + expect(tester.widget>(find.descendant(of: find.byType(RadioMenuButton).at(0), matching: find.byType(Radio))).groupValue, equals(Colors.green)); + expect(tester.widget(find.byType(Container)).color, equals(Colors.green)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyR); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await tester.pump(); + + expect(tester.widget>(find.descendant(of: find.byType(RadioMenuButton).at(1), matching: find.byType(Radio))).groupValue, equals(Colors.red)); + expect(tester.widget(find.byType(Container)).color, equals(Colors.red)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await tester.pump(); + + expect(tester.widget>(find.descendant(of: find.byType(RadioMenuButton).at(2), matching: find.byType(Radio))).groupValue, equals(Colors.blue)); + expect(tester.widget(find.byType(Container)).color, equals(Colors.blue)); + }); +} diff --git a/packages/flutter/lib/src/material/checkbox.dart b/packages/flutter/lib/src/material/checkbox.dart index e8d3280c48..5ebb6ad21c 100644 --- a/packages/flutter/lib/src/material/checkbox.dart +++ b/packages/flutter/lib/src/material/checkbox.dart @@ -208,7 +208,7 @@ class Checkbox extends StatefulWidget { /// If true the checkbox's [value] can be true, false, or null. /// - /// Checkbox displays a dash when its value is null. + /// [Checkbox] displays a dash when its value is null. /// /// When a tri-state checkbox ([tristate] is true) is tapped, its [onChanged] /// callback will be applied to true if the current value is false, to null if diff --git a/packages/flutter/lib/src/material/menu_anchor.dart b/packages/flutter/lib/src/material/menu_anchor.dart index 31671179e7..15ffe1611e 100644 --- a/packages/flutter/lib/src/material/menu_anchor.dart +++ b/packages/flutter/lib/src/material/menu_anchor.dart @@ -12,6 +12,7 @@ import 'package:flutter/widgets.dart'; import 'button_style.dart'; import 'button_style_button.dart'; +import 'checkbox.dart'; import 'color_scheme.dart'; import 'colors.dart'; import 'constants.dart'; @@ -24,10 +25,18 @@ import 'menu_bar_theme.dart'; import 'menu_button_theme.dart'; import 'menu_style.dart'; import 'menu_theme.dart'; +import 'radio.dart'; import 'text_button.dart'; import 'theme.dart'; import 'theme_data.dart'; +// Examples can assume: +// bool _throwShotAway = false; +// late BuildContext context; +// enum SingingCharacter { lafayette } +// late SingingCharacter? _character; +// late StateSetter setState; + // Enable if you want verbose logging about menu changes. const bool _kDebugMenus = false; @@ -1069,6 +1078,393 @@ class _MenuItemButtonState extends State { } } +/// A menu item that combines a [Checkbox] widget with a [MenuItemButton]. +/// +/// To style the checkbox separately from the button, add a [CheckboxTheme] +/// ancestor. +/// +/// {@tool dartpad} +/// This example shows a menu with a checkbox that shows a message in the body +/// of the app if checked. +/// +/// ** See code in examples/api/lib/material/menu_anchor/checkbox_menu_button.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// - [MenuBar], a widget that creates a menu bar of cascading menu items. +/// - [MenuAnchor], a widget that defines a region which can host a cascading +/// menu. +class CheckboxMenuButton extends StatelessWidget { + /// Creates a const [CheckboxMenuButton]. + /// + /// The [child], [value], and [onChanged] attributes are required. + const CheckboxMenuButton({ + super.key, + required this.value, + this.tristate = false, + this.isError = false, + required this.onChanged, + this.onHover, + this.onFocusChange, + this.focusNode, + this.shortcut, + this.style, + this.statesController, + this.clipBehavior = Clip.none, + this.trailingIcon, + required this.child, + }); + + /// Whether this checkbox is checked. + /// + /// When [tristate] is true, a value of null corresponds to the mixed state. + /// When [tristate] is false, this value must not be null. + final bool? value; + + /// If true, then the checkbox's [value] can be true, false, or null. + /// + /// [CheckboxMenuButton] displays a dash when its value is null. + /// + /// When a tri-state checkbox ([tristate] is true) is tapped, its [onChanged] + /// callback will be applied to true if the current value is false, to null if + /// value is true, and to false if value is null (i.e. it cycles through false + /// => true => null => false when tapped). + /// + /// If tristate is false (the default), [value] must not be null. + final bool tristate; + + /// True if this checkbox wants to show an error state. + /// + /// The checkbox will have different default container color and check color when + /// this is true. This is only used when [ThemeData.useMaterial3] is set to true. + /// + /// Must not be null. Defaults to false. + final bool isError; + + /// Called when the value of the checkbox should change. + /// + /// The checkbox passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the checkbox with the new + /// value. + /// + /// If this callback is null, the menu item will be displayed as disabled + /// and will not respond to input gestures. + /// + /// When the checkbox is tapped, if [tristate] is false (the default) then the + /// [onChanged] callback will be applied to `!value`. If [tristate] is true + /// this callback cycle from false to true to null and then back to false + /// again. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// CheckboxMenuButton( + /// value: _throwShotAway, + /// child: const Text('THROW'), + /// onChanged: (bool? newValue) { + /// setState(() { + /// _throwShotAway = newValue!; + /// }); + /// }, + /// ) + /// ``` + final ValueChanged? onChanged; + + /// Called when a pointer enters or exits the button response area. + /// + /// The value passed to the callback is true if a pointer has entered button + /// area and false if a pointer has exited. + final ValueChanged? onHover; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + final ValueChanged? onFocusChange; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// The optional shortcut that selects this [MenuItemButton]. + /// + /// {@macro flutter.material.menu_bar.shortcuts_note} + final MenuSerializableShortcut? shortcut; + + /// Customizes this button's appearance. + /// + /// Non-null properties of this style override the corresponding properties in + /// [MenuItemButton.themeStyleOf] and [MenuItemButton.defaultStyleOf]. + /// [MaterialStateProperty]s that resolve to non-null values will similarly + /// override the corresponding [MaterialStateProperty]s in + /// [MenuItemButton.themeStyleOf] and [MenuItemButton.defaultStyleOf]. + /// + /// Null by default. + final ButtonStyle? style; + + /// {@macro flutter.material.inkwell.statesController} + final MaterialStatesController? statesController; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// An optional icon to display after the [child] label. + final Widget? trailingIcon; + + /// The widget displayed in the center of this button. + /// + /// Typically this is the button's label, using a [Text] widget. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Whether the button is enabled or disabled. + /// + /// To enable a button, set its [onChanged] property to a non-null value. + bool get enabled => onChanged != null; + + @override + Widget build(BuildContext context) { + return MenuItemButton( + key: key, + onPressed: onChanged == null ? null : () { + switch (value) { + case false: + onChanged!.call(true); + break; + case true: + onChanged!.call(tristate ? null : false); + break; + case null: + onChanged!.call(false); + break; + } + }, + onHover: onHover, + onFocusChange: onFocusChange, + focusNode: focusNode, + style: style, + shortcut: shortcut, + statesController: statesController, + leadingIcon: ExcludeFocus( + child: IgnorePointer( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: Checkbox.width, + maxWidth: Checkbox.width, + ), + child: Checkbox( + tristate: tristate, + value: value, + onChanged: onChanged, + isError: isError, + ), + ), + ), + ), + clipBehavior: clipBehavior, + trailingIcon: trailingIcon, + child: child, + ); + } +} + +/// A menu item that combines a [Radio] widget with a [MenuItemButton]. +/// +/// To style the radio button separately from the overall button, add a +/// [RadioTheme] ancestor. +/// +/// {@tool dartpad} +/// This example shows a menu with three radio buttons with shortcuts that +/// changes the background color of the body when the buttons are selected. +/// +/// ** See code in examples/api/lib/material/menu_anchor/radio_menu_button.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// - [MenuBar], a widget that creates a menu bar of cascading menu items. +/// - [MenuAnchor], a widget that defines a region which can host a cascading +/// menu. +class RadioMenuButton extends StatelessWidget { + /// Creates a const [RadioMenuButton]. + /// + /// The [child] attribute is required. + const RadioMenuButton({ + super.key, + required this.value, + required this.groupValue, + required this.onChanged, + this.toggleable = false, + this.onHover, + this.onFocusChange, + this.focusNode, + this.shortcut, + this.style, + this.statesController, + this.clipBehavior = Clip.none, + this.trailingIcon, + required this.child, + }); + + /// The value represented by this radio button. + /// + /// This radio button is considered selected if its [value] matches the + /// [groupValue]. + final T value; + + /// The currently selected value for a group of radio buttons. + /// + /// This radio button is considered selected if its [value] matches the + /// [groupValue]. + final T? groupValue; + + /// Set to true if this radio button is allowed to be returned to an + /// indeterminate state by selecting it again when selected. + /// + /// To indicate returning to an indeterminate state, [onChanged] will be + /// called with null. + /// + /// If true, [onChanged] can be called with [value] when selected while + /// [groupValue] != [value], or with null when selected again while + /// [groupValue] == [value]. + /// + /// If false, [onChanged] will be called with [value] when it is selected + /// while [groupValue] != [value], and only by selecting another radio button + /// in the group (i.e. changing the value of [groupValue]) can this radio + /// button be unselected. + /// + /// The default is false. + final bool toggleable; + + /// Called when the user selects this radio button. + /// + /// The radio button passes [value] as a parameter to this callback. The radio + /// button does not actually change state until the parent widget rebuilds the + /// radio button with the new [groupValue]. + /// + /// If null, the radio button will be displayed as disabled. + /// + /// The provided callback will not be invoked if this radio button is already + /// selected. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// RadioMenuButton( + /// value: SingingCharacter.lafayette, + /// groupValue: _character, + /// onChanged: (SingingCharacter? newValue) { + /// setState(() { + /// _character = newValue; + /// }); + /// }, + /// child: const Text('Lafayette'), + /// ) + /// ``` + final ValueChanged? onChanged; + + /// Called when a pointer enters or exits the button response area. + /// + /// The value passed to the callback is true if a pointer has entered button + /// area and false if a pointer has exited. + final ValueChanged? onHover; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + final ValueChanged? onFocusChange; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// The optional shortcut that selects this [MenuItemButton]. + /// + /// {@macro flutter.material.menu_bar.shortcuts_note} + final MenuSerializableShortcut? shortcut; + + /// Customizes this button's appearance. + /// + /// Non-null properties of this style override the corresponding properties in + /// [MenuItemButton.themeStyleOf] and [MenuItemButton.defaultStyleOf]. + /// [MaterialStateProperty]s that resolve to non-null values will similarly + /// override the corresponding [MaterialStateProperty]s in + /// [MenuItemButton.themeStyleOf] and [MenuItemButton.defaultStyleOf]. + /// + /// Null by default. + final ButtonStyle? style; + + /// {@macro flutter.material.inkwell.statesController} + final MaterialStatesController? statesController; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// An optional icon to display after the [child] label. + final Widget? trailingIcon; + + /// The widget displayed in the center of this button. + /// + /// Typically this is the button's label, using a [Text] widget. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Whether the button is enabled or disabled. + /// + /// To enable a button, set its [onChanged] property to a non-null value. + bool get enabled => onChanged != null; + + @override + Widget build(BuildContext context) { + return MenuItemButton( + key: key, + onPressed: onChanged == null ? null : () { + if (toggleable && groupValue == value) { + onChanged!.call(null); + return; + } + onChanged!.call(value); + }, + onHover: onHover, + onFocusChange: onFocusChange, + focusNode: focusNode, + style: style, + shortcut: shortcut, + statesController: statesController, + leadingIcon: ExcludeFocus( + child: IgnorePointer( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: Checkbox.width, + maxWidth: Checkbox.width, + ), + child: Radio( + value: value, + groupValue: groupValue, + onChanged: onChanged, + toggleable: toggleable, + ), + ), + ), + ), + clipBehavior: clipBehavior, + trailingIcon: trailingIcon, + child: child, + ); + } +} + + /// A menu button that displays a cascading menu. /// /// It can be used as part of a [MenuBar], or as a standalone widget. diff --git a/packages/flutter/test/material/checkbox_list_tile_test.dart b/packages/flutter/test/material/checkbox_list_tile_test.dart index caaef61793..80efd9b460 100644 --- a/packages/flutter/test/material/checkbox_list_tile_test.dart +++ b/packages/flutter/test/material/checkbox_list_tile_test.dart @@ -148,7 +148,7 @@ void main() { final Rect tallerWidget = checkboxRect.height > titleRect.height ? checkboxRect : titleRect; - // Check the offsets of CheckBox and title after padding is applied. + // Check the offsets of Checkbox and title after padding is applied. expect(paddingRect.right, checkboxRect.right + 4); expect(paddingRect.left, titleRect.left - 10); diff --git a/packages/flutter/test/material/checkbox_test.dart b/packages/flutter/test/material/checkbox_test.dart index 7230c0d5eb..9ca7cc3634 100644 --- a/packages/flutter/test/material/checkbox_test.dart +++ b/packages/flutter/test/material/checkbox_test.dart @@ -60,7 +60,7 @@ void main() { expect(tester.getSize(find.byType(Checkbox)), const Size(40.0, 40.0)); }); - testWidgets('CheckBox semantics', (WidgetTester tester) async { + testWidgets('Checkbox semantics', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(Theme( @@ -193,7 +193,7 @@ void main() { handle.dispose(); }); - testWidgets('Can wrap CheckBox with Semantics', (WidgetTester tester) async { + testWidgets('Can wrap Checkbox with Semantics', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(Theme( @@ -222,7 +222,7 @@ void main() { handle.dispose(); }); - testWidgets('CheckBox tristate: true', (WidgetTester tester) async { + testWidgets('Checkbox tristate: true', (WidgetTester tester) async { bool? checkBoxValue; await tester.pumpWidget( @@ -388,7 +388,7 @@ void main() { semanticsTester.dispose(); }); - testWidgets('CheckBox tristate rendering, programmatic transitions', (WidgetTester tester) async { + testWidgets('Checkbox tristate rendering, programmatic transitions', (WidgetTester tester) async { Widget buildFrame(bool? checkboxValue) { return Theme( data: theme, @@ -439,7 +439,7 @@ void main() { expect(getCheckboxRenderer(), paints..line()); // null is rendered as a line (a "dash") }); - testWidgets('CheckBox color rendering', (WidgetTester tester) async { + testWidgets('Checkbox color rendering', (WidgetTester tester) async { const Color borderColor = Color(0xff2196f3); Color checkColor = const Color(0xffFFFFFF); Color activeColor; diff --git a/packages/flutter/test/material/menu_anchor_test.dart b/packages/flutter/test/material/menu_anchor_test.dart index 2b16a89a9e..652e0dbab0 100644 --- a/packages/flutter/test/material/menu_anchor_test.dart +++ b/packages/flutter/test/material/menu_anchor_test.dart @@ -1642,6 +1642,129 @@ void main() { expect(find.text(charExpected), findsOneWidget); }, variant: TargetPlatformVariant.all()); }); + + group('CheckboxMenuButton', () { + testWidgets('tapping toggles checkbox', (WidgetTester tester) async { + bool? checkBoxValue; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MenuBar( + children: [ + SubmenuButton( + menuChildren: [ + CheckboxMenuButton( + value: checkBoxValue, + onChanged: (bool? value) { + setState(() { + checkBoxValue = value; + }); + }, + tristate: true, + child: const Text('checkbox'), + ) + ], + child: const Text('submenu'), + ), + ], + ); + }, + ), + ), + ); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + + expect(tester.widget(find.byType(CheckboxMenuButton)).value, null); + + await tester.tap(find.byType(CheckboxMenuButton)); + await tester.pumpAndSettle(); + expect(checkBoxValue, false); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + await tester.tap(find.byType(CheckboxMenuButton)); + await tester.pumpAndSettle(); + expect(checkBoxValue, true); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + await tester.tap(find.byType(CheckboxMenuButton)); + await tester.pumpAndSettle(); + expect(checkBoxValue, null); + }); + }); + + group('RadioMenuButton', () { + testWidgets('tapping toggles radio button', (WidgetTester tester) async { + int? radioValue; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MenuBar( + children: [ + SubmenuButton( + menuChildren: [ + RadioMenuButton( + value: 0, + groupValue: radioValue, + onChanged: (int? value) { + setState(() { + radioValue = value; + }); + }, + toggleable: true, + child: const Text('radio 0'), + ), + RadioMenuButton( + value: 1, + groupValue: radioValue, + onChanged: (int? value) { + setState(() { + radioValue = value; + }); + }, + toggleable: true, + child: const Text('radio 1'), + ) + ], + child: const Text('submenu'), + ), + ], + ); + }, + ), + ), + ); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + + expect( + tester.widget>(find.byType(RadioMenuButton).first).groupValue, + null, + ); + + await tester.tap(find.byType(RadioMenuButton).first); + await tester.pumpAndSettle(); + expect(radioValue, 0); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + await tester.tap(find.byType(RadioMenuButton).first); + await tester.pumpAndSettle(); + expect(radioValue, null); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + await tester.tap(find.byType(RadioMenuButton).last); + await tester.pumpAndSettle(); + expect(radioValue, 1); + }); + }); } List createTestMenus({