// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms.Rendering.Animation;
using System.Windows.Forms.Rendering.Button;
namespace System.Windows.Forms.Rendering.CheckBox;
/// <summary>
/// Renders and animates a <see cref="Forms.CheckBox"/> or <see cref="Forms.RadioButton"/>
/// in <see cref="Appearance.ToggleSwitch"/> mode. Used when <see cref="Control.VisualStylesMode"/>
/// is <see cref="VisualStylesMode.Net11"/> or later.
/// </summary>
internal sealed class AnimatedToggleSwitchRenderer : AnimatedControlRenderer
{
private const int AnimationDuration = 300;
private readonly ModernCheckBoxStyle _switchStyle;
private float _focusCurrent;
private float _focusStart;
private float _focusTarget;
private float _hoverCurrent;
private float _hoverStart;
private float _hoverTarget;
private bool _interactionInitialized;
private float _onAmountCurrent;
private float _onAmountStart;
private float _onAmountTarget;
private bool _positionInitialized;
private float _positionCurrent;
private float _positionStart;
private float _positionTarget;
public AnimatedToggleSwitchRenderer(Control control, ModernCheckBoxStyle switchStyle)
: base(control)
{
_switchStyle = switchStyle;
}
public override void AnimationProc(float animationProgress)
{
base.AnimationProc(animationProgress);
float easedProgress = EaseOut(animationProgress);
_positionCurrent = Lerp(_positionStart, _positionTarget, easedProgress);
_onAmountCurrent = Lerp(_onAmountStart, _onAmountTarget, easedProgress);
_focusCurrent = Lerp(_focusStart, _focusTarget, easedProgress);
_hoverCurrent = Lerp(_hoverStart, _hoverTarget, easedProgress);
Invalidate();
}
protected override (int animationDuration, AnimationCycle animationCycle) OnAnimationStarted()
{
EnsurePositionInitialized();
_positionStart = _positionCurrent;
_positionTarget = IsChecked ? 1f : 0f;
_onAmountStart = _onAmountCurrent;
_onAmountTarget = IsChecked ? 1f : 0f;
_focusStart = _focusCurrent;
_hoverStart = _hoverCurrent;
AnimationProgress = 0;
return (AnimationDuration, AnimationCycle.Once);
}
/// <summary>
/// Called from the control's <c>OnPaint</c>. Works both while the animation is running (driven by
/// <see cref="AnimationProc"/>) and when it is settled (progress is 1).
/// </summary>
/// <param name="graphics">The graphics object to render into.</param>
public override void RenderControl(Graphics graphics)
{
EnsurePositionInitialized();
SetInteractionState(
hovered: MouseIsOver,
focused: Control.Focused && ShowFocusCues);
ToggleSwitchMetrics metrics = ToggleSwitchMetrics.Create(Control);
TextFormatFlags textFormatFlags = GetTextFormatFlags(Control);
Size textSize = MeasureText(Control, textFormatFlags);
Rectangle contentBounds = ToggleSwitchMetrics.GetContentBounds(Control);
Rectangle switchBounds = GetSwitchBounds(
Control,
RtlTranslatedCheckAlign,
metrics,
textSize);
Rectangle textBounds = GetTextBounds(
contentBounds,
switchBounds,
metrics,
RtlTranslatedCheckAlign);
PaintControlBackground(graphics);
if (contentBounds.Width <= 0 || contentBounds.Height <= 0)
{
return;
}
RenderSwitch(graphics, switchBounds, metrics);
RenderText(graphics, textBounds, textFormatFlags);
if (Control.Focused && ShowFocusCues)
{
Rectangle focusBounds = Rectangle.Inflate(Control.ClientRectangle, -1, -1);
ControlPaint.DrawFocusRectangle(
graphics,
focusBounds,
Control.ForeColor,
Control.BackColor);
}
}
internal void SetInteractionState(bool hovered, bool focused)
{
float hoverTarget = hovered ? 1f : 0f;
float focusTarget = focused ? 1f : 0f;
if (!_interactionInitialized)
{
_interactionInitialized = true;
_hoverCurrent = _hoverStart = _hoverTarget = hoverTarget;
_focusCurrent = _focusStart = _focusTarget = focusTarget;
return;
}
if (_hoverTarget == hoverTarget && _focusTarget == focusTarget)
{
return;
}
_hoverTarget = hoverTarget;
_focusTarget = focusTarget;
RestartAnimation();
}
private static bool IsSwitchOnRight(ContentAlignment checkAlign) => checkAlign is
ContentAlignment.TopRight or
ContentAlignment.MiddleRight or
ContentAlignment.BottomRight;
internal static Rectangle GetSwitchBounds(
Control control,
ContentAlignment checkAlign,
ToggleSwitchMetrics metrics)
{
TextFormatFlags textFormatFlags = GetTextFormatFlags(control);
return GetSwitchBounds(
control,
checkAlign,
metrics,
MeasureText(control, textFormatFlags));
}
private static Rectangle GetSwitchBounds(
Control control,
ContentAlignment checkAlign,
ToggleSwitchMetrics metrics,
Size textSize)
{
Rectangle contentBounds = ToggleSwitchMetrics.GetContentBounds(control);
int totalHeight = Math.Max(textSize.Height, metrics.SwitchHeight);
int contentTop = contentBounds.Top + Math.Max(0, (contentBounds.Height - totalHeight) / 2);
int switchY = contentTop + ((totalHeight - metrics.SwitchHeight) / 2);
int minimumSwitchX = contentBounds.Left;
int maximumSwitchX = Math.Max(minimumSwitchX, contentBounds.Right - metrics.SwitchWidth);
int edgeInset = Math.Max(1, (metrics.BorderThickness / 2) + 1);
int targetSwitchX = IsSwitchOnRight(checkAlign)
? maximumSwitchX - edgeInset
: minimumSwitchX + edgeInset;
int switchX = Math.Max(minimumSwitchX, Math.Min(maximumSwitchX, targetSwitchX));
return new Rectangle(switchX, switchY, metrics.SwitchWidth, metrics.SwitchHeight);
}
internal static Rectangle GetTextBounds(
Control control,
ContentAlignment checkAlign,
ToggleSwitchMetrics metrics)
{
TextFormatFlags textFormatFlags = GetTextFormatFlags(control);
Size textSize = MeasureText(control, textFormatFlags);
Rectangle contentBounds = ToggleSwitchMetrics.GetContentBounds(control);
Rectangle switchBounds = GetSwitchBounds(control, checkAlign, metrics, textSize);
return GetTextBounds(contentBounds, switchBounds, metrics, checkAlign);
}
private static Rectangle GetTextBounds(
Rectangle contentBounds,
Rectangle switchBounds,
ToggleSwitchMetrics metrics,
ContentAlignment checkAlign)
{
// Keep a small symmetric inset between the switch track and text to avoid visual collision
// with the rounded switch border in high DPI while preserving readability in narrow widths.
int textGapInset = GetSwitchTextInset(metrics);
int minimumTextGap = Math.Max(0, metrics.TextGap - textGapInset);
if (IsSwitchOnRight(checkAlign))
{
int right = Math.Min(
contentBounds.Right,
switchBounds.Left - minimumTextGap);
right = Math.Max(contentBounds.Left, right);
return Rectangle.FromLTRB(contentBounds.Left, contentBounds.Top, right, contentBounds.Bottom);
}
int left = Math.Max(
contentBounds.Left,
switchBounds.Right + minimumTextGap);
left = Math.Min(contentBounds.Right, left);
return Rectangle.FromLTRB(left, contentBounds.Top, contentBounds.Right, contentBounds.Bottom);
}
private static int GetSwitchTextInset(ToggleSwitchMetrics metrics)
=> Math.Max(1, (metrics.BorderThickness / 2) + 1);
internal static Size MeasureText(Control control, TextFormatFlags textFormatFlags)
=> TextRenderer.MeasureText(
text: control.Text,
font: control.Font,
proposedSize: new Size(int.MaxValue, int.MaxValue),
flags: textFormatFlags);
private void RenderText(
Graphics graphics,
Rectangle textBounds,
TextFormatFlags textFormatFlags)
{
if (textBounds.Width <= 0 || textBounds.Height <= 0)
{
return;
}
TextRenderer.DrawText(
graphics,
Control.Text,
Control.Font,
textBounds,
GetTextColor(),
textFormatFlags);
}
internal static TextFormatFlags GetTextFormatFlags(Control control) => control switch
{
Forms.CheckBox { FlatStyle: FlatStyle.System } checkBox => ControlPaint.CreateTextFormatFlags(
checkBox,
checkBox.TextAlign,
checkBox.ShowToolTip,
checkBox.UseMnemonic),
Forms.RadioButton { FlatStyle: FlatStyle.System } radioButton => ControlPaint.CreateTextFormatFlags(
radioButton,
radioButton.TextAlign,
radioButton.ShowToolTip,
radioButton.UseMnemonic),
Forms.CheckBox checkBox => checkBox.CreateTextFormatFlags(),
Forms.RadioButton radioButton => radioButton.CreateTextFormatFlags(),
_ => TextFormatFlags.WordBreak | TextFormatFlags.TextBoxControl
};
internal Color GetTextColor()
=> Control.Enabled
? Control.ForeColor
: ModernControlColorMath.GetDisabledTextColor(
Control.ForeColor,
Control.BackColor);
private void RenderSwitch(Graphics graphics, Rectangle rect, ToggleSwitchMetrics metrics)
{
if (rect.Width <= 0 || rect.Height <= 0)
{
return;
}
bool highContrast = SystemInformation.HighContrast;
Color onColor = highContrast
? SystemColors.Highlight
: WindowsAccentColor;
Color offColor = highContrast ? SystemColors.Window : SystemColors.ControlDark;
Color backgroundColor = Control.Enabled
? PopupButtonColorMath.Blend(offColor, onColor, _onAmountCurrent)
: SystemColors.Control;
Color highContrastForeground = PopupButtonColorMath.Blend(
SystemColors.WindowText,
SystemColors.HighlightText,
_onAmountCurrent);
Color borderColor = Control.Enabled
? highContrast
? highContrastForeground
: SystemColors.WindowFrame
: SystemColors.GrayText;
float focus = Control.Enabled && !highContrast ? _focusCurrent : 0f;
backgroundColor = ApplyInteractionShade(backgroundColor, focus);
Color circleColor = Control.Enabled
? highContrast
? highContrastForeground
: PopupButtonColorMath.GetReadableForeColor(backgroundColor)
: SystemColors.GrayText;
circleColor = ApplyInteractionShade(circleColor, focus);
borderColor = ApplyInteractionShade(borderColor, focus);
float thumbDiameter = Lerp(
metrics.ThumbDiameter,
metrics.HoverThumbDiameter,
Control.Enabled ? _hoverCurrent : 0f);
float circlePosition = (rect.Width - thumbDiameter) * _positionCurrent;
using var backgroundBrush = backgroundColor.GetCachedSolidBrushScope();
using var circleBrush = circleColor.GetCachedSolidBrushScope();
using var backgroundPen = borderColor.GetCachedPenScope(metrics.BorderThickness);
SmoothingMode previousSmoothingMode = graphics.SmoothingMode;
try
{
graphics.SmoothingMode = SmoothingMode.AntiAlias;
if (_switchStyle == ModernCheckBoxStyle.Rounded)
{
float radius = Math.Min(rect.Width, rect.Height) / 2f;
using GraphicsPath path = new();
path.AddArc(rect.X, rect.Y, radius * 2, radius * 2, 180, 90);
path.AddArc(rect.Right - radius * 2, rect.Y, radius * 2, radius * 2, 270, 90);
path.AddArc(rect.Right - radius * 2, rect.Bottom - radius * 2, radius * 2, radius * 2, 0, 90);
path.AddArc(rect.X, rect.Bottom - radius * 2, radius * 2, radius * 2, 90, 90);
path.CloseFigure();
graphics.FillPath(backgroundBrush, path);
graphics.DrawPath(backgroundPen, path);
}
else
{
graphics.FillRectangle(backgroundBrush, rect);
graphics.DrawRectangle(backgroundPen, rect);
}
float circleTop = rect.Y + ((rect.Height - thumbDiameter) / 2f);
graphics.FillEllipse(
circleBrush,
rect.X + circlePosition,
circleTop,
thumbDiameter,
thumbDiameter);
}
finally
{
graphics.SmoothingMode = previousSmoothingMode;
}
}
internal void PrepareStateChange()
{
EnsurePositionInitialized();
StopAnimation();
}
internal void SynchronizeState()
{
StopAnimation();
_positionCurrent = IsChecked ? 1f : 0f;
_positionStart = _positionCurrent;
_positionTarget = _positionCurrent;
_onAmountCurrent = IsChecked ? 1f : 0f;
_onAmountStart = _onAmountCurrent;
_onAmountTarget = _onAmountCurrent;
_focusCurrent = _focusStart = _focusTarget;
_hoverCurrent = _hoverStart = _hoverTarget;
_interactionInitialized = true;
_positionInitialized = true;
AnimationProgress = 1;
}
protected override void OnAnimationStopped()
{
}
protected override void OnAnimationEnded()
{
StopAnimation();
_positionCurrent = _positionTarget;
_positionStart = _positionCurrent;
_onAmountCurrent = _onAmountTarget;
_onAmountStart = _onAmountCurrent;
_focusCurrent = _focusTarget;
_focusStart = _focusCurrent;
_hoverCurrent = _hoverTarget;
_hoverStart = _hoverCurrent;
AnimationProgress = 1;
Invalidate();
}
private void EnsurePositionInitialized()
{
if (_positionInitialized)
{
return;
}
_positionCurrent = IsChecked ? 1f : 0f;
_positionStart = _positionCurrent;
_positionTarget = _positionCurrent;
_onAmountCurrent = IsChecked ? 1f : 0f;
_onAmountStart = _onAmountCurrent;
_onAmountTarget = _onAmountCurrent;
_positionInitialized = true;
}
private void PaintControlBackground(Graphics graphics)
{
if (Control.BackgroundImage is null && !Control.BackColor.HasTransparency())
{
graphics.Clear(Control.BackColor);
return;
}
Rectangle clipRectangle = Rectangle.Ceiling(graphics.ClipBounds);
clipRectangle.Intersect(Control.ClientRectangle);
if (clipRectangle.Width <= 0 || clipRectangle.Height <= 0)
{
return;
}
using PaintEventArgs paintEventArgs = new(graphics, clipRectangle);
Control.PaintBackground(paintEventArgs, clipRectangle);
}
private static float EaseOut(float value)
=> 1 - ((1 - value) * (1 - value));
private static float Lerp(float start, float end, float amount)
=> start + ((end - start) * amount);
private bool IsChecked => Control switch
{
Forms.CheckBox checkBox => checkBox.Checked,
Forms.RadioButton radioButton => radioButton.Checked,
_ => false
};
private ContentAlignment RtlTranslatedCheckAlign => Control switch
{
Forms.CheckBox checkBox => checkBox.RtlTranslatedCheckAlign,
Forms.RadioButton radioButton => radioButton.RtlTranslatedCheckAlign,
_ => ContentAlignment.MiddleLeft
};
private bool ShowFocusCues => Control switch
{
Forms.CheckBox checkBox => checkBox.ShowFocusCuesInternal,
Forms.RadioButton radioButton => radioButton.ShowFocusCuesInternal,
_ => false
};
private bool MouseIsOver => Control switch
{
Forms.CheckBox checkBox => checkBox.MouseIsOver,
Forms.RadioButton radioButton => radioButton.MouseIsOver,
_ => false
};
}
/// <summary>
/// Provides the geometry shared by toggle-switch rendering and preferred-size calculations.
/// </summary>
internal readonly struct ToggleSwitchMetrics
{
private ToggleSwitchMetrics(
int switchWidth,
int switchHeight,
int thumbDiameter,
int hoverThumbDiameter,
int borderThickness,
int textGap)
{
SwitchWidth = switchWidth;
SwitchHeight = switchHeight;
ThumbDiameter = thumbDiameter;
HoverThumbDiameter = hoverThumbDiameter;
BorderThickness = borderThickness;
TextGap = textGap;
}
internal int SwitchWidth { get; }
internal int SwitchHeight { get; }
internal int ThumbDiameter { get; }
internal int HoverThumbDiameter { get; }
internal int BorderThickness { get; }
internal int TextGap { get; }
internal static ToggleSwitchMetrics Create(Control control)
{
int switchHeight = Math.Max(
control.LogicalToDeviceUnits(13),
(int)(control.Font.Height * 0.9f));
int minimumMetric = Math.Max(1, control.LogicalToDeviceUnits(1));
int borderThickness = Math.Max(minimumMetric, switchHeight / 12);
int maximumThumbDiameter = Math.Max(1, switchHeight - (2 * borderThickness));
int thumbDiameter = Math.Max(
1,
Math.Min(
switchHeight - (2 * (borderThickness + minimumMetric)),
(int)Math.Floor(maximumThumbDiameter / 1.1f)));
int hoverThumbDiameter = Math.Min(
maximumThumbDiameter,
Math.Max(thumbDiameter, (int)Math.Ceiling(thumbDiameter * 1.1f)));
int textGap = Math.Max(2 * minimumMetric, switchHeight / 3);
return new(
switchWidth: 2 * switchHeight,
switchHeight: switchHeight,
thumbDiameter: thumbDiameter,
hoverThumbDiameter: hoverThumbDiameter,
borderThickness: borderThickness,
textGap: textGap);
}
internal static Rectangle GetContentBounds(Control control)
{
Padding padding = control.Padding;
return new Rectangle(
padding.Left,
padding.Top,
Math.Max(0, control.ClientSize.Width - padding.Horizontal),
Math.Max(0, control.ClientSize.Height - padding.Vertical));
}
internal Size GetPreferredSize(Control control)
{
TextFormatFlags textFormatFlags = AnimatedToggleSwitchRenderer.GetTextFormatFlags(control);
Size textSize = AnimatedToggleSwitchRenderer.MeasureText(control, textFormatFlags);
return new Size(
SwitchWidth + TextGap + textSize.Width + control.Padding.Horizontal,
Math.Max(SwitchHeight, textSize.Height) + control.Padding.Vertical);
}
}