diff --git a/dev/tools/gen_defaults/bin/gen_defaults.dart b/dev/tools/gen_defaults/bin/gen_defaults.dart index 71868770684..4e86db7017e 100644 --- a/dev/tools/gen_defaults/bin/gen_defaults.dart +++ b/dev/tools/gen_defaults/bin/gen_defaults.dart @@ -31,6 +31,7 @@ import 'package:gen_defaults/input_decorator_template.dart'; import 'package:gen_defaults/navigation_bar_template.dart'; import 'package:gen_defaults/navigation_rail_template.dart'; import 'package:gen_defaults/surface_tint.dart'; +import 'package:gen_defaults/switch_template.dart'; import 'package:gen_defaults/text_field_template.dart'; import 'package:gen_defaults/typography_template.dart'; @@ -121,6 +122,7 @@ Future main(List args) async { NavigationBarTemplate('NavigationBar', '$materialLib/navigation_bar.dart', tokens).updateFile(); NavigationRailTemplate('NavigationRail', '$materialLib/navigation_rail.dart', tokens).updateFile(); SurfaceTintTemplate('SurfaceTint', '$materialLib/elevation_overlay.dart', tokens).updateFile(); + SwitchTemplate('Switch', '$materialLib/switch.dart', tokens).updateFile(); TextFieldTemplate('TextField', '$materialLib/text_field.dart', tokens).updateFile(); TypographyTemplate('Typography', '$materialLib/typography.dart', tokens).updateFile(); } diff --git a/dev/tools/gen_defaults/lib/switch_template.dart b/dev/tools/gen_defaults/lib/switch_template.dart new file mode 100644 index 00000000000..07d4c39d7e0 --- /dev/null +++ b/dev/tools/gen_defaults/lib/switch_template.dart @@ -0,0 +1,208 @@ +// 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 'template.dart'; + +class SwitchTemplate extends TokenTemplate { + const SwitchTemplate(super.blockName, super.fileName, super.tokens, { + super.colorSchemePrefix = '_colors.', + }); + + @override + String generate() => ''' +class _${blockName}DefaultsM3 extends SwitchThemeData { + _${blockName}DefaultsM3(BuildContext context) + : _colors = Theme.of(context).colorScheme; + + final ColorScheme _colors; + + @override + MaterialStateProperty get thumbColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + if (states.contains(MaterialState.selected)) { + return ${componentColor('md.comp.switch.disabled.selected.handle')}; + } + return ${componentColor('md.comp.switch.disabled.unselected.handle')}; + } + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.switch.selected.pressed.handle')}; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.switch.selected.hover.handle')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.switch.selected.focus.handle')}; + } + return ${componentColor('md.comp.switch.selected.handle')}; + } + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.switch.unselected.pressed.handle')}; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.switch.unselected.hover.handle')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.switch.unselected.focus.handle')}; + } + return ${componentColor('md.comp.switch.unselected.handle')}; + }); + } + + @override + MaterialStateProperty get trackColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + if (states.contains(MaterialState.selected)) { + return ${componentColor('md.comp.switch.disabled.selected.track')}.withOpacity(${opacity('md.comp.switch.disabled.track.opacity')}); + } + return ${componentColor('md.comp.switch.disabled.unselected.track')}.withOpacity(${opacity('md.comp.switch.disabled.track.opacity')}); + } + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.switch.selected.pressed.track')}; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.switch.selected.hover.track')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.switch.selected.focus.track')}; + } + return ${componentColor('md.comp.switch.selected.track')}; + } + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.switch.unselected.pressed.track')}; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.switch.unselected.hover.track')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.switch.unselected.focus.track')}; + } + return ${componentColor('md.comp.switch.unselected.track')}; + }); + } + + @override + MaterialStateProperty get overlayColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.switch.selected.pressed.state-layer')}; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.switch.selected.hover.state-layer')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.switch.selected.focus.state-layer')}; + } + return null; + } + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.switch.unselected.pressed.state-layer')}; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.switch.unselected.hover.state-layer')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.switch.unselected.focus.state-layer')}; + } + return null; + }); + } + + @override + double get splashRadius => ${tokens['md.comp.switch.state-layer.size']} / 2; +} + +class _SwitchConfigM3 with _SwitchConfig { + _SwitchConfigM3(this.context) + : _colors = Theme.of(context).colorScheme; + + BuildContext context; + final ColorScheme _colors; + + static const double iconSize = ${tokens['md.comp.switch.unselected.icon.size']}; + + @override + double get activeThumbRadius => ${tokens['md.comp.switch.selected.handle.width']} / 2; + + @override + MaterialStateProperty get iconColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + if (states.contains(MaterialState.selected)) { + return ${componentColor('md.comp.switch.disabled.selected.icon')}; + } + return ${componentColor('md.comp.switch.disabled.unselected.icon')}; + } + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.switch.selected.pressed.icon')}; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.switch.selected.hover.icon')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.switch.selected.focus.icon')}; + } + return ${componentColor('md.comp.switch.selected.icon')}; + } + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.switch.unselected.pressed.icon')}; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.switch.unselected.hover.icon')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.switch.unselected.focus.icon')}; + } + return ${componentColor('md.comp.switch.unselected.icon')}; + }); + } + + @override + double get inactiveThumbRadius => ${tokens['md.comp.switch.unselected.handle.width']} / 2; + + @override + double get pressedThumbRadius => ${tokens['md.comp.switch.pressed.handle.width']} / 2; + + @override + double get switchHeight => _kSwitchMinSize + 8.0; + + @override + double get switchHeightCollapsed => _kSwitchMinSize; + + @override + double get switchWidth => trackWidth - 2 * (trackHeight / 2.0) + _kSwitchMinSize; + + @override + double get thumbRadiusWithIcon => ${tokens['md.comp.switch.with-icon.handle.width']} / 2; + + @override + List? get thumbShadow => kElevationToShadow[0]; + + @override + double get trackHeight => ${tokens['md.comp.switch.track.height']}; + + @override + MaterialStateProperty get trackOutlineColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return null; + } + if (states.contains(MaterialState.disabled)) { + return ${componentColor('md.comp.switch.disabled.unselected.track.outline')}.withOpacity(${opacity('md.comp.switch.disabled.track.opacity')}); + } + return ${componentColor('md.comp.switch.unselected.track.outline')}; + }); + } + + @override + double get trackWidth => ${tokens['md.comp.switch.track.width']}; +} +'''; + +} diff --git a/examples/api/lib/material/switch/switch.2.dart b/examples/api/lib/material/switch/switch.2.dart new file mode 100644 index 00000000000..3d9de18da29 --- /dev/null +++ b/examples/api/lib/material/switch/switch.2.dart @@ -0,0 +1,74 @@ +// 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 Switch + +import 'package:flutter/material.dart'; + +void main() => runApp(const SwitchApp()); + +class SwitchApp extends StatelessWidget { + const SwitchApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(useMaterial3: true, colorSchemeSeed: const Color(0xff6750a4)), + home: Scaffold( + appBar: AppBar(title: const Text('Switch Sample')), + body: const Center( + child: SwitchExample(), + ), + ), + ); + } +} + +class SwitchExample extends StatefulWidget { + const SwitchExample({super.key}); + + @override + State createState() => _SwitchExampleState(); +} + +class _SwitchExampleState extends State { + bool light0 = true; + bool light1 = true; + bool light2 = true; + + final MaterialStateProperty thumbIcon = MaterialStateProperty.resolveWith((Set states) { + // Thumb icon when the switch is selected. + if (states.contains(MaterialState.selected)) { + return const Icon(Icons.check); + } + return const Icon(Icons.close); + }, + ); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Switch( + value: light0, + onChanged: (bool value) { + setState(() { + light0 = value; + }); + }, + ), + Switch( + thumbIcon: thumbIcon, + value: light1, + onChanged: (bool value) { + setState(() { + light1 = value; + }); + }, + ), + ], + ); + } +} diff --git a/packages/flutter/lib/src/material/switch.dart b/packages/flutter/lib/src/material/switch.dart index e5204356dce..eb89ecc0f9e 100644 --- a/packages/flutter/lib/src/material/switch.dart +++ b/packages/flutter/lib/src/material/switch.dart @@ -2,11 +2,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; +import 'color_scheme.dart'; import 'colors.dart'; import 'constants.dart'; import 'debug.dart'; @@ -21,14 +24,7 @@ import 'toggleable.dart'; // bool _giveVerse = true; // late StateSetter setState; -const double _kTrackHeight = 14.0; -const double _kTrackWidth = 33.0; -const double _kTrackRadius = _kTrackHeight / 2.0; -const double _kThumbRadius = 10.0; const double _kSwitchMinSize = kMinInteractiveDimension - 8.0; -const double _kSwitchWidth = _kTrackWidth - 2 * _kTrackRadius + _kSwitchMinSize; -const double _kSwitchHeight = _kSwitchMinSize + 8.0; -const double _kSwitchHeightCollapsed = _kSwitchMinSize; enum _SwitchType { material, adaptive } @@ -48,6 +44,10 @@ enum _SwitchType { material, adaptive } /// /// Requires one of its ancestors to be a [Material] widget. /// +/// Material Design 3 provides the option to add icons on the thumb of the [Switch]. +/// If [ThemeData.useMaterial3] is set to true, users can use [Switch.thumbIcon] +/// to add optional Icons based on the different [MaterialState]s of the [Switch]. +/// /// {@tool dartpad} /// This example shows a toggleable [Switch]. When the thumb slides to the other /// side of the track, the switch is toggled between on/off. @@ -62,6 +62,13 @@ enum _SwitchType { material, adaptive } /// ** See code in examples/api/lib/material/switch/switch.1.dart ** /// {@end-tool} /// +/// {@tool dartpad} +/// This example shows how to add icons on the thumb of the [Switch] using the +/// [Switch.thumbIcon] property. +/// +/// ** See code in examples/api/lib/material/switch/switch.2.dart ** +/// {@end-tool} +/// /// See also: /// /// * [SwitchListTile], which combines this widget with a [ListTile] so that @@ -98,6 +105,7 @@ class Switch extends StatelessWidget { this.onInactiveThumbImageError, this.thumbColor, this.trackColor, + this.thumbIcon, this.materialTapTargetSize, this.dragStartBehavior = DragStartBehavior.start, this.mouseCursor, @@ -142,6 +150,7 @@ class Switch extends StatelessWidget { this.materialTapTargetSize, this.thumbColor, this.trackColor, + this.thumbIcon, this.dragStartBehavior = DragStartBehavior.start, this.mouseCursor, this.focusColor, @@ -322,6 +331,39 @@ class Switch extends StatelessWidget { /// | Disabled | `Colors.black12` | `Colors.white10` | final MaterialStateProperty? trackColor; + /// {@template flutter.material.switch.thumbIcon} + /// The icon to use on the thumb of this switch + /// + /// Resolved in the following states: + /// * [MaterialState.selected]. + /// * [MaterialState.hovered]. + /// * [MaterialState.focused]. + /// * [MaterialState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [thumbIcon] based on the current + /// [MaterialState] of the [Switch], providing a different [Icon] when it is + /// [MaterialState.disabled]. + /// + /// ```dart + /// Switch( + /// value: true, + /// onChanged: (_) => true, + /// thumbIcon: MaterialStateProperty.resolveWith((Set states) { + /// if (states.contains(MaterialState.disabled)) { + /// return const Icon(Icons.close); + /// } + /// return null; // All other states will use the default thumbIcon. + /// }), + /// ) + /// ``` + /// {@end-tool} + /// {@endtemplate} + /// + /// If null, then the value of [SwitchThemeData.thumbIcon] is used. If this is also null, + /// then the [Switch] does not have any icons on the thumb. + final MaterialStateProperty? thumbIcon; + /// {@template flutter.material.switch.materialTapTargetSize} /// Configures the minimum size of the tap target. /// {@endtemplate} @@ -419,15 +461,16 @@ class Switch extends StatelessWidget { Size _getSwitchSize(BuildContext context) { final ThemeData theme = Theme.of(context); final SwitchThemeData switchTheme = SwitchTheme.of(context); + final _SwitchConfig switchConfig = theme.useMaterial3 ? _SwitchConfigM3(context) : _SwitchConfigM2(); final MaterialTapTargetSize effectiveMaterialTapTargetSize = materialTapTargetSize ?? switchTheme.materialTapTargetSize ?? theme.materialTapTargetSize; switch (effectiveMaterialTapTargetSize) { case MaterialTapTargetSize.padded: - return const Size(_kSwitchWidth, _kSwitchHeight); + return Size(switchConfig.switchWidth, switchConfig.switchHeight); case MaterialTapTargetSize.shrinkWrap: - return const Size(_kSwitchWidth, _kSwitchHeightCollapsed); + return Size(switchConfig.switchWidth, switchConfig.switchHeightCollapsed); } } @@ -441,11 +484,11 @@ class Switch extends StatelessWidget { height: size.height, alignment: Alignment.center, child: CupertinoSwitch( - dragStartBehavior: dragStartBehavior, - value: value, - onChanged: onChanged, - activeColor: activeColor, - trackColor: inactiveTrackColor, + dragStartBehavior: dragStartBehavior, + value: value, + onChanged: onChanged, + activeColor: activeColor, + trackColor: inactiveTrackColor, ), ), ); @@ -466,6 +509,7 @@ class Switch extends StatelessWidget { onInactiveThumbImageError: onInactiveThumbImageError, thumbColor: thumbColor, trackColor: trackColor, + thumbIcon: thumbIcon, materialTapTargetSize: materialTapTargetSize, dragStartBehavior: dragStartBehavior, mouseCursor: mouseCursor, @@ -524,6 +568,7 @@ class _MaterialSwitch extends StatefulWidget { this.onInactiveThumbImageError, this.thumbColor, this.trackColor, + this.thumbIcon, this.materialTapTargetSize, this.dragStartBehavior = DragStartBehavior.start, this.mouseCursor, @@ -549,6 +594,7 @@ class _MaterialSwitch extends StatefulWidget { final ImageErrorListener? onInactiveThumbImageError; final MaterialStateProperty? thumbColor; final MaterialStateProperty? trackColor; + final MaterialStateProperty? thumbIcon; final MaterialTapTargetSize? materialTapTargetSize; final DragStartBehavior dragStartBehavior; final MouseCursor? mouseCursor; @@ -609,26 +655,8 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta }); } - MaterialStateProperty get _defaultThumbColor { - final ThemeData theme = Theme.of(context); - final bool isDark = theme.brightness == Brightness.dark; - - return MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.disabled)) { - return isDark ? Colors.grey.shade800 : Colors.grey.shade400; - } - if (states.contains(MaterialState.selected)) { - return theme.colorScheme.secondary; - } - return isDark ? Colors.grey.shade400 : Colors.grey.shade50; - }); - } - MaterialStateProperty get _widgetTrackColor { return MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.disabled)) { - return widget.inactiveTrackColor; - } if (states.contains(MaterialState.selected)) { return widget.activeTrackColor; } @@ -636,28 +664,15 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta }); } - MaterialStateProperty get _defaultTrackColor { - final ThemeData theme = Theme.of(context); - final bool isDark = theme.brightness == Brightness.dark; - const Color black32 = Color(0x52000000); // Black with 32% opacity - - return MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.disabled)) { - return isDark ? Colors.white10 : Colors.black12; - } - if (states.contains(MaterialState.selected)) { - final Set activeState = states..add(MaterialState.selected); - final Color activeColor = _widgetThumbColor.resolve(activeState) ?? _defaultThumbColor.resolve(activeState); - return activeColor.withAlpha(0x80); - } - return isDark ? Colors.white30 : black32; - }); - } - double get _trackInnerLength => widget.size.width - _kSwitchMinSize; + bool _isPressed = false; + void _handleDragStart(DragStartDetails details) { if (isInteractive) { + setState(() { + _isPressed = true; + }); reactionController.forward(); } } @@ -692,6 +707,9 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta } else { animateToValue(); } + setState(() { + _isPressed = false; + }); reactionController.reverse(); } @@ -713,49 +731,66 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta final ThemeData theme = Theme.of(context); final SwitchThemeData switchTheme = SwitchTheme.of(context); + final _SwitchConfig switchConfig = theme.useMaterial3 ? _SwitchConfigM3(context) : _SwitchConfigM2(); + final SwitchThemeData defaults = theme.useMaterial3 ? _SwitchDefaultsM3(context) : _SwitchDefaultsM2(context); // Colors need to be resolved in selected and non selected states separately // so that they can be lerped between. final Set activeStates = states..add(MaterialState.selected); final Set inactiveStates = states..remove(MaterialState.selected); - final Color effectiveActiveThumbColor = widget.thumbColor?.resolve(activeStates) + + final Color? activeThumbColor = widget.thumbColor?.resolve(activeStates) ?? _widgetThumbColor.resolve(activeStates) - ?? switchTheme.thumbColor?.resolve(activeStates) - ?? _defaultThumbColor.resolve(activeStates); - final Color effectiveInactiveThumbColor = widget.thumbColor?.resolve(inactiveStates) + ?? switchTheme.thumbColor?.resolve(activeStates); + final Color effectiveActiveThumbColor = activeThumbColor + ?? defaults.thumbColor!.resolve(activeStates)!; + final Color? inactiveThumbColor = widget.thumbColor?.resolve(inactiveStates) ?? _widgetThumbColor.resolve(inactiveStates) - ?? switchTheme.thumbColor?.resolve(inactiveStates) - ?? _defaultThumbColor.resolve(inactiveStates); + ?? switchTheme.thumbColor?.resolve(inactiveStates); + final Color effectiveInactiveThumbColor = inactiveThumbColor + ?? defaults.thumbColor!.resolve(inactiveStates)!; final Color effectiveActiveTrackColor = widget.trackColor?.resolve(activeStates) ?? _widgetTrackColor.resolve(activeStates) ?? switchTheme.trackColor?.resolve(activeStates) - ?? _defaultTrackColor.resolve(activeStates); + ?? _widgetThumbColor.resolve(activeStates)?.withAlpha(0x80) + ?? defaults.trackColor!.resolve(activeStates)!; final Color effectiveInactiveTrackColor = widget.trackColor?.resolve(inactiveStates) ?? _widgetTrackColor.resolve(inactiveStates) ?? switchTheme.trackColor?.resolve(inactiveStates) - ?? _defaultTrackColor.resolve(inactiveStates); + ?? defaults.trackColor!.resolve(inactiveStates)!; + final Color? effectiveInactiveTrackOutlineColor = switchConfig.trackOutlineColor?.resolve(inactiveStates); + + final Icon? effectiveActiveIcon = widget.thumbIcon?.resolve(activeStates) + ?? switchTheme.thumbIcon?.resolve(activeStates); + final Icon? effectiveInactiveIcon = widget.thumbIcon?.resolve(inactiveStates) + ?? switchTheme.thumbIcon?.resolve(inactiveStates); + + final Color effectiveActiveIconColor = effectiveActiveIcon?.color ?? switchConfig.iconColor.resolve(activeStates); + final Color effectiveInactiveIconColor = effectiveInactiveIcon?.color ?? switchConfig.iconColor.resolve(inactiveStates); final Set focusedStates = states..add(MaterialState.focused); final Color effectiveFocusOverlayColor = widget.overlayColor?.resolve(focusedStates) ?? widget.focusColor ?? switchTheme.overlayColor?.resolve(focusedStates) - ?? theme.focusColor; + ?? defaults.overlayColor!.resolve(focusedStates)!; final Set hoveredStates = states..add(MaterialState.hovered); final Color effectiveHoverOverlayColor = widget.overlayColor?.resolve(hoveredStates) - ?? widget.hoverColor - ?? switchTheme.overlayColor?.resolve(hoveredStates) - ?? theme.hoverColor; + ?? widget.hoverColor + ?? switchTheme.overlayColor?.resolve(hoveredStates) + ?? defaults.overlayColor!.resolve(hoveredStates)!; final Set activePressedStates = activeStates..add(MaterialState.pressed); final Color effectiveActivePressedOverlayColor = widget.overlayColor?.resolve(activePressedStates) - ?? switchTheme.overlayColor?.resolve(activePressedStates) - ?? effectiveActiveThumbColor.withAlpha(kRadialReactionAlpha); + ?? switchTheme.overlayColor?.resolve(activePressedStates) + ?? activeThumbColor?.withAlpha(kRadialReactionAlpha) + ?? defaults.overlayColor!.resolve(activePressedStates)!; final Set inactivePressedStates = inactiveStates..add(MaterialState.pressed); final Color effectiveInactivePressedOverlayColor = widget.overlayColor?.resolve(inactivePressedStates) - ?? switchTheme.overlayColor?.resolve(inactivePressedStates) - ?? effectiveInactiveThumbColor.withAlpha(kRadialReactionAlpha); + ?? switchTheme.overlayColor?.resolve(inactivePressedStates) + ?? inactiveThumbColor?.withAlpha(kRadialReactionAlpha) + ?? defaults.overlayColor!.resolve(inactivePressedStates)!; final MaterialStateProperty effectiveMouseCursor = MaterialStateProperty.resolveWith((Set states) { return MaterialStateProperty.resolveAs(widget.mouseCursor, states) @@ -763,6 +798,11 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta ?? MaterialStateProperty.resolveAs(MaterialStateMouseCursor.clickable, states); }); + final double effectiveActiveThumbRadius = effectiveActiveIcon == null ? switchConfig.activeThumbRadius : switchConfig.thumbRadiusWithIcon; + final double effectiveInactiveThumbRadius = effectiveInactiveIcon == null && widget.inactiveThumbImage == null + ? switchConfig.inactiveThumbRadius : switchConfig.thumbRadiusWithIcon; + final double effectiveSplashRadius = widget.splashRadius ?? switchTheme.splashRadius ?? defaults.splashRadius!; + return Semantics( toggled: widget.value, child: GestureDetector( @@ -785,10 +825,11 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta ..reactionColor = effectiveActivePressedOverlayColor ..hoverColor = effectiveHoverOverlayColor ..focusColor = effectiveFocusOverlayColor - ..splashRadius = widget.splashRadius ?? switchTheme.splashRadius ?? kRadialReactionRadius + ..splashRadius = effectiveSplashRadius ..downPosition = downPosition ..isFocused = states.contains(MaterialState.focused) ..isHovered = states.contains(MaterialState.hovered) + ..isPressed = _isPressed || downPosition != null ..activeColor = effectiveActiveThumbColor ..inactiveColor = effectiveInactiveThumbColor ..activeThumbImage = widget.activeThumbImage @@ -797,11 +838,23 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta ..onInactiveThumbImageError = widget.onInactiveThumbImageError ..activeTrackColor = effectiveActiveTrackColor ..inactiveTrackColor = effectiveInactiveTrackColor + ..inactiveTrackOutlineColor = effectiveInactiveTrackOutlineColor ..configuration = createLocalImageConfiguration(context) ..isInteractive = isInteractive ..trackInnerLength = _trackInnerLength ..textDirection = Directionality.of(context) - ..surfaceColor = theme.colorScheme.surface, + ..surfaceColor = theme.colorScheme.surface + ..inactiveThumbRadius = effectiveInactiveThumbRadius + ..activeThumbRadius = effectiveActiveThumbRadius + ..pressedThumbRadius = switchConfig.pressedThumbRadius + ..trackHeight = switchConfig.trackHeight + ..trackWidth = switchConfig.trackWidth + ..activeIconColor = effectiveActiveIconColor + ..inactiveIconColor = effectiveInactiveIconColor + ..activeIcon = effectiveActiveIcon + ..inactiveIcon = effectiveInactiveIcon + ..iconTheme = IconTheme.of(context) + ..thumbShadow = switchConfig.thumbShadow, ), ), ); @@ -809,6 +862,123 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta } class _SwitchPainter extends ToggleablePainter { + Icon? get activeIcon => _activeIcon; + Icon? _activeIcon; + set activeIcon(Icon? value) { + if (value == _activeIcon) { + return; + } + _activeIcon = value; + notifyListeners(); + } + + Icon? get inactiveIcon => _inactiveIcon; + Icon? _inactiveIcon; + set inactiveIcon(Icon? value) { + if (value == _inactiveIcon) { + return; + } + _inactiveIcon = value; + notifyListeners(); + } + + IconThemeData? get iconTheme => _iconTheme; + IconThemeData? _iconTheme; + set iconTheme(IconThemeData? value) { + if (value == _iconTheme) { + return; + } + _iconTheme = value; + notifyListeners(); + } + + Color get activeIconColor => _activeIconColor!; + Color? _activeIconColor; + set activeIconColor(Color value) { + assert(value != null); + if (value == _activeIconColor) { + return; + } + _activeIconColor = value; + notifyListeners(); + } + + Color get inactiveIconColor => _inactiveIconColor!; + Color? _inactiveIconColor; + set inactiveIconColor(Color value) { + assert(value != null); + if (value == _inactiveIconColor) { + return; + } + _inactiveIconColor = value; + notifyListeners(); + } + + bool get isPressed => _isPressed!; + bool? _isPressed; + set isPressed(bool? value) { + if (value == _isPressed) { + return; + } + _isPressed = value; + notifyListeners(); + } + + double get activeThumbRadius => _activeThumbRadius!; + double? _activeThumbRadius; + set activeThumbRadius(double value) { + assert(value != null); + if (value == _activeThumbRadius) { + return; + } + _activeThumbRadius = value; + notifyListeners(); + } + + double get inactiveThumbRadius => _inactiveThumbRadius!; + double? _inactiveThumbRadius; + set inactiveThumbRadius(double value) { + assert(value != null); + if (value == _inactiveThumbRadius) { + return; + } + _inactiveThumbRadius = value; + notifyListeners(); + } + + double get pressedThumbRadius => _pressedThumbRadius!; + double? _pressedThumbRadius; + set pressedThumbRadius(double value) { + assert(value != null); + if (value == _pressedThumbRadius) { + return; + } + _pressedThumbRadius = value; + notifyListeners(); + } + + double get trackHeight => _trackHeight!; + double? _trackHeight; + set trackHeight(double value) { + assert(value != null); + if (value == _trackHeight) { + return; + } + _trackHeight = value; + notifyListeners(); + } + + double get trackWidth => _trackWidth!; + double? _trackWidth; + set trackWidth(double value) { + assert(value != null); + if (value == _trackWidth) { + return; + } + _trackWidth = value; + notifyListeners(); + } + ImageProvider? get activeThumbImage => _activeThumbImage; ImageProvider? _activeThumbImage; set activeThumbImage(ImageProvider? value) { @@ -860,6 +1030,16 @@ class _SwitchPainter extends ToggleablePainter { notifyListeners(); } + Color? get inactiveTrackOutlineColor => _inactiveTrackOutlineColor; + Color? _inactiveTrackOutlineColor; + set inactiveTrackOutlineColor(Color? value) { + if (value == _inactiveTrackOutlineColor) { + return; + } + _inactiveTrackOutlineColor = value; + notifyListeners(); + } + Color get inactiveTrackColor => _inactiveTrackColor!; Color? _inactiveTrackColor; set inactiveTrackColor(Color value) { @@ -924,6 +1104,16 @@ class _SwitchPainter extends ToggleablePainter { notifyListeners(); } + List? get thumbShadow => _thumbShadow; + List? _thumbShadow; + set thumbShadow(List? value) { + if (value == _thumbShadow) { + return; + } + _thumbShadow = value; + notifyListeners(); + } + Color? _cachedThumbColor; ImageProvider? _cachedThumbImage; ImageErrorListener? _cachedThumbErrorListener; @@ -934,7 +1124,7 @@ class _SwitchPainter extends ToggleablePainter { color: color, image: image == null ? null : DecorationImage(image: image, onError: errorListener), shape: BoxShape.circle, - boxShadow: kElevationToShadow[1], + boxShadow: thumbShadow, ); } @@ -952,7 +1142,6 @@ class _SwitchPainter extends ToggleablePainter { @override void paint(Canvas canvas, Size size) { - final bool isEnabled = isInteractive; final double currentValue = position.value; final double visualPosition; @@ -965,29 +1154,32 @@ class _SwitchPainter extends ToggleablePainter { break; } + final double thumbRadius = isPressed + ? pressedThumbRadius + : lerpDouble(inactiveThumbRadius, activeThumbRadius, currentValue)!; final Color trackColor = Color.lerp(inactiveTrackColor, activeTrackColor, currentValue)!; + final Color? trackOutlineColor = inactiveTrackOutlineColor == null ? null + : Color.lerp(inactiveTrackOutlineColor, Colors.transparent, currentValue); final Color lerpedThumbColor = Color.lerp(inactiveColor, activeColor, currentValue)!; // Blend the thumb color against a `surfaceColor` background in case the // thumbColor is not opaque. This way we do not see through the thumb to the // track underneath. final Color thumbColor = Color.alphaBlend(lerpedThumbColor, surfaceColor); - final ImageProvider? thumbImage = isEnabled - ? (currentValue < 0.5 ? inactiveThumbImage : activeThumbImage) - : inactiveThumbImage; + final Icon? thumbIcon = currentValue < 0.5 ? inactiveIcon : activeIcon; - final ImageErrorListener? thumbErrorListener = isEnabled - ? (currentValue < 0.5 ? onInactiveThumbImageError : onActiveThumbImageError) - : onInactiveThumbImageError; + final ImageProvider? thumbImage = currentValue < 0.5 ? inactiveThumbImage : activeThumbImage; + + final ImageErrorListener? thumbErrorListener = currentValue < 0.5 ? onInactiveThumbImageError : onActiveThumbImageError; final Paint paint = Paint() ..color = trackColor; - final Offset trackPaintOffset = _computeTrackPaintOffset(size, _kTrackWidth, _kTrackHeight); + final Offset trackPaintOffset = _computeTrackPaintOffset(size, trackWidth, trackHeight); final Offset thumbPaintOffset = _computeThumbPaintOffset(trackPaintOffset, visualPosition); - final Offset radialReactionOrigin = Offset(thumbPaintOffset.dx + _kThumbRadius, size.height / 2); + final Offset radialReactionOrigin = Offset(thumbPaintOffset.dx + thumbRadius, size.height / 2); - _paintTrackWith(canvas, paint, trackPaintOffset); + _paintTrackWith(canvas, paint, trackPaintOffset, trackOutlineColor); paintRadialReaction(canvas: canvas, origin: radialReactionOrigin); _paintThumbWith( thumbPaintOffset, @@ -996,13 +1188,15 @@ class _SwitchPainter extends ToggleablePainter { thumbColor, thumbImage, thumbErrorListener, + thumbRadius, + thumbIcon, ); } /// Computes canvas offset for track's upper left corner Offset _computeTrackPaintOffset(Size canvasSize, double trackWidth, double trackHeight) { - final double horizontalOffset = (canvasSize.width - _kTrackWidth) / 2.0; - final double verticalOffset = (canvasSize.height - _kTrackHeight) / 2.0; + final double horizontalOffset = (canvasSize.width - trackWidth) / 2.0; + final double verticalOffset = (canvasSize.height - trackHeight) / 2.0; return Offset(horizontalOffset, verticalOffset); } @@ -1011,7 +1205,9 @@ class _SwitchPainter extends ToggleablePainter { /// square Offset _computeThumbPaintOffset(Offset trackPaintOffset, double visualPosition) { // How much thumb radius extends beyond the track - const double additionalThumbRadius = _kThumbRadius - _kTrackRadius; + final double trackRadius = trackHeight / 2; + final double thumbRadius = isPressed ? pressedThumbRadius : lerpDouble(inactiveThumbRadius, activeThumbRadius, position.value)!; + final double additionalThumbRadius = thumbRadius - trackRadius; final double horizontalProgress = visualPosition * trackInnerLength; final double thumbHorizontalOffset = trackPaintOffset.dx - additionalThumbRadius + horizontalProgress; @@ -1020,29 +1216,51 @@ class _SwitchPainter extends ToggleablePainter { return Offset(thumbHorizontalOffset, thumbVerticalOffset); } - void _paintTrackWith(Canvas canvas, Paint paint, Offset trackPaintOffset) { + void _paintTrackWith(Canvas canvas, Paint paint, Offset trackPaintOffset, Color? trackOutlineColor) { final Rect trackRect = Rect.fromLTWH( trackPaintOffset.dx, trackPaintOffset.dy, - _kTrackWidth, - _kTrackHeight, + trackWidth, + trackHeight, ); + final double trackRadius = trackHeight / 2; final RRect trackRRect = RRect.fromRectAndRadius( trackRect, - const Radius.circular(_kTrackRadius), + Radius.circular(trackRadius), ); canvas.drawRRect(trackRRect, paint); + + if (trackOutlineColor != null) { + // paint track outline + final Rect outlineTrackRect = Rect.fromLTWH( + trackPaintOffset.dx + 1, + trackPaintOffset.dy + 1, + trackWidth - 2, + trackHeight - 2, + ); + final RRect outlineTrackRRect = RRect.fromRectAndRadius( + outlineTrackRect, + Radius.circular(trackRadius), + ); + final Paint outlinePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = trackOutlineColor; + canvas.drawRRect(outlineTrackRRect, outlinePaint); + } } void _paintThumbWith( - Offset thumbPaintOffset, - Canvas canvas, - double currentValue, - Color thumbColor, - ImageProvider? thumbImage, - ImageErrorListener? thumbErrorListener, - ) { + Offset thumbPaintOffset, + Canvas canvas, + double currentValue, + Color thumbColor, + ImageProvider? thumbImage, + ImageErrorListener? thumbErrorListener, + double thumbRadius, + Icon? thumbIcon, + ) { try { _isPainting = true; if (_cachedThumbPainter == null || thumbColor != _cachedThumbColor || thumbImage != _cachedThumbImage || thumbErrorListener != _cachedThumbErrorListener) { @@ -1056,13 +1274,51 @@ class _SwitchPainter extends ToggleablePainter { // The thumb contracts slightly during the animation final double inset = 1.0 - (currentValue - 0.5).abs() * 2.0; - final double radius = _kThumbRadius - inset; + final double radius = thumbRadius - inset; thumbPainter.paint( canvas, thumbPaintOffset + Offset(0, inset), configuration.copyWith(size: Size.fromRadius(radius)), ); + + if (thumbIcon != null && thumbIcon.icon != null) { + final Color iconColor = Color.lerp(inactiveIconColor, activeIconColor, currentValue)!; + final double iconSize = thumbIcon.size ?? _SwitchConfigM3.iconSize; + final IconData iconData = thumbIcon.icon!; + final double? iconWeight = thumbIcon.weight ?? iconTheme?.weight; + final double? iconFill = thumbIcon.fill ?? iconTheme?.fill; + final double? iconGrade = thumbIcon.grade ?? iconTheme?.grade; + final double? iconOpticalSize = thumbIcon.opticalSize ?? iconTheme?.opticalSize; + final List? iconShadows = thumbIcon.shadows ?? iconTheme?.shadows; + + final TextSpan textSpan = TextSpan( + text: String.fromCharCode(iconData.codePoint), + style: TextStyle( + fontVariations: [ + if (iconFill != null) FontVariation('FILL', iconFill), + if (iconWeight != null) FontVariation('wght', iconWeight), + if (iconGrade != null) FontVariation('GRAD', iconGrade), + if (iconOpticalSize != null) FontVariation('opsz', iconOpticalSize), + ], + color: iconColor, + fontSize: iconSize, + inherit: false, + fontFamily: iconData.fontFamily, + package: iconData.fontPackage, + shadows: iconShadows, + ), + ); + final TextPainter textPainter = TextPainter( + textDirection: textDirection, + text: textSpan, + ); + textPainter.layout(); + final double additionalIconRadius = thumbRadius - iconSize / 2; + final Offset offset = thumbPaintOffset + Offset(additionalIconRadius, additionalIconRadius); + + textPainter.paint(canvas, offset); + } } finally { _isPainting = false; } @@ -1078,3 +1334,330 @@ class _SwitchPainter extends ToggleablePainter { super.dispose(); } } + +mixin _SwitchConfig { + double get trackHeight; + double get trackWidth; + double get switchWidth; + double get switchHeight; + double get switchHeightCollapsed; + double get activeThumbRadius; + double get inactiveThumbRadius; + double get pressedThumbRadius; + double get thumbRadiusWithIcon; + List? get thumbShadow; + MaterialStateProperty? get trackOutlineColor; + MaterialStateProperty get iconColor; +} + +// Hand coded defaults based on Material Design 2. +class _SwitchConfigM2 with _SwitchConfig { + _SwitchConfigM2(); + + @override + double get activeThumbRadius => 10.0; + + @override + MaterialStateProperty get iconColor => MaterialStateProperty.all(Colors.transparent); + + @override + double get inactiveThumbRadius => 10.0; + + @override + double get pressedThumbRadius => 10.0; + + @override + double get switchHeight => _kSwitchMinSize + 8.0; + + @override + double get switchHeightCollapsed => _kSwitchMinSize; + + @override + double get switchWidth => trackWidth - 2 * (trackHeight / 2.0) + _kSwitchMinSize; + + @override + double get thumbRadiusWithIcon => 10.0; + + @override + List? get thumbShadow => kElevationToShadow[1]; + + @override + double get trackHeight => 14.0; + + @override + MaterialStateProperty? get trackOutlineColor => null; + + @override + double get trackWidth => 33.0; +} + +class _SwitchDefaultsM2 extends SwitchThemeData { + _SwitchDefaultsM2(BuildContext context) + : _theme = Theme.of(context), + _colors = Theme.of(context).colorScheme; + + final ThemeData _theme; + final ColorScheme _colors; + + @override + MaterialStateProperty get thumbColor { + final bool isDark = _theme.brightness == Brightness.dark; + + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return isDark ? Colors.grey.shade800 : Colors.grey.shade400; + } + if (states.contains(MaterialState.selected)) { + return _colors.secondary; + } + return isDark ? Colors.grey.shade400 : Colors.grey.shade50; + }); + } + + @override + MaterialStateProperty get trackColor { + final bool isDark = _theme.brightness == Brightness.dark; + const Color black32 = Color(0x52000000); // Black with 32% opacity + + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return isDark ? Colors.white10 : Colors.black12; + } + if (states.contains(MaterialState.selected)) { + final Color activeColor = _colors.secondary; + return activeColor.withAlpha(0x80); + } + return isDark ? Colors.white30 : black32; + }); + } + + @override + MaterialTapTargetSize get materialTapTargetSize => _theme.materialTapTargetSize; + + @override + MaterialStateProperty get mouseCursor => + MaterialStateProperty.resolveWith((Set states) => MaterialStateMouseCursor.clickable.resolve(states)); + + @override + MaterialStateProperty get overlayColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.pressed)) { + return thumbColor.resolve(states).withAlpha(kRadialReactionAlpha); + } + if (states.contains(MaterialState.focused)) { + return _theme.focusColor; + } + if (states.contains(MaterialState.hovered)) { + return _theme.hoverColor; + } + return null; + }); + } + + @override + double get splashRadius => kRadialReactionRadius; +} + +// BEGIN GENERATED TOKEN PROPERTIES - Switch + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// Token database version: v0_101 + +class _SwitchDefaultsM3 extends SwitchThemeData { + _SwitchDefaultsM3(BuildContext context) + : _colors = Theme.of(context).colorScheme; + + final ColorScheme _colors; + + @override + MaterialStateProperty get thumbColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + if (states.contains(MaterialState.selected)) { + return _colors.surface.withOpacity(1.0); + } + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return _colors.primaryContainer; + } + if (states.contains(MaterialState.hovered)) { + return _colors.primaryContainer; + } + if (states.contains(MaterialState.focused)) { + return _colors.primaryContainer; + } + return _colors.onPrimary; + } + if (states.contains(MaterialState.pressed)) { + return _colors.onSurfaceVariant; + } + if (states.contains(MaterialState.hovered)) { + return _colors.onSurfaceVariant; + } + if (states.contains(MaterialState.focused)) { + return _colors.onSurfaceVariant; + } + return _colors.outline; + }); + } + + @override + MaterialStateProperty get trackColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + if (states.contains(MaterialState.selected)) { + return _colors.onSurface.withOpacity(0.12); + } + return _colors.surfaceVariant.withOpacity(0.12); + } + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return _colors.primary; + } + if (states.contains(MaterialState.hovered)) { + return _colors.primary; + } + if (states.contains(MaterialState.focused)) { + return _colors.primary; + } + return _colors.primary; + } + if (states.contains(MaterialState.pressed)) { + return _colors.surfaceVariant; + } + if (states.contains(MaterialState.hovered)) { + return _colors.surfaceVariant; + } + if (states.contains(MaterialState.focused)) { + return _colors.surfaceVariant; + } + return _colors.surfaceVariant; + }); + } + + @override + MaterialStateProperty get overlayColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return _colors.primary.withOpacity(0.12); + } + if (states.contains(MaterialState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return _colors.primary.withOpacity(0.12); + } + return null; + } + if (states.contains(MaterialState.pressed)) { + return _colors.onSurface.withOpacity(0.12); + } + if (states.contains(MaterialState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return _colors.onSurface.withOpacity(0.12); + } + return null; + }); + } + + @override + double get splashRadius => 40.0 / 2; +} + +class _SwitchConfigM3 with _SwitchConfig { + _SwitchConfigM3(this.context) + : _colors = Theme.of(context).colorScheme; + + BuildContext context; + final ColorScheme _colors; + + static const double iconSize = 16.0; + + @override + double get activeThumbRadius => 24.0 / 2; + + @override + MaterialStateProperty get iconColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + if (states.contains(MaterialState.selected)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.surfaceVariant.withOpacity(0.38); + } + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return _colors.onPrimaryContainer; + } + if (states.contains(MaterialState.hovered)) { + return _colors.onPrimaryContainer; + } + if (states.contains(MaterialState.focused)) { + return _colors.onPrimaryContainer; + } + return _colors.onPrimaryContainer; + } + if (states.contains(MaterialState.pressed)) { + return _colors.surfaceVariant; + } + if (states.contains(MaterialState.hovered)) { + return _colors.surfaceVariant; + } + if (states.contains(MaterialState.focused)) { + return _colors.surfaceVariant; + } + return _colors.surfaceVariant; + }); + } + + @override + double get inactiveThumbRadius => 16.0 / 2; + + @override + double get pressedThumbRadius => 28.0 / 2; + + @override + double get switchHeight => _kSwitchMinSize + 8.0; + + @override + double get switchHeightCollapsed => _kSwitchMinSize; + + @override + double get switchWidth => trackWidth - 2 * (trackHeight / 2.0) + _kSwitchMinSize; + + @override + double get thumbRadiusWithIcon => 24.0 / 2; + + @override + List? get thumbShadow => kElevationToShadow[0]; + + @override + double get trackHeight => 32.0; + + @override + MaterialStateProperty get trackOutlineColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return null; + } + if (states.contains(MaterialState.disabled)) { + return _colors.onSurface.withOpacity(0.12); + } + return _colors.outline; + }); + } + + @override + double get trackWidth => 52.0; +} + +// END GENERATED TOKEN PROPERTIES - Switch diff --git a/packages/flutter/lib/src/material/switch_theme.dart b/packages/flutter/lib/src/material/switch_theme.dart index 4660ceb12c2..04207e59d14 100644 --- a/packages/flutter/lib/src/material/switch_theme.dart +++ b/packages/flutter/lib/src/material/switch_theme.dart @@ -43,6 +43,7 @@ class SwitchThemeData with Diagnosticable { this.mouseCursor, this.overlayColor, this.splashRadius, + this.thumbIcon, }); /// {@macro flutter.material.switch.thumbColor} @@ -76,6 +77,11 @@ class SwitchThemeData with Diagnosticable { /// If specified, overrides the default value of [Switch.splashRadius]. final double? splashRadius; + /// {@macro flutter.material.switch.thumbIcon} + /// + /// It is overridden by [Switch.thumbIcon]. + final MaterialStateProperty? thumbIcon; + /// Creates a copy of this object but with the given fields replaced with the /// new values. SwitchThemeData copyWith({ @@ -85,6 +91,7 @@ class SwitchThemeData with Diagnosticable { MaterialStateProperty? mouseCursor, MaterialStateProperty? overlayColor, double? splashRadius, + MaterialStateProperty? thumbIcon, }) { return SwitchThemeData( thumbColor: thumbColor ?? this.thumbColor, @@ -93,6 +100,7 @@ class SwitchThemeData with Diagnosticable { mouseCursor: mouseCursor ?? this.mouseCursor, overlayColor: overlayColor ?? this.overlayColor, splashRadius: splashRadius ?? this.splashRadius, + thumbIcon: thumbIcon ?? this.thumbIcon, ); } @@ -107,6 +115,7 @@ class SwitchThemeData with Diagnosticable { mouseCursor: t < 0.5 ? a?.mouseCursor : b?.mouseCursor, overlayColor: MaterialStateProperty.lerp(a?.overlayColor, b?.overlayColor, t, Color.lerp), splashRadius: lerpDouble(a?.splashRadius, b?.splashRadius, t), + thumbIcon: t < 0.5 ? a?.thumbIcon : b?.thumbIcon, ); } @@ -118,6 +127,7 @@ class SwitchThemeData with Diagnosticable { mouseCursor, overlayColor, splashRadius, + thumbIcon, ); @override @@ -134,7 +144,8 @@ class SwitchThemeData with Diagnosticable { && other.materialTapTargetSize == materialTapTargetSize && other.mouseCursor == mouseCursor && other.overlayColor == overlayColor - && other.splashRadius == splashRadius; + && other.splashRadius == splashRadius + && other.thumbIcon == thumbIcon; } @override @@ -146,6 +157,7 @@ class SwitchThemeData with Diagnosticable { properties.add(DiagnosticsProperty>('mouseCursor', mouseCursor, defaultValue: null)); properties.add(DiagnosticsProperty>('overlayColor', overlayColor, defaultValue: null)); properties.add(DoubleProperty('splashRadius', splashRadius, defaultValue: null)); + properties.add(DiagnosticsProperty>('thumbIcon', thumbIcon, defaultValue: null)); } } diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 20836050ad5..f8cbe8dd3e2 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -1252,7 +1252,7 @@ class ThemeData with Diagnosticable { /// * Typography: `typography` (see table above) /// /// ### Components - /// * Common buttons: [ElevatedButton], [FilledButton], [OutlinedButton], [TextButton] + /// * Common buttons: [ElevatedButton], [FilledButton], [OutlinedButton], [TextButton], [IconButton] /// * FAB: [FloatingActionButton] /// * Extended FAB: [FloatingActionButton.extended] /// * Cards: [Card] @@ -1266,6 +1266,7 @@ class ThemeData with Diagnosticable { /// * Lists: [ListTile] /// * Navigation bar: [NavigationBar] (new, replacing [BottomNavigationBar]) /// * [Navigation rail](https://m3.material.io/components/navigation-rail): [NavigationRail] + /// * Switch: [Switch] /// * Top app bar: [AppBar] /// /// In addition, this flag enables features introduced in Android 12. diff --git a/packages/flutter/test/material/switch_test.dart b/packages/flutter/test/material/switch_test.dart index 536ad39c053..1d3274c7d5f 100644 --- a/packages/flutter/test/material/switch_test.dart +++ b/packages/flutter/test/material/switch_test.dart @@ -21,6 +21,8 @@ import '../rendering/mock_canvas.dart'; import '../widgets/semantics_tester.dart'; void main() { + final ThemeData theme = ThemeData(); + testWidgets('Switch can toggle on tap', (WidgetTester tester) async { final Key switchKey = UniqueKey(); bool value = false; @@ -30,17 +32,20 @@ void main() { textDirection: TextDirection.ltr, child: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { - return Material( - child: Center( - child: Switch( - dragStartBehavior: DragStartBehavior.down, - key: switchKey, - value: value, - onChanged: (bool newValue) { - setState(() { - value = newValue; - }); - }, + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + key: switchKey, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), ), ), ); @@ -55,9 +60,10 @@ void main() { }); testWidgets('Switch size is configurable by ThemeData.materialTapTargetSize', (WidgetTester tester) async { + final bool material3 = theme.useMaterial3; await tester.pumpWidget( Theme( - data: ThemeData(materialTapTargetSize: MaterialTapTargetSize.padded), + data: theme.copyWith(materialTapTargetSize: MaterialTapTargetSize.padded), child: Directionality( textDirection: TextDirection.ltr, child: Material( @@ -73,11 +79,14 @@ void main() { ), ); - expect(tester.getSize(find.byType(Switch)), const Size(59.0, 48.0)); + // switch width = trackWidth - 2 * trackRadius + _kSwitchMinSize + // M2 width = 33 - 2 * 7 + 40 + // M3 width = 52 - 2 * 16 + 40 + expect(tester.getSize(find.byType(Switch)), material3 ? const Size(60.0, 48.0) : const Size(59.0, 48.0)); await tester.pumpWidget( Theme( - data: ThemeData(materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), + data: theme.copyWith(materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), child: Directionality( textDirection: TextDirection.ltr, child: Material( @@ -93,10 +102,10 @@ void main() { ), ); - expect(tester.getSize(find.byType(Switch)), const Size(59.0, 40.0)); + expect(tester.getSize(find.byType(Switch)), material3 ? const Size(60.0, 40.0) : const Size(59.0, 40.0)); }); - testWidgets('Switch does not get distorted upon changing constraints with parent', (WidgetTester tester) async { + testWidgets('Switch does not get distorted upon changing constraints with parent - M2', (WidgetTester tester) async { const double maxWidth = 300; const double maxHeight = 100; @@ -151,24 +160,27 @@ void main() { bool value = false; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return Material( - child: Center( - child: Switch( - dragStartBehavior: DragStartBehavior.down, - value: value, - onChanged: (bool newValue) { - setState(() { - value = newValue; - }); - }, + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -198,24 +210,27 @@ void main() { bool value = false; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return Material( - child: Center( - child: Switch( - dragStartBehavior: DragStartBehavior.down, - value: value, - onChanged: (bool newValue) { - setState(() { - value = newValue; - }); - }, + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -287,24 +302,27 @@ void main() { bool value = false; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.rtl, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return Material( - child: Center( - child: Switch( - dragStartBehavior: DragStartBehavior.down, - value: value, - onChanged: (bool newValue) { - setState(() { - value = newValue; - }); - }, + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -513,24 +531,27 @@ void main() { bool value = false; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return Material( - child: Center( - child: Switch( - dragStartBehavior: DragStartBehavior.down, - value: value, - onChanged: (bool newValue) { - setState(() { - value = newValue; - }); - }, + MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -554,24 +575,27 @@ void main() { bool value = false; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return Material( - child: Center( - child: Switch( - dragStartBehavior: DragStartBehavior.down, - value: value, - onChanged: (bool newValue) { - setState(() { - value = value || newValue; - }); - }, + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = value || newValue; + }); + }, + ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -637,23 +661,26 @@ void main() { final SemanticsTester semanticsTester = SemanticsTester(tester); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return Material( - child: Center( - child: Switch( - value: value, - onChanged: (bool newValue) { - setState(() { - value = newValue; - }); - }, + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -682,6 +709,7 @@ void main() { await tester.pumpWidget( MaterialApp( + theme: theme, home: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { void onChanged(bool newValue) { @@ -859,6 +887,7 @@ void main() { const double splashRadius = 30; Widget buildApp() { return MaterialApp( + theme: theme, home: Material( child: Center( child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { @@ -962,6 +991,7 @@ void main() { bool value = true; Widget buildApp({bool enabled = true}) { return MaterialApp( + theme: theme, home: Material( child: Center( child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { @@ -1001,6 +1031,7 @@ void main() { // Test Switch.adaptive() constructor await tester.pumpWidget( MaterialApp( + theme: theme, home: Scaffold( body: Align( alignment: Alignment.topLeft, @@ -1029,6 +1060,7 @@ void main() { // Test Switch() constructor await tester.pumpWidget( MaterialApp( + theme: theme, home: Scaffold( body: Align( alignment: Alignment.topLeft, @@ -1053,6 +1085,7 @@ void main() { // Test default cursor await tester.pumpWidget( MaterialApp( + theme: theme, home: Scaffold( body: Align( alignment: Alignment.topLeft, @@ -1074,8 +1107,9 @@ void main() { // Test default cursor when disabled await tester.pumpWidget( - const MaterialApp( - home: Scaffold( + MaterialApp( + theme: theme, + home: const Scaffold( body: Align( alignment: Alignment.topLeft, child: Material( @@ -1103,24 +1137,27 @@ void main() { bool enabled = true; late StateSetter stateSetter; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - stateSetter = setState; - return Material( - child: Center( - child: Switch( - value: value, - onChanged: !enabled ? null : (bool newValue) { - setState(() { - value = newValue; - }); - }, + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Material( + child: Center( + child: Switch( + value: value, + onChanged: !enabled ? null : (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -1580,6 +1617,7 @@ void main() { Widget buildSwitch({bool active = false, bool focused = false, bool useOverlay = true}) { return MaterialApp( + theme: theme, home: Scaffold( body: Switch( focusNode: focusNode, @@ -1701,6 +1739,7 @@ void main() { testWidgets('Do not crash when widget disappears while pointer is down', (WidgetTester tester) async { Widget buildSwitch(bool show) { return MaterialApp( + theme: theme, home: Material( child: Center( child: show ? Switch(value: true, onChanged: (_) { }) : Container(), @@ -1785,11 +1824,53 @@ void main() { image = await createTestImage(width: 100, height: 100); }); + testWidgets('thumb image shows up', (WidgetTester tester) async { + imageCache.clear(); + final _TestImageProvider provider1 = _TestImageProvider(); + final _TestImageProvider provider2 = _TestImageProvider(); + + expect(provider1.loadCallCount, 0); + expect(provider2.loadCallCount, 0); + + bool value1 = true; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Switch( + activeThumbImage: provider1, + inactiveThumbImage: provider2, + value: value1, + onChanged: (bool val) { + setState(() { + value1 = val; + }); + }, + ), + ); + } + ) + ) + ); + + expect(provider1.loadCallCount, 1); + expect(provider2.loadCallCount, 0); + expect(imageCache.liveImageCount, 1); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(provider1.loadCallCount, 1); + expect(provider2.loadCallCount, 1); + expect(imageCache.liveImageCount, 2); + }); + testWidgets('do not crash when imageProvider completes after Switch is disposed', (WidgetTester tester) async { final DelayedImageProvider imageProvider = DelayedImageProvider(image); await tester.pumpWidget( MaterialApp( + theme: theme, home: Material( child: Center( child: Switch( @@ -1819,6 +1900,7 @@ void main() { Future buildSwitch(ImageProvider imageProvider) { return tester.pumpWidget( MaterialApp( + theme: theme, home: Material( child: Center( child: Switch( @@ -1850,6 +1932,871 @@ void main() { expect(tester.takeException(), isNull); }); }); + + group('Switch M3 tests', () { + testWidgets('Switch has default colors when enabled - M3', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true, colorSchemeSeed: const Color(0xff6750a4), brightness: Brightness.light); + final ColorScheme colors = theme.colorScheme; + bool value = false; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceVariant, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect( + style: PaintingStyle.stroke, + color: colors.outline, + rrect: RRect.fromLTRBR(5.0, 9.0, 55.0, 39.0, const Radius.circular(16.0)), + ) + ..circle(color: colors.outline), // thumb color + reason: 'Inactive enabled switch should match these colors', + ); + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + await tester.pump(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: colors.onPrimary), // thumb color + reason: 'Active enabled switch should match these colors', + ); + }); + + testWidgets('Inactive Switch has default colors when disabled - M3', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(useMaterial3: true, colorSchemeSeed: const Color(0xff6750a4), brightness: Brightness.light); + final ColorScheme colors = themeData.colorScheme; + + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return const Material( + child: Center( + child: Switch( + value: false, + onChanged: null, + ), + ), + ); + }, + ), + ), + )); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceVariant.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect( + style: PaintingStyle.stroke, + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(5.0, 9.0, 55.0, 39.0, const Radius.circular(16.0)), + ) + ..circle(color: Color.alphaBlend(colors.onSurface.withOpacity(0.38), colors.surface)), // thumb color + reason: 'Inactive disabled switch should match these colors', + ); + }); + + testWidgets('Active Switch has default colors when disabled - M3', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(useMaterial3: true, + colorSchemeSeed: const Color(0xff6750a4), + brightness: Brightness.light); + final ColorScheme colors = themeData.colorScheme; + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return const Material( + child: Center( + child: Switch( + value: true, + onChanged: null, + ), + ), + ); + }, + ), + ), + )); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: colors.surface), // thumb color + reason: 'Active disabled switch should match these colors', + ); + }); + + testWidgets('Switch can be set color - M3', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(useMaterial3: true, colorSchemeSeed: const Color(0xff6750a4), brightness: Brightness.light); + final ColorScheme colors = themeData.colorScheme; + + bool value = false; + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + activeColor: Colors.red[500], + activeTrackColor: Colors.green[500], + inactiveThumbColor: Colors.yellow[500], + inactiveTrackColor: Colors.blue[500], + ), + ), + ); + }, + ), + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: Colors.blue[500], + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect( + style: PaintingStyle.stroke, + color: colors.outline, + ) + ..circle(color: Colors.yellow[500]), // thumb color + ); + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + await tester.pump(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: Colors.green[500], + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: Colors.red[500]), // thumb color + ); + }); + + testWidgets('Switch is focusable and has correct focus color - M3', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(useMaterial3: true, colorSchemeSeed: const Color(0xff6750a4), brightness: Brightness.light); + final ColorScheme colors = themeData.colorScheme; + final FocusNode focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool value = true; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { + return Switch( + value: value, + onChanged: enabled ? (bool newValue) { + setState(() { + value = newValue; + }); + } : null, + focusColor: Colors.orange[500], + autofocus: true, + focusNode: focusNode, + ); + }), + ), + ), + ); + } + await tester.pumpWidget(buildApp()); + + // active, enabled switch + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: Colors.orange[500]), + ); + + // Check the false value: inactive enabled switch + value = false; + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceVariant, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect( + style: PaintingStyle.stroke, + color: colors.outline, + rrect: RRect.fromLTRBR(5.0, 9.0, 55.0, 39.0, const Radius.circular(16.0)), + ) + ..circle(color: Colors.orange[500]) + ); + + // Check what happens when disabled: inactive disabled switch. + value = false; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceVariant.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect( + style: PaintingStyle.stroke, + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(5.0, 9.0, 55.0, 39.0, const Radius.circular(16.0)), + ) + ..circle(color: Color.alphaBlend(colors.onSurface.withOpacity(0.38), colors.surface)), + ); + }); + + testWidgets('Switch can be hovered and has correct hover color - M3', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(useMaterial3: true, colorSchemeSeed: const Color(0xff6750a4), brightness: Brightness.light); + final ColorScheme colors = themeData.colorScheme; + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool value = true; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { + return Switch( + value: value, + onChanged: enabled ? (bool newValue) { + setState(() { + value = newValue; + }); + } : null, + hoverColor: Colors.orange[500], + ); + }), + ), + ), + ); + } + + // active enabled switch + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: colors.onPrimary), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: Colors.orange[500]), + ); + + // Check what happens for disabled active switch + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: colors.surface.withOpacity(1.0)), + ); + }); + + testWidgets('Switch thumb color resolves in active/enabled states - M3', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(useMaterial3: true, colorSchemeSeed: const Color(0xff6750a4), brightness: Brightness.light); + final ColorScheme colors = themeData.colorScheme; + const Color activeEnabledThumbColor = Color(0xFF000001); + const Color activeDisabledThumbColor = Color(0xFF000002); + const Color inactiveEnabledThumbColor = Color(0xFF000003); + const Color inactiveDisabledThumbColor = Color(0xFF000004); + + Color getThumbColor(Set states) { + if (states.contains(MaterialState.disabled)) { + if (states.contains(MaterialState.selected)) { + return activeDisabledThumbColor; + } + return inactiveDisabledThumbColor; + } + if (states.contains(MaterialState.selected)) { + return activeEnabledThumbColor; + } + return inactiveEnabledThumbColor; + } + + final MaterialStateProperty thumbColor = MaterialStateColor.resolveWith(getThumbColor); + + Widget buildSwitch({required bool enabled, required bool active}) { + return Theme( + data: themeData, + child: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + thumbColor: thumbColor, + value: active, + onChanged: enabled ? (_) { } : null, + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: false)); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceVariant.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect( + style: PaintingStyle.stroke, + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(5.0, 9.0, 55.0, 39.0, const Radius.circular(16.0)), + ) + ..circle(color: inactiveDisabledThumbColor), + reason: 'Inactive disabled switch should default track and custom thumb color', + ); + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: activeDisabledThumbColor), + reason: 'Active disabled switch should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceVariant, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: inactiveEnabledThumbColor), + reason: 'Inactive enabled switch should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: activeEnabledThumbColor), + reason: 'Active enabled switch should match these colors', + ); + }); + + testWidgets('Switch thumb color resolves in hovered/focused states - M3', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(useMaterial3: true, colorSchemeSeed: const Color(0xff6750a4), brightness: Brightness.light); + final ColorScheme colors = themeData.colorScheme; + final FocusNode focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const Color hoveredThumbColor = Color(0xFF000001); + const Color focusedThumbColor = Color(0xFF000002); + + Color getThumbColor(Set states) { + if (states.contains(MaterialState.hovered)) { + return hoveredThumbColor; + } + if (states.contains(MaterialState.focused)) { + return focusedThumbColor; + } + return Colors.transparent; + } + + final MaterialStateProperty thumbColor = MaterialStateColor.resolveWith(getThumbColor); + + Widget buildSwitch() { + return MaterialApp( + theme: themeData, + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + focusNode: focusNode, + autofocus: true, + value: true, + thumbColor: thumbColor, + onChanged: (_) { }, + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: colors.primary.withOpacity(0.12)) + ..circle(color: focusedThumbColor), + reason: 'active enabled switch should default track and custom thumb color', + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: colors.primary.withOpacity(0.08)) + ..circle(color: hoveredThumbColor), + reason: 'active enabled switch should default track and custom thumb color', + ); + }); + + testWidgets('Track color resolves in active/enabled states - M3', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(useMaterial3: true, colorSchemeSeed: const Color(0xff6750a4), brightness: Brightness.light); + const Color activeEnabledTrackColor = Color(0xFF000001); + const Color activeDisabledTrackColor = Color(0xFF000002); + const Color inactiveEnabledTrackColor = Color(0xFF000003); + const Color inactiveDisabledTrackColor = Color(0xFF000004); + + Color getTrackColor(Set states) { + if (states.contains(MaterialState.disabled)) { + if (states.contains(MaterialState.selected)) { + return activeDisabledTrackColor; + } + return inactiveDisabledTrackColor; + } + if (states.contains(MaterialState.selected)) { + return activeEnabledTrackColor; + } + return inactiveEnabledTrackColor; + } + + final MaterialStateProperty trackColor = + MaterialStateColor.resolveWith(getTrackColor); + + Widget buildSwitch({required bool enabled, required bool active}) { + return Theme( + data: themeData, + child: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + trackColor: trackColor, + value: active, + onChanged: enabled ? (_) { } : null, + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: false)); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: inactiveDisabledTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ), + reason: 'Inactive disabled switch track should use this value', + ); + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: activeDisabledTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ), + reason: 'Active disabled switch should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: inactiveEnabledTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ), + reason: 'Inactive enabled switch should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: activeEnabledTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ), + reason: 'Active enabled switch should match these colors', + ); + }); + + testWidgets('Switch track color resolves in hovered/focused states', (WidgetTester tester) async { + final ThemeData themeData = ThemeData(useMaterial3: true, colorSchemeSeed: const Color(0xff6750a4), brightness: Brightness.light); + final FocusNode focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const Color hoveredTrackColor = Color(0xFF000001); + const Color focusedTrackColor = Color(0xFF000002); + + Color getTrackColor(Set states) { + if (states.contains(MaterialState.hovered)) { + return hoveredTrackColor; + } + if (states.contains(MaterialState.focused)) { + return focusedTrackColor; + } + return Colors.transparent; + } + + final MaterialStateProperty trackColor = + MaterialStateColor.resolveWith(getTrackColor); + + Widget buildSwitch() { + return Theme( + data: themeData, + child: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + focusNode: focusNode, + autofocus: true, + value: true, + trackColor: trackColor, + onChanged: (_) { }, + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: focusedTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ), + reason: 'Active enabled switch should match these colors', + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: hoveredTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ), + reason: 'Active enabled switch should match these colors', + ); + }); + + testWidgets('Switch thumb color is blended against surface color - M3', (WidgetTester tester) async { + final Color activeDisabledThumbColor = Colors.blue.withOpacity(.60); + final ThemeData theme = ThemeData.light(useMaterial3: true); + final ColorScheme colors = theme.colorScheme; + + Color getThumbColor(Set states) { + if (states.contains(MaterialState.disabled)) { + return activeDisabledThumbColor; + } + return Colors.black; + } + + final MaterialStateProperty thumbColor = + MaterialStateColor.resolveWith(getThumbColor); + + Widget buildSwitch({required bool enabled, required bool active}) { + return Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Theme( + data: theme, + child: Material( + child: Center( + child: Switch( + thumbColor: thumbColor, + value: active, + onChanged: enabled ? (_) { } : null, + ), + ), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + + final Color expectedThumbColor = Color.alphaBlend(activeDisabledThumbColor, theme.colorScheme.surface); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: expectedThumbColor), + reason: 'Active disabled thumb color should be blended on top of surface color', + ); + }); + + testWidgets('Switch can set icon - M3', (WidgetTester tester) async { + final ThemeData themeData = ThemeData( + useMaterial3: true, + colorSchemeSeed: const Color(0xff6750a4), + brightness: Brightness.light); + + MaterialStateProperty thumbIcon(Icon? activeIcon, Icon? inactiveIcon) { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return activeIcon; + } + return inactiveIcon; + }); + } + Widget buildSwitch({required bool enabled, required bool active, Icon? activeIcon, Icon? inactiveIcon}) { + return Theme( + data: themeData, + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + thumbIcon: thumbIcon(activeIcon, inactiveIcon), + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ); + }, + ), + ), + ); + } + + // active icon shows when switch is on. + await tester.pumpWidget(buildSwitch(enabled: true, active: true, activeIcon: const Icon(Icons.close))); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect()..circle() + ..paragraph(offset: const Offset(32.0, 16.0)), + ); + + // inactive icon shows when switch is off. + await tester.pumpWidget(buildSwitch(enabled: true, active: false, inactiveIcon: const Icon(Icons.close))); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect()..rrect() + ..circle() + ..paragraph(offset: const Offset(12.0, 16.0)), + ); + + // active icon doesn't show when switch is off. + await tester.pumpWidget(buildSwitch(enabled: true, active: false, activeIcon: const Icon(Icons.check))); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect()..rrect()..circle() + ); + + // inactive icon doesn't show when switch is on. + await tester.pumpWidget(buildSwitch(enabled: true, active: true, inactiveIcon: const Icon(Icons.check))); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect()..circle()..restore(), + ); + + // without icon + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect()..rrect()..circle()..restore(), + ); + }); + }); } class DelayedImageProvider extends ImageProvider { @@ -1876,3 +2823,44 @@ class DelayedImageProvider extends ImageProvider { @override String toString() => '${describeIdentity(this)}()'; } + +class _TestImageProvider extends ImageProvider { + _TestImageProvider({ImageStreamCompleter? streamCompleter}) { + _streamCompleter = streamCompleter + ?? OneFrameImageStreamCompleter(_completer.future); + } + + final Completer _completer = Completer(); + late ImageStreamCompleter _streamCompleter; + + bool get loadCalled => _loadCallCount > 0; + int get loadCallCount => _loadCallCount; + int _loadCallCount = 0; + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture<_TestImageProvider>(this); + } + + @override + void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, Object key, ImageErrorListener handleError) { + super.resolveStreamForKey(configuration, stream, key, handleError); + } + + @override + ImageStreamCompleter load(Object key, DecoderCallback decode) { + _loadCallCount += 1; + return _streamCompleter; + } + + void complete(ui.Image image) { + _completer.complete(ImageInfo(image: image)); + } + + void fail(Object exception, StackTrace? stackTrace) { + _completer.completeError(exception, stackTrace); + } + + @override + String toString() => '${describeIdentity(this)}()'; +} diff --git a/packages/flutter/test/material/switch_theme_test.dart b/packages/flutter/test/material/switch_theme_test.dart index 6da9d3afd50..c9628073dbb 100644 --- a/packages/flutter/test/material/switch_theme_test.dart +++ b/packages/flutter/test/material/switch_theme_test.dart @@ -23,6 +23,7 @@ void main() { expect(themeData.materialTapTargetSize, null); expect(themeData.overlayColor, null); expect(themeData.splashRadius, null); + expect(themeData.thumbIcon, null); const SwitchTheme theme = SwitchTheme(data: SwitchThemeData(), child: SizedBox()); expect(theme.data.thumbColor, null); @@ -31,6 +32,7 @@ void main() { expect(theme.data.materialTapTargetSize, null); expect(theme.data.overlayColor, null); expect(theme.data.splashRadius, null); + expect(theme.data.thumbIcon, null); }); testWidgets('Default SwitchThemeData debugFillProperties', (WidgetTester tester) async { @@ -54,6 +56,7 @@ void main() { materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, overlayColor: MaterialStatePropertyAll(Color(0xfffffff2)), splashRadius: 1.0, + thumbIcon: MaterialStatePropertyAll(Icon(IconData(123))), ).debugFillProperties(builder); final List description = builder.properties @@ -67,6 +70,7 @@ void main() { expect(description[3], 'mouseCursor: MaterialStatePropertyAll(SystemMouseCursor(click))'); expect(description[4], 'overlayColor: MaterialStatePropertyAll(Color(0xfffffff2))'); expect(description[5], 'splashRadius: 1.0'); + expect(description[6], 'thumbIcon: MaterialStatePropertyAll(Icon(IconData(U+0007B)))'); }); testWidgets('Switch is themeable', (WidgetTester tester) async { @@ -81,37 +85,48 @@ void main() { const Color focusOverlayColor = Color(0xfffffff4); const Color hoverOverlayColor = Color(0xfffffff5); const double splashRadius = 1.0; + const Icon icon1 = Icon(Icons.check); + const Icon icon2 = Icon(Icons.close); + final ThemeData themeData = ThemeData( + useMaterial3: true, + switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return selectedThumbColor; + } + return defaultThumbColor; + }), + trackColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return selectedTrackColor; + } + return defaultTrackColor; + }), + mouseCursor: const MaterialStatePropertyAll(mouseCursor), + materialTapTargetSize: materialTapTargetSize, + overlayColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.focused)) { + return focusOverlayColor; + } + if (states.contains(MaterialState.hovered)) { + return hoverOverlayColor; + } + return null; + }), + splashRadius: splashRadius, + thumbIcon: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return icon1; + } + return icon2; + }), + ), + ); + final bool material3 = themeData.useMaterial3; Widget buildSwitch({bool selected = false, bool autofocus = false}) { return MaterialApp( - theme: ThemeData( - switchTheme: SwitchThemeData( - thumbColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.selected)) { - return selectedThumbColor; - } - return defaultThumbColor; - }), - trackColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.selected)) { - return selectedTrackColor; - } - return defaultTrackColor; - }), - mouseCursor: const MaterialStatePropertyAll(mouseCursor), - materialTapTargetSize: materialTapTargetSize, - overlayColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.focused)) { - return focusOverlayColor; - } - if (states.contains(MaterialState.hovered)) { - return hoverOverlayColor; - } - return null; - }), - splashRadius: splashRadius, - ), - ), + theme: themeData, home: Scaffold( body: Switch( dragStartBehavior: DragStartBehavior.down, @@ -128,27 +143,39 @@ void main() { await tester.pumpAndSettle(); expect( _getSwitchMaterial(tester), - paints + material3 + ? (paints + ..rrect(color: defaultTrackColor) + ..rrect(color: themeData.colorScheme.outline) + ..circle(color: defaultThumbColor) + ..paragraph() + ) + : (paints ..rrect(color: defaultTrackColor) ..circle() ..circle() ..circle() - ..circle(color: defaultThumbColor), + ..circle(color: defaultThumbColor) + ) ); // Size from MaterialTapTargetSize.shrinkWrap. - expect(tester.getSize(find.byType(Switch)), const Size(59.0, 40.0)); + expect(tester.getSize(find.byType(Switch)), material3 ? const Size(60.0, 40.0) : const Size(59.0, 40.0)); // Selected switch. await tester.pumpWidget(buildSwitch(selected: true)); await tester.pumpAndSettle(); expect( _getSwitchMaterial(tester), - paints + material3 + ? (paints + ..rrect(color: selectedTrackColor) + ..circle(color: selectedThumbColor)..paragraph()) + : (paints ..rrect(color: selectedTrackColor) ..circle() ..circle() ..circle() - ..circle(color: selectedThumbColor), + ..circle(color: selectedThumbColor)) ); // Switch with hover. @@ -187,36 +214,45 @@ void main() { const Color hoverColor = Color(0xffffff5f); const double splashRadius = 2.0; + final ThemeData themeData = ThemeData( + switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return themeSelectedThumbColor; + } + return themeDefaultThumbColor; + }), + trackColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return themeSelectedTrackColor; + } + return themeDefaultTrackColor; + }), + mouseCursor: const MaterialStatePropertyAll(themeMouseCursor), + materialTapTargetSize: themeMaterialTapTargetSize, + overlayColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.focused)) { + return themeFocusOverlayColor; + } + if (states.contains(MaterialState.hovered)) { + return themeHoverOverlayColor; + } + return null; + }), + splashRadius: themeSplashRadius, + thumbIcon: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return null; + } + return null; + }), + ), + ); + final bool material3 = themeData.useMaterial3; + Widget buildSwitch({bool selected = false, bool autofocus = false}) { return MaterialApp( - theme: ThemeData( - switchTheme: SwitchThemeData( - thumbColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.selected)) { - return themeSelectedThumbColor; - } - return themeDefaultThumbColor; - }), - trackColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.selected)) { - return themeSelectedTrackColor; - } - return themeDefaultTrackColor; - }), - mouseCursor: const MaterialStatePropertyAll(themeMouseCursor), - materialTapTargetSize: themeMaterialTapTargetSize, - overlayColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.focused)) { - return themeFocusOverlayColor; - } - if (states.contains(MaterialState.hovered)) { - return themeHoverOverlayColor; - } - return null; - }), - splashRadius: themeSplashRadius, - ), - ), + theme: themeData, home: Scaffold( body: Switch( value: selected, @@ -239,6 +275,12 @@ void main() { focusColor: focusColor, hoverColor: hoverColor, splashRadius: splashRadius, + thumbIcon: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return const Icon(Icons.add); + } + return const Icon(Icons.access_alarm); + }), ), ), ); @@ -249,27 +291,36 @@ void main() { await tester.pumpAndSettle(); expect( _getSwitchMaterial(tester), - paints + material3 + ? (paints + ..rrect(color: defaultTrackColor) + ..rrect(color: themeData.colorScheme.outline) + ..circle(color: defaultThumbColor)..paragraph(offset: const Offset(12, 16))) + : (paints ..rrect(color: defaultTrackColor) ..circle() ..circle() ..circle() - ..circle(color: defaultThumbColor), + ..circle(color: defaultThumbColor)) ); // Size from MaterialTapTargetSize.shrinkWrap. - expect(tester.getSize(find.byType(Switch)), const Size(59.0, 40.0)); + expect(tester.getSize(find.byType(Switch)), material3 ? const Size(60.0, 40.0) : const Size(59.0, 40.0)); // Selected switch. await tester.pumpWidget(buildSwitch(selected: true)); await tester.pumpAndSettle(); expect( _getSwitchMaterial(tester), - paints + material3 + ? (paints + ..rrect(color: selectedTrackColor) + ..circle(color: selectedThumbColor)) + : (paints ..rrect(color: selectedTrackColor) ..circle() ..circle() ..circle() - ..circle(color: selectedThumbColor), + ..circle(color: selectedThumbColor)) ); // Switch with hover. @@ -298,24 +349,27 @@ void main() { const Color defaultTrackColor = Color(0xffffff2f); const Color selectedTrackColor = Color(0xffffff3f); + final ThemeData themeData = ThemeData( + switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return themeSelectedThumbColor; + } + return themeDefaultThumbColor; + }), + trackColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return themeSelectedTrackColor; + } + return themeDefaultTrackColor; + }), + ), + ); + final bool material3 = themeData.useMaterial3; + Widget buildSwitch({bool selected = false, bool autofocus = false}) { return MaterialApp( - theme: ThemeData( - switchTheme: SwitchThemeData( - thumbColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.selected)) { - return themeSelectedThumbColor; - } - return themeDefaultThumbColor; - }), - trackColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.selected)) { - return themeSelectedTrackColor; - } - return themeDefaultTrackColor; - }), - ), - ), + theme: themeData, home: Scaffold( body: Switch( value: selected, @@ -335,12 +389,17 @@ void main() { await tester.pumpAndSettle(); expect( _getSwitchMaterial(tester), - paints - ..rrect(color: defaultTrackColor) - ..circle() - ..circle() - ..circle() - ..circle(color: defaultThumbColor), + material3 + ? (paints + ..rrect(color: defaultTrackColor) + ..rrect(color: themeData.colorScheme.outline) + ..circle(color: defaultThumbColor)) + : (paints + ..rrect(color: defaultTrackColor) + ..circle() + ..circle() + ..circle() + ..circle(color: defaultThumbColor)) ); // Selected switch. @@ -348,12 +407,16 @@ void main() { await tester.pumpAndSettle(); expect( _getSwitchMaterial(tester), - paints + material3 + ? (paints + ..rrect(color: selectedTrackColor) + ..circle(color: selectedThumbColor)) + : (paints ..rrect(color: selectedTrackColor) ..circle() ..circle() ..circle() - ..circle(color: selectedThumbColor), + ..circle(color: selectedThumbColor)) ); }); @@ -371,15 +434,17 @@ void main() { return null; } const double splashRadius = 24.0; + final ThemeData themeData = ThemeData( + switchTheme: SwitchThemeData( + overlayColor: MaterialStateProperty.resolveWith(getOverlayColor), + splashRadius: splashRadius, + ), + ); + final bool material3 = themeData.useMaterial3; Widget buildSwitch({required bool active}) { return MaterialApp( - theme: ThemeData( - switchTheme: SwitchThemeData( - overlayColor: MaterialStateProperty.resolveWith(getOverlayColor), - splashRadius: splashRadius, - ), - ), + theme: themeData, home: Scaffold( body: Switch( value: active, @@ -395,12 +460,20 @@ void main() { expect( _getSwitchMaterial(tester), - paints + material3 + ? ((paints + ..rrect() + ..rrect()) + ..circle( + color: inactivePressedOverlayColor, + radius: splashRadius, + )) + : (paints ..rrect() ..circle( color: inactivePressedOverlayColor, radius: splashRadius, - ), + )), reason: 'Inactive pressed Switch should have overlay color: $inactivePressedOverlayColor', ); @@ -426,14 +499,16 @@ void main() { const Color localThemeThumbColor = Color(0xffff0000); const Color localThemeTrackColor = Color(0xffff0000); + final ThemeData themeData = ThemeData( + switchTheme: const SwitchThemeData( + thumbColor: MaterialStatePropertyAll(globalThemeThumbColor), + trackColor: MaterialStatePropertyAll(globalThemeTrackColor), + ), + ); + final bool material3 = themeData.useMaterial3; Widget buildSwitch({bool selected = false, bool autofocus = false}) { return MaterialApp( - theme: ThemeData( - switchTheme: const SwitchThemeData( - thumbColor: MaterialStatePropertyAll(globalThemeThumbColor), - trackColor: MaterialStatePropertyAll(globalThemeTrackColor), - ), - ), + theme: themeData, home: Scaffold( body: SwitchTheme( data: const SwitchThemeData( @@ -454,12 +529,16 @@ void main() { await tester.pumpAndSettle(); expect( _getSwitchMaterial(tester), - paints + material3 + ? (paints + ..rrect(color: localThemeTrackColor) + ..circle(color: localThemeThumbColor)) + : (paints ..rrect(color: localThemeTrackColor) ..circle() ..circle() ..circle() - ..circle(color: localThemeThumbColor), + ..circle(color: localThemeThumbColor)) ); }); }