Migrate `IconButton` to Material 3 - Part 2 (#106437)
diff --git a/dev/tools/gen_defaults/lib/icon_button_template.dart b/dev/tools/gen_defaults/lib/icon_button_template.dart
index bb1d394..1fbd304 100644
--- a/dev/tools/gen_defaults/lib/icon_button_template.dart
+++ b/dev/tools/gen_defaults/lib/icon_button_template.dart
@@ -35,12 +35,26 @@
if (states.contains(MaterialState.disabled)) {
return ${componentColor('md.comp.icon-button.disabled.icon')};
}
+ if (states.contains(MaterialState.selected)) {
+ return ${componentColor('md.comp.icon-button.selected.icon')};
+ }
return ${componentColor('md.comp.icon-button.unselected.icon')};
});
@override
MaterialStateProperty<Color?>? get overlayColor =>
MaterialStateProperty.resolveWith((Set<MaterialState> states) {
+ if (states.contains(MaterialState.selected)) {
+ if (states.contains(MaterialState.hovered)) {
+ return ${componentColor('md.comp.icon-button.selected.hover.state-layer')};
+ }
+ if (states.contains(MaterialState.focused)) {
+ return ${componentColor('md.comp.icon-button.selected.focus.state-layer')};
+ }
+ if (states.contains(MaterialState.pressed)) {
+ return ${componentColor('md.comp.icon-button.selected.pressed.state-layer')};
+ }
+ }
if (states.contains(MaterialState.hovered)) {
return ${componentColor('md.comp.icon-button.unselected.hover.state-layer')};
}
diff --git a/examples/api/lib/material/icon_button/icon_button.3.dart b/examples/api/lib/material/icon_button/icon_button.3.dart
new file mode 100644
index 0000000..230d292
--- /dev/null
+++ b/examples/api/lib/material/icon_button/icon_button.3.dart
@@ -0,0 +1,192 @@
+// 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 IconButton with toggle feature
+
+import 'package:flutter/material.dart';
+
+void main() {
+ runApp(const IconButtonToggleApp());
+}
+
+class IconButtonToggleApp extends StatelessWidget {
+ const IconButtonToggleApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ theme: ThemeData(
+ colorSchemeSeed: const Color(0xff6750a4),
+ useMaterial3: true,
+ // Desktop and web platforms have a compact visual density by default.
+ // To see buttons with circular background on desktop/web, the "visualDensity"
+ // needs to be set to "VisualDensity.standard".
+ visualDensity: VisualDensity.standard,
+ ),
+ title: 'Icon Button Types',
+ home: const Scaffold(
+ body: DemoIconToggleButtons(),
+ ),
+ );
+ }
+}
+
+class DemoIconToggleButtons extends StatefulWidget {
+ const DemoIconToggleButtons({super.key});
+
+ @override
+ State<DemoIconToggleButtons> createState() => _DemoIconToggleButtonsState();
+}
+
+class _DemoIconToggleButtonsState extends State<DemoIconToggleButtons> {
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: <Widget>[
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ // Standard IconButton
+ children: const <Widget>[
+ DemoIconToggleButton(isEnabled: true),
+ SizedBox(width: 10),
+ DemoIconToggleButton(isEnabled: false),
+ ]
+ ),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: const <Widget>[
+ // Filled IconButton
+ DemoIconToggleButton(isEnabled: true, getDefaultStyle: enabledFilledButtonStyle,),
+ SizedBox(width: 10),
+ DemoIconToggleButton(isEnabled: false, getDefaultStyle: disabledFilledButtonStyle,)
+ ]
+ ),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: const <Widget>[
+ // Filled Tonal IconButton
+ DemoIconToggleButton(isEnabled: true, getDefaultStyle: enabledFilledTonalButtonStyle,),
+ SizedBox(width: 10),
+ DemoIconToggleButton(isEnabled: false, getDefaultStyle: disabledFilledTonalButtonStyle,),
+ ]
+ ),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: const <Widget>[
+ // Outlined IconButton
+ DemoIconToggleButton(isEnabled: true, getDefaultStyle: enabledOutlinedButtonStyle,),
+ SizedBox(width: 10),
+ DemoIconToggleButton(isEnabled: false, getDefaultStyle: disabledOutlinedButtonStyle,),
+ ]
+ ),
+ ]
+ ),
+ );
+ }
+}
+
+class DemoIconToggleButton extends StatefulWidget {
+ const DemoIconToggleButton({required this.isEnabled, this.getDefaultStyle, super.key});
+
+ final bool isEnabled;
+ final ButtonStyle? Function(bool, ColorScheme)? getDefaultStyle;
+
+ @override
+ State<DemoIconToggleButton> createState() => _DemoIconToggleButtonState();
+}
+
+class _DemoIconToggleButtonState extends State<DemoIconToggleButton> {
+ bool selected = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final ColorScheme colors = Theme.of(context).colorScheme;
+ final VoidCallback? onPressed = widget.isEnabled
+ ? () {
+ setState(() {
+ selected = !selected;
+ });
+ }
+ : null;
+ ButtonStyle? style;
+ if (widget.getDefaultStyle != null) {
+ style = widget.getDefaultStyle!(selected, colors);
+ }
+
+ return IconButton(
+ isSelected: selected,
+ icon: const Icon(Icons.settings_outlined),
+ selectedIcon: const Icon(Icons.settings),
+ onPressed: onPressed,
+ style: style,
+ );
+ }
+}
+
+ButtonStyle enabledFilledButtonStyle(bool selected, ColorScheme colors) {
+ return IconButton.styleFrom(
+ foregroundColor: selected ? colors.onPrimary : colors.primary,
+ backgroundColor: selected ? colors.primary : colors.surfaceVariant,
+ disabledForegroundColor: colors.onSurface.withOpacity(0.38),
+ disabledBackgroundColor: colors.onSurface.withOpacity(0.12),
+ hoverColor: selected ? colors.onPrimary.withOpacity(0.08) : colors.primary.withOpacity(0.08),
+ focusColor: selected ? colors.onPrimary.withOpacity(0.12) : colors.primary.withOpacity(0.12),
+ highlightColor: selected ? colors.onPrimary.withOpacity(0.12) : colors.primary.withOpacity(0.12),
+ );
+}
+
+ButtonStyle disabledFilledButtonStyle(bool selected, ColorScheme colors) {
+ return IconButton.styleFrom(
+ disabledForegroundColor: colors.onSurface.withOpacity(0.38),
+ disabledBackgroundColor: colors.onSurface.withOpacity(0.12),
+ );
+}
+
+ButtonStyle enabledFilledTonalButtonStyle(bool selected, ColorScheme colors) {
+ return IconButton.styleFrom(
+ foregroundColor: selected ? colors.onSecondaryContainer : colors.onSurfaceVariant,
+ backgroundColor: selected ? colors.secondaryContainer : colors.surfaceVariant,
+ hoverColor: selected ? colors.onSecondaryContainer.withOpacity(0.08) : colors.onSurfaceVariant.withOpacity(0.08),
+ focusColor: selected ? colors.onSecondaryContainer.withOpacity(0.12) : colors.onSurfaceVariant.withOpacity(0.12),
+ highlightColor: selected ? colors.onSecondaryContainer.withOpacity(0.12) : colors.onSurfaceVariant.withOpacity(0.12),
+ );
+}
+
+ButtonStyle disabledFilledTonalButtonStyle(bool selected, ColorScheme colors) {
+ return IconButton.styleFrom(
+ disabledForegroundColor: colors.onSurface.withOpacity(0.38),
+ disabledBackgroundColor: colors.onSurface.withOpacity(0.12),
+ );
+}
+
+ButtonStyle enabledOutlinedButtonStyle(bool selected, ColorScheme colors) {
+ return IconButton.styleFrom(
+ backgroundColor: selected ? colors.inverseSurface : null,
+ hoverColor: selected ? colors.onInverseSurface.withOpacity(0.08) : colors.onSurfaceVariant.withOpacity(0.08),
+ focusColor: selected ? colors.onInverseSurface.withOpacity(0.12) : colors.onSurfaceVariant.withOpacity(0.12),
+ highlightColor: selected ? colors.onInverseSurface.withOpacity(0.12) : colors.onSurface.withOpacity(0.12),
+ side: BorderSide(color: colors.outline),
+ ).copyWith(
+ foregroundColor: MaterialStateProperty.resolveWith((Set<MaterialState> states) {
+ if (states.contains(MaterialState.selected)) {
+ return colors.onInverseSurface;
+ }
+ if (states.contains(MaterialState.pressed)) {
+ return colors.onSurface;
+ }
+ return null;
+ }),
+ );
+}
+
+ButtonStyle disabledOutlinedButtonStyle(bool selected, ColorScheme colors) {
+ return IconButton.styleFrom(
+ disabledForegroundColor: colors.onSurface.withOpacity(0.38),
+ disabledBackgroundColor: selected ? colors.onSurface.withOpacity(0.12) : null,
+ side: selected ? null : BorderSide(color: colors.outline.withOpacity(0.12)),
+ );
+}
diff --git a/packages/flutter/lib/src/material/icon_button.dart b/packages/flutter/lib/src/material/icon_button.dart
index 95a606a..a11b66a 100644
--- a/packages/flutter/lib/src/material/icon_button.dart
+++ b/packages/flutter/lib/src/material/icon_button.dart
@@ -101,6 +101,14 @@
/// The default [IconButton] is the standard type, and contained icon buttons can be produced
/// by configuring the [IconButton] widget's properties.
///
+/// Material Design 3 also treats [IconButton]s as toggle buttons. In order
+/// to not break existing apps, the toggle feature can be optionally controlled
+/// by the [isSelected] property.
+///
+/// If [isSelected] is null it will behave as a normal button. If [isSelected] is not
+/// null then it will behave as a toggle button. If [isSelected] is true then it will
+/// show [selectedIcon], if it false it will show the normal [icon].
+///
/// {@tool dartpad}
/// This sample shows creation of [IconButton] widgets for standard, filled,
/// filled tonal and outlined types, as described in: https://m3.material.io/components/icon-buttons/overview
@@ -108,6 +116,14 @@
/// ** See code in examples/api/lib/material/icon_button/icon_button.2.dart **
/// {@end-tool}
///
+/// {@tool dartpad}
+/// This sample shows creation of [IconButton] widgets with toggle feature for
+/// standard, filled, filled tonal and outlined types, as described
+/// in: https://m3.material.io/components/icon-buttons/overview
+///
+/// ** See code in examples/api/lib/material/icon_button/icon_button.3.dart **
+/// {@end-tool}
+///
/// See also:
///
/// * [Icons], the library of Material Icons.
@@ -151,6 +167,8 @@
this.enableFeedback = true,
this.constraints,
this.style,
+ this.isSelected,
+ this.selectedIcon,
required this.icon,
}) : assert(padding != null),
assert(alignment != null),
@@ -218,12 +236,34 @@
/// See [Icon], [ImageIcon].
final Widget icon;
- /// The color for the button's icon when it has the input focus.
+ /// The color for the button when it has the input focus.
+ ///
+ /// If [ThemeData.useMaterial3] is set to true, this [focusColor] will be mapped
+ /// to be the [ButtonStyle.overlayColor] in focused state, which paints on top of
+ /// the button, as an overlay. Therefore, using a color with some transparency
+ /// is recommended. For example, one could customize the [focusColor] below:
+ ///
+ /// ```dart
+ /// IconButton(
+ /// focusColor: Colors.orange.withOpacity(0.3),
+ /// )
+ /// ```
///
/// Defaults to [ThemeData.focusColor] of the ambient theme.
final Color? focusColor;
- /// The color for the button's icon when a pointer is hovering over it.
+ /// The color for the button when a pointer is hovering over it.
+ ///
+ /// If [ThemeData.useMaterial3] is set to true, this [hoverColor] will be mapped
+ /// to be the [ButtonStyle.overlayColor] in hovered state, which paints on top of
+ /// the button, as an overlay. Therefore, using a color with some transparency
+ /// is recommended. For example, one could customize the [hoverColor] below:
+ ///
+ /// ```dart
+ /// IconButton(
+ /// hoverColor: Colors.orange.withOpacity(0.3),
+ /// )
+ /// ```
///
/// Defaults to [ThemeData.hoverColor] of the ambient theme.
final Color? hoverColor;
@@ -249,7 +289,9 @@
/// fill the button area if the touch is held for long enough time. If the splash
/// color has transparency then the highlight and button color will show through.
///
- /// If [ThemeData.useMaterial3] is set to true, this will not be used.
+ /// If [ThemeData.useMaterial3] is set to true, this will not be used. Use
+ /// [highlightColor] instead to show the overlay color of the button when the button
+ /// is in the pressed state.
///
/// Defaults to the Theme's splash color, [ThemeData.splashColor].
final Color? splashColor;
@@ -259,6 +301,17 @@
/// button color (if any). If the highlight color has transparency, the button color
/// will show through. The highlight fades in quickly as the button is held down.
///
+ /// If [ThemeData.useMaterial3] is set to true, this [highlightColor] will be mapped
+ /// to be the [ButtonStyle.overlayColor] in pressed state, which paints on top
+ /// of the button, as an overlay. Therefore, using a color with some transparency
+ /// is recommended. For example, one could customize the [highlightColor] below:
+ ///
+ /// ```dart
+ /// IconButton(
+ /// highlightColor: Colors.orange.withOpacity(0.3),
+ /// )
+ /// ```
+ ///
/// Defaults to the Theme's highlight color, [ThemeData.highlightColor].
final Color? highlightColor;
@@ -341,6 +394,32 @@
/// Null by default.
final ButtonStyle? style;
+ /// The optional selection state of the icon button.
+ ///
+ /// If this property is null, the button will behave as a normal push button,
+ /// otherwise, the button will toggle between showing [icon] and [selectedIcon]
+ /// based on the value of [isSelected]. If true, it will show [selectedIcon],
+ /// if false it will show [icon].
+ ///
+ /// This property is only used if [ThemeData.useMaterial3] is true.
+ final bool? isSelected;
+
+ /// The icon to display inside the button when [isSelected] is true. This property
+ /// can be null. The original [icon] will be used for both selected and unselected
+ /// status if it is null.
+ ///
+ /// The [Icon.size] and [Icon.color] of the icon is configured automatically
+ /// based on the [iconSize] and [color] properties using an [IconTheme] and
+ /// therefore should not be explicitly configured in the icon widget.
+ ///
+ /// This property is only used if [ThemeData.useMaterial3] is true.
+ ///
+ /// See also:
+ ///
+ /// * [Icon], for icons based on glyphs from fonts instead of images.
+ /// * [ImageIcon], for showing icons from [AssetImage]s or other [ImageProvider]s.
+ final Widget? selectedIcon;
+
/// A static convenience method that constructs an icon button
/// [ButtonStyle] given simple values. This method is only used for Material 3.
///
@@ -484,11 +563,16 @@
adjustedStyle = style!.merge(adjustedStyle);
}
+ Widget effectiveIcon = icon;
+ if ((isSelected ?? false) && selectedIcon != null) {
+ effectiveIcon = selectedIcon!;
+ }
+
Widget iconButton = IconTheme.merge(
data: IconThemeData(
size: effectiveIconSize,
),
- child: icon,
+ child: effectiveIcon,
);
if (tooltip != null) {
iconButton = Tooltip(
@@ -496,11 +580,13 @@
child: iconButton,
);
}
- return _IconButtonM3(
+
+ return _SelectableIconButton(
style: adjustedStyle,
onPressed: onPressed,
autofocus: autofocus,
focusNode: focusNode,
+ isSelected: isSelected,
child: iconButton,
);
}
@@ -574,12 +660,76 @@
}
}
+class _SelectableIconButton extends StatefulWidget {
+ const _SelectableIconButton({
+ this.isSelected,
+ this.style,
+ this.focusNode,
+ required this.autofocus,
+ required this.onPressed,
+ required this.child,
+ });
+
+ final bool? isSelected;
+ final ButtonStyle? style;
+ final FocusNode? focusNode;
+ final bool autofocus;
+ final VoidCallback? onPressed;
+ final Widget child;
+
+ @override
+ State<_SelectableIconButton> createState() => _SelectableIconButtonState();
+}
+
+class _SelectableIconButtonState extends State<_SelectableIconButton> {
+ late final MaterialStatesController statesController;
+
+ @override
+ void initState() {
+ super.initState();
+ if (widget.isSelected == null) {
+ statesController = MaterialStatesController();
+ } else {
+ statesController = MaterialStatesController(<MaterialState>{
+ if (widget.isSelected!) MaterialState.selected
+ });
+ }
+ }
+
+ @override
+ void didUpdateWidget(_SelectableIconButton oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (widget.isSelected == null) {
+ if (statesController.value.contains(MaterialState.selected)) {
+ statesController.update(MaterialState.selected, false);
+ }
+ return;
+ }
+ if (widget.isSelected != oldWidget.isSelected) {
+ statesController.update(MaterialState.selected, widget.isSelected!);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return _IconButtonM3(
+ statesController: statesController,
+ style: widget.style,
+ autofocus: widget.autofocus,
+ focusNode: widget.focusNode,
+ onPressed: widget.onPressed,
+ child: widget.child,
+ );
+ }
+}
+
class _IconButtonM3 extends ButtonStyleButton {
const _IconButtonM3({
required super.onPressed,
super.style,
super.focusNode,
super.autofocus = false,
+ super.statesController,
required Widget super.child,
}) : super(
onLongPress: null,
@@ -596,8 +746,12 @@
/// * `backgroundColor` - transparent
/// * `foregroundColor`
/// * disabled - Theme.colorScheme.onSurface(0.38)
+ /// * selected - Theme.colorScheme.primary
/// * others - Theme.colorScheme.onSurfaceVariant
/// * `overlayColor`
+ /// * selected
+ /// * hovered - Theme.colorScheme.primary(0.08)
+ /// * focused or pressed - Theme.colorScheme.primary(0.12)
/// * hovered or focused - Theme.colorScheme.onSurfaceVariant(0.08)
/// * pressed - Theme.colorScheme.onSurfaceVariant(0.12)
/// * others - null
@@ -684,15 +838,26 @@
@override
Color? resolve(Set<MaterialState> states) {
+ if (states.contains(MaterialState.selected)) {
+ if (states.contains(MaterialState.pressed)) {
+ return highlightColor ?? foregroundColor?.withOpacity(0.12);
+ }
+ if (states.contains(MaterialState.hovered)) {
+ return hoverColor ?? foregroundColor?.withOpacity(0.08);
+ }
+ if (states.contains(MaterialState.focused)) {
+ return focusColor ?? foregroundColor?.withOpacity(0.12);
+ }
+ }
+ if (states.contains(MaterialState.pressed)) {
+ return highlightColor ?? foregroundColor?.withOpacity(0.12);
+ }
if (states.contains(MaterialState.hovered)) {
return hoverColor ?? foregroundColor?.withOpacity(0.08);
}
if (states.contains(MaterialState.focused)) {
return focusColor ?? foregroundColor?.withOpacity(0.08);
}
- if (states.contains(MaterialState.pressed)) {
- return highlightColor ?? foregroundColor?.withOpacity(0.12);
- }
return null;
}
@@ -748,12 +913,26 @@
if (states.contains(MaterialState.disabled)) {
return _colors.onSurface.withOpacity(0.38);
}
+ if (states.contains(MaterialState.selected)) {
+ return _colors.primary;
+ }
return _colors.onSurfaceVariant;
});
@override
MaterialStateProperty<Color?>? get overlayColor =>
MaterialStateProperty.resolveWith((Set<MaterialState> states) {
+ if (states.contains(MaterialState.selected)) {
+ if (states.contains(MaterialState.hovered)) {
+ return _colors.primary.withOpacity(0.08);
+ }
+ if (states.contains(MaterialState.focused)) {
+ return _colors.primary.withOpacity(0.12);
+ }
+ if (states.contains(MaterialState.pressed)) {
+ return _colors.primary.withOpacity(0.12);
+ }
+ }
if (states.contains(MaterialState.hovered)) {
return _colors.onSurfaceVariant.withOpacity(0.08);
}
diff --git a/packages/flutter/test/material/icon_button_test.dart b/packages/flutter/test/material/icon_button_test.dart
index ae0354d..b8c07e6 100644
--- a/packages/flutter/test/material/icon_button_test.dart
+++ b/packages/flutter/test/material/icon_button_test.dart
@@ -1034,7 +1034,7 @@
expect(material.textStyle, null);
expect(material.type, MaterialType.button);
- // Disabled TextButton
+ // Disabled IconButton
await tester.pumpWidget(
MaterialApp(
theme: themeM3,
@@ -1108,12 +1108,14 @@
);
testWidgets('IconButton uses stateful color for icon color in different states - M3', (WidgetTester tester) async {
+ bool isSelected = false;
final FocusNode focusNode = FocusNode();
const Color pressedColor = Color(0x00000001);
const Color hoverColor = Color(0x00000002);
const Color focusedColor = Color(0x00000003);
const Color defaultColor = Color(0x00000004);
+ const Color selectedColor = Color(0x00000005);
Color getIconColor(Set<MaterialState> states) {
if (states.contains(MaterialState.pressed)) {
@@ -1125,23 +1127,35 @@
if (states.contains(MaterialState.focused)) {
return focusedColor;
}
+ if (states.contains(MaterialState.selected)) {
+ return selectedColor;
+ }
return defaultColor;
}
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: true),
- home: Scaffold(
- body: Center(
- child: IconButton(
- style: ButtonStyle(
- foregroundColor: MaterialStateProperty.resolveWith<Color>(getIconColor),
+ home: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return Scaffold(
+ body: Center(
+ child: IconButton(
+ style: ButtonStyle(
+ foregroundColor: MaterialStateProperty.resolveWith<Color>(getIconColor),
+ ),
+ isSelected: isSelected,
+ onPressed: () {
+ setState(() {
+ isSelected = !isSelected;
+ });
+ },
+ focusNode: focusNode,
+ icon: const Icon(Icons.ac_unit),
+ ),
),
- onPressed: () {},
- focusNode: focusNode,
- icon: const Icon(Icons.ac_unit),
- ),
- ),
+ );
+ }
),
),
);
@@ -1151,6 +1165,12 @@
// Default, not disabled.
expect(iconColor(), equals(defaultColor));
+ // Selected
+ final Finder button = find.byType(IconButton);
+ await tester.tap(button);
+ await tester.pumpAndSettle();
+ expect(iconColor(), selectedColor);
+
// Focused.
focusNode.requestFocus();
await tester.pumpAndSettle();
@@ -1319,6 +1339,236 @@
);
expect(paddingWidget3.padding, const EdgeInsets.all(22));
});
+
+ testWidgets('Default IconButton is not selectable - M3', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: true),
+ home: IconButton(icon: const Icon(Icons.ac_unit), onPressed: (){},)
+ )
+ );
+
+ final Finder button = find.byType(IconButton);
+ IconButton buttonWidget() => tester.widget<IconButton>(button);
+
+ Material buttonMaterial() {
+ return tester.widget<Material>(
+ find.descendant(
+ of: find.byType(IconButton),
+ matching: find.byType(Material),
+ )
+ );
+ }
+
+ Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color;
+
+ expect(buttonWidget().isSelected, null);
+ expect(iconColor(), equals(const ColorScheme.light().onSurfaceVariant));
+ expect(buttonMaterial().color, Colors.transparent);
+
+ await tester.tap(button); // The non-toggle IconButton should not change appearance after clicking
+ await tester.pumpAndSettle();
+
+ expect(buttonWidget().isSelected, null);
+ expect(iconColor(), equals(const ColorScheme.light().onSurfaceVariant));
+ expect(buttonMaterial().color, Colors.transparent);
+ });
+
+ testWidgets('Icon button is selectable when isSelected is not null - M3', (WidgetTester tester) async {
+ bool isSelected = false;
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: true),
+ home: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return IconButton(
+ isSelected: isSelected,
+ icon: const Icon(Icons.ac_unit),
+ onPressed: (){
+ setState(() {
+ isSelected = !isSelected;
+ });
+ },
+ );
+ }
+ )
+ )
+ );
+
+ final Finder button = find.byType(IconButton);
+ IconButton buttonWidget() => tester.widget<IconButton>(button);
+ Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color;
+
+ Material buttonMaterial() {
+ return tester.widget<Material>(
+ find.descendant(
+ of: find.byType(IconButton),
+ matching: find.byType(Material),
+ )
+ );
+ }
+
+ expect(buttonWidget().isSelected, false);
+ expect(iconColor(), equals(const ColorScheme.light().onSurfaceVariant));
+ expect(buttonMaterial().color, Colors.transparent);
+
+ await tester.tap(button); // The toggle IconButton should change appearance after clicking
+ await tester.pumpAndSettle();
+
+ expect(buttonWidget().isSelected, true);
+ expect(iconColor(), equals(const ColorScheme.light().primary));
+ expect(buttonMaterial().color, Colors.transparent);
+
+ await tester.tap(button); // The IconButton should be unselected if it's clicked again
+ await tester.pumpAndSettle();
+
+ expect(buttonWidget().isSelected, false);
+ expect(iconColor(), equals(const ColorScheme.light().onSurfaceVariant));
+ expect(buttonMaterial().color, Colors.transparent);
+ });
+
+ testWidgets('The IconButton is in selected status if isSelected is true by default - M3', (WidgetTester tester) async {
+ bool isSelected = true;
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: true),
+ home: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return IconButton(
+ isSelected: isSelected,
+ icon: const Icon(Icons.ac_unit),
+ onPressed: (){
+ setState(() {
+ isSelected = !isSelected;
+ });
+ },
+ );
+ }
+ )
+ )
+ );
+
+ final Finder button = find.byType(IconButton);
+ IconButton buttonWidget() => tester.widget<IconButton>(button);
+ Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color;
+
+ Material buttonMaterial() {
+ return tester.widget<Material>(
+ find.descendant(
+ of: find.byType(IconButton),
+ matching: find.byType(Material),
+ )
+ );
+ }
+
+ expect(buttonWidget().isSelected, true);
+ expect(iconColor(), equals(const ColorScheme.light().primary));
+ expect(buttonMaterial().color, Colors.transparent);
+
+ await tester.tap(button); // The IconButton becomes unselected if it's clicked
+ await tester.pumpAndSettle();
+
+ expect(buttonWidget().isSelected, false);
+ expect(iconColor(), equals(const ColorScheme.light().onSurfaceVariant));
+ expect(buttonMaterial().color, Colors.transparent);
+ });
+
+ testWidgets("The selectedIcon is used if it's not null and the button is clicked" , (WidgetTester tester) async {
+ bool isSelected = false;
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: true),
+ home: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return IconButton(
+ isSelected: isSelected,
+ selectedIcon: const Icon(Icons.account_box),
+ icon: const Icon(Icons.account_box_outlined),
+ onPressed: (){
+ setState(() {
+ isSelected = !isSelected;
+ });
+ },
+ );
+ }
+ )
+ )
+ );
+
+ final Finder button = find.byType(IconButton);
+
+ expect(find.byIcon(Icons.account_box_outlined), findsOneWidget);
+ expect(find.byIcon(Icons.account_box), findsNothing);
+
+ await tester.tap(button); // The icon becomes to selectedIcon
+ await tester.pumpAndSettle();
+
+ expect(find.byIcon(Icons.account_box), findsOneWidget);
+ expect(find.byIcon(Icons.account_box_outlined), findsNothing);
+
+ await tester.tap(button); // The icon becomes the original icon when it's clicked again
+ await tester.pumpAndSettle();
+
+ expect(find.byIcon(Icons.account_box_outlined), findsOneWidget);
+ expect(find.byIcon(Icons.account_box), findsNothing);
+ });
+
+ testWidgets('The original icon is used for selected and unselected status when selectedIcon is null' , (WidgetTester tester) async {
+ bool isSelected = false;
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: true),
+ home: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return IconButton(
+ isSelected: isSelected,
+ icon: const Icon(Icons.account_box),
+ onPressed: (){
+ setState(() {
+ isSelected = !isSelected;
+ });
+ },
+ );
+ }
+ )
+ )
+ );
+
+ final Finder button = find.byType(IconButton);
+ IconButton buttonWidget() => tester.widget<IconButton>(button);
+
+ expect(buttonWidget().isSelected, false);
+ expect(buttonWidget().selectedIcon, null);
+ expect(find.byIcon(Icons.account_box), findsOneWidget);
+
+ await tester.tap(button); // The icon becomes the original icon when it's clicked again
+ await tester.pumpAndSettle();
+
+ expect(buttonWidget().isSelected, true);
+ expect(buttonWidget().selectedIcon, null);
+ expect(find.byIcon(Icons.account_box), findsOneWidget);
+ });
+
+ testWidgets('The selectedIcon is used for disabled button if isSelected is true - M3' , (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: true),
+ home: const IconButton(
+ isSelected: true,
+ icon: Icon(Icons.account_box),
+ selectedIcon: Icon(Icons.ac_unit),
+ onPressed: null,
+ )
+ )
+ );
+
+ final Finder button = find.byType(IconButton);
+ IconButton buttonWidget() => tester.widget<IconButton>(button);
+
+ expect(buttonWidget().isSelected, true);
+ expect(find.byIcon(Icons.account_box), findsNothing);
+ expect(find.byIcon(Icons.ac_unit), findsOneWidget);
+ });
}
Widget wrap({required Widget child, required bool useMaterial3}) {