// 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 stepper control with increment/decrement buttons.
/// Implements IStepper interface requirements:
/// - Minimum, Maximum, Value, Increment properties
/// - ValueChanged event with old/new values
///
public class SkiaStepper : 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 ValueProperty =
BindableProperty.Create(
nameof(Value),
typeof(double),
typeof(SkiaStepper),
0.0,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStepper)b).OnValuePropertyChanged((double)o, (double)n));
public static readonly BindableProperty MinimumProperty =
BindableProperty.Create(
nameof(Minimum),
typeof(double),
typeof(SkiaStepper),
0.0,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStepper)b).OnRangeChanged());
public static readonly BindableProperty MaximumProperty =
BindableProperty.Create(
nameof(Maximum),
typeof(double),
typeof(SkiaStepper),
100.0,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStepper)b).OnRangeChanged());
public static readonly BindableProperty IncrementProperty =
BindableProperty.Create(
nameof(Increment),
typeof(double),
typeof(SkiaStepper),
1.0,
BindingMode.TwoWay);
public static readonly BindableProperty ButtonBackgroundColorProperty =
BindableProperty.Create(
nameof(ButtonBackgroundColor),
typeof(Color),
typeof(SkiaStepper),
Color.FromRgb(0xE0, 0xE0, 0xE0),
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
public static readonly BindableProperty ButtonPressedColorProperty =
BindableProperty.Create(
nameof(ButtonPressedColor),
typeof(Color),
typeof(SkiaStepper),
Color.FromRgb(0xBD, 0xBD, 0xBD),
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
public static readonly BindableProperty ButtonDisabledColorProperty =
BindableProperty.Create(
nameof(ButtonDisabledColor),
typeof(Color),
typeof(SkiaStepper),
Color.FromRgb(0xF5, 0xF5, 0xF5),
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
public static readonly BindableProperty BorderColorProperty =
BindableProperty.Create(
nameof(BorderColor),
typeof(Color),
typeof(SkiaStepper),
Color.FromRgb(0xBD, 0xBD, 0xBD),
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
public static readonly BindableProperty SymbolColorProperty =
BindableProperty.Create(
nameof(SymbolColor),
typeof(Color),
typeof(SkiaStepper),
Colors.Black,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
public static readonly BindableProperty SymbolDisabledColorProperty =
BindableProperty.Create(
nameof(SymbolDisabledColor),
typeof(Color),
typeof(SkiaStepper),
Color.FromRgb(0xBD, 0xBD, 0xBD),
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
public static readonly BindableProperty CornerRadiusProperty =
BindableProperty.Create(
nameof(CornerRadius),
typeof(double),
typeof(SkiaStepper),
4.0,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
public static readonly BindableProperty ButtonWidthProperty =
BindableProperty.Create(
nameof(ButtonWidth),
typeof(double),
typeof(SkiaStepper),
40.0,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStepper)b).InvalidateMeasure());
#endregion
#region Properties
///
/// 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 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 increment amount.
///
public double Increment
{
get => (double)GetValue(IncrementProperty);
set => SetValue(IncrementProperty, Math.Max(0.001, value));
}
///
/// Gets or sets the button background color.
///
public Color ButtonBackgroundColor
{
get => (Color)GetValue(ButtonBackgroundColorProperty);
set => SetValue(ButtonBackgroundColorProperty, value);
}
///
/// Gets or sets the button pressed color.
///
public Color ButtonPressedColor
{
get => (Color)GetValue(ButtonPressedColorProperty);
set => SetValue(ButtonPressedColorProperty, value);
}
///
/// Gets or sets the button disabled color.
///
public Color ButtonDisabledColor
{
get => (Color)GetValue(ButtonDisabledColorProperty);
set => SetValue(ButtonDisabledColorProperty, value);
}
///
/// Gets or sets the border color.
///
public Color BorderColor
{
get => (Color)GetValue(BorderColorProperty);
set => SetValue(BorderColorProperty, value);
}
///
/// Gets or sets the symbol color.
///
public Color SymbolColor
{
get => (Color)GetValue(SymbolColorProperty);
set => SetValue(SymbolColorProperty, value);
}
///
/// Gets or sets the symbol disabled color.
///
public Color SymbolDisabledColor
{
get => (Color)GetValue(SymbolDisabledColorProperty);
set => SetValue(SymbolDisabledColorProperty, value);
}
///
/// Gets or sets the corner radius.
///
public double CornerRadius
{
get => (double)GetValue(CornerRadiusProperty);
set => SetValue(CornerRadiusProperty, value);
}
///
/// Gets or sets the button width.
///
public double ButtonWidth
{
get => (double)GetValue(ButtonWidthProperty);
set => SetValue(ButtonWidthProperty, value);
}
///
/// Gets whether the minus button is currently pressed.
///
public bool IsMinusPressed { get; private set; }
///
/// Gets whether the plus button is currently pressed.
///
public bool IsPlusPressed { get; private set; }
#endregion
#region Events
///
/// Event raised when the value changes.
///
public event EventHandler? ValueChanged;
#endregion
#region Constructor
public SkiaStepper()
{
IsFocusable = true;
}
#endregion
#region Event Handlers
private void OnValuePropertyChanged(double oldValue, double newValue)
{
ValueChanged?.Invoke(this, new ValueChangedEventArgs(oldValue, newValue));
Invalidate();
}
private void OnRangeChanged()
{
var clamped = Math.Clamp(Value, Minimum, Maximum);
if (Math.Abs(Value - clamped) > double.Epsilon)
{
Value = clamped;
}
Invalidate();
}
#endregion
#region Rendering
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
{
var buttonWidth = (float)ButtonWidth;
var cornerRadius = (float)CornerRadius;
var minusRect = new SKRect(bounds.Left, bounds.Top, bounds.Left + buttonWidth, bounds.Bottom);
var plusRect = new SKRect(bounds.Right - buttonWidth, bounds.Top, bounds.Right, bounds.Bottom);
// Get colors
var buttonBgColorSK = ToSKColor(ButtonBackgroundColor);
var buttonPressedColorSK = ToSKColor(ButtonPressedColor);
var buttonDisabledColorSK = ToSKColor(ButtonDisabledColor);
var borderColorSK = ToSKColor(BorderColor);
var symbolColorSK = ToSKColor(SymbolColor);
var symbolDisabledColorSK = ToSKColor(SymbolDisabledColor);
// Draw focus ring
if (IsFocused)
{
using var focusPaint = new SKPaint
{
Color = ToSKColor(Color.FromRgb(0x21, 0x96, 0xF3)).WithAlpha(60),
Style = SKPaintStyle.Stroke,
StrokeWidth = 3,
IsAntialias = true
};
var focusRect = new SKRect(bounds.Left - 2, bounds.Top - 2, bounds.Right + 2, bounds.Bottom + 2);
canvas.DrawRoundRect(new SKRoundRect(focusRect, cornerRadius + 2), focusPaint);
}
DrawButton(canvas, minusRect, "-", IsMinusPressed, !CanDecrement(),
buttonBgColorSK, buttonPressedColorSK, buttonDisabledColorSK,
symbolColorSK, symbolDisabledColorSK, cornerRadius, true);
DrawButton(canvas, plusRect, "+", IsPlusPressed, !CanIncrement(),
buttonBgColorSK, buttonPressedColorSK, buttonDisabledColorSK,
symbolColorSK, symbolDisabledColorSK, cornerRadius, false);
// Draw border
using var borderPaint = new SKPaint
{
Color = borderColorSK,
Style = SKPaintStyle.Stroke,
StrokeWidth = 1,
IsAntialias = true
};
var totalRect = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Bottom);
canvas.DrawRoundRect(new SKRoundRect(totalRect, cornerRadius), borderPaint);
// Draw center divider
var centerX = bounds.MidX;
canvas.DrawLine(centerX, bounds.Top, centerX, bounds.Bottom, borderPaint);
}
private void DrawButton(SKCanvas canvas, SKRect rect, string symbol, bool isPressed, bool isDisabled,
SKColor bgColor, SKColor pressedColor, SKColor disabledColor,
SKColor symbolColor, SKColor symbolDisabledColor, float cornerRadius, bool isLeft)
{
// Draw background with rounded corners on the appropriate side
using var bgPaint = new SKPaint
{
Color = isDisabled ? disabledColor : (isPressed ? pressedColor : bgColor),
Style = SKPaintStyle.Fill,
IsAntialias = true
};
// Create path for rounded corners on one side only
using var path = new SKPath();
if (isLeft)
{
path.MoveTo(rect.Left + cornerRadius, rect.Top);
path.LineTo(rect.Right, rect.Top);
path.LineTo(rect.Right, rect.Bottom);
path.LineTo(rect.Left + cornerRadius, rect.Bottom);
path.ArcTo(new SKRect(rect.Left, rect.Bottom - cornerRadius * 2, rect.Left + cornerRadius * 2, rect.Bottom), 90, 90, false);
path.LineTo(rect.Left, rect.Top + cornerRadius);
path.ArcTo(new SKRect(rect.Left, rect.Top, rect.Left + cornerRadius * 2, rect.Top + cornerRadius * 2), 180, 90, false);
}
else
{
path.MoveTo(rect.Left, rect.Top);
path.LineTo(rect.Right - cornerRadius, rect.Top);
path.ArcTo(new SKRect(rect.Right - cornerRadius * 2, rect.Top, rect.Right, rect.Top + cornerRadius * 2), 270, 90, false);
path.LineTo(rect.Right, rect.Bottom - cornerRadius);
path.ArcTo(new SKRect(rect.Right - cornerRadius * 2, rect.Bottom - cornerRadius * 2, rect.Right, rect.Bottom), 0, 90, false);
path.LineTo(rect.Left, rect.Bottom);
}
path.Close();
canvas.DrawPath(path, bgPaint);
// Draw symbol
using var font = new SKFont(SKTypeface.Default, 20);
using var textPaint = new SKPaint(font)
{
Color = isDisabled ? symbolDisabledColor : symbolColor,
IsAntialias = true
};
var textBounds = new SKRect();
textPaint.MeasureText(symbol, ref textBounds);
canvas.DrawText(symbol, rect.MidX - textBounds.MidX, rect.MidY - textBounds.MidY, textPaint);
}
#endregion
#region Helper Methods
private bool CanIncrement() => IsEnabled && Value < Maximum;
private bool CanDecrement() => IsEnabled && Value > Minimum;
#endregion
#region Pointer Events
public override void OnPointerPressed(PointerEventArgs e)
{
if (!IsEnabled) return;
var buttonWidth = (float)ButtonWidth;
if (e.X < buttonWidth)
{
IsMinusPressed = true;
if (CanDecrement()) Value -= Increment;
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed);
}
else if (e.X > Bounds.Width - buttonWidth)
{
IsPlusPressed = true;
if (CanIncrement()) Value += Increment;
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed);
}
e.Handled = true;
Invalidate();
}
public override void OnPointerReleased(PointerEventArgs e)
{
if (IsMinusPressed || IsPlusPressed)
{
IsMinusPressed = false;
IsPlusPressed = false;
SkiaVisualStateManager.GoToState(this, IsEnabled
? SkiaVisualStateManager.CommonStates.Normal
: SkiaVisualStateManager.CommonStates.Disabled);
Invalidate();
}
}
public override void OnPointerEntered(PointerEventArgs e)
{
if (IsEnabled)
{
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.PointerOver);
}
}
public override void OnPointerExited(PointerEventArgs e)
{
SkiaVisualStateManager.GoToState(this, IsEnabled
? SkiaVisualStateManager.CommonStates.Normal
: SkiaVisualStateManager.CommonStates.Disabled);
}
#endregion
#region Keyboard Events
public override void OnKeyDown(KeyEventArgs e)
{
if (!IsEnabled) return;
switch (e.Key)
{
case Key.Up:
case Key.Right:
if (CanIncrement()) Value += Increment;
e.Handled = true;
break;
case Key.Down:
case Key.Left:
if (CanDecrement()) Value -= Increment;
e.Handled = true;
break;
case Key.PageUp:
if (CanIncrement()) Value += Increment * 10;
e.Handled = true;
break;
case Key.PageDown:
if (CanDecrement()) Value -= Increment * 10;
e.Handled = true;
break;
case Key.Home:
Value = Minimum;
e.Handled = true;
break;
case Key.End:
Value = Maximum;
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 buttonWidth = ButtonWidth;
return new Size(buttonWidth * 2 + 1, 32);
}
#endregion
}