// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; using Microsoft.Maui.Controls; using Microsoft.Maui.Graphics; using SkiaSharp; namespace Microsoft.Maui.Platform; /// /// Skia-rendered toggle switch control with full MAUI compliance. /// Implements ISwitch interface requirements: /// - IsOn property with Toggled event /// - OnColor (TrackColor when on) /// - ThumbColor /// - Smooth animation on toggle /// public class SkiaSwitch : SkiaView { #region SKColor Helper private static SKColor ToSKColor(Color? color) { if (color == null) return SKColors.Transparent; return color.ToSKColor(); } #endregion #region BindableProperties public static readonly BindableProperty IsOnProperty = BindableProperty.Create( nameof(IsOn), typeof(bool), typeof(SkiaSwitch), false, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSwitch)b).OnIsOnChanged((bool)o, (bool)n)); public static readonly BindableProperty OnTrackColorProperty = BindableProperty.Create( nameof(OnTrackColor), typeof(Color), typeof(SkiaSwitch), Color.FromRgb(33, 150, 243), // Material Blue BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate()); public static readonly BindableProperty OffTrackColorProperty = BindableProperty.Create( nameof(OffTrackColor), typeof(Color), typeof(SkiaSwitch), Color.FromRgb(158, 158, 158), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate()); public static readonly BindableProperty ThumbColorProperty = BindableProperty.Create( nameof(ThumbColor), typeof(Color), typeof(SkiaSwitch), Colors.White, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate()); public static readonly BindableProperty DisabledColorProperty = BindableProperty.Create( nameof(DisabledColor), typeof(Color), typeof(SkiaSwitch), Color.FromRgb(189, 189, 189), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate()); public static readonly BindableProperty TrackWidthProperty = BindableProperty.Create( nameof(TrackWidth), typeof(double), typeof(SkiaSwitch), 52.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSwitch)b).InvalidateMeasure()); public static readonly BindableProperty TrackHeightProperty = BindableProperty.Create( nameof(TrackHeight), typeof(double), typeof(SkiaSwitch), 32.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSwitch)b).InvalidateMeasure()); public static readonly BindableProperty ThumbRadiusProperty = BindableProperty.Create( nameof(ThumbRadius), typeof(double), typeof(SkiaSwitch), 12.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate()); public static readonly BindableProperty ThumbPaddingProperty = BindableProperty.Create( nameof(ThumbPadding), typeof(double), typeof(SkiaSwitch), 4.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate()); #endregion #region Properties /// /// Gets or sets whether the switch is on. /// public bool IsOn { get => (bool)GetValue(IsOnProperty); set => SetValue(IsOnProperty, value); } /// /// Gets or sets the track color when the switch is on. /// This is the primary MAUI Switch.OnColor property. /// public Color OnTrackColor { get => (Color)GetValue(OnTrackColorProperty); set => SetValue(OnTrackColorProperty, value); } /// /// Gets or sets the track color when the switch is off. /// public Color OffTrackColor { get => (Color)GetValue(OffTrackColorProperty); set => SetValue(OffTrackColorProperty, value); } /// /// Gets or sets the thumb color. /// public Color ThumbColor { get => (Color)GetValue(ThumbColorProperty); set => SetValue(ThumbColorProperty, value); } /// /// Gets or sets the color used when the control is disabled. /// public Color DisabledColor { get => (Color)GetValue(DisabledColorProperty); set => SetValue(DisabledColorProperty, value); } /// /// Gets or sets the track width in device-independent units. /// public double TrackWidth { get => (double)GetValue(TrackWidthProperty); set => SetValue(TrackWidthProperty, value); } /// /// Gets or sets the track height in device-independent units. /// public double TrackHeight { get => (double)GetValue(TrackHeightProperty); set => SetValue(TrackHeightProperty, value); } /// /// Gets or sets the thumb radius in device-independent units. /// public double ThumbRadius { get => (double)GetValue(ThumbRadiusProperty); set => SetValue(ThumbRadiusProperty, value); } /// /// Gets or sets the thumb padding in device-independent units. /// public double ThumbPadding { get => (double)GetValue(ThumbPaddingProperty); set => SetValue(ThumbPaddingProperty, value); } #endregion #region Animation Fields private float _animationProgress; private System.Timers.Timer? _animationTimer; private bool _animatingToOn; private const int AnimationDurationMs = 200; private const int AnimationFrameMs = 16; // ~60fps #endregion #region Events /// /// Event raised when the switch is toggled. /// public event EventHandler? Toggled; #endregion #region Constructor public SkiaSwitch() { IsFocusable = true; _animationProgress = 0f; } #endregion #region Event Handlers private void OnIsOnChanged(bool oldValue, bool newValue) { // Start animation StartAnimation(newValue); Toggled?.Invoke(this, new ToggledEventArgs(newValue)); SkiaVisualStateManager.GoToState(this, newValue ? "On" : "Off"); } #endregion #region Animation private void StartAnimation(bool toOn) { _animatingToOn = toOn; // Stop existing animation _animationTimer?.Stop(); _animationTimer?.Dispose(); // Create new animation timer _animationTimer = new System.Timers.Timer(AnimationFrameMs); _animationTimer.Elapsed += OnAnimationFrame; _animationTimer.AutoReset = true; _animationTimer.Start(); } private void OnAnimationFrame(object? sender, System.Timers.ElapsedEventArgs e) { float step = AnimationFrameMs / (float)AnimationDurationMs; if (_animatingToOn) { _animationProgress += step; if (_animationProgress >= 1f) { _animationProgress = 1f; StopAnimation(); } } else { _animationProgress -= step; if (_animationProgress <= 0f) { _animationProgress = 0f; StopAnimation(); } } // Request redraw on UI thread Invalidate(); } private void StopAnimation() { _animationTimer?.Stop(); _animationTimer?.Dispose(); _animationTimer = null; } #endregion #region Rendering protected override void OnDraw(SKCanvas canvas, SKRect bounds) { var trackWidth = (float)TrackWidth; var trackHeight = (float)TrackHeight; var thumbRadius = (float)ThumbRadius; var thumbPadding = (float)ThumbPadding; var centerY = bounds.MidY; var trackLeft = bounds.MidX - trackWidth / 2f; var trackRight = trackLeft + trackWidth; // Calculate thumb position based on animation progress var thumbMinX = trackLeft + thumbPadding + thumbRadius; var thumbMaxX = trackRight - thumbPadding - thumbRadius; var thumbX = thumbMinX + _animationProgress * (thumbMaxX - thumbMinX); // Get colors var onColorSK = ToSKColor(OnTrackColor); var offColorSK = ToSKColor(OffTrackColor); var thumbColorSK = ToSKColor(ThumbColor); var disabledColorSK = ToSKColor(DisabledColor); // Interpolate track color based on animation progress var trackColor = IsEnabled ? InterpolateColor(offColorSK, onColorSK, _animationProgress) : disabledColorSK; // Draw track using var trackPaint = new SKPaint { Color = trackColor, IsAntialias = true, Style = SKPaintStyle.Fill }; var trackRect = new SKRoundRect( new SKRect(trackLeft, centerY - trackHeight / 2f, trackRight, centerY + trackHeight / 2f), trackHeight / 2f); canvas.DrawRoundRect(trackRect, trackPaint); // Draw thumb shadow (only when enabled) if (IsEnabled) { using var shadowPaint = new SKPaint { Color = SkiaTheme.Shadow25SK, IsAntialias = true, MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 2f) }; canvas.DrawCircle(thumbX + 1f, centerY + 1f, thumbRadius, shadowPaint); } // Draw thumb using var thumbPaint = new SKPaint { Color = IsEnabled ? thumbColorSK : SkiaTheme.Gray100SK, IsAntialias = true, Style = SKPaintStyle.Fill }; canvas.DrawCircle(thumbX, centerY, thumbRadius, thumbPaint); // Draw focus ring if (IsFocused) { using var focusPaint = new SKPaint { Color = onColorSK.WithAlpha(60), IsAntialias = true, Style = SKPaintStyle.Stroke, StrokeWidth = 3f }; var focusRect = new SKRoundRect(trackRect.Rect, trackHeight / 2f); focusRect.Inflate(3f, 3f); canvas.DrawRoundRect(focusRect, focusPaint); } } private static SKColor InterpolateColor(SKColor from, SKColor to, float t) { // Clamp t to [0, 1] t = Math.Max(0f, Math.Min(1f, t)); return new SKColor( (byte)(from.Red + (to.Red - from.Red) * t), (byte)(from.Green + (to.Green - from.Green) * t), (byte)(from.Blue + (to.Blue - from.Blue) * t), (byte)(from.Alpha + (to.Alpha - from.Alpha) * t)); } #endregion #region Pointer Events public override void OnPointerPressed(PointerEventArgs e) { if (IsEnabled) { IsOn = !IsOn; e.Handled = true; } } public override void OnPointerReleased(PointerEventArgs e) { // No action needed } #endregion #region Keyboard Events public override void OnKeyDown(KeyEventArgs e) { if (IsEnabled && (e.Key == Key.Space || e.Key == Key.Enter)) { IsOn = !IsOn; e.Handled = true; } } #endregion #region Lifecycle protected override void OnEnabledChanged() { base.OnEnabledChanged(); SkiaVisualStateManager.GoToState(this, IsEnabled ? "Normal" : "Disabled"); } protected override void Dispose(bool disposing) { if (disposing) { StopAnimation(); } base.Dispose(disposing); } #endregion #region Layout protected override Size MeasureOverride(Size availableSize) { var trackWidth = TrackWidth; var trackHeight = TrackHeight; return new Size(trackWidth + 8, trackHeight + 8); } #endregion }