Unverified Commit 6b32c069 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add checkbox and radio menu buttons (#112821)

parent 91b5079f
......@@ -211,10 +211,7 @@ class _ControlsState extends State<_Controls> {
alignmentOffset: const Offset(100, -8),
menuChildren: <Widget>[
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<ShortcutActivator, Intent> shortcuts = <ShortcutActivator, Intent>{};
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: <Widget>[
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<TestMenu>(
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<TestMenu>(
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<TestMenu>(
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: <Widget>[
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: <Widget>[
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;
}
// 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<MyCheckboxMenu> createState() => _MyCheckboxMenuState();
}
class _MyCheckboxMenuState extends State<MyCheckboxMenu> {
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: <ShortcutActivator, VoidCallback>{
_showShortcut: () {
_setMessageVisibility(!_showingMessage);
},
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
MenuAnchor(
childFocusNode: _buttonFocusNode,
menuChildren: <Widget>[
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: <Widget>[
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)),
);
}
}
// 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<MyRadioMenu> createState() => _MyRadioMenuState();
}
class _MyRadioMenuState extends State<MyRadioMenu> {
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(<ShortcutActivator, VoidCallbackIntent>{
_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: <Widget>[
MenuAnchor(
childFocusNode: _buttonFocusNode,
menuChildren: <Widget>[
RadioMenuButton<Color>(
value: Colors.red,
shortcut: _redShortcut,
groupValue: _backgroundColor,
onChanged: _setBackgroundColor,
child: const Text('Red Background'),
),
RadioMenuButton<Color>(
value: Colors.green,
shortcut: _greenShortcut,
groupValue: _backgroundColor,
onChanged: _setBackgroundColor,
child: const Text('Green Background'),
),
RadioMenuButton<Color>(
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()),
);
}
}
// 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);
});
}
// 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<Color>), findsNWidgets(3));
expect(tester.widget<Container>(find.byType(Container)).color, equals(Colors.red));
await tester.tap(find.text('Green Background'));
await tester.pump();
expect(tester.widget<Container>(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<Color>), findsNWidgets(3));
expect(tester.widget<Container>(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<Radio<Color>>(find.descendant(of: find.byType(RadioMenuButton<Color>).at(0), matching: find.byType(Radio<Color>))).groupValue, equals(Colors.green));
expect(tester.widget<Container>(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<Radio<Color>>(find.descendant(of: find.byType(RadioMenuButton<Color>).at(1), matching: find.byType(Radio<Color>))).groupValue, equals(Colors.red));
expect(tester.widget<Container>(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<Radio<Color>>(find.descendant(of: find.byType(RadioMenuButton<Color>).at(2), matching: find.byType(Radio<Color>))).groupValue, equals(Colors.blue));
expect(tester.widget<Container>(find.byType(Container)).color, equals(Colors.blue));
});
}
......@@ -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
......
......@@ -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<MenuItemButton> {
}
}
/// 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<bool?>? 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<bool>? 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<bool>? 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<T> 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<SingingCharacter>(
/// value: SingingCharacter.lafayette,
/// groupValue: _character,
/// onChanged: (SingingCharacter? newValue) {
/// setState(() {
/// _character = newValue;
/// });
/// },
/// child: const Text('Lafayette'),
/// )
/// ```
final ValueChanged<T?>? 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<bool>? 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<bool>? 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<T>(
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.
......
......@@ -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);
......
......@@ -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;
......
......@@ -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: <Widget>[
SubmenuButton(
menuChildren: <Widget>[
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<CheckboxMenuButton>(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: <Widget>[
SubmenuButton(
menuChildren: <Widget>[
RadioMenuButton<int>(
value: 0,
groupValue: radioValue,
onChanged: (int? value) {
setState(() {
radioValue = value;
});
},
toggleable: true,
child: const Text('radio 0'),
),
RadioMenuButton<int>(
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<RadioMenuButton<int>>(find.byType(RadioMenuButton<int>).first).groupValue,
null,
);
await tester.tap(find.byType(RadioMenuButton<int>).first);
await tester.pumpAndSettle();
expect(radioValue, 0);
await tester.tap(find.byType(SubmenuButton));
await tester.pump();
await tester.tap(find.byType(RadioMenuButton<int>).first);
await tester.pumpAndSettle();
expect(radioValue, null);
await tester.tap(find.byType(SubmenuButton));
await tester.pump();
await tester.tap(find.byType(RadioMenuButton<int>).last);
await tester.pumpAndSettle();
expect(radioValue, 1);
});
});
}
List<Widget> createTestMenus({
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment