diff --git a/dev/tools/gen_defaults/lib/switch_template.dart b/dev/tools/gen_defaults/lib/switch_template.dart index 07d4c39d7e..9190babe93 100644 --- a/dev/tools/gen_defaults/lib/switch_template.dart +++ b/dev/tools/gen_defaults/lib/switch_template.dart @@ -202,6 +202,18 @@ class _SwitchConfigM3 with _SwitchConfig { @override double get trackWidth => ${tokens['md.comp.switch.track.width']}; + + // The thumb size at the middle of the track. Hand coded default based on the animation specs. + @override + Size get transitionalThumbSize => const Size(34, 22); + + // Hand coded default based on the animation specs. + @override + int get toggleDuration => 300; + + // Hand coded default based on the animation specs. + @override + double? get thumbOffset => null; } '''; diff --git a/packages/flutter/lib/src/material/switch.dart b/packages/flutter/lib/src/material/switch.dart index a3a1178cd1..c4285220ab 100644 --- a/packages/flutter/lib/src/material/switch.dart +++ b/packages/flutter/lib/src/material/switch.dart @@ -666,13 +666,8 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta double get _trackInnerLength => widget.size.width - _kSwitchMinSize; - bool _isPressed = false; - void _handleDragStart(DragStartDetails details) { if (isInteractive) { - setState(() { - _isPressed = true; - }); reactionController.forward(); } } @@ -707,9 +702,6 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta } else { animateToValue(); } - setState(() { - _isPressed = false; - }); reactionController.reverse(); } @@ -734,6 +726,13 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta final _SwitchConfig switchConfig = theme.useMaterial3 ? _SwitchConfigM3(context) : _SwitchConfigM2(); final SwitchThemeData defaults = theme.useMaterial3 ? _SwitchDefaultsM3(context) : _SwitchDefaultsM2(context); + positionController.duration = Duration(milliseconds: switchConfig.toggleDuration); + if (theme.useMaterial3) { + position + ..curve = Curves.easeOutBack + ..reverseCurve = Curves.easeOutBack.flipped; + } + // 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); @@ -829,7 +828,6 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta ..downPosition = downPosition ..isFocused = states.contains(MaterialState.focused) ..isHovered = states.contains(MaterialState.hovered) - ..isPressed = _isPressed || downPosition != null ..activeColor = effectiveActiveThumbColor ..inactiveColor = effectiveInactiveThumbColor ..activeThumbImage = widget.activeThumbImage @@ -847,6 +845,7 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta ..inactiveThumbRadius = effectiveInactiveThumbRadius ..activeThumbRadius = effectiveActiveThumbRadius ..pressedThumbRadius = switchConfig.pressedThumbRadius + ..thumbOffset = switchConfig.thumbOffset ..trackHeight = switchConfig.trackHeight ..trackWidth = switchConfig.trackWidth ..activeIconColor = effectiveActiveIconColor @@ -854,7 +853,9 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta ..activeIcon = effectiveActiveIcon ..inactiveIcon = effectiveInactiveIcon ..iconTheme = IconTheme.of(context) - ..thumbShadow = switchConfig.thumbShadow, + ..thumbShadow = switchConfig.thumbShadow + ..transitionalThumbSize = switchConfig.transitionalThumbSize + ..positionController = positionController, ), ), ); @@ -862,6 +863,17 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta } class _SwitchPainter extends ToggleablePainter { + AnimationController get positionController => _positionController!; + AnimationController? _positionController; + set positionController(AnimationController? value) { + assert(value != null); + if (value == _positionController) { + return; + } + _positionController = value; + notifyListeners(); + } + Icon? get activeIcon => _activeIcon; Icon? _activeIcon; set activeIcon(Icon? value) { @@ -914,16 +926,6 @@ class _SwitchPainter extends ToggleablePainter { 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) { @@ -957,6 +959,27 @@ class _SwitchPainter extends ToggleablePainter { notifyListeners(); } + double? get thumbOffset => _thumbOffset; + double? _thumbOffset; + set thumbOffset(double? value) { + if (value == _thumbOffset) { + return; + } + _thumbOffset = value; + notifyListeners(); + } + + Size get transitionalThumbSize => _transitionalThumbSize!; + Size? _transitionalThumbSize; + set transitionalThumbSize(Size value) { + assert(value != null); + if (value == _transitionalThumbSize) { + return; + } + _transitionalThumbSize = value; + notifyListeners(); + } + double get trackHeight => _trackHeight!; double? _trackHeight; set trackHeight(double value) { @@ -1119,12 +1142,12 @@ class _SwitchPainter extends ToggleablePainter { ImageErrorListener? _cachedThumbErrorListener; BoxPainter? _cachedThumbPainter; - BoxDecoration _createDefaultThumbDecoration(Color color, ImageProvider? image, ImageErrorListener? errorListener) { - return BoxDecoration( + ShapeDecoration _createDefaultThumbDecoration(Color color, ImageProvider? image, ImageErrorListener? errorListener) { + return ShapeDecoration( color: color, image: image == null ? null : DecorationImage(image: image, onError: errorListener), - shape: BoxShape.circle, - boxShadow: thumbShadow, + shape: const StadiumBorder(), + shadows: thumbShadow, ); } @@ -1140,6 +1163,10 @@ class _SwitchPainter extends ToggleablePainter { } } + bool _stopPressAnimation = false; + double? _pressedInactiveThumbRadius; + double? _pressedActiveThumbRadius; + @override void paint(Canvas canvas, Size size) { final double currentValue = position.value; @@ -1153,10 +1180,88 @@ class _SwitchPainter extends ToggleablePainter { visualPosition = currentValue; break; } + if (reaction.status == AnimationStatus.reverse && _stopPressAnimation == false) { + _stopPressAnimation = true; + } else { + _stopPressAnimation = false; + } + + // To get the thumb radius when the press ends, the value can be any number + // between activeThumbRadius/inactiveThumbRadius and pressedThumbRadius. + if (!_stopPressAnimation) { + if (reaction.status == AnimationStatus.completed) { + // This happens when the thumb is dragged instead of being tapped. + _pressedInactiveThumbRadius = lerpDouble(inactiveThumbRadius, pressedThumbRadius, reaction.value); + _pressedActiveThumbRadius = lerpDouble(activeThumbRadius, pressedThumbRadius, reaction.value); + } + if (currentValue == 0) { + _pressedInactiveThumbRadius = lerpDouble(inactiveThumbRadius, pressedThumbRadius, reaction.value); + _pressedActiveThumbRadius = activeThumbRadius; + } + if (currentValue == 1) { + _pressedActiveThumbRadius = lerpDouble(activeThumbRadius, pressedThumbRadius, reaction.value); + _pressedInactiveThumbRadius = inactiveThumbRadius; + } + } + + final Size inactiveThumbSize = Size.fromRadius(_pressedInactiveThumbRadius ?? inactiveThumbRadius); + final Size activeThumbSize = Size.fromRadius(_pressedActiveThumbRadius ?? activeThumbRadius); + Animation thumbSizeAnimation(bool isForward) { + List> thumbSizeSequence; + if (isForward) { + thumbSizeSequence = >[ + TweenSequenceItem( + tween: Tween(begin: inactiveThumbSize, end: transitionalThumbSize) + .chain(CurveTween(curve: const Cubic(0.31, 0.00, 0.56, 1.00))), + weight: 11, + ), + TweenSequenceItem( + tween: Tween(begin: transitionalThumbSize, end: activeThumbSize) + .chain(CurveTween(curve: const Cubic(0.20, 0.00, 0.00, 1.00))), + weight: 72, + ), + TweenSequenceItem( + tween: ConstantTween(activeThumbSize), + weight: 17, + ) + ]; + } else { + thumbSizeSequence = >[ + TweenSequenceItem( + tween: ConstantTween(inactiveThumbSize), + weight: 17, + ), + TweenSequenceItem( + tween: Tween(begin: inactiveThumbSize, end: transitionalThumbSize) + .chain(CurveTween(curve: const Cubic(0.20, 0.00, 0.00, 1.00).flipped)), + weight: 72, + ), + TweenSequenceItem( + tween: Tween(begin: transitionalThumbSize, end: activeThumbSize) + .chain(CurveTween(curve: const Cubic(0.31, 0.00, 0.56, 1.00).flipped)), + weight: 11, + ), + ]; + } + + return TweenSequence(thumbSizeSequence).animate(positionController); + } + + Size thumbSize; + if (reaction.status == AnimationStatus.completed) { + thumbSize = Size.fromRadius(pressedThumbRadius); + } else { + if (position.status == AnimationStatus.dismissed || position.status == AnimationStatus.forward) { + thumbSize = thumbSizeAnimation(true).value; + } else { + thumbSize = thumbSizeAnimation(false).value; + } + } + + // The thumb contracts slightly during the animation in Material 2. + final double inset = thumbOffset == null ? 0 : 1.0 - (currentValue - thumbOffset!).abs() * 2.0; + thumbSize = Size(thumbSize.width - inset, thumbSize.height - inset); - 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); @@ -1176,8 +1281,8 @@ class _SwitchPainter extends ToggleablePainter { ..color = trackColor; final Offset trackPaintOffset = _computeTrackPaintOffset(size, trackWidth, trackHeight); - final Offset thumbPaintOffset = _computeThumbPaintOffset(trackPaintOffset, visualPosition); - final Offset radialReactionOrigin = Offset(thumbPaintOffset.dx + thumbRadius, size.height / 2); + final Offset thumbPaintOffset = _computeThumbPaintOffset(trackPaintOffset, thumbSize, visualPosition); + final Offset radialReactionOrigin = Offset(thumbPaintOffset.dx + thumbSize.height / 2, size.height / 2); _paintTrackWith(canvas, paint, trackPaintOffset, trackOutlineColor); paintRadialReaction(canvas: canvas, origin: radialReactionOrigin); @@ -1188,8 +1293,9 @@ class _SwitchPainter extends ToggleablePainter { thumbColor, thumbImage, thumbErrorListener, - thumbRadius, thumbIcon, + thumbSize, + inset, ); } @@ -1203,14 +1309,14 @@ class _SwitchPainter extends ToggleablePainter { /// Computes canvas offset for thumb's upper left corner as if it were a /// square - Offset _computeThumbPaintOffset(Offset trackPaintOffset, double visualPosition) { + Offset _computeThumbPaintOffset(Offset trackPaintOffset, Size thumbSize, double visualPosition) { // How much thumb radius extends beyond the track final double trackRadius = trackHeight / 2; - final double thumbRadius = isPressed ? pressedThumbRadius : lerpDouble(inactiveThumbRadius, activeThumbRadius, position.value)!; - final double additionalThumbRadius = thumbRadius - trackRadius; + final double additionalThumbRadius = thumbSize.height / 2 - trackRadius; + final double additionalRectWidth = (thumbSize.width - thumbSize.height) / 2; final double horizontalProgress = visualPosition * trackInnerLength; - final double thumbHorizontalOffset = trackPaintOffset.dx - additionalThumbRadius + horizontalProgress; + final double thumbHorizontalOffset = trackPaintOffset.dx - additionalThumbRadius - additionalRectWidth + horizontalProgress; final double thumbVerticalOffset = trackPaintOffset.dy - additionalThumbRadius; return Offset(thumbHorizontalOffset, thumbVerticalOffset); @@ -1258,8 +1364,9 @@ class _SwitchPainter extends ToggleablePainter { Color thumbColor, ImageProvider? thumbImage, ImageErrorListener? thumbErrorListener, - double thumbRadius, Icon? thumbIcon, + Size thumbSize, + double inset, ) { try { _isPainting = true; @@ -1272,14 +1379,10 @@ class _SwitchPainter extends ToggleablePainter { } final BoxPainter thumbPainter = _cachedThumbPainter!; - // The thumb contracts slightly during the animation - final double inset = 1.0 - (currentValue - 0.5).abs() * 2.0; - final double radius = thumbRadius - inset; - thumbPainter.paint( canvas, thumbPaintOffset + Offset(0, inset), - configuration.copyWith(size: Size.fromRadius(radius)), + configuration.copyWith(size: thumbSize), ); if (thumbIcon != null && thumbIcon.icon != null) { @@ -1314,8 +1417,9 @@ class _SwitchPainter extends ToggleablePainter { text: textSpan, ); textPainter.layout(); - final double additionalIconRadius = thumbRadius - iconSize / 2; - final Offset offset = thumbPaintOffset + Offset(additionalIconRadius, additionalIconRadius); + final double additionalHorizontalOffset = (thumbSize.width - iconSize) / 2; + final double additionalVerticalOffset = (thumbSize.height - iconSize) / 2; + final Offset offset = thumbPaintOffset + Offset(additionalHorizontalOffset, additionalVerticalOffset); textPainter.paint(canvas, offset); } @@ -1348,6 +1452,9 @@ mixin _SwitchConfig { List? get thumbShadow; MaterialStateProperty? get trackOutlineColor; MaterialStateProperty get iconColor; + double? get thumbOffset; + Size get transitionalThumbSize; + int get toggleDuration; } // Hand coded defaults based on Material Design 2. @@ -1389,6 +1496,15 @@ class _SwitchConfigM2 with _SwitchConfig { @override double get trackWidth => 33.0; + + @override + double get thumbOffset => 0.5; + + @override + Size get transitionalThumbSize => const Size(20, 20); + + @override + int get toggleDuration => 200; } class _SwitchDefaultsM2 extends SwitchThemeData { @@ -1658,6 +1774,18 @@ class _SwitchConfigM3 with _SwitchConfig { @override double get trackWidth => 52.0; + + // The thumb size at the middle of the track. Hand coded default based on the animation specs. + @override + Size get transitionalThumbSize => const Size(34, 22); + + // Hand coded default based on the animation specs. + @override + int get toggleDuration => 300; + + // Hand coded default based on the animation specs. + @override + double? get thumbOffset => null; } // END GENERATED TOKEN PROPERTIES - Switch diff --git a/packages/flutter/test/material/switch_list_tile_test.dart b/packages/flutter/test/material/switch_list_tile_test.dart index aa732eb03b..c84566fa3d 100644 --- a/packages/flutter/test/material/switch_list_tile_test.dart +++ b/packages/flutter/test/material/switch_list_tile_test.dart @@ -148,10 +148,10 @@ void main() { find.byType(Switch), paints ..rrect(color: Colors.blue[500]) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: Colors.yellow[500]), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.yellow[500]), ); await tester.tap(find.byType(Switch)); @@ -161,10 +161,10 @@ void main() { Material.of(tester.element(find.byType(Switch))), paints ..rrect(color: Colors.green[500]) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: Colors.red[500]), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.red[500]), ); }); diff --git a/packages/flutter/test/material/switch_test.dart b/packages/flutter/test/material/switch_test.dart index 1d3274c7d5..533b482f90 100644 --- a/packages/flutter/test/material/switch_test.dart +++ b/packages/flutter/test/material/switch_test.dart @@ -378,10 +378,10 @@ void main() { color: const Color(0x52000000), // Black with 32% opacity rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: Colors.grey.shade50), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.grey.shade50), reason: 'Inactive enabled switch should match these colors', ); await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); @@ -394,10 +394,10 @@ void main() { color: const Color(0x802196f3), rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: const Color(0xff2196f3)), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xff2196f3)), reason: 'Active enabled switch should match these colors', ); }); @@ -428,10 +428,10 @@ void main() { color: Colors.black12, rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: Colors.grey.shade400), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.grey.shade400), reason: 'Inactive disabled switch should match these colors', ); @@ -460,10 +460,10 @@ void main() { color: Colors.black12, rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: Colors.grey.shade400), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.grey.shade400), reason: 'Active disabled switch should match these colors', ); }); @@ -504,10 +504,10 @@ void main() { color: Colors.blue[500], rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: Colors.yellow[500]), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.yellow[500]), ); await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); await tester.pump(); @@ -519,10 +519,10 @@ void main() { color: Colors.green[500], rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: Colors.red[500]), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.red[500]), ); }); @@ -838,10 +838,10 @@ void main() { rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) ..circle(color: Colors.orange[500]) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: const Color(0xff2196f3)), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xff2196f3)), ); // Check the false value. @@ -857,10 +857,10 @@ void main() { rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) ..circle(color: Colors.orange[500]) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: const Color(0xfffafafa)), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xfffafafa)), ); // Check what happens when disabled. @@ -875,10 +875,10 @@ void main() { color: const Color(0x1f000000), rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: const Color(0xffbdbdbd)), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xffbdbdbd)), ); }); @@ -942,10 +942,10 @@ void main() { color: const Color(0x802196f3), rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: const Color(0xff2196f3)), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xff2196f3)), ); // Start hovering @@ -963,10 +963,10 @@ void main() { rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) ..circle(color: Colors.orange[500]) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: const Color(0xff2196f3)), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xff2196f3)), ); // Check what happens when disabled. @@ -979,10 +979,10 @@ void main() { color: const Color(0x1f000000), rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: const Color(0xffbdbdbd)), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xffbdbdbd)), ); }); @@ -1228,10 +1228,10 @@ void main() { color: Colors.black12, rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: inactiveDisabledThumbColor), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: inactiveDisabledThumbColor), reason: 'Inactive disabled switch should default track and custom thumb color', ); @@ -1245,10 +1245,10 @@ void main() { color: Colors.black12, rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: activeDisabledThumbColor), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: activeDisabledThumbColor), reason: 'Active disabled switch should match these colors', ); @@ -1262,10 +1262,10 @@ void main() { color: const Color(0x52000000), // Black with 32% opacity, rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: inactiveEnabledThumbColor), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: inactiveEnabledThumbColor), reason: 'Inactive enabled switch should match these colors', ); @@ -1279,10 +1279,10 @@ void main() { color: Colors.black12, rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: inactiveDisabledThumbColor), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: inactiveDisabledThumbColor), reason: 'Inactive disabled switch should match these colors', ); }); @@ -1338,10 +1338,10 @@ void main() { rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) ..circle(color: const Color(0x1f000000)) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: focusedThumbColor), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: focusedThumbColor), reason: 'Inactive disabled switch should default track and custom thumb color', ); @@ -1359,10 +1359,10 @@ void main() { rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) ..circle(color: const Color(0x1f000000)) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: hoveredThumbColor), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: hoveredThumbColor), reason: 'Inactive disabled switch should default track and custom thumb color', ); }); @@ -1577,10 +1577,10 @@ void main() { color: Colors.black12, rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) - ..circle(color: const Color(0x33000000)) - ..circle(color: const Color(0x24000000)) - ..circle(color: const Color(0x1f000000)) - ..circle(color: expectedThumbColor), + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: expectedThumbColor), reason: 'Active disabled thumb color should be blended on top of surface color', ); }); @@ -1934,6 +1934,140 @@ void main() { }); group('Switch M3 tests', () { + testWidgets('M3 Switch has a 300-millisecond animation in total', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + 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( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + expect(value, isFalse); + + final Rect switchRect = tester.getRect(find.byType(Switch)); + final TestGesture gesture = await tester.startGesture(switchRect.centerLeft); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // M2 animation duration + expect(tester.hasRunningAnimations, true); + await tester.pump(const Duration(milliseconds: 101)); + expect(tester.hasRunningAnimations, false); + }); + + testWidgets('M3 Switch has a stadium shape in the middle of the track', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true, colorSchemeSeed: Colors.deepPurple); + bool value = false; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: 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; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + expect(value, isFalse); + + final Rect switchRect = tester.getRect(find.byType(Switch)); + final TestGesture gesture = await tester.startGesture(switchRect.centerLeft); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // After 33 milliseconds, the switch thumb moves to the middle + // and has a stadium shape with a size of (34x22). + await tester.pump(const Duration(milliseconds: 33)); + expect(tester.hasRunningAnimations, true); + + await expectLater( + find.byType(Switch), + matchesGoldenFile('switch_test.m3.transition.png'), + ); + }); + + testWidgets('M3 Switch thumb bounces in the end of the animation', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + bool value = false; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: 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; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + expect(value, isFalse); + + final Rect switchRect = tester.getRect(find.byType(Switch)); + final TestGesture gesture = await tester.startGesture(switchRect.centerLeft); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // The value on y axis is greater than 1 when t > 0.375 + // 300 * 0.375 = 112.5 + await tester.pump(const Duration(milliseconds: 113)); + final ToggleableStateMixin state = tester.state( + find.descendant( + of: find.byType(Switch), + matching: find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_MaterialSwitch', + ), + ), + ); + expect(tester.hasRunningAnimations, true); + expect(state.position.value, greaterThan(1)); + }); + 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; @@ -1978,7 +2112,7 @@ void main() { 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 + ..rrect(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)); @@ -1993,7 +2127,8 @@ void main() { 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 + ..rrect() + ..rrect(color: colors.onPrimary), // thumb color reason: 'Active enabled switch should match these colors', ); }); @@ -2035,7 +2170,7 @@ void main() { 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 + ..rrect(color: Color.alphaBlend(colors.onSurface.withOpacity(0.38), colors.surface)), // thumb color reason: 'Inactive disabled switch should match these colors', ); }); @@ -2073,7 +2208,8 @@ void main() { 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 + ..rrect() + ..rrect(color: colors.surface), // thumb color reason: 'Active disabled switch should match these colors', ); }); @@ -2125,7 +2261,7 @@ void main() { style: PaintingStyle.stroke, color: colors.outline, ) - ..circle(color: Colors.yellow[500]), // thumb color + ..rrect(color: Colors.yellow[500]), // thumb color ); await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); await tester.pump(); @@ -2138,7 +2274,8 @@ void main() { 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 + ..rrect() + ..rrect(color: Colors.red[500]), // thumb color ); }); @@ -2225,7 +2362,7 @@ void main() { 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)), + ..rrect(color: Color.alphaBlend(colors.onSurface.withOpacity(0.38), colors.surface)), ); }); @@ -2265,7 +2402,8 @@ void main() { color: colors.primary, rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), ) - ..circle(color: colors.onPrimary), + ..rrect() + ..rrect(color: colors.onPrimary), ); // Start hovering @@ -2295,7 +2433,8 @@ void main() { 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)), + ..rrect() + ..rrect(color: colors.surface.withOpacity(1.0)), ); }); @@ -2359,7 +2498,7 @@ void main() { 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), + ..rrect(color: inactiveDisabledThumbColor), reason: 'Inactive disabled switch should default track and custom thumb color', ); @@ -2374,7 +2513,8 @@ void main() { 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), + ..rrect() + ..rrect(color: activeDisabledThumbColor), reason: 'Active disabled switch should match these colors', ); @@ -2389,7 +2529,8 @@ void main() { color: colors.surfaceVariant, rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), ) - ..circle(color: inactiveEnabledThumbColor), + ..rrect() + ..rrect(color: inactiveEnabledThumbColor), reason: 'Inactive enabled switch should match these colors', ); @@ -2404,7 +2545,8 @@ void main() { color: colors.primary, rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), ) - ..circle(color: activeEnabledThumbColor), + ..rrect() + ..rrect(color: activeEnabledThumbColor), reason: 'Active enabled switch should match these colors', ); }); @@ -2465,7 +2607,7 @@ void main() { 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), + ..rrect(color: focusedThumbColor), reason: 'active enabled switch should default track and custom thumb color', ); @@ -2484,7 +2626,7 @@ void main() { 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), + ..rrect(color: hoveredThumbColor), reason: 'active enabled switch should default track and custom thumb color', ); }); @@ -2708,7 +2850,8 @@ void main() { 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), + ..rrect() + ..rrect(color: expectedThumbColor), reason: 'Active disabled thumb color should be blended on top of surface color', ); }); @@ -2755,7 +2898,7 @@ void main() { expect( Material.of(tester.element(find.byType(Switch))), paints - ..rrect()..circle() + ..rrect()..rrect() ..paragraph(offset: const Offset(32.0, 16.0)), ); @@ -2766,7 +2909,7 @@ void main() { Material.of(tester.element(find.byType(Switch))), paints ..rrect()..rrect() - ..circle() + ..rrect() ..paragraph(offset: const Offset(12.0, 16.0)), ); @@ -2776,7 +2919,7 @@ void main() { expect( Material.of(tester.element(find.byType(Switch))), paints - ..rrect()..rrect()..circle() + ..rrect()..rrect()..rrect() ); // inactive icon doesn't show when switch is on. @@ -2785,7 +2928,7 @@ void main() { expect( Material.of(tester.element(find.byType(Switch))), paints - ..rrect()..circle()..restore(), + ..rrect()..rrect()..restore(), ); // without icon @@ -2793,7 +2936,7 @@ void main() { expect( Material.of(tester.element(find.byType(Switch))), paints - ..rrect()..rrect()..circle()..restore(), + ..rrect()..rrect()..rrect()..restore(), ); }); }); diff --git a/packages/flutter/test/material/switch_theme_test.dart b/packages/flutter/test/material/switch_theme_test.dart index c9628073db..a38ac734ad 100644 --- a/packages/flutter/test/material/switch_theme_test.dart +++ b/packages/flutter/test/material/switch_theme_test.dart @@ -147,15 +147,15 @@ void main() { ? (paints ..rrect(color: defaultTrackColor) ..rrect(color: themeData.colorScheme.outline) - ..circle(color: defaultThumbColor) + ..rrect(color: defaultThumbColor) ..paragraph() ) : (paints ..rrect(color: defaultTrackColor) - ..circle() - ..circle() - ..circle() - ..circle(color: defaultThumbColor) + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: defaultThumbColor) ) ); // Size from MaterialTapTargetSize.shrinkWrap. @@ -168,14 +168,14 @@ void main() { _getSwitchMaterial(tester), material3 ? (paints - ..rrect(color: selectedTrackColor) - ..circle(color: selectedThumbColor)..paragraph()) + ..rrect(color: selectedTrackColor)..rrect() + ..rrect(color: selectedThumbColor)..paragraph()) : (paints ..rrect(color: selectedTrackColor) - ..circle() - ..circle() - ..circle() - ..circle(color: selectedThumbColor)) + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: selectedThumbColor)) ); // Switch with hover. @@ -295,13 +295,13 @@ void main() { ? (paints ..rrect(color: defaultTrackColor) ..rrect(color: themeData.colorScheme.outline) - ..circle(color: defaultThumbColor)..paragraph(offset: const Offset(12, 16))) + ..rrect(color: defaultThumbColor)..paragraph(offset: const Offset(12, 16))) : (paints ..rrect(color: defaultTrackColor) - ..circle() - ..circle() - ..circle() - ..circle(color: defaultThumbColor)) + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: defaultThumbColor)) ); // Size from MaterialTapTargetSize.shrinkWrap. expect(tester.getSize(find.byType(Switch)), material3 ? const Size(60.0, 40.0) : const Size(59.0, 40.0)); @@ -314,13 +314,13 @@ void main() { material3 ? (paints ..rrect(color: selectedTrackColor) - ..circle(color: selectedThumbColor)) + ..rrect(color: selectedThumbColor)) : (paints ..rrect(color: selectedTrackColor) - ..circle() - ..circle() - ..circle() - ..circle(color: selectedThumbColor)) + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: selectedThumbColor)) ); // Switch with hover. @@ -393,13 +393,13 @@ void main() { ? (paints ..rrect(color: defaultTrackColor) ..rrect(color: themeData.colorScheme.outline) - ..circle(color: defaultThumbColor)) + ..rrect(color: defaultThumbColor)) : (paints ..rrect(color: defaultTrackColor) - ..circle() - ..circle() - ..circle() - ..circle(color: defaultThumbColor)) + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: defaultThumbColor)) ); // Selected switch. @@ -410,13 +410,13 @@ void main() { material3 ? (paints ..rrect(color: selectedTrackColor) - ..circle(color: selectedThumbColor)) + ..rrect(color: selectedThumbColor)) : (paints ..rrect(color: selectedTrackColor) - ..circle() - ..circle() - ..circle() - ..circle(color: selectedThumbColor)) + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: selectedThumbColor)) ); }); @@ -532,13 +532,13 @@ void main() { material3 ? (paints ..rrect(color: localThemeTrackColor) - ..circle(color: localThemeThumbColor)) + ..rrect(color: localThemeThumbColor)) : (paints ..rrect(color: localThemeTrackColor) - ..circle() - ..circle() - ..circle() - ..circle(color: localThemeThumbColor)) + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: localThemeThumbColor)) ); }); }