// 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 slider control with full MAUI compliance. /// Implements ISlider interface requirements: /// - Minimum, Maximum, Value properties /// - MinimumTrackColor, MaximumTrackColor, ThumbColor /// - ValueChanged, DragStarted, DragCompleted events /// public class SkiaSlider : 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 MinimumProperty = BindableProperty.Create( nameof(Minimum), typeof(double), typeof(SkiaSlider), 0.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSlider)b).OnRangeChanged()); /// /// Maximum property - default is 1.0 to match MAUI Slider.Maximum. /// public static readonly BindableProperty MaximumProperty = BindableProperty.Create( nameof(Maximum), typeof(double), typeof(SkiaSlider), 1.0, // MAUI default is 1.0, not 100.0 BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSlider)b).OnRangeChanged()); public static readonly BindableProperty ValueProperty = BindableProperty.Create( nameof(Value), typeof(double), typeof(SkiaSlider), 0.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSlider)b).OnValuePropertyChanged((double)o, (double)n)); /// /// MinimumTrackColor - default is null to match MAUI (platform default). /// public static readonly BindableProperty MinimumTrackColorProperty = BindableProperty.Create( nameof(MinimumTrackColor), typeof(Color), typeof(SkiaSlider), null, // MAUI default is null (platform default) BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate()); /// /// MaximumTrackColor - default is null to match MAUI (platform default). /// public static readonly BindableProperty MaximumTrackColorProperty = BindableProperty.Create( nameof(MaximumTrackColor), typeof(Color), typeof(SkiaSlider), null, // MAUI default is null (platform default) BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate()); /// /// ThumbColor - default is null to match MAUI (platform default). /// public static readonly BindableProperty ThumbColorProperty = BindableProperty.Create( nameof(ThumbColor), typeof(Color), typeof(SkiaSlider), null, // MAUI default is null (platform default) BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate()); public static readonly BindableProperty DisabledColorProperty = BindableProperty.Create( nameof(DisabledColor), typeof(Color), typeof(SkiaSlider), Color.FromRgb(0xBD, 0xBD, 0xBD), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate()); public static readonly BindableProperty TrackHeightProperty = BindableProperty.Create( nameof(TrackHeight), typeof(double), typeof(SkiaSlider), 4.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate()); public static readonly BindableProperty ThumbRadiusProperty = BindableProperty.Create( nameof(ThumbRadius), typeof(double), typeof(SkiaSlider), 10.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaSlider)b).InvalidateMeasure()); #endregion #region Properties /// /// Gets or sets the minimum value. /// public double Minimum { get => (double)GetValue(MinimumProperty); set => SetValue(MinimumProperty, value); } /// /// Gets or sets the maximum value. /// public double Maximum { get => (double)GetValue(MaximumProperty); set => SetValue(MaximumProperty, value); } /// /// Gets or sets the current value. /// public double Value { get => (double)GetValue(ValueProperty); set => SetValue(ValueProperty, Math.Clamp(value, Minimum, Maximum)); } /// /// Gets or sets the color of the track from minimum to current value. /// Null means platform default (Material Blue on Linux). /// public Color? MinimumTrackColor { get => (Color?)GetValue(MinimumTrackColorProperty); set => SetValue(MinimumTrackColorProperty, value); } /// /// Gets or sets the color of the track from current value to maximum. /// Null means platform default (gray on Linux). /// public Color? MaximumTrackColor { get => (Color?)GetValue(MaximumTrackColorProperty); set => SetValue(MaximumTrackColorProperty, value); } /// /// Gets or sets the thumb color. /// Null means platform default (Material Blue on Linux). /// public Color? ThumbColor { get => (Color?)GetValue(ThumbColorProperty); set => SetValue(ThumbColorProperty, value); } // Platform defaults for colors when null - using centralized theme private static readonly SKColor DefaultMinimumTrackColor = SkiaTheme.PrimarySK; // Material Blue private static readonly SKColor DefaultMaximumTrackColor = SkiaTheme.Gray300SK; // Gray private static readonly SKColor DefaultThumbColor = SkiaTheme.PrimarySK; // Material Blue private SKColor GetEffectiveMinimumTrackColor() => MinimumTrackColor != null ? ToSKColor(MinimumTrackColor) : DefaultMinimumTrackColor; private SKColor GetEffectiveMaximumTrackColor() => MaximumTrackColor != null ? ToSKColor(MaximumTrackColor) : DefaultMaximumTrackColor; private SKColor GetEffectiveThumbColor() => ThumbColor != null ? ToSKColor(ThumbColor) : DefaultThumbColor; /// /// Gets or sets the color used when disabled. /// public Color DisabledColor { get => (Color)GetValue(DisabledColorProperty); set => SetValue(DisabledColorProperty, 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 whether the slider is currently being dragged. /// public bool IsDragging { get; private set; } #endregion #region Events /// /// Event raised when the value changes. /// public event EventHandler? ValueChanged; /// /// Event raised when drag starts. /// public event EventHandler? DragStarted; /// /// Event raised when drag completes. /// public event EventHandler? DragCompleted; #endregion #region Constructor public SkiaSlider() { IsFocusable = true; } #endregion #region Event Handlers private void OnRangeChanged() { // Clamp value to new range var clamped = Math.Clamp(Value, Minimum, Maximum); if (Math.Abs(Value - clamped) > double.Epsilon) { Value = clamped; } Invalidate(); } private void OnValuePropertyChanged(double oldValue, double newValue) { ValueChanged?.Invoke(this, new ValueChangedEventArgs(oldValue, newValue)); Invalidate(); } #endregion #region Rendering protected override void OnDraw(SKCanvas canvas, SKRect bounds) { var trackHeight = (float)TrackHeight; var thumbRadius = (float)ThumbRadius; var trackY = bounds.MidY; var trackLeft = bounds.Left + thumbRadius; var trackRight = bounds.Right - thumbRadius; var trackWidth = trackRight - trackLeft; var percentage = Maximum > Minimum ? (Value - Minimum) / (Maximum - Minimum) : 0; var thumbX = trackLeft + (float)(percentage * trackWidth); // Get colors (using helper methods for platform defaults when null) var minTrackColorSK = GetEffectiveMinimumTrackColor(); var maxTrackColorSK = GetEffectiveMaximumTrackColor(); var thumbColorSK = GetEffectiveThumbColor(); var disabledColorSK = ToSKColor(DisabledColor); // Draw inactive (maximum) track using var inactiveTrackPaint = new SKPaint { Color = IsEnabled ? maxTrackColorSK : disabledColorSK, IsAntialias = true, Style = SKPaintStyle.Fill }; var inactiveRect = new SKRoundRect( new SKRect(trackLeft, trackY - trackHeight / 2, trackRight, trackY + trackHeight / 2), trackHeight / 2); canvas.DrawRoundRect(inactiveRect, inactiveTrackPaint); // Draw active (minimum) track if (percentage > 0) { using var activeTrackPaint = new SKPaint { Color = IsEnabled ? minTrackColorSK : disabledColorSK, IsAntialias = true, Style = SKPaintStyle.Fill }; var activeRect = new SKRoundRect( new SKRect(trackLeft, trackY - trackHeight / 2, thumbX, trackY + trackHeight / 2), trackHeight / 2); canvas.DrawRoundRect(activeRect, activeTrackPaint); } // Draw focus ring behind thumb if (IsFocused) { using var focusPaint = new SKPaint { Color = thumbColorSK.WithAlpha(60), IsAntialias = true, Style = SKPaintStyle.Fill }; canvas.DrawCircle(thumbX, trackY, thumbRadius + 8, focusPaint); } // Draw thumb shadow if (IsEnabled) { using var shadowPaint = new SKPaint { Color = SkiaTheme.Shadow20SK, IsAntialias = true, MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 3) }; canvas.DrawCircle(thumbX + 1, trackY + 2, thumbRadius, shadowPaint); } // Draw thumb using var thumbPaint = new SKPaint { Color = IsEnabled ? thumbColorSK : disabledColorSK, IsAntialias = true, Style = SKPaintStyle.Fill }; canvas.DrawCircle(thumbX, trackY, thumbRadius, thumbPaint); // Draw pressed state (larger thumb when dragging) if (IsDragging) { using var pressedPaint = new SKPaint { Color = thumbColorSK.WithAlpha(40), IsAntialias = true, Style = SKPaintStyle.Fill }; canvas.DrawCircle(thumbX, trackY, thumbRadius + 4, pressedPaint); } } #endregion #region Pointer Events public override void OnPointerPressed(PointerEventArgs e) { if (!IsEnabled) return; IsDragging = true; UpdateValueFromPosition(e.X); DragStarted?.Invoke(this, EventArgs.Empty); SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed); e.Handled = true; } public override void OnPointerMoved(PointerEventArgs e) { if (!IsEnabled || !IsDragging) return; UpdateValueFromPosition(e.X); } public override void OnPointerReleased(PointerEventArgs e) { if (IsDragging) { IsDragging = false; DragCompleted?.Invoke(this, EventArgs.Empty); SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled); Invalidate(); } } private void UpdateValueFromPosition(float x) { var thumbRadius = (float)ThumbRadius; var trackLeft = Bounds.Left + thumbRadius; var trackRight = Bounds.Right - thumbRadius; var trackWidth = trackRight - trackLeft; if (trackWidth <= 0) return; var percentage = Math.Clamp((x - trackLeft) / trackWidth, 0, 1); Value = Minimum + percentage * (Maximum - Minimum); } #endregion #region Keyboard Events public override void OnKeyDown(KeyEventArgs e) { if (!IsEnabled) return; var step = (Maximum - Minimum) / 100; // 1% steps var largeStep = step * 10; // 10% for arrow keys switch (e.Key) { case Key.Left: case Key.Down: Value -= largeStep; e.Handled = true; break; case Key.Right: case Key.Up: Value += largeStep; e.Handled = true; break; case Key.Home: Value = Minimum; e.Handled = true; break; case Key.End: Value = Maximum; e.Handled = true; break; case Key.PageDown: Value -= (Maximum - Minimum) * 0.1; // 10% e.Handled = true; break; case Key.PageUp: Value += (Maximum - Minimum) * 0.1; // 10% e.Handled = true; break; } } #endregion #region Lifecycle protected override void OnEnabledChanged() { base.OnEnabledChanged(); SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled); } #endregion #region Layout protected override Size MeasureOverride(Size availableSize) { var thumbRadius = ThumbRadius; return new Size(200, thumbRadius * 2 + 16); } #endregion }