From 159166e32e2a4177785bf75f2fe25cfe18ef05fa Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 5 Feb 2026 02:42:50 +0000
Subject: [PATCH 1/4] Initial plan
From 0d450e970984019757a8c4041f251610a6c2e805 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 5 Feb 2026 02:48:58 +0000
Subject: [PATCH 2/4] Add SKSignaturePadView control with pressure-sensitive
ink rendering
Co-authored-by: mattleibow <1096616+mattleibow@users.noreply.github.com>
---
.../Controls/Signature/SKInkStroke.shared.cs | 328 +++++++++++
.../Signature/SKSignaturePadView.shared.cs | 527 ++++++++++++++++++
.../SKSignaturePadViewResources.shared.xaml | 22 +
...SKSignaturePadViewResources.shared.xaml.cs | 15 +
...ignatureStrokeCompletedEventArgs.shared.cs | 21 +
5 files changed, 913 insertions(+)
create mode 100644 source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKInkStroke.shared.cs
create mode 100644 source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadView.shared.cs
create mode 100644 source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadViewResources.shared.xaml
create mode 100644 source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadViewResources.shared.xaml.cs
create mode 100644 source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignatureStrokeCompletedEventArgs.shared.cs
diff --git a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKInkStroke.shared.cs b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKInkStroke.shared.cs
new file mode 100644
index 00000000..45729878
--- /dev/null
+++ b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKInkStroke.shared.cs
@@ -0,0 +1,328 @@
+namespace SkiaSharp.Extended.UI.Controls;
+
+///
+/// Represents a single ink stroke with pressure-sensitive variable-width rendering.
+/// Uses quadratic Bezier curves for smooth path interpolation and renders
+/// variable-width strokes as filled polygons based on pressure data.
+///
+public class SKInkStroke
+{
+ // Implementation adapted from @colinta on StackOverflow: https://stackoverflow.com/a/35229104
+ // Enhanced with pressure sensitivity for fluid ink rendering
+
+ private const float MinimumPointDistance = 2.0f;
+ private const float DefaultPressure = 0.5f;
+
+ private readonly List points = new();
+ private readonly List pressures = new();
+ private readonly float minStrokeWidth;
+ private readonly float maxStrokeWidth;
+
+ private SKPath? cachedPath;
+ private bool isDirty = true;
+
+ ///
+ /// Creates a new ink stroke with the specified stroke width range.
+ ///
+ /// Minimum stroke width (at zero pressure).
+ /// Maximum stroke width (at full pressure).
+ public SKInkStroke(float minStrokeWidth = 1f, float maxStrokeWidth = 8f)
+ {
+ this.minStrokeWidth = minStrokeWidth;
+ this.maxStrokeWidth = maxStrokeWidth;
+ }
+
+ ///
+ /// Gets the list of points in this stroke.
+ ///
+ public IReadOnlyList Points => points;
+
+ ///
+ /// Gets the list of pressure values for each point.
+ ///
+ public IReadOnlyList Pressures => pressures;
+
+ ///
+ /// Gets whether the stroke has no points.
+ ///
+ public bool IsEmpty => points.Count == 0;
+
+ ///
+ /// Gets the filled path representing this variable-width stroke.
+ /// The path is cached and only regenerated when points change.
+ ///
+ public SKPath? Path
+ {
+ get
+ {
+ if (isDirty)
+ {
+ cachedPath?.Dispose();
+ cachedPath = GenerateVariableWidthPath();
+ isDirty = false;
+ }
+ return cachedPath;
+ }
+ }
+
+ ///
+ /// Adds a point to the stroke with pressure information.
+ ///
+ /// The point location.
+ /// The pressure value (0.0 to 1.0).
+ /// Whether this is the final point in the stroke.
+ public void AddPoint(SKPoint point, float pressure, bool isLastPoint = false)
+ {
+ // Clamp pressure to valid range
+ pressure = Math.Clamp(pressure, 0f, 1f);
+
+ // Use default pressure if no pressure data is available (finger touch)
+ if (pressure == 0f && !isLastPoint)
+ pressure = DefaultPressure;
+
+ // Do not add a point if the current point is too close to the previous point
+ if (!isLastPoint && points.Count > 0 && !HasMovedFarEnough(points[^1], point))
+ return;
+
+ points.Add(point);
+ pressures.Add(pressure);
+ isDirty = true;
+ }
+
+ ///
+ /// Clears all points from the stroke.
+ ///
+ public void Clear()
+ {
+ points.Clear();
+ pressures.Clear();
+ cachedPath?.Dispose();
+ cachedPath = null;
+ isDirty = true;
+ }
+
+ ///
+ /// Disposes of the cached path resources.
+ ///
+ public void Dispose()
+ {
+ cachedPath?.Dispose();
+ cachedPath = null;
+ }
+
+ private bool HasMovedFarEnough(SKPoint prevPoint, SKPoint currPoint)
+ {
+ var deltaX = currPoint.X - prevPoint.X;
+ var deltaY = currPoint.Y - prevPoint.Y;
+ var distance = MathF.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
+ return distance >= MinimumPointDistance;
+ }
+
+ ///
+ /// Generates a variable-width path by creating a filled polygon
+ /// that represents the stroke with width varying based on pressure.
+ ///
+ private SKPath? GenerateVariableWidthPath()
+ {
+ if (points.Count < 2)
+ {
+ // For a single point, draw a circle
+ if (points.Count == 1)
+ {
+ var path = new SKPath();
+ var radius = GetStrokeWidth(pressures[0]) / 2f;
+ path.AddCircle(points[0].X, points[0].Y, radius);
+ return path;
+ }
+ return null;
+ }
+
+ // Generate smoothed centerline points using quadratic Bezier interpolation
+ var smoothedPoints = GenerateSmoothedPoints();
+ if (smoothedPoints.Count < 2)
+ return null;
+
+ // Generate left and right offset points based on pressure
+ var leftPoints = new List();
+ var rightPoints = new List();
+
+ for (int i = 0; i < smoothedPoints.Count; i++)
+ {
+ var point = smoothedPoints[i].Point;
+ var pressure = smoothedPoints[i].Pressure;
+ var halfWidth = GetStrokeWidth(pressure) / 2f;
+
+ // Calculate the normal (perpendicular) at this point
+ SKPoint tangent;
+ if (i == 0)
+ {
+ // First point: use direction to next point
+ tangent = Normalize(smoothedPoints[i + 1].Point - point);
+ }
+ else if (i == smoothedPoints.Count - 1)
+ {
+ // Last point: use direction from previous point
+ tangent = Normalize(point - smoothedPoints[i - 1].Point);
+ }
+ else
+ {
+ // Middle point: average direction
+ var prev = smoothedPoints[i - 1].Point;
+ var next = smoothedPoints[i + 1].Point;
+ tangent = Normalize(next - prev);
+ }
+
+ // Normal is perpendicular to tangent
+ var normal = new SKPoint(-tangent.Y, tangent.X);
+
+ // Offset points on both sides
+ leftPoints.Add(new SKPoint(point.X + normal.X * halfWidth, point.Y + normal.Y * halfWidth));
+ rightPoints.Add(new SKPoint(point.X - normal.X * halfWidth, point.Y - normal.Y * halfWidth));
+ }
+
+ // Build the filled polygon path
+ var path = new SKPath();
+
+ // Start with the left side (forward direction)
+ path.MoveTo(leftPoints[0]);
+ for (int i = 1; i < leftPoints.Count; i++)
+ {
+ path.LineTo(leftPoints[i]);
+ }
+
+ // Add rounded end cap at the end
+ var endRadius = GetStrokeWidth(smoothedPoints[^1].Pressure) / 2f;
+ var endCenter = smoothedPoints[^1].Point;
+ var endTangent = Normalize(smoothedPoints[^1].Point - smoothedPoints[^2].Point);
+ AddRoundedCap(path, endCenter, endTangent, endRadius);
+
+ // Continue with the right side (reverse direction)
+ for (int i = rightPoints.Count - 1; i >= 0; i--)
+ {
+ path.LineTo(rightPoints[i]);
+ }
+
+ // Add rounded start cap
+ var startRadius = GetStrokeWidth(smoothedPoints[0].Pressure) / 2f;
+ var startCenter = smoothedPoints[0].Point;
+ var startTangent = Normalize(smoothedPoints[0].Point - smoothedPoints[1].Point);
+ AddRoundedCap(path, startCenter, startTangent, startRadius);
+
+ path.Close();
+
+ return path;
+ }
+
+ ///
+ /// Generates smoothed points using quadratic Bezier interpolation.
+ ///
+ private List<(SKPoint Point, float Pressure)> GenerateSmoothedPoints()
+ {
+ var result = new List<(SKPoint Point, float Pressure)>();
+
+ if (points.Count == 0)
+ return result;
+
+ if (points.Count == 1)
+ {
+ result.Add((points[0], pressures[0]));
+ return result;
+ }
+
+ // Add the first point
+ result.Add((points[0], pressures[0]));
+
+ // Generate intermediate points using quadratic Bezier interpolation
+ for (int i = 0; i < points.Count - 1; i++)
+ {
+ var p0 = points[i];
+ var p1 = points[i + 1];
+ var pressure0 = pressures[i];
+ var pressure1 = pressures[i + 1];
+
+ // Calculate midpoint
+ var midPoint = new SKPoint((p0.X + p1.X) / 2f, (p0.Y + p1.Y) / 2f);
+ var midPressure = (pressure0 + pressure1) / 2f;
+
+ if (i == 0)
+ {
+ // First segment: add midpoint
+ result.Add((midPoint, midPressure));
+ }
+ else if (i < points.Count - 2)
+ {
+ // Middle segments: add quadratic curve samples
+ var prevMid = result[^1];
+ AddQuadraticSamples(result, prevMid.Point, prevMid.Pressure, p0, pressure0, midPoint, midPressure);
+ }
+ }
+
+ // Add the last point
+ result.Add((points[^1], pressures[^1]));
+
+ return result;
+ }
+
+ ///
+ /// Adds samples along a quadratic Bezier curve for smooth interpolation.
+ ///
+ private static void AddQuadraticSamples(
+ List<(SKPoint Point, float Pressure)> result,
+ SKPoint p0, float pressure0,
+ SKPoint control, float controlPressure,
+ SKPoint p1, float pressure1)
+ {
+ // Sample the quadratic curve at a few points for smooth rendering
+ const int samples = 4;
+ for (int i = 1; i <= samples; i++)
+ {
+ float t = i / (float)samples;
+ float u = 1 - t;
+
+ // Quadratic Bezier formula: B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2
+ var x = u * u * p0.X + 2 * u * t * control.X + t * t * p1.X;
+ var y = u * u * p0.Y + 2 * u * t * control.Y + t * t * p1.Y;
+ var pressure = u * u * pressure0 + 2 * u * t * controlPressure + t * t * pressure1;
+
+ result.Add((new SKPoint(x, y), pressure));
+ }
+ }
+
+ ///
+ /// Adds a rounded cap at the end of the stroke.
+ ///
+ private static void AddRoundedCap(SKPath path, SKPoint center, SKPoint direction, float radius)
+ {
+ // Add a semicircle cap
+ var perpendicular = new SKPoint(-direction.Y, direction.X);
+ var startAngle = MathF.Atan2(perpendicular.Y, perpendicular.X) * 180f / MathF.PI;
+
+ var rect = new SKRect(
+ center.X - radius,
+ center.Y - radius,
+ center.X + radius,
+ center.Y + radius);
+
+ path.ArcTo(rect, startAngle, 180, false);
+ }
+
+ ///
+ /// Calculates the stroke width based on pressure.
+ ///
+ private float GetStrokeWidth(float pressure)
+ {
+ return minStrokeWidth + (maxStrokeWidth - minStrokeWidth) * pressure;
+ }
+
+ ///
+ /// Normalizes a vector to unit length.
+ ///
+ private static SKPoint Normalize(SKPoint vector)
+ {
+ var length = MathF.Sqrt(vector.X * vector.X + vector.Y * vector.Y);
+ if (length < 0.0001f)
+ return new SKPoint(1, 0); // Default direction for zero-length vectors
+
+ return new SKPoint(vector.X / length, vector.Y / length);
+ }
+}
diff --git a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadView.shared.cs b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadView.shared.cs
new file mode 100644
index 00000000..1e6088df
--- /dev/null
+++ b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadView.shared.cs
@@ -0,0 +1,527 @@
+using System.Windows.Input;
+
+namespace SkiaSharp.Extended.UI.Controls;
+
+///
+/// A signature pad control that provides fluid, pressure-sensitive ink rendering.
+/// Supports stylus/pen pressure for variable stroke width (harder pressure = thicker lines).
+/// Uses quadratic Bezier curves to smooth jagged input from finger or pen movement.
+///
+public class SKSignaturePadView : SKSurfaceView
+{
+ ///
+ /// Bindable property for the stroke color.
+ ///
+ public static readonly BindableProperty StrokeColorProperty = BindableProperty.Create(
+ nameof(StrokeColor),
+ typeof(Color),
+ typeof(SKSignaturePadView),
+ Colors.Black,
+ propertyChanged: OnStrokeColorChanged);
+
+ ///
+ /// Bindable property for the minimum stroke width (at zero pressure).
+ ///
+ public static readonly BindableProperty MinStrokeWidthProperty = BindableProperty.Create(
+ nameof(MinStrokeWidth),
+ typeof(float),
+ typeof(SKSignaturePadView),
+ 1f,
+ propertyChanged: OnStrokeWidthChanged);
+
+ ///
+ /// Bindable property for the maximum stroke width (at full pressure).
+ ///
+ public static readonly BindableProperty MaxStrokeWidthProperty = BindableProperty.Create(
+ nameof(MaxStrokeWidth),
+ typeof(float),
+ typeof(SKSignaturePadView),
+ 8f,
+ propertyChanged: OnStrokeWidthChanged);
+
+ ///
+ /// Bindable property for the pad background color.
+ ///
+ public static readonly BindableProperty PadBackgroundColorProperty = BindableProperty.Create(
+ nameof(PadBackgroundColor),
+ typeof(Color),
+ typeof(SKSignaturePadView),
+ Colors.White,
+ propertyChanged: OnPadBackgroundColorChanged);
+
+ ///
+ /// Bindable property for the command executed when strokes are cleared.
+ ///
+ public static readonly BindableProperty ClearedCommandProperty = BindableProperty.Create(
+ nameof(ClearedCommand),
+ typeof(ICommand),
+ typeof(SKSignaturePadView),
+ default(ICommand));
+
+ ///
+ /// Bindable property for the parameter passed to the ClearedCommand.
+ ///
+ public static readonly BindableProperty ClearedCommandParameterProperty = BindableProperty.Create(
+ nameof(ClearedCommandParameter),
+ typeof(object),
+ typeof(SKSignaturePadView),
+ null);
+
+ ///
+ /// Bindable property for the command executed when a stroke is completed.
+ ///
+ public static readonly BindableProperty StrokeCompletedCommandProperty = BindableProperty.Create(
+ nameof(StrokeCompletedCommand),
+ typeof(ICommand),
+ typeof(SKSignaturePadView),
+ default(ICommand));
+
+ ///
+ /// Bindable property for the parameter passed to the StrokeCompletedCommand.
+ ///
+ public static readonly BindableProperty StrokeCompletedCommandParameterProperty = BindableProperty.Create(
+ nameof(StrokeCompletedCommandParameter),
+ typeof(object),
+ typeof(SKSignaturePadView),
+ null);
+
+ ///
+ /// Internal bindable property key for the read-only IsBlank property.
+ ///
+ internal static readonly BindablePropertyKey IsBlankPropertyKey = BindableProperty.CreateReadOnly(
+ nameof(IsBlank),
+ typeof(bool),
+ typeof(SKSignaturePadView),
+ true);
+
+ ///
+ /// Bindable property for the IsBlank property.
+ ///
+ public static readonly BindableProperty IsBlankProperty = IsBlankPropertyKey.BindableProperty;
+
+ private readonly List strokes = new();
+ private readonly SKPaint strokePaint;
+ private SKInkStroke? currentStroke;
+ private SKColor skStrokeColor = SKColors.Black;
+ private SKColor skBackgroundColor = SKColors.White;
+ private SKCanvasView? canvasView;
+
+ ///
+ /// Creates a new signature pad view.
+ ///
+ public SKSignaturePadView()
+ {
+ ResourceLoader.EnsureRegistered(this);
+
+ strokePaint = new SKPaint
+ {
+ Color = skStrokeColor,
+ Style = SKPaintStyle.Fill,
+ IsAntialias = true
+ };
+ }
+
+ ///
+ /// Occurs when all strokes are cleared from the pad.
+ ///
+ public event EventHandler? Cleared;
+
+ ///
+ /// Occurs when a stroke is completed.
+ ///
+ public event EventHandler? StrokeCompleted;
+
+ ///
+ /// Gets whether the signature pad is blank (has no strokes).
+ ///
+ public bool IsBlank
+ {
+ get => (bool)GetValue(IsBlankProperty);
+ private set => SetValue(IsBlankPropertyKey, value);
+ }
+
+ ///
+ /// Gets or sets the stroke color.
+ ///
+ public Color StrokeColor
+ {
+ get => (Color)GetValue(StrokeColorProperty);
+ set => SetValue(StrokeColorProperty, value);
+ }
+
+ ///
+ /// Gets or sets the minimum stroke width (at zero pressure).
+ ///
+ public float MinStrokeWidth
+ {
+ get => (float)GetValue(MinStrokeWidthProperty);
+ set => SetValue(MinStrokeWidthProperty, value);
+ }
+
+ ///
+ /// Gets or sets the maximum stroke width (at full pressure).
+ ///
+ public float MaxStrokeWidth
+ {
+ get => (float)GetValue(MaxStrokeWidthProperty);
+ set => SetValue(MaxStrokeWidthProperty, value);
+ }
+
+ ///
+ /// Gets or sets the background color of the signature pad.
+ ///
+ public Color PadBackgroundColor
+ {
+ get => (Color)GetValue(PadBackgroundColorProperty);
+ set => SetValue(PadBackgroundColorProperty, value);
+ }
+
+ ///
+ /// Gets or sets the command executed when strokes are cleared.
+ ///
+ public ICommand? ClearedCommand
+ {
+ get => (ICommand?)GetValue(ClearedCommandProperty);
+ set => SetValue(ClearedCommandProperty, value);
+ }
+
+ ///
+ /// Gets or sets the parameter passed to the ClearedCommand.
+ ///
+ public object? ClearedCommandParameter
+ {
+ get => GetValue(ClearedCommandParameterProperty);
+ set => SetValue(ClearedCommandParameterProperty, value);
+ }
+
+ ///
+ /// Gets or sets the command executed when a stroke is completed.
+ ///
+ public ICommand? StrokeCompletedCommand
+ {
+ get => (ICommand?)GetValue(StrokeCompletedCommandProperty);
+ set => SetValue(StrokeCompletedCommandProperty, value);
+ }
+
+ ///
+ /// Gets or sets the parameter passed to the StrokeCompletedCommand.
+ ///
+ public object? StrokeCompletedCommandParameter
+ {
+ get => GetValue(StrokeCompletedCommandParameterProperty);
+ set => SetValue(StrokeCompletedCommandParameterProperty, value);
+ }
+
+ ///
+ /// Gets the number of strokes in the signature.
+ ///
+ public int StrokeCount => strokes.Count;
+
+ ///
+ /// Clears all strokes from the signature pad.
+ ///
+ public void Clear()
+ {
+ foreach (var stroke in strokes)
+ {
+ stroke.Dispose();
+ }
+ strokes.Clear();
+
+ currentStroke?.Dispose();
+ currentStroke = null;
+
+ UpdateIsBlank();
+ Invalidate();
+
+ Cleared?.Invoke(this, EventArgs.Empty);
+
+ if (ClearedCommand?.CanExecute(ClearedCommandParameter) == true)
+ {
+ ClearedCommand.Execute(ClearedCommandParameter);
+ }
+ }
+
+ ///
+ /// Removes the last stroke from the signature pad (undo).
+ ///
+ /// True if a stroke was removed, false if there were no strokes.
+ public bool Undo()
+ {
+ if (strokes.Count == 0)
+ return false;
+
+ var lastStroke = strokes[^1];
+ strokes.RemoveAt(strokes.Count - 1);
+ lastStroke.Dispose();
+
+ UpdateIsBlank();
+ Invalidate();
+
+ return true;
+ }
+
+ ///
+ /// Gets a combined path of all strokes.
+ ///
+ /// An SKPath containing all strokes, or null if the pad is blank.
+ public SKPath? ToPath()
+ {
+ if (strokes.Count == 0)
+ return null;
+
+ var combinedPath = new SKPath();
+ foreach (var stroke in strokes)
+ {
+ if (stroke.Path is SKPath path)
+ {
+ combinedPath.AddPath(path);
+ }
+ }
+
+ return combinedPath;
+ }
+
+ ///
+ /// Renders the signature to an SKImage.
+ ///
+ /// The width of the output image.
+ /// The height of the output image.
+ /// The background color, or null for transparent.
+ /// An SKImage containing the rendered signature.
+ public SKImage? ToImage(int width, int height, SKColor? backgroundColor = null)
+ {
+ if (strokes.Count == 0)
+ return null;
+
+ var info = new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var surface = SKSurface.Create(info);
+
+ if (surface == null)
+ return null;
+
+ var canvas = surface.Canvas;
+
+ if (backgroundColor.HasValue)
+ {
+ canvas.Clear(backgroundColor.Value);
+ }
+ else
+ {
+ canvas.Clear(SKColors.Transparent);
+ }
+
+ // Calculate scale to fit the signature in the image
+ var bounds = GetStrokeBounds();
+ if (bounds.IsEmpty)
+ return null;
+
+ var scale = Math.Min(width / bounds.Width, height / bounds.Height) * 0.9f;
+ var offsetX = (width - bounds.Width * scale) / 2f - bounds.Left * scale;
+ var offsetY = (height - bounds.Height * scale) / 2f - bounds.Top * scale;
+
+ canvas.Translate(offsetX, offsetY);
+ canvas.Scale(scale);
+
+ // Draw all strokes
+ foreach (var stroke in strokes)
+ {
+ if (stroke.Path is SKPath path)
+ {
+ canvas.DrawPath(path, strokePaint);
+ }
+ }
+
+ return surface.Snapshot();
+ }
+
+ ///
+ /// Gets the bounding rectangle of all strokes.
+ ///
+ /// The bounding rectangle.
+ public SKRect GetStrokeBounds()
+ {
+ if (strokes.Count == 0)
+ return SKRect.Empty;
+
+ var bounds = SKRect.Empty;
+ foreach (var stroke in strokes)
+ {
+ if (stroke.Path is SKPath path)
+ {
+ var pathBounds = path.Bounds;
+ if (bounds.IsEmpty)
+ {
+ bounds = pathBounds;
+ }
+ else
+ {
+ bounds = SKRect.Union(bounds, pathBounds);
+ }
+ }
+ }
+
+ return bounds;
+ }
+
+ ///
+ protected override void OnApplyTemplate()
+ {
+ base.OnApplyTemplate();
+
+ // Get access to the underlying canvas view to enable touch events
+ var templateChild = GetTemplateChild("PART_DrawingSurface");
+
+ if (templateChild is SKCanvasView view)
+ {
+ canvasView = view;
+ canvasView.EnableTouchEvents = true;
+ canvasView.Touch += OnCanvasTouch;
+ }
+ }
+
+ ///
+ protected override void OnPaintSurface(SKCanvas canvas, SKSize size)
+ {
+ // Clear with background color
+ canvas.Clear(skBackgroundColor);
+
+ // Draw all completed strokes
+ foreach (var stroke in strokes)
+ {
+ if (stroke.Path is SKPath path)
+ {
+ canvas.DrawPath(path, strokePaint);
+ }
+ }
+
+ // Draw the current stroke being drawn
+ if (currentStroke?.Path is SKPath currentPath)
+ {
+ canvas.DrawPath(currentPath, strokePaint);
+ }
+ }
+
+ ///
+ /// Handles touch events on the canvas.
+ ///
+ private void OnCanvasTouch(object? sender, SKTouchEventArgs e)
+ {
+ var scale = (float)(canvasView?.CanvasSize.Width / Width ?? 1);
+ var location = new SKPoint(e.Location.X / scale, e.Location.Y / scale);
+ var pressure = e.Pressure;
+
+ switch (e.ActionType)
+ {
+ case SKTouchAction.Pressed:
+ StartStroke(location, pressure);
+ e.Handled = true;
+ break;
+
+ case SKTouchAction.Moved:
+ if (e.InContact)
+ {
+ ContinueStroke(location, pressure);
+ e.Handled = true;
+ }
+ break;
+
+ case SKTouchAction.Released:
+ EndStroke(location, pressure);
+ e.Handled = true;
+ break;
+
+ case SKTouchAction.Cancelled:
+ CancelStroke();
+ e.Handled = true;
+ break;
+ }
+ }
+
+ private void StartStroke(SKPoint point, float pressure)
+ {
+ currentStroke = new SKInkStroke(MinStrokeWidth, MaxStrokeWidth);
+ currentStroke.AddPoint(point, pressure);
+ Invalidate();
+ }
+
+ private void ContinueStroke(SKPoint point, float pressure)
+ {
+ currentStroke?.AddPoint(point, pressure);
+ Invalidate();
+ }
+
+ private void EndStroke(SKPoint point, float pressure)
+ {
+ if (currentStroke != null)
+ {
+ currentStroke.AddPoint(point, pressure, isLastPoint: true);
+
+ if (!currentStroke.IsEmpty)
+ {
+ strokes.Add(currentStroke);
+ UpdateIsBlank();
+
+ StrokeCompleted?.Invoke(this, new SKSignatureStrokeCompletedEventArgs(strokes.Count));
+
+ if (StrokeCompletedCommand?.CanExecute(StrokeCompletedCommandParameter) == true)
+ {
+ StrokeCompletedCommand.Execute(StrokeCompletedCommandParameter);
+ }
+ }
+ else
+ {
+ currentStroke.Dispose();
+ }
+
+ currentStroke = null;
+ }
+
+ Invalidate();
+ }
+
+ private void CancelStroke()
+ {
+ currentStroke?.Dispose();
+ currentStroke = null;
+ Invalidate();
+ }
+
+ private void UpdateIsBlank()
+ {
+ IsBlank = strokes.Count == 0;
+ }
+
+ private static void OnStrokeColorChanged(BindableObject bindable, object oldValue, object newValue)
+ {
+ if (bindable is SKSignaturePadView view && newValue is Color color)
+ {
+ view.skStrokeColor = new SKColor(
+ (byte)(color.Red * 255),
+ (byte)(color.Green * 255),
+ (byte)(color.Blue * 255),
+ (byte)(color.Alpha * 255));
+ view.strokePaint.Color = view.skStrokeColor;
+ view.Invalidate();
+ }
+ }
+
+ private static void OnStrokeWidthChanged(BindableObject bindable, object oldValue, object newValue)
+ {
+ // Stroke width changes will affect new strokes only
+ // No need to invalidate existing strokes
+ }
+
+ private static void OnPadBackgroundColorChanged(BindableObject bindable, object oldValue, object newValue)
+ {
+ if (bindable is SKSignaturePadView view && newValue is Color color)
+ {
+ view.skBackgroundColor = new SKColor(
+ (byte)(color.Red * 255),
+ (byte)(color.Green * 255),
+ (byte)(color.Blue * 255),
+ (byte)(color.Alpha * 255));
+ view.Invalidate();
+ }
+ }
+}
diff --git a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadViewResources.shared.xaml b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadViewResources.shared.xaml
new file mode 100644
index 00000000..1cc82ac7
--- /dev/null
+++ b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadViewResources.shared.xaml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadViewResources.shared.xaml.cs b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadViewResources.shared.xaml.cs
new file mode 100644
index 00000000..a99ed9e1
--- /dev/null
+++ b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadViewResources.shared.xaml.cs
@@ -0,0 +1,15 @@
+namespace SkiaSharp.Extended.UI.Controls.Themes;
+
+///
+/// Resource dictionary for SKSignaturePadView control styles and templates.
+///
+public partial class SKSignaturePadViewResources : ResourceDictionary
+{
+ ///
+ /// Initializes the resource dictionary.
+ ///
+ public SKSignaturePadViewResources()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignatureStrokeCompletedEventArgs.shared.cs b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignatureStrokeCompletedEventArgs.shared.cs
new file mode 100644
index 00000000..91607dbc
--- /dev/null
+++ b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignatureStrokeCompletedEventArgs.shared.cs
@@ -0,0 +1,21 @@
+namespace SkiaSharp.Extended.UI.Controls;
+
+///
+/// Event arguments for when a stroke is completed on the signature pad.
+///
+public class SKSignatureStrokeCompletedEventArgs : EventArgs
+{
+ ///
+ /// Creates a new instance of the event arguments.
+ ///
+ /// The total number of strokes after completion.
+ public SKSignatureStrokeCompletedEventArgs(int strokeCount)
+ {
+ StrokeCount = strokeCount;
+ }
+
+ ///
+ /// Gets the total number of strokes on the signature pad.
+ ///
+ public int StrokeCount { get; }
+}
From bf4cfc6796f4a7c97cc7d1336b412bf7bddcaab1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 5 Feb 2026 02:53:16 +0000
Subject: [PATCH 3/4] Fix division by zero and add documentation for signature
pad
Co-authored-by: mattleibow <1096616+mattleibow@users.noreply.github.com>
---
docs/docs/sksignaturepadview.md | 132 ++++++++++++++++++
docs/docs/toc.yml | 4 +-
.../Controls/Signature/SKInkStroke.shared.cs | 2 +-
.../Signature/SKSignaturePadView.shared.cs | 2 +-
4 files changed, 137 insertions(+), 3 deletions(-)
create mode 100644 docs/docs/sksignaturepadview.md
diff --git a/docs/docs/sksignaturepadview.md b/docs/docs/sksignaturepadview.md
new file mode 100644
index 00000000..19d3cb04
--- /dev/null
+++ b/docs/docs/sksignaturepadview.md
@@ -0,0 +1,132 @@
+# SKSignaturePadView
+
+The signature pad view provides fluid, pressure-sensitive ink rendering for capturing signatures or handwritten input. It supports stylus/pen pressure for variable stroke width (harder pressure = thicker lines) and uses quadratic Bezier curves to smooth jagged input from finger or pen movement.
+
+## Properties
+
+The main properties of a signature pad view are:
+
+| Property | Type | Description |
+| :--------------------- | :-------- | :---------- |
+| **StrokeColor** | `Color` | The color of the ink strokes. Default is `Black`. |
+| **MinStrokeWidth** | `float` | The minimum stroke width at zero pressure. Default is `1`. |
+| **MaxStrokeWidth** | `float` | The maximum stroke width at full pressure. Default is `8`. |
+| **PadBackgroundColor** | `Color` | The background color of the signature pad. Default is `White`. |
+| **IsBlank** | `bool` | A read-only value indicating whether the pad has no strokes. |
+| **StrokeCount** | `int` | The number of strokes on the signature pad. |
+
+## Methods
+
+The signature pad provides several methods for manipulating and exporting signatures:
+
+| Method | Return Type | Description |
+| :----------- | :---------- | :---------- |
+| **Clear()** | `void` | Clears all strokes from the signature pad. |
+| **Undo()** | `bool` | Removes the last stroke. Returns `true` if a stroke was removed. |
+| **ToPath()** | `SKPath?` | Gets a combined path of all strokes. Returns `null` if blank. |
+| **ToImage(int width, int height, SKColor? backgroundColor)** | `SKImage?` | Renders the signature to an image with the specified dimensions. |
+| **GetStrokeBounds()** | `SKRect` | Gets the bounding rectangle of all strokes. |
+
+## Events
+
+| Event | Type | Description |
+| :------------------ | :-------------------------------------------- | :---------- |
+| **Cleared** | `EventHandler` | Raised when all strokes are cleared. |
+| **StrokeCompleted** | `EventHandler` | Raised when a stroke is completed. |
+
+## Commands
+
+| Command | Description |
+| :---------------------------- | :---------- |
+| **ClearedCommand** | Executed when strokes are cleared. |
+| **ClearedCommandParameter** | Parameter passed to the ClearedCommand. |
+| **StrokeCompletedCommand** | Executed when a stroke is completed. |
+| **StrokeCompletedCommandParameter** | Parameter passed to the StrokeCompletedCommand. |
+
+## Parts
+
+The default template uses an SKCanvasView for rendering:
+
+```xaml
+
+
+
+```
+
+| Part | Description |
+| :----------------------- | :---------- |
+| **PART_DrawingSurface** | This part can either be a `SKCanvasView` or a `SKGLView` and describes the actual rendering surface for the signature. |
+
+## Usage
+
+### Basic Usage
+
+```xaml
+
+```
+
+### With Commands
+
+```xaml
+
+
+
+```
+
+### Exporting Signature
+
+```csharp
+// Get the signature as an image
+var image = signaturePad.ToImage(400, 200, SKColors.White);
+
+// Get the signature as a path for vector export
+var path = signaturePad.ToPath();
+
+// Check if signature exists
+if (!signaturePad.IsBlank)
+{
+ // Process signature
+}
+```
+
+### Undo Support
+
+```csharp
+// Undo the last stroke
+if (signaturePad.Undo())
+{
+ // Stroke was removed
+}
+```
+
+## Pressure Sensitivity
+
+The signature pad automatically uses pressure data from stylus/pen input when available:
+
+- **No pressure data (finger touch)**: Uses a default pressure of 0.5, resulting in medium stroke width
+- **Pressure data available**: Maps pressure (0.0-1.0) to stroke width between `MinStrokeWidth` and `MaxStrokeWidth`
+- **Harder pressure**: Produces thicker lines
+- **Lighter pressure**: Produces thinner lines
+
+## Ink Rendering Algorithm
+
+The signature pad uses a sophisticated algorithm for fluid ink rendering:
+
+1. **Point Collection**: Touch points are collected with associated pressure values
+2. **Minimum Distance Filtering**: Points too close together are filtered out to reduce noise
+3. **Quadratic Bezier Smoothing**: Points are interpolated using quadratic Bezier curves for smooth paths
+4. **Variable Width Calculation**: Stroke width is calculated at each point based on pressure
+5. **Polygon Generation**: The stroke is rendered as a filled polygon with offset curves on each side
+6. **Rounded Caps**: Start and end caps are added for natural ink appearance
+
+This approach mimics the fluid ink rendering found in Windows Ink and other professional digital ink systems.
diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml
index e14b5551..6b259dca 100644
--- a/docs/docs/toc.yml
+++ b/docs/docs/toc.yml
@@ -15,4 +15,6 @@ items:
- name: SKConfettiView
href: skconfettiview.md
- name: SKLottieView
- href: sklottieview.md
\ No newline at end of file
+ href: sklottieview.md
+- name: SKSignaturePadView
+ href: sksignaturepadview.md
\ No newline at end of file
diff --git a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKInkStroke.shared.cs b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKInkStroke.shared.cs
index 45729878..99f64fe0 100644
--- a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKInkStroke.shared.cs
+++ b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKInkStroke.shared.cs
@@ -202,7 +202,7 @@ private bool HasMovedFarEnough(SKPoint prevPoint, SKPoint currPoint)
path.LineTo(rightPoints[i]);
}
- // Add rounded start cap
+ // Add rounded start cap (direction is reversed since we're drawing back to start)
var startRadius = GetStrokeWidth(smoothedPoints[0].Pressure) / 2f;
var startCenter = smoothedPoints[0].Point;
var startTangent = Normalize(smoothedPoints[0].Point - smoothedPoints[1].Point);
diff --git a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadView.shared.cs b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadView.shared.cs
index 1e6088df..b96bd839 100644
--- a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadView.shared.cs
+++ b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadView.shared.cs
@@ -407,7 +407,7 @@ protected override void OnPaintSurface(SKCanvas canvas, SKSize size)
///
private void OnCanvasTouch(object? sender, SKTouchEventArgs e)
{
- var scale = (float)(canvasView?.CanvasSize.Width / Width ?? 1);
+ var scale = Width > 0 ? (float)(canvasView?.CanvasSize.Width / Width ?? 1) : 1f;
var location = new SKPoint(e.Location.X / scale, e.Location.Y / scale);
var pressure = e.Pressure;
From 2f908afa8ff0d4e3ddaa7623aa2b8da3839d1563 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 5 Feb 2026 03:28:28 +0000
Subject: [PATCH 4/4] Fix memory leaks, add multi-touch rejection, support
SKGLView
Co-authored-by: mattleibow <1096616+mattleibow@users.noreply.github.com>
---
.../Controls/Signature/SKInkStroke.shared.cs | 2 +-
.../Signature/SKSignaturePadView.shared.cs | 125 +++++++++++++++++-
2 files changed, 123 insertions(+), 4 deletions(-)
diff --git a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKInkStroke.shared.cs b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKInkStroke.shared.cs
index 99f64fe0..22f431ce 100644
--- a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKInkStroke.shared.cs
+++ b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKInkStroke.shared.cs
@@ -5,7 +5,7 @@ namespace SkiaSharp.Extended.UI.Controls;
/// Uses quadratic Bezier curves for smooth path interpolation and renders
/// variable-width strokes as filled polygons based on pressure data.
///
-public class SKInkStroke
+public class SKInkStroke : IDisposable
{
// Implementation adapted from @colinta on StackOverflow: https://stackoverflow.com/a/35229104
// Enhanced with pressure sensitivity for fluid ink rendering
diff --git a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadView.shared.cs b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadView.shared.cs
index b96bd839..46ff202b 100644
--- a/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadView.shared.cs
+++ b/source/SkiaSharp.Extended.UI.Maui/Controls/Signature/SKSignaturePadView.shared.cs
@@ -7,7 +7,7 @@ namespace SkiaSharp.Extended.UI.Controls;
/// Supports stylus/pen pressure for variable stroke width (harder pressure = thicker lines).
/// Uses quadratic Bezier curves to smooth jagged input from finger or pen movement.
///
-public class SKSignaturePadView : SKSurfaceView
+public class SKSignaturePadView : SKSurfaceView, IDisposable
{
///
/// Bindable property for the stroke color.
@@ -105,6 +105,9 @@ public class SKSignaturePadView : SKSurfaceView
private SKColor skStrokeColor = SKColors.Black;
private SKColor skBackgroundColor = SKColors.White;
private SKCanvasView? canvasView;
+ private SKGLView? glView;
+ private long? activeTouchId;
+ private bool isDisposed;
///
/// Creates a new signature pad view.
@@ -369,7 +372,20 @@ protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
- // Get access to the underlying canvas view to enable touch events
+ // Unsubscribe from previous views to prevent event handler accumulation
+ if (canvasView is not null)
+ {
+ canvasView.Touch -= OnCanvasTouch;
+ canvasView = null;
+ }
+
+ if (glView is not null)
+ {
+ glView.Touch -= OnGLViewTouch;
+ glView = null;
+ }
+
+ // Get access to the underlying drawing surface to enable touch events
var templateChild = GetTemplateChild("PART_DrawingSurface");
if (templateChild is SKCanvasView view)
@@ -378,6 +394,12 @@ protected override void OnApplyTemplate()
canvasView.EnableTouchEvents = true;
canvasView.Touch += OnCanvasTouch;
}
+ else if (templateChild is SKGLView gl)
+ {
+ glView = gl;
+ glView.EnableTouchEvents = true;
+ glView.Touch += OnGLViewTouch;
+ }
}
///
@@ -403,22 +425,54 @@ protected override void OnPaintSurface(SKCanvas canvas, SKSize size)
}
///
- /// Handles touch events on the canvas.
+ /// Handles touch events on the canvas view.
///
private void OnCanvasTouch(object? sender, SKTouchEventArgs e)
{
var scale = Width > 0 ? (float)(canvasView?.CanvasSize.Width / Width ?? 1) : 1f;
+ HandleTouchEvent(e, scale);
+ }
+
+ ///
+ /// Handles touch events on the GL view.
+ ///
+ private void OnGLViewTouch(object? sender, SKTouchEventArgs e)
+ {
+ var scale = Width > 0 ? (float)(glView?.CanvasSize.Width / Width ?? 1) : 1f;
+ HandleTouchEvent(e, scale);
+ }
+
+ ///
+ /// Common touch event handling logic.
+ ///
+ private void HandleTouchEvent(SKTouchEventArgs e, float scale)
+ {
var location = new SKPoint(e.Location.X / scale, e.Location.Y / scale);
var pressure = e.Pressure;
+ var touchId = e.Id;
switch (e.ActionType)
{
case SKTouchAction.Pressed:
+ // Only accept the first touch - reject multi-touch
+ if (activeTouchId.HasValue)
+ {
+ // Already have an active touch, ignore this one but mark as handled
+ e.Handled = true;
+ return;
+ }
+ activeTouchId = touchId;
StartStroke(location, pressure);
e.Handled = true;
break;
case SKTouchAction.Moved:
+ // Only process if this is our active touch
+ if (activeTouchId != touchId)
+ {
+ e.Handled = true;
+ return;
+ }
if (e.InContact)
{
ContinueStroke(location, pressure);
@@ -427,11 +481,25 @@ private void OnCanvasTouch(object? sender, SKTouchEventArgs e)
break;
case SKTouchAction.Released:
+ // Only process if this is our active touch
+ if (activeTouchId != touchId)
+ {
+ e.Handled = true;
+ return;
+ }
+ activeTouchId = null;
EndStroke(location, pressure);
e.Handled = true;
break;
case SKTouchAction.Cancelled:
+ // Only process if this is our active touch
+ if (activeTouchId != touchId)
+ {
+ e.Handled = true;
+ return;
+ }
+ activeTouchId = null;
CancelStroke();
e.Handled = true;
break;
@@ -524,4 +592,55 @@ private static void OnPadBackgroundColorChanged(BindableObject bindable, object
view.Invalidate();
}
}
+
+ ///
+ /// Releases all resources used by this control.
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Releases the unmanaged resources used by the control and optionally releases the managed resources.
+ ///
+ /// True to release both managed and unmanaged resources; false to release only unmanaged resources.
+ protected virtual void Dispose(bool disposing)
+ {
+ if (isDisposed)
+ return;
+
+ if (disposing)
+ {
+ // Unsubscribe from touch events
+ if (canvasView is not null)
+ {
+ canvasView.Touch -= OnCanvasTouch;
+ canvasView = null;
+ }
+
+ if (glView is not null)
+ {
+ glView.Touch -= OnGLViewTouch;
+ glView = null;
+ }
+
+ // Dispose all strokes
+ foreach (var stroke in strokes)
+ {
+ stroke.Dispose();
+ }
+ strokes.Clear();
+
+ // Dispose current stroke
+ currentStroke?.Dispose();
+ currentStroke = null;
+
+ // Dispose paint
+ strokePaint.Dispose();
+ }
+
+ isDisposed = true;
+ }
}