File: System\Windows\Forms\Rendering\CheckBox\AnimatedToggleSwitchRenderer.cs
Web Access
Project: src\winforms\src\System.Windows.Forms\System.Windows.Forms.csproj (System.Windows.Forms)
// 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);
        Size textSize = TextRenderer.MeasureText(Control.Text, Control.Font);
        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 textY = contentTop + ((totalHeight - textSize.Height) / 2);
        Rectangle switchBounds = GetSwitchBounds(
            Control,
            RtlTranslatedCheckAlign,
            metrics,
            textSize);
 
        graphics.Clear(Control.BackColor);
 
        if (contentBounds.Width <= 0 || contentBounds.Height <= 0)
        {
            return;
        }
 
        if (IsSwitchOnRight(RtlTranslatedCheckAlign))
        {
            int textX = Math.Max(contentBounds.Left, switchBounds.Left - metrics.TextGap - textSize.Width);
            RenderSwitch(graphics, switchBounds, metrics);
            RenderText(graphics, new Point(textX, textY));
        }
        else
        {
            RenderSwitch(graphics, switchBounds, metrics);
            RenderText(graphics, new Point(contentBounds.Left + metrics.SwitchWidth + metrics.TextGap, textY));
        }
 
        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)
        => GetSwitchBounds(
            control,
            checkAlign,
            metrics,
            TextRenderer.MeasureText(control.Text, control.Font));
 
    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 switchX = IsSwitchOnRight(checkAlign)
            ? Math.Max(contentBounds.Left, contentBounds.Right - metrics.SwitchWidth)
            : contentBounds.Left;
 
        return new Rectangle(switchX, switchY, metrics.SwitchWidth, metrics.SwitchHeight);
    }
 
    private void RenderText(Graphics graphics, Point position)
    {
        TextRenderer.DrawText(
            graphics,
            Control.Text,
            Control.Font,
            position,
            GetTextColor());
    }
 
    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(offColor, onColor)
            : 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 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)
    {
        Size textSize = TextRenderer.MeasureText(control.Text, control.Font);
        return new Size(
            SwitchWidth + TextGap + textSize.Width + control.Padding.Horizontal,
            Math.Max(SwitchHeight, textSize.Height) + control.Padding.Vertical);
    }
}