From 436c9d60cbdf0f3851887f984e5bfc27c06f2fa2 Mon Sep 17 00:00:00 2001 From: logikonline Date: Fri, 16 Jan 2026 05:01:27 +0000 Subject: [PATCH] Stepper completed --- Handlers/StepperHandler.cs | 36 +++- Views/SkiaStepper.cs | 375 ++++++++++++++++++++++++++++++++----- 2 files changed, 350 insertions(+), 61 deletions(-) diff --git a/Handlers/StepperHandler.cs b/Handlers/StepperHandler.cs index d521c16..066a3b7 100644 --- a/Handlers/StepperHandler.cs +++ b/Handlers/StepperHandler.cs @@ -5,12 +5,12 @@ using Microsoft.Maui.Handlers; using Microsoft.Maui.Graphics; using Microsoft.Maui.Controls; using Microsoft.Maui.Platform; -using SkiaSharp; namespace Microsoft.Maui.Platform.Linux.Handlers; /// /// Handler for Stepper on Linux using Skia rendering. +/// Maps IStepper interface to SkiaStepper platform view. /// public partial class StepperHandler : ViewHandler { @@ -52,12 +52,21 @@ public partial class StepperHandler : ViewHandler // Apply dark theme colors if needed if (Application.Current?.UserAppTheme == AppTheme.Dark) { - platformView.ButtonBackgroundColor = new SKColor(66, 66, 66); - platformView.ButtonPressedColor = new SKColor(97, 97, 97); - platformView.ButtonDisabledColor = new SKColor(48, 48, 48); - platformView.SymbolColor = new SKColor(224, 224, 224); - platformView.SymbolDisabledColor = new SKColor(97, 97, 97); - platformView.BorderColor = new SKColor(97, 97, 97); + platformView.ButtonBackgroundColor = Color.FromRgb(66, 66, 66); + platformView.ButtonPressedColor = Color.FromRgb(97, 97, 97); + platformView.ButtonDisabledColor = Color.FromRgb(48, 48, 48); + platformView.SymbolColor = Color.FromRgb(224, 224, 224); + platformView.SymbolDisabledColor = Color.FromRgb(97, 97, 97); + platformView.BorderColor = Color.FromRgb(97, 97, 97); + } + + // Sync properties + if (VirtualView != null) + { + MapValue(this, VirtualView); + MapMinimum(this, VirtualView); + MapMaximum(this, VirtualView); + MapIsEnabled(this, VirtualView); } } @@ -67,16 +76,22 @@ public partial class StepperHandler : ViewHandler base.DisconnectHandler(platformView); } - private void OnValueChanged(object? sender, EventArgs e) + private void OnValueChanged(object? sender, ValueChangedEventArgs e) { if (VirtualView is null || PlatformView is null) return; - VirtualView.Value = PlatformView.Value; + + if (Math.Abs(VirtualView.Value - e.NewValue) > 0.0001) + { + VirtualView.Value = e.NewValue; + } } public static void MapValue(StepperHandler handler, IStepper stepper) { if (handler.PlatformView is null) return; - handler.PlatformView.Value = stepper.Value; + + if (Math.Abs(handler.PlatformView.Value - stepper.Value) > 0.0001) + handler.PlatformView.Value = stepper.Value; } public static void MapMinimum(StepperHandler handler, IStepper stepper) @@ -115,5 +130,6 @@ public partial class StepperHandler : ViewHandler { if (handler.PlatformView is null) return; handler.PlatformView.IsEnabled = stepper.IsEnabled; + handler.PlatformView.Invalidate(); } } diff --git a/Views/SkiaStepper.cs b/Views/SkiaStepper.cs index 494e168..445bc8a 100644 --- a/Views/SkiaStepper.cs +++ b/Views/SkiaStepper.cs @@ -1,205 +1,404 @@ // 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 new SKColor( + (byte)(color.Red * 255), + (byte)(color.Green * 255), + (byte)(color.Blue * 255), + (byte)(color.Alpha * 255)); + } + + #endregion + #region BindableProperties public static readonly BindableProperty ValueProperty = - BindableProperty.Create(nameof(Value), typeof(double), typeof(SkiaStepper), 0.0, BindingMode.OneWay, + 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, + 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, + 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); + BindableProperty.Create( + nameof(Increment), + typeof(double), + typeof(SkiaStepper), + 1.0, + BindingMode.TwoWay); public static readonly BindableProperty ButtonBackgroundColorProperty = - BindableProperty.Create(nameof(ButtonBackgroundColor), typeof(SKColor), typeof(SkiaStepper), new SKColor(0xE0, 0xE0, 0xE0), BindingMode.TwoWay, + 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(SKColor), typeof(SkiaStepper), new SKColor(0xBD, 0xBD, 0xBD), BindingMode.TwoWay, + 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(SKColor), typeof(SkiaStepper), new SKColor(0xF5, 0xF5, 0xF5), BindingMode.TwoWay, + 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(SKColor), typeof(SkiaStepper), new SKColor(0xBD, 0xBD, 0xBD), BindingMode.TwoWay, + 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(SKColor), typeof(SkiaStepper), SKColors.Black, BindingMode.TwoWay, + 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(SKColor), typeof(SkiaStepper), new SKColor(0xBD, 0xBD, 0xBD), BindingMode.TwoWay, + 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(float), typeof(SkiaStepper), 4f, BindingMode.TwoWay, + 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(float), typeof(SkiaStepper), 40f, BindingMode.TwoWay, + 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)); } - public SKColor ButtonBackgroundColor + /// + /// Gets or sets the button background color. + /// + public Color ButtonBackgroundColor { - get => (SKColor)GetValue(ButtonBackgroundColorProperty); + get => (Color)GetValue(ButtonBackgroundColorProperty); set => SetValue(ButtonBackgroundColorProperty, value); } - public SKColor ButtonPressedColor + /// + /// Gets or sets the button pressed color. + /// + public Color ButtonPressedColor { - get => (SKColor)GetValue(ButtonPressedColorProperty); + get => (Color)GetValue(ButtonPressedColorProperty); set => SetValue(ButtonPressedColorProperty, value); } - public SKColor ButtonDisabledColor + /// + /// Gets or sets the button disabled color. + /// + public Color ButtonDisabledColor { - get => (SKColor)GetValue(ButtonDisabledColorProperty); + get => (Color)GetValue(ButtonDisabledColorProperty); set => SetValue(ButtonDisabledColorProperty, value); } - public SKColor BorderColor + /// + /// Gets or sets the border color. + /// + public Color BorderColor { - get => (SKColor)GetValue(BorderColorProperty); + get => (Color)GetValue(BorderColorProperty); set => SetValue(BorderColorProperty, value); } - public SKColor SymbolColor + /// + /// Gets or sets the symbol color. + /// + public Color SymbolColor { - get => (SKColor)GetValue(SymbolColorProperty); + get => (Color)GetValue(SymbolColorProperty); set => SetValue(SymbolColorProperty, value); } - public SKColor SymbolDisabledColor + /// + /// Gets or sets the symbol disabled color. + /// + public Color SymbolDisabledColor { - get => (SKColor)GetValue(SymbolDisabledColorProperty); + get => (Color)GetValue(SymbolDisabledColorProperty); set => SetValue(SymbolDisabledColorProperty, value); } - public float CornerRadius + /// + /// Gets or sets the corner radius. + /// + public double CornerRadius { - get => (float)GetValue(CornerRadiusProperty); + get => (double)GetValue(CornerRadiusProperty); set => SetValue(CornerRadiusProperty, value); } - public float ButtonWidth + /// + /// Gets or sets the button width. + /// + public double ButtonWidth { - get => (float)GetValue(ButtonWidthProperty); + 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 - private bool _isMinusPressed; - private bool _isPlusPressed; + #region Events - public event EventHandler? ValueChanged; + /// + /// 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, EventArgs.Empty); + ValueChanged?.Invoke(this, new ValueChangedEventArgs(oldValue, newValue)); Invalidate(); } private void OnRangeChanged() { var clamped = Math.Clamp(Value, Minimum, Maximum); - if (Value != clamped) + if (Math.Abs(Value - clamped) > double.Epsilon) { Value = clamped; } Invalidate(); } + #endregion + + #region Rendering + protected override void OnDraw(SKCanvas canvas, SKRect bounds) { - 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); + var buttonWidth = (float)ButtonWidth; + var cornerRadius = (float)CornerRadius; - DrawButton(canvas, minusRect, "-", _isMinusPressed, !CanDecrement()); - DrawButton(canvas, plusRect, "+", _isPlusPressed, !CanIncrement()); + 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 = BorderColor, + 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); + 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) + 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 ? ButtonDisabledColor : (isPressed ? ButtonPressedColor : ButtonBackgroundColor), + Color = isDisabled ? disabledColor : (isPressed ? pressedColor : bgColor), Style = SKPaintStyle.Fill, IsAntialias = true }; - canvas.DrawRect(rect, bgPaint); + // 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, + Color = isDisabled ? symbolDisabledColor : symbolColor, IsAntialias = true }; @@ -208,33 +407,72 @@ public class SkiaStepper : SkiaView 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; - if (e.X < ButtonWidth) + var buttonWidth = (float)ButtonWidth; + + if (e.X < buttonWidth) { - _isMinusPressed = true; + IsMinusPressed = true; if (CanDecrement()) Value -= Increment; + SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed); } - else if (e.X > Bounds.Width - ButtonWidth) + else if (e.X > Bounds.Width - buttonWidth) { - _isPlusPressed = true; + IsPlusPressed = true; if (CanIncrement()) Value += Increment; + SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed); } + + e.Handled = true; Invalidate(); } public override void OnPointerReleased(PointerEventArgs e) { - _isMinusPressed = false; - _isPlusPressed = false; - Invalidate(); + 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; @@ -251,11 +489,46 @@ public class SkiaStepper : SkiaView 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 SKSize MeasureOverride(SKSize availableSize) { - return new SKSize(ButtonWidth * 2 + 1, 32); + var buttonWidth = (float)ButtonWidth; + return new SKSize(buttonWidth * 2 + 1, 32); } + + #endregion }