diff --git a/docs/docs/gesture-configuration.md b/docs/docs/gesture-configuration.md
new file mode 100644
index 0000000000..3be8685c0e
--- /dev/null
+++ b/docs/docs/gesture-configuration.md
@@ -0,0 +1,123 @@
+# Configuration & Customization
+
+This page covers how to configure gesture thresholds, enable/disable features, read transform state, and programmatically control `SKGestureTracker`. For the quick-start guide, see [Gestures](gestures.md).
+
+## Options
+
+Configure thresholds and behavior through `SKGestureTrackerOptions`:
+
+```csharp
+var options = new SKGestureTrackerOptions
+{
+ // Detection thresholds (inherited from SKGestureDetectorOptions)
+ TouchSlop = 8f, // Pixels to move before pan starts (default: 8)
+ DoubleTapSlop = 40f, // Max distance between double-tap taps (default: 40)
+ FlingThreshold = 200f, // Min velocity (px/s) to trigger fling (default: 200)
+ LongPressDuration = 500, // Milliseconds to hold for long press (default: 500)
+
+ // Scale limits
+ MinScale = 0.1f, // Minimum zoom level (default: 0.1)
+ MaxScale = 10f, // Maximum zoom level (default: 10)
+
+ // Double-tap zoom
+ DoubleTapZoomFactor = 2f, // Scale multiplier on double tap (default: 2)
+ ZoomAnimationDuration = 250, // Animation duration in ms (default: 250)
+ ZoomAnimationInterval = 16, // Frame interval for zoom animation in ms (~60fps) (default: 16)
+
+ // Scroll zoom
+ ScrollZoomFactor = 0.1f, // Zoom per scroll unit (default: 0.1)
+
+ // Fling animation
+ FlingFriction = 0.08f, // Velocity decay per frame (default: 0.08)
+ FlingMinVelocity = 5f, // Stop threshold in px/s (default: 5)
+ FlingFrameInterval = 16, // Frame interval in ms (~60fps) (default: 16)
+};
+
+var tracker = new SKGestureTracker(options);
+```
+
+You can also modify options at runtime:
+
+```csharp
+tracker.Options.MinScale = 0.5f;
+tracker.Options.MaxScale = 20f;
+tracker.Options.DoubleTapZoomFactor = 3f;
+```
+
+## Feature Toggles
+
+Enable or disable individual gesture types at runtime. Feature toggles can be set at construction time or modified later:
+
+```csharp
+// Configure at construction time via options
+var options = new SKGestureTrackerOptions
+{
+ IsTapEnabled = true,
+ IsPanEnabled = true,
+ IsPinchEnabled = false,
+ IsRotateEnabled = false,
+};
+var tracker = new SKGestureTracker(options);
+
+// Or toggle at runtime
+tracker.IsTapEnabled = false;
+tracker.IsDoubleTapEnabled = false;
+tracker.IsLongPressEnabled = false;
+tracker.IsPanEnabled = false;
+tracker.IsPinchEnabled = false;
+tracker.IsRotateEnabled = false;
+tracker.IsFlingEnabled = false;
+tracker.IsDoubleTapZoomEnabled = false;
+tracker.IsScrollZoomEnabled = false;
+tracker.IsHoverEnabled = false;
+```
+
+When a gesture is disabled, the tracker suppresses its events. The underlying detector still recognizes the gesture, so you can re-enable it at runtime without losing state.
+
+## Reading Transform State
+
+```csharp
+float scale = tracker.Scale; // Current zoom level
+float rotation = tracker.Rotation; // Current rotation in degrees
+SKPoint offset = tracker.Offset; // Current pan offset
+SKMatrix matrix = tracker.Matrix; // Combined transform matrix
+```
+
+## Programmatic Transform Control
+
+You can set the transform directly without any touch input:
+
+```csharp
+// Reset everything back to identity
+tracker.Reset();
+
+// Set all values at once
+tracker.SetTransform(scale: 2f, rotation: 45f, offset: new SKPoint(100, 50));
+
+// Set individual components
+tracker.SetScale(1.5f);
+tracker.SetScale(2f, pivot: new SKPoint(400, 300)); // Scale around a specific point
+tracker.SetRotation(0f);
+tracker.SetRotation(45f, pivot: new SKPoint(400, 300)); // Rotate around a specific point
+tracker.SetOffset(SKPoint.Empty);
+```
+
+### Animated Zoom
+
+Use `ZoomTo` to animate a zoom by a given factor with a smooth ease-out curve:
+
+```csharp
+// Zoom in by 3x at the center of the view
+tracker.ZoomTo(factor: 3f, focalPoint: new SKPoint(400, 300));
+
+// Check animation state
+bool animating = tracker.IsZoomAnimating;
+```
+
+The animation duration and frame interval are controlled by `ZoomAnimationDuration` and `ZoomAnimationInterval` in the options.
+
+## See Also
+
+- [Gestures — Quick Start](gestures.md)
+- [Gesture Events](gesture-events.md)
+- [API Reference — SKGestureTrackerOptions](xref:SkiaSharp.Extended.SKGestureTrackerOptions)
diff --git a/docs/docs/gesture-events.md b/docs/docs/gesture-events.md
new file mode 100644
index 0000000000..133c930071
--- /dev/null
+++ b/docs/docs/gesture-events.md
@@ -0,0 +1,175 @@
+# Gesture Events
+
+This page covers all gesture events raised by `SKGestureTracker`, with code examples for each. For the quick-start guide and architecture overview, see [Gestures](gestures.md).
+
+## Tap, Double Tap, Long Press
+
+Single finger gestures detected after the finger lifts (or after a timeout for long press).
+
+```csharp
+tracker.TapDetected += (s, e) =>
+{
+ // e.Location — where the tap occurred
+ // e.TapCount — always 1 for single tap
+};
+
+tracker.DoubleTapDetected += (s, e) =>
+{
+ // Two taps within DoubleTapSlop distance and timing
+ // By default, also triggers a zoom animation (see Double Tap Zoom below)
+ // Set e.Handled = true to prevent the zoom
+};
+
+tracker.LongPressDetected += (s, e) =>
+{
+ // Finger held down without moving for LongPressDuration (default 500ms)
+ // e.Location — where the press occurred
+ // e.Duration — how long the finger was held
+};
+```
+
+## Pan
+
+Single finger drag. The tracker automatically updates its internal offset.
+
+```csharp
+tracker.PanDetected += (s, e) =>
+{
+ // e.Location — current position
+ // e.PreviousLocation — previous position
+ // e.Delta — movement since last event
+ // e.Velocity — current velocity in pixels/second
+};
+```
+
+## Pinch (Scale)
+
+Two finger pinch gesture. The tracker automatically updates its internal scale, clamped to `MinScale`/`MaxScale`.
+
+```csharp
+tracker.PinchDetected += (s, e) =>
+{
+ // e.ScaleDelta — relative scale change (>1 = spread, <1 = pinch)
+ // e.FocalPoint — midpoint between the two fingers
+ // e.PreviousFocalPoint — previous midpoint
+};
+```
+
+## Rotate
+
+Two finger rotation. The tracker automatically updates its internal rotation.
+
+```csharp
+tracker.RotateDetected += (s, e) =>
+{
+ // e.RotationDelta — change in degrees
+ // e.FocalPoint — center of rotation
+};
+```
+
+## Fling
+
+Momentum-based animation after a fast pan. The tracker runs a fling animation that decays over time.
+
+```csharp
+tracker.FlingDetected += (s, e) =>
+{
+ // Fling started — e.Velocity.X, e.Velocity.Y in px/s
+};
+
+tracker.FlingUpdated += (s, e) =>
+{
+ // Called each frame during fling animation
+};
+
+tracker.FlingCompleted += (s, e) =>
+{
+ // Fling animation finished
+};
+```
+
+## Drag (App-Level Object Dragging)
+
+The tracker provides a drag lifecycle derived from pan events. Use this to move objects within your canvas (e.g., stickers, nodes in a graph editor).
+
+```csharp
+tracker.DragStarted += (s, e) =>
+{
+ if (HitTest(e.Location) is { } item)
+ {
+ selectedItem = item;
+ e.Handled = true; // Prevents pan from updating the transform
+ }
+};
+
+tracker.DragUpdated += (s, e) =>
+{
+ if (selectedItem != null)
+ {
+ // Convert screen delta to content coordinates
+ var inverse = tracker.Matrix; inverse.TryInvert(out inverse);
+ var contentDelta = inverse.MapVector(e.Delta.X, e.Delta.Y);
+ selectedItem.Position += contentDelta;
+ e.Handled = true;
+ }
+};
+
+tracker.DragEnded += (s, e) =>
+{
+ selectedItem = null;
+};
+```
+
+When `DragStarted` or `DragUpdated` sets `Handled = true`, the tracker skips its normal pan offset update **and** suppresses fling after release.
+
+## Scroll (Mouse Wheel)
+
+Mouse wheel zoom. Call `ProcessMouseWheel` to feed wheel events.
+
+```csharp
+tracker.ScrollDetected += (s, e) =>
+{
+ // e.Location — mouse position
+ // e.Delta.X, e.Delta.Y — scroll amounts
+};
+```
+
+## Hover
+
+Mouse movement without any buttons pressed. Useful for cursor-based UI feedback.
+
+```csharp
+tracker.HoverDetected += (s, e) =>
+{
+ // e.Location — current mouse position
+};
+```
+
+## Double Tap Zoom
+
+By default, double-tapping zooms in by `DoubleTapZoomFactor` (2×). Double-tapping again at max scale resets to 1×. The zoom animates smoothly over `ZoomAnimationDuration` milliseconds.
+
+To use double tap for your own logic instead, set `e.Handled = true` in your `DoubleTapDetected` handler, or disable it entirely:
+
+```csharp
+tracker.IsDoubleTapZoomEnabled = false;
+```
+
+## Lifecycle Events
+
+```csharp
+// Fired when the first finger touches down (once per gesture sequence)
+tracker.GestureStarted += (s, e) => { /* gesture began */ };
+
+// Fired when all fingers lift
+tracker.GestureEnded += (s, e) => { /* gesture ended */ };
+
+// Fired whenever the transform matrix changes (pan, zoom, rotate, fling frame)
+tracker.TransformChanged += (s, e) => canvas.Invalidate();
+```
+
+## See Also
+
+- [Gestures — Quick Start](gestures.md)
+- [Configuration & Customization](gesture-configuration.md)
+- [API Reference — SKGestureTracker](xref:SkiaSharp.Extended.SKGestureTracker)
diff --git a/docs/docs/gestures.md b/docs/docs/gestures.md
new file mode 100644
index 0000000000..f4033d5d82
--- /dev/null
+++ b/docs/docs/gestures.md
@@ -0,0 +1,116 @@
+# Gestures
+
+Add pan, pinch, rotate, fling, tap, and more to any SkiaSharp canvas — on any platform — with a single, unified API. `SKGestureTracker` handles all the math so you can focus on what your app does with the gestures.
+
+## Quick Start
+
+### 1. Create a tracker and subscribe to events
+
+```csharp
+using SkiaSharp.Extended;
+
+var tracker = new SKGestureTracker();
+
+// Transform events — the tracker manages the matrix for you
+tracker.TransformChanged += (s, e) => canvas.Invalidate();
+
+// Discrete gesture events
+tracker.TapDetected += (s, e) => Console.WriteLine($"Tap at {e.Location}");
+tracker.DoubleTapDetected += (s, e) => Console.WriteLine("Double tap!");
+tracker.LongPressDetected += (s, e) => Console.WriteLine("Long press!");
+```
+
+### 2. Feed touch events from your platform
+
+**MAUI** — forward `SKTouchEventArgs`:
+
+```csharp
+private void OnTouch(object? sender, SKTouchEventArgs e)
+{
+ e.Handled = e.ActionType switch
+ {
+ SKTouchAction.Pressed => tracker.ProcessTouchDown(e.Id, e.Location, e.DeviceType == SKTouchDeviceType.Mouse),
+ SKTouchAction.Moved => tracker.ProcessTouchMove(e.Id, e.Location, e.InContact),
+ SKTouchAction.Released => tracker.ProcessTouchUp(e.Id, e.Location, e.DeviceType == SKTouchDeviceType.Mouse),
+ SKTouchAction.Cancelled => tracker.ProcessTouchCancel(e.Id),
+ SKTouchAction.WheelChanged => tracker.ProcessMouseWheel(e.Location, 0, e.WheelDelta),
+ _ => true,
+ };
+
+ if (e.Handled)
+ canvasView.InvalidateSurface();
+}
+```
+
+**Blazor** — forward `PointerEventArgs`:
+
+```csharp
+private void OnPointerDown(PointerEventArgs e)
+{
+ var location = new SKPoint((float)e.OffsetX * displayScale, (float)e.OffsetY * displayScale);
+ tracker.ProcessTouchDown(e.PointerId, location, e.PointerType == "mouse");
+}
+```
+
+### 3. Apply the transform when drawing
+
+```csharp
+private void OnPaintSurface(object? sender, SKPaintSurfaceEventArgs e)
+{
+ var canvas = e.Surface.Canvas;
+ canvas.Clear(SKColors.White);
+
+ // Apply the tracked transform (pan + zoom + rotation)
+ canvas.Save();
+ canvas.Concat(tracker.Matrix);
+
+ // Draw your content here — it will pan, zoom, and rotate
+ DrawContent(canvas);
+
+ canvas.Restore();
+}
+```
+
+## How It Works
+
+The gesture system has two layers:
+
+| Layer | Class | Role |
+| :---- | :---- | :--- |
+| **Detection** | `SKGestureDetector` | Recognizes raw touch sequences as gestures (tap, pan, pinch, etc.) |
+| **Tracking** | `SKGestureTracker` | Manages a transform matrix (pan/zoom/rotate), fling animation, and drag lifecycle |
+
+Most apps only need `SKGestureTracker`. It wraps a detector internally and translates gesture events into transform updates. If you need raw gesture detection without transform management, you can use `SKGestureDetector` directly.
+
+### Coordinate spaces
+
+The tracker is coordinate-space-agnostic — it operates on whatever numbers you pass in. The important rule is: **touch input and canvas drawing must use the same coordinate space.**
+
+- **MAUI**: `SKTouchEventArgs.Location` is already in device pixels (same as the canvas), so pass them through directly.
+- **Blazor**: `PointerEventArgs.OffsetX/Y` are in CSS pixels, but the canvas renders in device pixels. Multiply by `devicePixelRatio` to match.
+
+## Supported Gestures
+
+| Gesture | Trigger | Key Event Args |
+| :------ | :------ | :------------- |
+| **Tap** | Single finger tap and release | `Location`, `TapCount` |
+| **Double Tap** | Two taps in quick succession | `Location`, `TapCount` |
+| **Long Press** | Finger held still for 500ms+ | `Location`, `Duration` |
+| **Pan** | Single finger drag | `Delta`, `Velocity` |
+| **Pinch** | Two finger spread/pinch | `ScaleDelta`, `FocalPoint` |
+| **Rotate** | Two finger rotation | `RotationDelta`, `FocalPoint` |
+| **Fling** | Fast pan with momentum | `Velocity` |
+| **Drag** | App-level object dragging | `Location`, `Delta` |
+| **Scroll** | Mouse wheel | `Delta` |
+| **Hover** | Mouse move (no buttons) | `Location` |
+
+For detailed code examples and event handler patterns for each gesture, see [Gesture Events](gesture-events.md).
+
+## Next Steps
+
+- **[Gesture Events](gesture-events.md)** — Detailed reference for every gesture event with code examples
+- **[Configuration](gesture-configuration.md)** — Options, feature toggles, transform state, and programmatic control
+- [API Reference — SKGestureTracker](xref:SkiaSharp.Extended.SKGestureTracker) — Full property and event documentation
+- [API Reference — SKGestureDetector](xref:SkiaSharp.Extended.SKGestureDetector) — Low-level gesture detection
+- [MAUI Sample](https://github.com/mono/SkiaSharp.Extended/tree/main/samples/SkiaSharpDemo/Demos/Gestures) — Full MAUI demo with stickers
+- [Blazor Sample](https://github.com/mono/SkiaSharp.Extended/tree/main/samples/SkiaSharpDemo.Blazor/Pages/Gestures.razor) — Full Blazor demo
diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml
index 6fb3c47b97..46ab4bfd3c 100644
--- a/docs/docs/toc.yml
+++ b/docs/docs/toc.yml
@@ -12,6 +12,13 @@ items:
href: path-interpolation.md
- name: Pixel Comparer
href: pixel-comparer.md
+- name: Gestures
+ href: gestures.md
+ items:
+ - name: Gesture Events
+ href: gesture-events.md
+ - name: Configuration
+ href: gesture-configuration.md
- name: SkiaSharp.Extended.UI.Maui
- name: Confetti Effects
@@ -23,4 +30,4 @@ items:
- name: Migration Guides
items:
- name: SVG Migration (Extended.Svg to Svg.Skia)
- href: svg-migration.md
\ No newline at end of file
+ href: svg-migration.md
diff --git a/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor b/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor
index cca6db98fe..77b60f20f3 100644
--- a/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor
+++ b/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor
@@ -37,6 +37,14 @@
Pixel Comparer
+
+
+
+
+
+ Gestures
+
+
diff --git a/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor.css b/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor.css
index 95cc1cd77b..d8e8fabd38 100644
--- a/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor.css
+++ b/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor.css
@@ -41,6 +41,10 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z'/%3E%3Cpath d='M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z'/%3E%3C/svg%3E");
}
+.bi-hand-index-nav-menu {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M6.75 1a.75.75 0 0 1 .75.75V8a.5.5 0 0 0 1 0V5.467l.086-.004a1.5 1.5 0 0 1 1.653 1.32l.07.728a8.47 8.47 0 0 1-.07 2.313c-.154.756-.388 1.476-.712 2.166l-.047.1a1 1 0 0 1-.896.557H4.863a1 1 0 0 1-.904-.58c-.613-1.292-1.09-2.3-1.43-3.024-.338-.72-.544-1.236-.594-1.615a1.5 1.5 0 0 1 1.333-1.672 1.5 1.5 0 0 1 .732.076V1.75a.75.75 0 0 1 .75-.75z'/%3E%3C/svg%3E");
+}
+
.nav-group-header {
font-size: 0.7rem;
font-weight: 600;
diff --git a/samples/SkiaSharpDemo.Blazor/Pages/Gestures.razor b/samples/SkiaSharpDemo.Blazor/Pages/Gestures.razor
new file mode 100644
index 0000000000..b4ce19c241
--- /dev/null
+++ b/samples/SkiaSharpDemo.Blazor/Pages/Gestures.razor
@@ -0,0 +1,547 @@
+@page "/gestures"
+@implements IDisposable
+
+Gestures
+
+Gestures
+
+
+ Interactive demo using SKGestureTracker for pan, zoom, rotate, tap, long press, and fling gestures.
+
+ Try: Drag to pan • Scroll to zoom • Double-click to zoom in/reset • Click stickers to select • Drag stickers to move
+
+
+
+
+
+
+
+
@_statusText
+ @foreach (var log in _eventLog)
+ {
+
@log
+ }
+
+
+
+
+ ⚙️ @(_showSettings ? "Hide" : "Show") Settings
+
+
+ @if (_showSettings)
+ {
+
+ }
+
+
+@code {
+ private SKCanvasView? _canvasView;
+ private ElementReference _containerRef;
+ private readonly SKGestureTracker _tracker = new();
+
+ // Sticker data for demonstration
+ private readonly List _stickers = new()
+ {
+ new Sticker { Position = new SKPoint(100, 100), Size = 80, Color = SKColors.Red, Label = "1" },
+ new Sticker { Position = new SKPoint(200, 200), Size = 60, Color = SKColors.Green, Label = "2" },
+ new Sticker { Position = new SKPoint(300, 150), Size = 70, Color = SKColors.Blue, Label = "3" },
+ };
+ private Sticker? _selectedSticker;
+ private readonly Queue _eventLog = new();
+ private const int MaxLogEntries = 5;
+
+ // Canvas state
+ private string _statusText = "Touch/click the canvas to begin";
+ private int _canvasWidth;
+ private int _canvasHeight;
+ private float _displayScale = 1f;
+
+ // Feature toggles
+ private bool _enableTap = true;
+ private bool _enableDrag = true;
+ private bool _showSettings;
+
+ private void ToggleSettings() => _showSettings = !_showSettings;
+
+ private void ResetView()
+ {
+ _tracker.Reset();
+ _selectedSticker = null;
+ Invalidate();
+ }
+
+ protected override void OnInitialized()
+ {
+ SubscribeTrackerEvents();
+ }
+
+ public void Dispose()
+ {
+ UnsubscribeTrackerEvents();
+ _tracker.Dispose();
+ }
+
+ private void SubscribeTrackerEvents()
+ {
+ _tracker.TapDetected += OnTap;
+ _tracker.DoubleTapDetected += OnDoubleTap;
+ _tracker.LongPressDetected += OnLongPress;
+ _tracker.PanDetected += OnPan;
+ _tracker.PinchDetected += OnPinch;
+ _tracker.RotateDetected += OnRotate;
+ _tracker.FlingDetected += OnFling;
+ _tracker.FlingUpdated += OnFlingUpdated;
+ _tracker.FlingCompleted += OnFlingCompleted;
+ _tracker.ScrollDetected += OnScroll;
+ _tracker.HoverDetected += OnHover;
+ _tracker.DragStarted += OnDragStarted;
+ _tracker.DragUpdated += OnDragUpdated;
+ _tracker.DragEnded += OnDragEnded;
+ _tracker.TransformChanged += OnTransformChanged;
+ }
+
+ private void UnsubscribeTrackerEvents()
+ {
+ _tracker.TapDetected -= OnTap;
+ _tracker.DoubleTapDetected -= OnDoubleTap;
+ _tracker.LongPressDetected -= OnLongPress;
+ _tracker.PanDetected -= OnPan;
+ _tracker.PinchDetected -= OnPinch;
+ _tracker.RotateDetected -= OnRotate;
+ _tracker.FlingDetected -= OnFling;
+ _tracker.FlingUpdated -= OnFlingUpdated;
+ _tracker.FlingCompleted -= OnFlingCompleted;
+ _tracker.ScrollDetected -= OnScroll;
+ _tracker.HoverDetected -= OnHover;
+ _tracker.DragStarted -= OnDragStarted;
+ _tracker.DragUpdated -= OnDragUpdated;
+ _tracker.DragEnded -= OnDragEnded;
+ _tracker.TransformChanged -= OnTransformChanged;
+ }
+
+ #region Pointer Event Handlers
+
+ private void OnPointerDown(PointerEventArgs e)
+ {
+ var location = GetCanvasLocation(e);
+ var isMouse = e.PointerType == "mouse";
+ _tracker.ProcessTouchDown(e.PointerId, location, isMouse);
+ Invalidate();
+ }
+
+ private void OnPointerMove(PointerEventArgs e)
+ {
+ var location = GetCanvasLocation(e);
+ var inContact = e.Pressure > 0 || (e.Buttons & 1) != 0;
+ _tracker.ProcessTouchMove(e.PointerId, location, inContact);
+ Invalidate();
+ }
+
+ private void OnPointerUp(PointerEventArgs e)
+ {
+ var location = GetCanvasLocation(e);
+ var isMouse = e.PointerType == "mouse";
+ _tracker.ProcessTouchUp(e.PointerId, location, isMouse);
+ Invalidate();
+ }
+
+ private void OnPointerCancel(PointerEventArgs e)
+ {
+ _tracker.ProcessTouchCancel(e.PointerId);
+ Invalidate();
+ }
+
+ private void OnWheel(WheelEventArgs e)
+ {
+ var location = GetCanvasLocation(e);
+ // Convert wheel delta to SkiaSharp convention (positive = zoom in)
+ var deltaY = -(float)e.DeltaY / 100f;
+ _tracker.ProcessMouseWheel(location, 0, deltaY);
+ Invalidate();
+ }
+
+ private SKPoint GetCanvasLocation(MouseEventArgs e)
+ {
+ // OffsetX/OffsetY are in CSS pixels; scale to device pixels to match the canvas
+ return new SKPoint((float)e.OffsetX * _displayScale, (float)e.OffsetY * _displayScale);
+ }
+
+ private void Invalidate()
+ {
+ _canvasView?.Invalidate();
+ StateHasChanged();
+ }
+
+ #endregion
+
+ #region Paint
+
+ private void OnPaintSurface(SKPaintSurfaceEventArgs e)
+ {
+ var canvas = e.Surface.Canvas;
+ var width = e.Info.Width;
+ var height = e.Info.Height;
+
+ _canvasWidth = width;
+ _canvasHeight = height;
+
+ // Calculate display scale from canvas pixel size vs CSS size
+ // PointerEventArgs provides CSS pixels, but canvas size is in device pixels
+ // This handles high-DPI displays where devicePixelRatio > 1
+ const float expectedCssHeight = 600f;
+ _displayScale = height / expectedCssHeight;
+
+ // Clear background
+ canvas.Clear(SKColors.White);
+
+ // Apply transform from the tracker
+ canvas.Save();
+ canvas.Concat(_tracker.Matrix);
+
+ // Draw grid background
+ DrawGrid(canvas, width, height);
+
+ // Draw stickers
+ foreach (var sticker in _stickers)
+ {
+ DrawSticker(canvas, sticker, sticker == _selectedSticker);
+ }
+
+ canvas.Restore();
+
+ // Draw crosshair at center for reference
+ using var crosshairPaint = new SKPaint
+ {
+ Color = SKColors.Gray.WithAlpha(100),
+ StrokeWidth = 1,
+ IsAntialias = true
+ };
+ canvas.DrawLine(width / 2f, 0, width / 2f, height, crosshairPaint);
+ canvas.DrawLine(0, height / 2f, width, height / 2f, crosshairPaint);
+ }
+
+ private void DrawGrid(SKCanvas canvas, int width, int height)
+ {
+ const int gridSize = 40;
+
+ using var lightPaint = new SKPaint { Color = new SKColor(240, 240, 240) };
+ using var darkPaint = new SKPaint { Color = new SKColor(220, 220, 220) };
+
+ // Expand grid to fill when zoomed out
+ var scale = _tracker.Scale;
+ var extra = (int)(Math.Max(width, height) / scale) + gridSize * 4;
+ var startX = -(extra / gridSize) * gridSize;
+ var startY = startX;
+ var endX = width - startX;
+ var endY = height - startY;
+
+ for (int y = startY, row = 0; y < endY; y += gridSize, row++)
+ {
+ for (int x = startX, col = 0; x < endX; x += gridSize, col++)
+ {
+ var isLight = (col + row) % 2 == 0;
+ var rect = new SKRect(x, y, x + gridSize, y + gridSize);
+ canvas.DrawRect(rect, isLight ? lightPaint : darkPaint);
+ }
+ }
+ }
+
+ private void DrawSticker(SKCanvas canvas, Sticker sticker, bool isSelected)
+ {
+ var radius = sticker.Size / 2f;
+
+ // Draw shadow
+ using var shadowPaint = new SKPaint
+ {
+ Color = SKColors.Black.WithAlpha(50),
+ IsAntialias = true,
+ MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 5)
+ };
+ canvas.DrawCircle(sticker.Position.X + 3, sticker.Position.Y + 3, radius, shadowPaint);
+
+ // Draw sticker fill
+ using var fillPaint = new SKPaint
+ {
+ Color = sticker.Color,
+ IsAntialias = true,
+ Style = SKPaintStyle.Fill
+ };
+ canvas.DrawCircle(sticker.Position, radius, fillPaint);
+
+ // Draw selection ring
+ if (isSelected)
+ {
+ using var selectPaint = new SKPaint
+ {
+ Color = SKColors.Yellow,
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 4
+ };
+ canvas.DrawCircle(sticker.Position, radius + 4, selectPaint);
+ }
+
+ // Draw border
+ using var borderPaint = new SKPaint
+ {
+ Color = SKColors.White,
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 2
+ };
+ canvas.DrawCircle(sticker.Position, radius, borderPaint);
+
+ // Draw label
+ using var textFont = new SKFont
+ {
+ Size = sticker.Size * 0.4f,
+ Typeface = SKTypeface.FromFamilyName("Arial", SKFontStyle.Bold)
+ };
+ using var textPaint = new SKPaint
+ {
+ Color = SKColors.White,
+ IsAntialias = true
+ };
+ canvas.DrawText(sticker.Label, sticker.Position.X, sticker.Position.Y + textFont.Size * 0.35f, SKTextAlign.Center, textFont, textPaint);
+ }
+
+ #endregion
+
+ #region Gesture Event Handlers
+
+ private void OnTap(object? sender, SKTapGestureEventArgs e)
+ {
+ if (!_enableTap) return;
+ LogEvent($"Tap at ({e.Location.X:F0}, {e.Location.Y:F0})");
+
+ var hitSticker = HitTest(e.Location);
+ if (hitSticker != null)
+ {
+ _selectedSticker = hitSticker;
+ _statusText = $"Selected: Sticker {hitSticker.Label}";
+ }
+ else
+ {
+ _selectedSticker = null;
+ _statusText = "No selection";
+ }
+
+ Invalidate();
+ }
+
+ private void OnDoubleTap(object? sender, SKTapGestureEventArgs e)
+ {
+ LogEvent($"Double tap ({e.TapCount}x) at ({e.Location.X:F0}, {e.Location.Y:F0})");
+
+ var hitSticker = HitTest(e.Location);
+ if (hitSticker != null)
+ {
+ _selectedSticker = hitSticker;
+ _statusText = $"Selected: Sticker {hitSticker.Label}";
+ e.Handled = true;
+ Invalidate();
+ }
+ }
+
+ private void OnLongPress(object? sender, SKLongPressGestureEventArgs e)
+ {
+ LogEvent($"Long press at ({e.Location.X:F0}, {e.Location.Y:F0})");
+
+ var hitSticker = HitTest(e.Location);
+ if (hitSticker != null)
+ {
+ _selectedSticker = hitSticker;
+ _statusText = $"Long press selected: Sticker {hitSticker.Label}";
+ }
+
+ Invalidate();
+ }
+
+ private void OnPan(object? sender, SKPanGestureEventArgs e)
+ {
+ _statusText = $"Pan: Δ({e.Delta.X:F1}, {e.Delta.Y:F1})";
+ }
+
+ private void OnPinch(object? sender, SKPinchGestureEventArgs e)
+ {
+ LogEvent($"Pinch scale: {e.ScaleDelta:F2}");
+ _statusText = $"Scale: {_tracker.Scale:F2}";
+ }
+
+ private void OnRotate(object? sender, SKRotateGestureEventArgs e)
+ {
+ LogEvent($"Rotate: {e.RotationDelta:F1}°");
+ _statusText = $"Rotation: {_tracker.Rotation:F1}°";
+ }
+
+ private void OnFling(object? sender, SKFlingGestureEventArgs e)
+ {
+ LogEvent($"Fling: ({e.Velocity.X:F0}, {e.Velocity.Y:F0}) px/s");
+ _statusText = $"Flinging at {e.Speed:F0} px/s";
+ }
+
+ private void OnFlingUpdated(object? sender, SKFlingGestureEventArgs e)
+ {
+ _statusText = $"Flinging... ({e.Speed:F0} px/s)";
+ }
+
+ private void OnFlingCompleted(object? sender, EventArgs e)
+ {
+ _statusText = "Fling ended";
+ }
+
+ private void OnScroll(object? sender, SKScrollGestureEventArgs e)
+ {
+ _statusText = $"Scroll zoom: {_tracker.Scale:F2}x";
+ }
+
+ private void OnHover(object? sender, SKHoverGestureEventArgs e)
+ {
+ _statusText = $"Hover: ({e.Location.X:F0}, {e.Location.Y:F0})";
+ }
+
+ private void OnDragStarted(object? sender, SKDragGestureEventArgs e)
+ {
+ if (!_enableDrag) return;
+ LogEvent($"Drag started at ({e.Location.X:F0}, {e.Location.Y:F0})");
+
+ if (_selectedSticker != null)
+ {
+ _statusText = $"Dragging Sticker {_selectedSticker.Label}";
+ e.Handled = true;
+ }
+ }
+
+ private void OnDragUpdated(object? sender, SKDragGestureEventArgs e)
+ {
+ if (!_enableDrag) return;
+
+ if (_selectedSticker != null)
+ {
+ var matrix = _tracker.Matrix;
+ if (matrix.TryInvert(out var inverse))
+ {
+ var contentDelta = inverse.MapVector(e.Delta.X, e.Delta.Y);
+ _selectedSticker.Position = new SKPoint(
+ _selectedSticker.Position.X + contentDelta.X,
+ _selectedSticker.Position.Y + contentDelta.Y);
+ }
+
+ e.Handled = true;
+ Invalidate();
+ }
+ }
+
+ private void OnDragEnded(object? sender, SKDragGestureEventArgs e)
+ {
+ if (!_enableDrag) return;
+ LogEvent($"Drag ended at ({e.Location.X:F0}, {e.Location.Y:F0})");
+ _statusText = "Drag completed";
+ }
+
+ private void OnTransformChanged(object? sender, EventArgs e)
+ {
+ Invalidate();
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private Sticker? HitTest(SKPoint location)
+ {
+ if (_canvasWidth <= 0 || _canvasHeight <= 0)
+ return null;
+
+ var matrix = _tracker.Matrix;
+ if (!matrix.TryInvert(out var inverse))
+ return null;
+
+ var transformed = inverse.MapPoint(location);
+
+ // Check stickers in reverse order (top to bottom)
+ for (int i = _stickers.Count - 1; i >= 0; i--)
+ {
+ var sticker = _stickers[i];
+ var dist = SKPoint.Distance(transformed, sticker.Position);
+ if (dist <= sticker.Size / 2)
+ return sticker;
+ }
+
+ return null;
+ }
+
+ private void LogEvent(string message)
+ {
+ _eventLog.Enqueue($"[{DateTime.Now:HH:mm:ss}] {message}");
+ while (_eventLog.Count > MaxLogEntries)
+ _eventLog.Dequeue();
+ }
+
+ private class Sticker
+ {
+ public SKPoint Position { get; set; }
+ public float Size { get; set; }
+ public SKColor Color { get; set; }
+ public string Label { get; set; } = "";
+ }
+
+ #endregion
+}
diff --git a/samples/SkiaSharpDemo.Blazor/Pages/Gestures.razor.css b/samples/SkiaSharpDemo.Blazor/Pages/Gestures.razor.css
new file mode 100644
index 0000000000..ab62d6d570
--- /dev/null
+++ b/samples/SkiaSharpDemo.Blazor/Pages/Gestures.razor.css
@@ -0,0 +1,77 @@
+.gesture-container {
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ overflow: hidden;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.gesture-container:active {
+ cursor: grabbing;
+}
+
+.status-panel {
+ margin-top: 10px;
+ padding: 10px;
+ background: #f5f5f5;
+ border-radius: 4px;
+ font-family: monospace;
+ font-size: 12px;
+}
+
+.status-label {
+ font-weight: bold;
+ margin-bottom: 5px;
+}
+
+.event-log {
+ color: #666;
+ font-size: 11px;
+}
+
+.settings-panel {
+ margin-top: 10px;
+}
+
+.settings-grid {
+ padding: 10px;
+ background: #f0f0f0;
+ border-radius: 4px;
+ font-size: 13px;
+}
+
+.toggle-row {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+ margin-bottom: 4px;
+}
+
+.toggle-row label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+}
+
+.slider-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+.slider-row label {
+ min-width: 180px;
+ font-size: 12px;
+}
+
+.slider-row input[type=range] {
+ flex: 1;
+}
+
+.state-info {
+ font-family: monospace;
+ font-size: 12px;
+ color: #555;
+}
diff --git a/samples/SkiaSharpDemo.Blazor/Pages/Home.razor b/samples/SkiaSharpDemo.Blazor/Pages/Home.razor
index 6744556e63..d5bd4bba8f 100644
--- a/samples/SkiaSharpDemo.Blazor/Pages/Home.razor
+++ b/samples/SkiaSharpDemo.Blazor/Pages/Home.razor
@@ -56,3 +56,18 @@
+
+CONTROLS
+
+
+
+
+
Gestures
+
+ Platform-agnostic gesture recognition for SkiaSharp surfaces.
+ Pan, pinch, rotate, fling, double-tap zoom, and more.
+
+
Open Demo
+
+
+
diff --git a/samples/SkiaSharpDemo/Demos/Gestures/GesturePage.xaml b/samples/SkiaSharpDemo/Demos/Gestures/GesturePage.xaml
new file mode 100644
index 0000000000..9fd2aa1571
--- /dev/null
+++ b/samples/SkiaSharpDemo/Demos/Gestures/GesturePage.xaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/SkiaSharpDemo/Demos/Gestures/GesturePage.xaml.cs b/samples/SkiaSharpDemo/Demos/Gestures/GesturePage.xaml.cs
new file mode 100644
index 0000000000..edaa411351
--- /dev/null
+++ b/samples/SkiaSharpDemo/Demos/Gestures/GesturePage.xaml.cs
@@ -0,0 +1,603 @@
+using SkiaSharp;
+using SkiaSharp.Extended;
+using SkiaSharp.Views.Maui;
+
+namespace SkiaSharpDemo.Demos;
+
+///
+/// Demo page showcasing gesture features using SKGestureTracker directly with SKCanvasView.
+/// This demonstrates the recommended pattern: apps use the tracker directly rather than
+/// a wrapper view, giving full control over which gestures to handle.
+///
+public partial class GesturePage : ContentPage
+{
+ // Gesture tracker - the core gesture recognition component
+ private SKGestureTracker _tracker = null!;
+
+ // Sticker data for demonstration
+ private readonly List _stickers = new();
+ private Sticker? _selectedSticker;
+ private readonly Queue _eventLog = new();
+ private const int MaxLogEntries = 5;
+
+ // Feature toggles (for tap/longpress/drag which are app-level)
+ private bool _enableTap = true;
+ private bool _enableDoubleTap = true;
+ private bool _enableLongPress = true;
+ private bool _enableDrag = true;
+
+ // Canvas dimensions for hit testing
+ private int _canvasWidth;
+ private int _canvasHeight;
+
+ public GesturePage()
+ {
+ InitializeComponent();
+
+ // Initialize with some stickers
+ _stickers.Add(new Sticker { Position = new SKPoint(100, 100), Size = 80, Color = SKColors.Red, Label = "1" });
+ _stickers.Add(new Sticker { Position = new SKPoint(200, 200), Size = 60, Color = SKColors.Green, Label = "2" });
+ _stickers.Add(new Sticker { Position = new SKPoint(300, 150), Size = 70, Color = SKColors.Blue, Label = "3" });
+
+ CreateTracker();
+ }
+
+ protected override void OnAppearing()
+ {
+ base.OnAppearing();
+ canvasView.InvalidateSurface();
+ }
+
+ // Dispose on handler detach, recreate on handler attach.
+ // This survives push/pop navigation (which doesn't change handler)
+ // but properly cleans up when the page is removed from the tree.
+ protected override void OnHandlerChanged()
+ {
+ base.OnHandlerChanged();
+
+ if (Handler != null)
+ {
+ // Page attached to visual tree — ensure tracker exists
+ if (_tracker == null!)
+ CreateTracker();
+ }
+ else
+ {
+ // Page removed from visual tree — dispose to release timers
+ if (_tracker != null!)
+ {
+ UnsubscribeTrackerEvents();
+ _tracker.Dispose();
+ _tracker = null!;
+ }
+ }
+ }
+
+ private void CreateTracker()
+ {
+ _tracker = new SKGestureTracker();
+ SubscribeTrackerEvents();
+ }
+
+ private void SubscribeTrackerEvents()
+ {
+ _tracker.TapDetected += OnTap;
+ _tracker.DoubleTapDetected += OnDoubleTap;
+ _tracker.LongPressDetected += OnLongPress;
+ _tracker.PanDetected += OnPan;
+ _tracker.PinchDetected += OnPinch;
+ _tracker.RotateDetected += OnRotate;
+ _tracker.FlingDetected += OnFling;
+ _tracker.FlingUpdated += OnFlingUpdated;
+ _tracker.FlingCompleted += OnFlingCompleted;
+ _tracker.ScrollDetected += OnScroll;
+ _tracker.HoverDetected += OnHover;
+ _tracker.DragStarted += OnDragStarted;
+ _tracker.DragUpdated += OnDragUpdated;
+ _tracker.DragEnded += OnDragEnded;
+ _tracker.TransformChanged += OnTransformChanged;
+ }
+
+ private void UnsubscribeTrackerEvents()
+ {
+ _tracker.TapDetected -= OnTap;
+ _tracker.DoubleTapDetected -= OnDoubleTap;
+ _tracker.LongPressDetected -= OnLongPress;
+ _tracker.PanDetected -= OnPan;
+ _tracker.PinchDetected -= OnPinch;
+ _tracker.RotateDetected -= OnRotate;
+ _tracker.FlingDetected -= OnFling;
+ _tracker.FlingUpdated -= OnFlingUpdated;
+ _tracker.FlingCompleted -= OnFlingCompleted;
+ _tracker.ScrollDetected -= OnScroll;
+ _tracker.HoverDetected -= OnHover;
+ _tracker.DragStarted -= OnDragStarted;
+ _tracker.DragUpdated -= OnDragUpdated;
+ _tracker.DragEnded -= OnDragEnded;
+ _tracker.TransformChanged -= OnTransformChanged;
+ }
+
+ ///
+ /// Handle touch events from SKCanvasView and forward to the tracker.
+ ///
+ private void OnTouch(object? sender, SKTouchEventArgs e)
+ {
+ // Convert MAUI touch event to tracker input
+ e.Handled = ProcessTouch(_tracker, e);
+
+ if (e.Handled)
+ canvasView.InvalidateSurface();
+ }
+
+ ///
+ /// Processes a MAUI SKTouchEventArgs through the gesture tracker.
+ /// SKTouchEventArgs.Location is already in device-pixel coordinates (same as the canvas),
+ /// so no coordinate conversion is needed.
+ ///
+ private static bool ProcessTouch(SKGestureTracker tracker, SKTouchEventArgs e)
+ {
+ var isMouse = e.DeviceType == SKTouchDeviceType.Mouse;
+ var location = e.Location;
+
+ return e.ActionType switch
+ {
+ SKTouchAction.Pressed => tracker.ProcessTouchDown(e.Id, location, isMouse),
+ SKTouchAction.Moved => tracker.ProcessTouchMove(e.Id, location, e.InContact),
+ SKTouchAction.Released => tracker.ProcessTouchUp(e.Id, location, isMouse),
+ SKTouchAction.Cancelled => tracker.ProcessTouchCancel(e.Id),
+ SKTouchAction.WheelChanged => tracker.ProcessMouseWheel(location, 0, e.WheelDelta),
+ _ => true, // Entered/Exited — accept to keep receiving events
+ };
+ }
+
+ ///
+ /// Invalidate canvas when transform changes (pan, zoom, rotate, fling).
+ ///
+ private void OnTransformChanged(object? sender, EventArgs e)
+ {
+ canvasView.InvalidateSurface();
+ }
+
+ private void OnPaintSurface(object? sender, SKPaintSurfaceEventArgs e)
+ {
+ var canvas = e.Surface.Canvas;
+ var width = e.Info.Width;
+ var height = e.Info.Height;
+
+ // Cache canvas size for hit testing
+ _canvasWidth = width;
+ _canvasHeight = height;
+
+ // Clear background
+ canvas.Clear(SKColors.White);
+
+ // Apply transform from the tracker
+ canvas.Save();
+ canvas.Concat(_tracker.Matrix);
+
+ // Draw grid background inside the transform so it pans/zooms/rotates with content
+ DrawGrid(canvas, width, height);
+
+ // Draw stickers
+ foreach (var sticker in _stickers)
+ {
+ DrawSticker(canvas, sticker, sticker == _selectedSticker);
+ }
+
+ canvas.Restore();
+
+ // Draw crosshair at center for reference
+ using var crosshairPaint = new SKPaint
+ {
+ Color = SKColors.Gray.WithAlpha(100),
+ StrokeWidth = 1,
+ IsAntialias = true
+ };
+ canvas.DrawLine(width / 2f, 0, width / 2f, height, crosshairPaint);
+ canvas.DrawLine(0, height / 2f, width, height / 2f, crosshairPaint);
+ }
+
+ private void DrawGrid(SKCanvas canvas, int width, int height)
+ {
+ const int gridSize = 40;
+
+ using var lightPaint = new SKPaint { Color = new SKColor(240, 240, 240) };
+ using var darkPaint = new SKPaint { Color = new SKColor(220, 220, 220) };
+
+ // Expand grid coverage so it fills the view when zoomed out
+ var scale = _tracker.Scale;
+ var extra = (int)(Math.Max(width, height) / scale) + gridSize * 4;
+ // Snap start to grid boundary to keep checker pattern correct
+ var startX = -(extra / gridSize) * gridSize;
+ var startY = startX;
+ var endX = width - startX;
+ var endY = height - startY;
+
+ for (int y = startY, row = 0; y < endY; y += gridSize, row++)
+ {
+ for (int x = startX, col = 0; x < endX; x += gridSize, col++)
+ {
+ var isLight = (col + row) % 2 == 0;
+ var rect = new SKRect(x, y, x + gridSize, y + gridSize);
+ canvas.DrawRect(rect, isLight ? lightPaint : darkPaint);
+ }
+ }
+ }
+
+ private void DrawSticker(SKCanvas canvas, Sticker sticker, bool isSelected)
+ {
+ var radius = sticker.Size / 2f;
+
+ // Draw shadow
+ using var shadowPaint = new SKPaint
+ {
+ Color = SKColors.Black.WithAlpha(50),
+ IsAntialias = true,
+ MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 5)
+ };
+ canvas.DrawCircle(sticker.Position.X + 3, sticker.Position.Y + 3, radius, shadowPaint);
+
+ // Draw sticker fill
+ using var fillPaint = new SKPaint
+ {
+ Color = sticker.Color,
+ IsAntialias = true,
+ Style = SKPaintStyle.Fill
+ };
+ canvas.DrawCircle(sticker.Position, radius, fillPaint);
+
+ // Draw selection ring
+ if (isSelected)
+ {
+ using var selectPaint = new SKPaint
+ {
+ Color = SKColors.Yellow,
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 4
+ };
+ canvas.DrawCircle(sticker.Position, radius + 4, selectPaint);
+ }
+
+ // Draw border
+ using var borderPaint = new SKPaint
+ {
+ Color = SKColors.White,
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 2
+ };
+ canvas.DrawCircle(sticker.Position, radius, borderPaint);
+
+ // Draw label
+ using var textFont = new SKFont
+ {
+ Size = sticker.Size * 0.4f,
+ Typeface = SKTypeface.FromFamilyName("Arial", SKFontStyle.Bold)
+ };
+ using var textPaint = new SKPaint
+ {
+ Color = SKColors.White,
+ IsAntialias = true
+ };
+ canvas.DrawText(sticker.Label, sticker.Position.X, sticker.Position.Y + textFont.Size * 0.35f, SKTextAlign.Center, textFont, textPaint);
+ }
+
+ private void OnTap(object? sender, SKTapGestureEventArgs e)
+ {
+ if (!_enableTap) return;
+ LogEvent($"Tap at ({e.Location.X:F0}, {e.Location.Y:F0})");
+
+ // Try to select a sticker
+ var hitSticker = HitTest(e.Location);
+ if (hitSticker != null)
+ {
+ _selectedSticker = hitSticker;
+ statusLabel.Text = $"Selected: Sticker {hitSticker.Label}";
+ }
+ else
+ {
+ _selectedSticker = null;
+ statusLabel.Text = "No selection";
+ }
+
+ canvasView.InvalidateSurface();
+ }
+
+ private void OnDoubleTap(object? sender, SKTapGestureEventArgs e)
+ {
+ if (!_enableDoubleTap) return;
+ LogEvent($"Double tap ({e.TapCount}x) at ({e.Location.X:F0}, {e.Location.Y:F0})");
+
+ // If a sticker is under the double-tap, select it and suppress zoom
+ var hitSticker = HitTest(e.Location);
+ if (hitSticker != null)
+ {
+ _selectedSticker = hitSticker;
+ statusLabel.Text = $"Selected: Sticker {hitSticker.Label}";
+ e.Handled = true;
+ canvasView.InvalidateSurface();
+ }
+ }
+
+ private void OnLongPress(object? sender, SKLongPressGestureEventArgs e)
+ {
+ if (!_enableLongPress) return;
+ LogEvent($"Long press at ({e.Location.X:F0}, {e.Location.Y:F0})");
+
+ var hitSticker = HitTest(e.Location);
+ if (hitSticker != null)
+ {
+ _selectedSticker = hitSticker;
+ statusLabel.Text = $"Long press selected: Sticker {hitSticker.Label}";
+ }
+
+ canvasView.InvalidateSurface();
+ }
+
+ private void OnPan(object? sender, SKPanGestureEventArgs e)
+ {
+ // Transform is handled by the tracker
+ statusLabel.Text = $"Pan: Δ({e.Delta.X:F1}, {e.Delta.Y:F1})";
+ }
+
+ private void OnPinch(object? sender, SKPinchGestureEventArgs e)
+ {
+ LogEvent($"Pinch scale: {e.ScaleDelta:F2}");
+ statusLabel.Text = $"Scale: {_tracker.Scale:F2}";
+ }
+
+ private void OnRotate(object? sender, SKRotateGestureEventArgs e)
+ {
+ LogEvent($"Rotate: {e.RotationDelta:F1}°");
+ statusLabel.Text = $"Rotation: {_tracker.Rotation:F1}°";
+ }
+
+ private void OnFling(object? sender, SKFlingGestureEventArgs e)
+ {
+ LogEvent($"Fling: ({e.Velocity.X:F0}, {e.Velocity.Y:F0}) px/s");
+ statusLabel.Text = $"Flinging at {e.Speed:F0} px/s";
+ }
+
+ private void OnFlingUpdated(object? sender, SKFlingGestureEventArgs e)
+ {
+ // Fling transform is handled by the tracker
+ statusLabel.Text = $"Flinging... ({e.Speed:F0} px/s)";
+ }
+
+ private void OnFlingCompleted(object? sender, EventArgs e)
+ {
+ statusLabel.Text = "Fling ended";
+ }
+
+ private void OnScroll(object? sender, SKScrollGestureEventArgs e)
+ {
+ // Scroll zoom is handled by the tracker
+ statusLabel.Text = $"Scroll zoom: {_tracker.Scale:F2}x";
+ }
+
+ private void OnHover(object? sender, SKHoverGestureEventArgs e)
+ {
+ statusLabel.Text = $"Hover: ({e.Location.X:F0}, {e.Location.Y:F0})";
+ }
+
+ private void OnDragStarted(object? sender, SKDragGestureEventArgs e)
+ {
+ if (!_enableDrag) return;
+ LogEvent($"Drag started at ({e.Location.X:F0}, {e.Location.Y:F0})");
+
+ if (_selectedSticker != null)
+ {
+ statusLabel.Text = $"Dragging Sticker {_selectedSticker.Label}";
+ e.Handled = true; // suppress canvas pan
+ }
+ }
+
+ private void OnDragUpdated(object? sender, SKDragGestureEventArgs e)
+ {
+ if (!_enableDrag) return;
+ // Move selected sticker in content-space
+ if (_selectedSticker != null)
+ {
+ // Convert screen-space delta to content-space via inverse matrix
+ var matrix = _tracker.Matrix;
+ if (matrix.TryInvert(out var inverse))
+ {
+ var contentDelta = inverse.MapVector(e.Delta.X, e.Delta.Y);
+ _selectedSticker.Position = new SKPoint(
+ _selectedSticker.Position.X + contentDelta.X,
+ _selectedSticker.Position.Y + contentDelta.Y);
+ }
+
+ e.Handled = true; // suppress canvas pan
+ canvasView.InvalidateSurface();
+ }
+ }
+
+ private void OnDragEnded(object? sender, SKDragGestureEventArgs e)
+ {
+ if (!_enableDrag) return;
+ LogEvent($"Drag ended at ({e.Location.X:F0}, {e.Location.Y:F0})");
+ statusLabel.Text = "Drag completed";
+ }
+
+ private Sticker? HitTest(SKPoint location)
+ {
+ if (_canvasWidth <= 0 || _canvasHeight <= 0)
+ return null;
+
+ var matrix = _tracker.Matrix;
+ if (!matrix.TryInvert(out var inverse))
+ return null;
+
+ var transformed = inverse.MapPoint(location);
+
+ // Check stickers in reverse order (top to bottom)
+ for (int i = _stickers.Count - 1; i >= 0; i--)
+ {
+ var sticker = _stickers[i];
+ var dist = SKPoint.Distance(transformed, sticker.Position);
+ if (dist <= sticker.Size / 2)
+ return sticker;
+ }
+
+ return null;
+ }
+
+ private async void OnSettingsClicked(object? sender, EventArgs e)
+ {
+ var page = new ContentPage { Title = "Gesture Settings" };
+
+ var layout = new VerticalStackLayout { Padding = 20, Spacing = 12 };
+
+ // --- Feature Toggles (Tracker-level) ---
+ layout.Children.Add(new Label { Text = "Tracker Feature Toggles", FontAttributes = FontAttributes.Bold, FontSize = 16 });
+
+ var trackerToggles = new (string Label, bool Value, Action Setter)[]
+ {
+ ("Tap", _tracker.IsTapEnabled, v => _tracker.IsTapEnabled = v),
+ ("Double Tap", _tracker.IsDoubleTapEnabled, v => _tracker.IsDoubleTapEnabled = v),
+ ("Long Press", _tracker.IsLongPressEnabled, v => _tracker.IsLongPressEnabled = v),
+ ("Pan", _tracker.IsPanEnabled, v => _tracker.IsPanEnabled = v),
+ ("Pinch (Zoom)", _tracker.IsPinchEnabled, v => _tracker.IsPinchEnabled = v),
+ ("Rotate", _tracker.IsRotateEnabled, v => _tracker.IsRotateEnabled = v),
+ ("Fling", _tracker.IsFlingEnabled, v => _tracker.IsFlingEnabled = v),
+ ("Double Tap Zoom", _tracker.IsDoubleTapZoomEnabled, v => _tracker.IsDoubleTapZoomEnabled = v),
+ ("Scroll Zoom", _tracker.IsScrollZoomEnabled, v => _tracker.IsScrollZoomEnabled = v),
+ ("Hover", _tracker.IsHoverEnabled, v => _tracker.IsHoverEnabled = v),
+ };
+
+ foreach (var (label, value, setter) in trackerToggles)
+ {
+ var sw = new Switch { IsToggled = value };
+ var captured = setter;
+ sw.Toggled += (_, args) => captured(args.Value);
+ layout.Children.Add(new HorizontalStackLayout
+ {
+ Spacing = 10,
+ Children = { sw, new Label { Text = label, VerticalOptions = LayoutOptions.Center } }
+ });
+ }
+
+ // --- App-level Toggles ---
+ layout.Children.Add(new Label { Text = "App Toggles", FontAttributes = FontAttributes.Bold, FontSize = 16, Margin = new Thickness(0, 10, 0, 0) });
+
+ var appToggles = new (string Label, bool Value, Action Setter)[]
+ {
+ ("Tap Selection (App)", _enableTap, v => _enableTap = v),
+ ("Double Tap Log (App)", _enableDoubleTap, v => _enableDoubleTap = v),
+ ("Long Press Info (App)", _enableLongPress, v => _enableLongPress = v),
+ ("Drag Sticker (App)", _enableDrag, v => _enableDrag = v),
+ };
+
+ foreach (var (label, value, setter) in appToggles)
+ {
+ var sw = new Switch { IsToggled = value };
+ var captured = setter;
+ sw.Toggled += (_, args) => captured(args.Value);
+ layout.Children.Add(new HorizontalStackLayout
+ {
+ Spacing = 10,
+ Children = { sw, new Label { Text = label, VerticalOptions = LayoutOptions.Center } }
+ });
+ }
+
+ // --- Detection Thresholds ---
+ layout.Children.Add(new Label { Text = "Detection Thresholds", FontAttributes = FontAttributes.Bold, FontSize = 16, Margin = new Thickness(0, 10, 0, 0) });
+
+ AddSlider(layout, "Touch Slop", "px", 1, 50, _tracker.Options.TouchSlop, v => _tracker.Options.TouchSlop = v);
+ AddSlider(layout, "Double Tap Slop", "px", 10, 200, _tracker.Options.DoubleTapSlop, v => _tracker.Options.DoubleTapSlop = v);
+ AddSlider(layout, "Fling Threshold", "px/s", 50, 1000, _tracker.Options.FlingThreshold, v => _tracker.Options.FlingThreshold = v);
+ AddSliderInt(layout, "Long Press Duration", "ms", 100, 2000, (int)_tracker.Options.LongPressDuration.TotalMilliseconds, v => _tracker.Options.LongPressDuration = TimeSpan.FromMilliseconds(v));
+
+ // --- Fling Settings ---
+ layout.Children.Add(new Label { Text = "Fling Settings", FontAttributes = FontAttributes.Bold, FontSize = 16, Margin = new Thickness(0, 10, 0, 0) });
+
+ AddSlider(layout, "Friction", "", 0.01f, 0.5f, _tracker.Options.FlingFriction, v => _tracker.Options.FlingFriction = v, "F3");
+ AddSlider(layout, "Min Velocity", "px/s", 1, 50, _tracker.Options.FlingMinVelocity, v => _tracker.Options.FlingMinVelocity = v);
+ AddSliderInt(layout, "Frame Interval", "ms", 8, 50, (int)_tracker.Options.FlingFrameInterval.TotalMilliseconds, v => _tracker.Options.FlingFrameInterval = TimeSpan.FromMilliseconds(v));
+
+ // --- Zoom Settings ---
+ layout.Children.Add(new Label { Text = "Zoom Settings", FontAttributes = FontAttributes.Bold, FontSize = 16, Margin = new Thickness(0, 10, 0, 0) });
+
+ AddSlider(layout, "Double Tap Zoom", "x", 1.5f, 5, _tracker.Options.DoubleTapZoomFactor, v => _tracker.Options.DoubleTapZoomFactor = v, "F1");
+ AddSliderInt(layout, "Zoom Animation", "ms", 50, 1000, (int)_tracker.Options.ZoomAnimationDuration.TotalMilliseconds, v => _tracker.Options.ZoomAnimationDuration = TimeSpan.FromMilliseconds(v));
+ AddSlider(layout, "Scroll Zoom Factor", "", 0.01f, 0.5f, _tracker.Options.ScrollZoomFactor, v => _tracker.Options.ScrollZoomFactor = v, "F3");
+ AddSlider(layout, "Min Scale", "x", 0.1f, 1, _tracker.Options.MinScale, v => _tracker.Options.MinScale = v, "F2");
+ AddSlider(layout, "Max Scale", "x", 2, 20, _tracker.Options.MaxScale, v => _tracker.Options.MaxScale = v, "F1");
+
+ // --- Current State ---
+ layout.Children.Add(new Label { Text = "Current State", FontAttributes = FontAttributes.Bold, FontSize = 16, Margin = new Thickness(0, 10, 0, 0) });
+ layout.Children.Add(new Label { Text = $"Scale: {_tracker.Scale:F2}x" });
+ layout.Children.Add(new Label { Text = $"Rotation: {_tracker.Rotation:F1}°" });
+ layout.Children.Add(new Label { Text = $"Offset: ({_tracker.Offset.X:F0}, {_tracker.Offset.Y:F0})" });
+ layout.Children.Add(new Label { Text = $"Selected: {(_selectedSticker != null ? $"Sticker {_selectedSticker.Label}" : "None")}" });
+
+ // Reset button
+ var resetBtn = new Button { Text = "Reset View", Margin = new Thickness(0, 10, 0, 0) };
+ resetBtn.Clicked += (_, _) =>
+ {
+ _tracker.Reset();
+ _selectedSticker = null;
+ canvasView.InvalidateSurface();
+ };
+ layout.Children.Add(resetBtn);
+
+ page.Content = new ScrollView { Content = layout };
+ await Navigation.PushAsync(page);
+ }
+
+ private static void AddSlider(VerticalStackLayout layout, string name, string unit, float min, float max, float value, Action setter, string format = "F0")
+ {
+ var suffix = string.IsNullOrEmpty(unit) ? "" : $" {unit}";
+ var label = new Label { Text = $"{name}: {value.ToString(format)}{suffix}" };
+ var slider = new Slider { Minimum = min, Maximum = max, Value = value };
+ slider.ValueChanged += (_, args) =>
+ {
+ setter((float)args.NewValue);
+ label.Text = $"{name}: {((float)args.NewValue).ToString(format)}{suffix}";
+ };
+ layout.Children.Add(label);
+ layout.Children.Add(slider);
+ }
+
+ private static void AddSliderInt(VerticalStackLayout layout, string name, string unit, int min, int max, int value, Action setter)
+ {
+ var label = new Label { Text = $"{name}: {value} {unit}" };
+ var slider = new Slider { Minimum = min, Maximum = max, Value = value };
+ slider.ValueChanged += (_, args) =>
+ {
+ setter((int)args.NewValue);
+ label.Text = $"{name}: {(int)args.NewValue} {unit}";
+ };
+ layout.Children.Add(label);
+ layout.Children.Add(slider);
+ }
+
+ private void LogEvent(string message)
+ {
+ _eventLog.Enqueue($"[{DateTime.Now:HH:mm:ss}] {message}");
+ while (_eventLog.Count > MaxLogEntries)
+ _eventLog.Dequeue();
+
+ var labels = new[] { eventLog1, eventLog2, eventLog3, eventLog4, eventLog5 };
+ var events = _eventLog.ToArray();
+ for (int i = 0; i < labels.Length; i++)
+ {
+ labels[i].Text = i < events.Length ? events[i] : "";
+ }
+ }
+
+ ///
+ /// Represents a draggable sticker.
+ ///
+ private class Sticker
+ {
+ public SKPoint Position { get; set; }
+ public float Size { get; set; }
+ public SKColor Color { get; set; }
+ public string Label { get; set; } = "";
+ public float Rotation { get; set; }
+ public float Scale { get; set; } = 1f;
+ }
+}
diff --git a/samples/SkiaSharpDemo/Models/ExtendedDemos.cs b/samples/SkiaSharpDemo/Models/ExtendedDemos.cs
index 770c0790c0..17b7d9bff9 100644
--- a/samples/SkiaSharpDemo/Models/ExtendedDemos.cs
+++ b/samples/SkiaSharpDemo/Models/ExtendedDemos.cs
@@ -47,6 +47,13 @@ public static List GetAllDemos() =>
PageType = typeof(LottiePage),
Color = Colors.SteelBlue,
},
+ new Demo
+ {
+ Title = "Gestures",
+ Description = "Pan, pinch, rotate, tap, long press, fling - all the gestures you need for interactive SkiaSharp views!",
+ PageType = typeof(GesturePage),
+ Color = Colors.DarkOrange,
+ },
//new Demo
//{
// Title = "ToImage",
diff --git a/source/SkiaSharp.Extended/Gestures/SKDragGestureEventArgs.cs b/source/SkiaSharp.Extended/Gestures/SKDragGestureEventArgs.cs
new file mode 100644
index 0000000000..e06fc9217c
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKDragGestureEventArgs.cs
@@ -0,0 +1,69 @@
+using System;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Provides data for drag gesture lifecycle events ( ,
+/// , and ).
+///
+///
+/// Drag events provide a higher-level lifecycle built on top of the underlying pan gesture.
+/// The lifecycle is:
+///
+/// : Fired once when the first pan movement
+/// occurs.
+/// : Fired continuously as the touch moves.
+/// contains the incremental displacement from the previous position.
+/// : Fired once when all touches are released.
+///
+/// Set to during
+/// or
+/// to prevent the tracker from applying its default pan offset behavior.
+///
+///
+///
+///
+///
+public class SKDragGestureEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The current touch location, in view coordinates.
+ /// The previous touch location, in view coordinates.
+ public SKDragGestureEventArgs(SKPoint location, SKPoint previousLocation)
+ {
+ Location = location;
+ PreviousLocation = previousLocation;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the event has been handled.
+ ///
+ ///
+ /// if the event has been handled by a consumer and default processing
+ /// should be skipped; otherwise, . The default is .
+ ///
+ public bool Handled { get; set; }
+
+ ///
+ /// Gets the current touch location in view coordinates.
+ ///
+ /// An representing the current position of the touch.
+ public SKPoint Location { get; }
+
+ ///
+ /// Gets the previous touch location in view coordinates.
+ ///
+ /// An representing the previous position of the touch.
+ public SKPoint PreviousLocation { get; }
+
+ ///
+ /// Gets the displacement from to .
+ ///
+ ///
+ /// An where X and Y represent the incremental change in pixels.
+ ///
+ /// Calculated as Location - PreviousLocation .
+ public SKPoint Delta => new SKPoint(Location.X - PreviousLocation.X, Location.Y - PreviousLocation.Y);
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKFlingGestureEventArgs.cs b/source/SkiaSharp.Extended/Gestures/SKFlingGestureEventArgs.cs
new file mode 100644
index 0000000000..d2ed965275
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKFlingGestureEventArgs.cs
@@ -0,0 +1,70 @@
+using System;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Provides data for fling gesture events, including the initial fling detection and
+/// per-frame animation updates.
+///
+///
+/// This class is used by two distinct events:
+///
+/// / :
+/// Fired once when a fling is initiated. contains the initial velocity.
+/// is .
+/// : Fired each animation frame during
+/// the fling deceleration. contains the current (decaying) velocity, and
+/// contains the per-frame displacement in pixels.
+///
+///
+///
+///
+///
+public class SKFlingGestureEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class with
+ /// velocity only. Used for the initial event.
+ ///
+ /// The initial velocity in pixels per second.
+ public SKFlingGestureEventArgs(SKPoint velocity)
+ : this(velocity, SKPoint.Empty)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with
+ /// velocity and per-frame displacement. Used for events.
+ ///
+ /// The current velocity in pixels per second.
+ /// The displacement for this animation frame, in pixels.
+ public SKFlingGestureEventArgs(SKPoint velocity, SKPoint delta)
+ {
+ Velocity = velocity;
+ Delta = delta;
+ }
+
+ ///
+ /// Gets the velocity of the fling.
+ ///
+ ///
+ /// An where X and Y are the velocity components in pixels
+ /// per second. Positive X is rightward; positive Y is downward.
+ ///
+ public SKPoint Velocity { get; }
+
+ ///
+ /// Gets the displacement for this animation frame.
+ ///
+ ///
+ /// An with the per-frame displacement in pixels. This is
+ /// for events.
+ ///
+ public SKPoint Delta { get; }
+
+ ///
+ /// Gets the current speed (magnitude of the velocity vector).
+ ///
+ /// The speed in pixels per second, computed as sqrt(Velocity.X² + Velocity.Y²) .
+ public float Speed => (float)Math.Sqrt(Velocity.X * Velocity.X + Velocity.Y * Velocity.Y);
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKFlingTracker.cs b/source/SkiaSharp.Extended/Gestures/SKFlingTracker.cs
new file mode 100644
index 0000000000..b8a585b2cf
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKFlingTracker.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Collections.Generic;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Tracks touch events to calculate fling velocity.
+///
+internal sealed class SKFlingTracker
+{
+ // Use only events from the last 200 ms
+ private const long ThresholdTicks = 200 * TimeSpan.TicksPerMillisecond;
+ private const int MaxSize = 5;
+
+ private readonly Dictionary> _events = new();
+
+ public void AddEvent(long id, SKPoint location, long ticks)
+ {
+ if (!_events.TryGetValue(id, out var queue))
+ {
+ queue = new Queue();
+ _events[id] = queue;
+ }
+
+ queue.Enqueue(new FlingEvent(location.X, location.Y, ticks));
+
+ if (queue.Count > MaxSize)
+ queue.Dequeue();
+ }
+
+ public void RemoveId(long id)
+ {
+ _events.Remove(id);
+ }
+
+ public void Clear()
+ {
+ _events.Clear();
+ }
+
+ public SKPoint CalculateVelocity(long id, long now)
+ {
+ if (!_events.TryGetValue(id, out var queue) || queue.Count < 2)
+ return SKPoint.Empty;
+
+ var array = queue.ToArray();
+
+ // Find the oldest event within the threshold window
+ var startIndex = -1;
+ for (int i = 0; i < array.Length; i++)
+ {
+ if (now - array[i].Ticks < ThresholdTicks)
+ {
+ startIndex = i;
+ break;
+ }
+ }
+
+ if (startIndex < 0 || startIndex >= array.Length - 1)
+ return SKPoint.Empty;
+
+ // Use weighted average of velocities between consecutive events,
+ // with time-based weighting (more recent = higher weight)
+ float totalVelocityX = 0, totalVelocityY = 0, totalWeight = 0;
+ var windowStart = array[startIndex].Ticks;
+ var windowSpan = (float)(now - windowStart);
+ if (windowSpan <= 0)
+ windowSpan = 1;
+
+ for (int i = startIndex; i < array.Length - 1; i++)
+ {
+ var dt = array[i + 1].Ticks - array[i].Ticks;
+ if (dt <= 0)
+ continue;
+
+ var vx = (array[i + 1].X - array[i].X) * TimeSpan.TicksPerSecond / dt;
+ var vy = (array[i + 1].Y - array[i].Y) * TimeSpan.TicksPerSecond / dt;
+
+ // Time-based weight: how recent is this segment (0..1, 1 = most recent)
+ var recency = (float)(array[i + 1].Ticks - windowStart) / windowSpan;
+ var weight = 0.5f + recency; // range [0.5, 1.5] — still uses older data but favors newer
+ totalVelocityX += vx * weight;
+ totalVelocityY += vy * weight;
+ totalWeight += weight;
+ }
+
+ if (totalWeight <= 0)
+ return SKPoint.Empty;
+
+ return new SKPoint(totalVelocityX / totalWeight, totalVelocityY / totalWeight);
+ }
+
+ private readonly record struct FlingEvent(float X, float Y, long Ticks);
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKGestureDetector.cs b/source/SkiaSharp.Extended/Gestures/SKGestureDetector.cs
new file mode 100644
index 0000000000..9bdb218175
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKGestureDetector.cs
@@ -0,0 +1,724 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+
+namespace SkiaSharp.Extended;
+
+///
+/// A platform-agnostic gesture recognition engine that detects taps, long presses,
+/// pan, pinch, rotation, and fling gestures from touch input.
+///
+///
+/// This engine is a pure gesture detector. It processes touch events and raises
+/// events when gestures are recognized. It does not maintain transform state or run
+/// animations — use for that.
+/// The engine must be used on the UI thread. It captures the current
+/// when processing touch events and uses it
+/// to marshal timer callbacks back to the UI thread.
+/// Call to clean up resources when done.
+///
+public sealed class SKGestureDetector : IDisposable
+{
+ // Timing constants
+ private const long ShortTapTicks = 125 * TimeSpan.TicksPerMillisecond;
+ private const long ShortClickTicks = 250 * TimeSpan.TicksPerMillisecond;
+ private const long DoubleTapDelayTicks = 300 * TimeSpan.TicksPerMillisecond;
+
+ private readonly Dictionary _touches = new();
+ private readonly SKFlingTracker _flingTracker = new();
+ private SynchronizationContext? _syncContext;
+ private Timer? _longPressTimer;
+ private int _longPressToken;
+
+ private SKPoint _initialTouch = SKPoint.Empty;
+ private SKPoint _lastTapLocation = SKPoint.Empty;
+ private long _lastTapTicks;
+ private int _tapCount;
+ private GestureState _gestureState = GestureState.None;
+ private PinchState _pinchState;
+ private bool _longPressTriggered;
+ private long _touchStartTicks;
+ private bool _disposed;
+
+ ///
+ /// Initializes a new instance of with default options.
+ ///
+ public SKGestureDetector()
+ : this(new SKGestureDetectorOptions())
+ {
+ }
+
+ ///
+ /// Initializes a new instance of with the specified options.
+ ///
+ public SKGestureDetector(SKGestureDetectorOptions options)
+ {
+ Options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ ///
+ /// Gets the configuration options for this detector.
+ ///
+ /// The instance controlling detection thresholds.
+ public SKGestureDetectorOptions Options { get; }
+
+ ///
+ /// Gets or sets the time provider function used to obtain the current time in ticks.
+ ///
+ ///
+ /// A that returns the current time in ticks (10,000 ticks per millisecond).
+ /// The default uses ticks, which provides
+ /// consistent behavior across all target frameworks including netstandard2.0.
+ ///
+ ///
+ /// Override this for deterministic testing by supplying a custom tick source.
+ ///
+ public Func TimeProvider
+ {
+ get => _timeProvider;
+ set => _timeProvider = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ private Func _timeProvider = () => DateTime.UtcNow.Ticks;
+
+ ///
+ /// Gets or sets a value indicating whether the gesture detector is enabled.
+ ///
+ ///
+ /// if the detector processes touch events; otherwise, .
+ /// The default is . When disabled, all ProcessTouch* methods return .
+ ///
+ public bool IsEnabled { get; set; } = true;
+
+ ///
+ /// Gets a value indicating whether a gesture is currently in progress.
+ ///
+ ///
+ /// if the detector is currently tracking an active gesture (detecting, panning,
+ /// or pinching); otherwise, .
+ ///
+ public bool IsGestureActive => _gestureState != GestureState.None;
+
+ ///
+ /// Occurs when a single tap is detected.
+ ///
+ ///
+ /// A tap is recognized when a touch down and up occur within the
+ /// distance and within the long press duration threshold.
+ ///
+ public event EventHandler? TapDetected;
+
+ ///
+ /// Occurs when a double tap is detected.
+ ///
+ ///
+ /// A double tap is recognized when two taps occur within 300 ms of each other and within the
+ /// distance.
+ ///
+ public event EventHandler? DoubleTapDetected;
+
+ ///
+ /// Occurs when a long press is detected.
+ ///
+ ///
+ /// A long press is recognized when a touch is held stationary for at least
+ /// milliseconds without exceeding
+ /// the distance.
+ ///
+ public event EventHandler? LongPressDetected;
+
+ ///
+ /// Occurs when a single-finger pan (drag) gesture is detected.
+ ///
+ ///
+ /// Pan events fire continuously as a single touch moves beyond the
+ /// threshold.
+ ///
+ public event EventHandler? PanDetected;
+
+ ///
+ /// Occurs when a two-finger pinch (scale) gesture is detected.
+ ///
+ ///
+ /// Pinch events fire continuously while two or more touches are active and moving.
+ /// The is a per-event relative multiplier.
+ ///
+ public event EventHandler? PinchDetected;
+
+ ///
+ /// Occurs when a two-finger rotation gesture is detected.
+ ///
+ ///
+ /// Rotation events fire simultaneously with pinch events when two or more touches are active.
+ ///
+ public event EventHandler? RotateDetected;
+
+ ///
+ /// Occurs when a fling gesture is detected (fired once with initial velocity upon touch release).
+ ///
+ ///
+ /// A fling is triggered when a single-finger pan ends with a velocity exceeding the
+ /// . Flings are not triggered after
+ /// multi-finger gestures (pinch/rotate).
+ ///
+ public event EventHandler? FlingDetected;
+
+ ///
+ /// Occurs when a mouse hover (move without contact) is detected.
+ ///
+ public event EventHandler? HoverDetected;
+
+ ///
+ /// Occurs when a mouse scroll (wheel) event is detected.
+ ///
+ public event EventHandler? ScrollDetected;
+
+ ///
+ /// Occurs when a touch gesture interaction begins (first finger touches the surface).
+ ///
+ public event EventHandler? GestureStarted;
+
+ ///
+ /// Occurs when a touch gesture interaction ends (last finger lifts from the surface).
+ ///
+ public event EventHandler? GestureEnded;
+
+ ///
+ /// Processes a touch down event.
+ ///
+ /// The unique identifier for this touch.
+ /// The location of the touch.
+ /// Whether this is a mouse event.
+ /// True if the event was handled.
+ public bool ProcessTouchDown(long id, SKPoint location, bool isMouse = false)
+ {
+ if (!IsEnabled || _disposed)
+ return false;
+
+ // Capture the synchronization context on first touch (UI thread)
+ _syncContext ??= SynchronizationContext.Current;
+
+ var ticks = TimeProvider();
+
+ _touches[id] = new TouchState(id, location, ticks, true, isMouse);
+
+ // Only set initial touch state for the first finger
+ if (_touches.Count == 1)
+ {
+ _initialTouch = location;
+ _touchStartTicks = ticks;
+ _longPressTriggered = false;
+ // Start the long press timer only on the first finger (not on 2nd+ during pinch)
+ StartLongPressTimer();
+ }
+
+ // Check for double tap using the last completed tap location
+ if (_touches.Count == 1 &&
+ ticks - _lastTapTicks < DoubleTapDelayTicks &&
+ SKPoint.Distance(location, _lastTapLocation) < Options.DoubleTapSlop)
+ {
+ _tapCount++;
+ }
+ else if (_touches.Count == 1)
+ {
+ _tapCount = 1;
+ }
+
+ var touchPoints = GetActiveTouchPoints();
+
+ if (touchPoints.Length > 0)
+ {
+ // Only raise GestureStarted for the first touch
+ if (_touches.Count == 1)
+ OnGestureStarted(new SKGestureLifecycleEventArgs());
+
+ if (touchPoints.Length >= 2)
+ {
+ StopLongPressTimer();
+ _tapCount = 0;
+ _lastTapTicks = 0;
+ _pinchState = PinchState.FromLocations(touchPoints);
+ _gestureState = GestureState.Pinching;
+ }
+ else
+ {
+ _pinchState = new PinchState(touchPoints[0], 0, 0);
+ _gestureState = GestureState.Detecting;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Processes a touch move event.
+ ///
+ /// The unique identifier for this touch.
+ /// The new location of the touch.
+ /// Whether the touch is in contact with the surface.
+ /// True if the event was handled.
+ public bool ProcessTouchMove(long id, SKPoint location, bool inContact = true)
+ {
+ if (!IsEnabled || _disposed)
+ return false;
+
+ var ticks = TimeProvider();
+
+ // Handle hover (mouse without contact) — no prior touch down required
+ if (!inContact)
+ {
+ OnHoverDetected(new SKHoverGestureEventArgs(location));
+ return true;
+ }
+
+ if (!_touches.TryGetValue(id, out var existingTouch))
+ return false;
+
+ _touches[id] = new TouchState(id, location, ticks, inContact, existingTouch.IsMouse);
+ _flingTracker.AddEvent(id, location, ticks);
+
+ var touchPoints = GetActiveTouchPoints();
+ var distance = SKPoint.Distance(location, _initialTouch);
+
+ // Start pan if moved beyond touch slop
+ if (_gestureState == GestureState.Detecting && distance >= Options.TouchSlop)
+ {
+ StopLongPressTimer();
+ _gestureState = GestureState.Panning;
+ // Invalidate double-tap counter — this touch became a pan, not a tap
+ _tapCount = 0;
+ _lastTapTicks = 0;
+ }
+
+ switch (_gestureState)
+ {
+ case GestureState.Panning:
+ if (touchPoints.Length == 1)
+ {
+ var velocity = _flingTracker.CalculateVelocity(id, ticks);
+ OnPanDetected(new SKPanGestureEventArgs(location, _pinchState.Center, velocity));
+ _pinchState = new PinchState(location, 0, 0);
+ }
+ break;
+
+ case GestureState.Pinching:
+ if (touchPoints.Length >= 2)
+ {
+ var newPinch = PinchState.FromLocations(touchPoints);
+
+ // Calculate scale
+ var scaleDelta = _pinchState.Radius > 0 ? newPinch.Radius / _pinchState.Radius : 1f;
+ OnPinchDetected(new SKPinchGestureEventArgs(newPinch.Center, _pinchState.Center, scaleDelta));
+
+ // Calculate rotation
+ var rotationDelta = newPinch.Angle - _pinchState.Angle;
+ rotationDelta = NormalizeAngle(rotationDelta);
+ OnRotateDetected(new SKRotateGestureEventArgs(newPinch.Center, _pinchState.Center, rotationDelta));
+
+ _pinchState = newPinch;
+ }
+ break;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Processes a touch up event.
+ ///
+ /// The unique identifier for this touch.
+ /// The final location of the touch.
+ /// Whether this is a mouse event (kept for backward compatibility; the stored value from touch-down is used internally).
+ /// True if the event was handled.
+ public bool ProcessTouchUp(long id, SKPoint location, bool isMouse = false)
+ {
+ if (!IsEnabled || _disposed)
+ return false;
+
+ StopLongPressTimer();
+ var ticks = TimeProvider();
+
+ if (!_touches.TryGetValue(id, out var releasedTouch))
+ return false;
+
+ // Use the stored IsMouse value from touch-down (more reliable than caller-supplied value)
+ var storedIsMouse = releasedTouch.IsMouse;
+
+ _touches.Remove(id);
+
+ var touchPoints = GetActiveTouchPoints();
+ var handled = false;
+
+ // Check for fling — only after a single-finger pan, not after pinch/rotate
+ if (touchPoints.Length == 0 && _gestureState == GestureState.Panning)
+ {
+ var velocity = _flingTracker.CalculateVelocity(id, ticks);
+ var velocityMagnitude = (float)Math.Sqrt(velocity.X * velocity.X + velocity.Y * velocity.Y);
+
+ if (velocityMagnitude > Options.FlingThreshold)
+ {
+ OnFlingDetected(new SKFlingGestureEventArgs(velocity));
+ handled = true;
+ }
+ }
+
+ // Check for tap — only if we haven't transitioned to panning/pinching
+ if (touchPoints.Length == 0 && _gestureState == GestureState.Detecting)
+ {
+ var distance = SKPoint.Distance(location, _initialTouch);
+ var duration = ticks - _touchStartTicks;
+ var maxTapDuration = storedIsMouse ? ShortClickTicks : Options.LongPressDuration.Ticks;
+
+ if (distance < Options.TouchSlop && duration < maxTapDuration && !_longPressTriggered)
+ {
+ _lastTapTicks = ticks;
+ _lastTapLocation = location;
+
+ if (_tapCount > 1)
+ {
+ OnDoubleTapDetected(new SKTapGestureEventArgs(location, _tapCount));
+ _tapCount = 0;
+ }
+ else
+ {
+ OnTapDetected(new SKTapGestureEventArgs(location, 1));
+ }
+ handled = true;
+ }
+ else
+ {
+ // Touch ended but failed tap validation (moved too far or held too long).
+ // Reset the counter so the next touch-down is not misidentified as a double-tap.
+ _tapCount = 0;
+ _lastTapTicks = 0;
+ }
+ }
+
+ _flingTracker.RemoveId(id);
+
+ // Transition gesture state
+ if (touchPoints.Length == 0)
+ {
+ if (_gestureState != GestureState.None)
+ {
+ OnGestureEnded(new SKGestureLifecycleEventArgs());
+ _gestureState = GestureState.None;
+ }
+ }
+ else if (touchPoints.Length == 1)
+ {
+ // Transition from pinch to pan
+ if (_gestureState == GestureState.Pinching)
+ {
+ _initialTouch = touchPoints[0];
+ // Clear velocity history so rotation movement doesn't cause a fling
+ _flingTracker.Clear();
+ }
+ _gestureState = GestureState.Panning;
+ _pinchState = new PinchState(touchPoints[0], 0, 0);
+ }
+ else if (touchPoints.Length >= 2)
+ {
+ // Recalculate pinch state for remaining fingers to avoid jumps
+ _pinchState = PinchState.FromLocations(touchPoints);
+ }
+
+ return handled;
+ }
+
+ ///
+ /// Processes a touch cancel event.
+ ///
+ /// The unique identifier for this touch.
+ /// True if the event was handled.
+ public bool ProcessTouchCancel(long id)
+ {
+ if (!IsEnabled || _disposed)
+ return false;
+
+ StopLongPressTimer();
+ _touches.Remove(id);
+ _flingTracker.RemoveId(id);
+
+ var touchPoints = GetActiveTouchPoints();
+ if (touchPoints.Length == 0)
+ {
+ if (_gestureState != GestureState.None)
+ {
+ OnGestureEnded(new SKGestureLifecycleEventArgs());
+ _gestureState = GestureState.None;
+ }
+ }
+ else if (touchPoints.Length == 1)
+ {
+ // Transition from pinch to pan when one finger is cancelled
+ if (_gestureState == GestureState.Pinching)
+ {
+ _initialTouch = touchPoints[0];
+ // Clear velocity history so rotation movement doesn't cause a fling
+ _flingTracker.Clear();
+ }
+ _gestureState = GestureState.Panning;
+ _pinchState = new PinchState(touchPoints[0], 0, 0);
+ }
+ else if (touchPoints.Length >= 2)
+ {
+ // Recalculate pinch state for remaining fingers to avoid jumps
+ _pinchState = PinchState.FromLocations(touchPoints);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Processes a mouse wheel (scroll) event.
+ ///
+ /// The location of the mouse pointer.
+ /// The horizontal scroll delta.
+ /// The vertical scroll delta.
+ /// True if the event was handled.
+ public bool ProcessMouseWheel(SKPoint location, float deltaX, float deltaY)
+ {
+ if (!IsEnabled || _disposed)
+ return false;
+
+ OnScrollDetected(new SKScrollGestureEventArgs(location, new SKPoint(deltaX, deltaY)));
+ return true;
+ }
+
+ ///
+ /// Resets the gesture detector to its initial state, clearing all active touches and
+ /// cancelling any pending timers.
+ ///
+ public void Reset()
+ {
+ StopLongPressTimer();
+ _touches.Clear();
+ _flingTracker.Clear();
+ _gestureState = GestureState.None;
+ _tapCount = 0;
+ _lastTapTicks = 0;
+ _lastTapLocation = SKPoint.Empty;
+ _longPressTriggered = false;
+ }
+
+ ///
+ /// Releases all resources used by this instance.
+ ///
+ ///
+ /// Stops any active long press timer and resets all internal state. After disposal,
+ /// all ProcessTouch* methods return .
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ StopLongPressTimer();
+ Reset();
+ }
+
+ private void StartLongPressTimer()
+ {
+ StopLongPressTimer();
+ var token = Interlocked.Increment(ref _longPressToken);
+ var timer = new Timer(OnLongPressTimerTick, token, (int)Options.LongPressDuration.TotalMilliseconds, Timeout.Infinite);
+ _longPressTimer = timer;
+ }
+
+ private void StopLongPressTimer()
+ {
+ Interlocked.Increment(ref _longPressToken);
+ var timer = _longPressTimer;
+ _longPressTimer = null;
+ timer?.Change(Timeout.Infinite, Timeout.Infinite);
+ timer?.Dispose();
+ }
+
+ private void OnLongPressTimerTick(object? state)
+ {
+ // Verify this callback is for the current timer (not a stale one)
+ if (state is not int token || token != Volatile.Read(ref _longPressToken))
+ return;
+
+ // Marshal to UI thread if we have a sync context
+ var ctx = _syncContext;
+ if (ctx != null)
+ {
+ ctx.Post(_ =>
+ {
+ if (token == Volatile.Read(ref _longPressToken))
+ HandleLongPress();
+ }, null);
+ }
+ else
+ {
+ // No sync context (testing or console app) - run directly
+ HandleLongPress();
+ }
+ }
+
+ private void HandleLongPress()
+ {
+ if (_disposed || !IsEnabled || _longPressTriggered || _gestureState != GestureState.Detecting)
+ return;
+
+ var touchPoints = GetActiveTouchPoints();
+
+ if (touchPoints.Length == 1)
+ {
+ var distance = SKPoint.Distance(touchPoints[0], _initialTouch);
+ if (distance < Options.TouchSlop)
+ {
+ _longPressTriggered = true;
+ StopLongPressTimer();
+ var duration = TimeSpan.FromTicks(TimeProvider() - _touchStartTicks);
+ OnLongPressDetected(new SKLongPressGestureEventArgs(touchPoints[0], duration));
+ }
+ }
+ }
+
+ private SKPoint[] GetActiveTouchPoints()
+ {
+ // Avoid LINQ allocations in this 60Hz hot path.
+ // Sort by touch ID for stable ordering — prevents angle jumps when fingers
+ // are added/removed and Dictionary iteration order changes.
+ var count = 0;
+ foreach (var kv in _touches)
+ {
+ if (kv.Value.InContact)
+ count++;
+ }
+
+ if (count == 0)
+ return Array.Empty();
+
+ var ids = new long[count];
+ var points = new SKPoint[count];
+ var i = 0;
+ foreach (var kv in _touches)
+ {
+ if (kv.Value.InContact)
+ {
+ ids[i] = kv.Key;
+ points[i] = kv.Value.Location;
+ i++;
+ }
+ }
+
+ // Simple insertion sort (typically 1-5 elements)
+ for (i = 1; i < count; i++)
+ {
+ var keyId = ids[i];
+ var keyPt = points[i];
+ var j = i - 1;
+ while (j >= 0 && ids[j] > keyId)
+ {
+ ids[j + 1] = ids[j];
+ points[j + 1] = points[j];
+ j--;
+ }
+ ids[j + 1] = keyId;
+ points[j + 1] = keyPt;
+ }
+
+ return points;
+ }
+
+ private static float NormalizeAngle(float angle)
+ {
+ angle %= 360f;
+ if (angle > 180f)
+ angle -= 360f;
+ if (angle < -180f)
+ angle += 360f;
+ return angle;
+ }
+
+ // Event invokers
+
+ /// Raises the event.
+ /// The event data.
+ private void OnTapDetected(SKTapGestureEventArgs e) => TapDetected?.Invoke(this, e);
+
+ /// Raises the event.
+ /// The event data.
+ private void OnDoubleTapDetected(SKTapGestureEventArgs e) => DoubleTapDetected?.Invoke(this, e);
+
+ /// Raises the event.
+ /// The event data.
+ private void OnLongPressDetected(SKLongPressGestureEventArgs e) => LongPressDetected?.Invoke(this, e);
+
+ /// Raises the event.
+ /// The event data.
+ private void OnPanDetected(SKPanGestureEventArgs e) => PanDetected?.Invoke(this, e);
+
+ /// Raises the event.
+ /// The event data.
+ private void OnPinchDetected(SKPinchGestureEventArgs e) => PinchDetected?.Invoke(this, e);
+
+ /// Raises the event.
+ /// The event data.
+ private void OnRotateDetected(SKRotateGestureEventArgs e) => RotateDetected?.Invoke(this, e);
+
+ /// Raises the event.
+ /// The event data.
+ private void OnFlingDetected(SKFlingGestureEventArgs e) => FlingDetected?.Invoke(this, e);
+
+ /// Raises the event.
+ /// The event data.
+ private void OnHoverDetected(SKHoverGestureEventArgs e) => HoverDetected?.Invoke(this, e);
+
+ /// Raises the event.
+ /// The event data.
+ private void OnScrollDetected(SKScrollGestureEventArgs e) => ScrollDetected?.Invoke(this, e);
+
+ /// Raises the event.
+ /// The event data.
+ private void OnGestureStarted(SKGestureLifecycleEventArgs e) => GestureStarted?.Invoke(this, e);
+
+ /// Raises the event.
+ /// The event data.
+ private void OnGestureEnded(SKGestureLifecycleEventArgs e) => GestureEnded?.Invoke(this, e);
+
+ private enum GestureState
+ {
+ None,
+ Detecting,
+ Panning,
+ Pinching
+ }
+
+ private readonly record struct TouchState(long Id, SKPoint Location, long Ticks, bool InContact, bool IsMouse);
+
+ private readonly record struct PinchState(SKPoint Center, float Radius, float Angle)
+ {
+ public static PinchState FromLocations(SKPoint[] locations)
+ {
+ if (locations == null || locations.Length < 2)
+ return new PinchState(locations?.Length > 0 ? locations[0] : SKPoint.Empty, 0, 0);
+
+ var centerX = 0f;
+ var centerY = 0f;
+ foreach (var loc in locations)
+ {
+ centerX += loc.X;
+ centerY += loc.Y;
+ }
+ centerX /= locations.Length;
+ centerY /= locations.Length;
+
+ var center = new SKPoint(centerX, centerY);
+ var radius = 0f;
+ foreach (var loc in locations)
+ radius += SKPoint.Distance(center, loc);
+ radius /= locations.Length;
+ var angle = (float)(Math.Atan2(locations[1].Y - locations[0].Y, locations[1].X - locations[0].X) * 180 / Math.PI);
+
+ return new PinchState(center, radius, angle);
+ }
+ }
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKGestureDetectorOptions.cs b/source/SkiaSharp.Extended/Gestures/SKGestureDetectorOptions.cs
new file mode 100644
index 0000000000..382388adfb
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKGestureDetectorOptions.cs
@@ -0,0 +1,89 @@
+using System;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Configuration options for the gesture recognition engine.
+///
+///
+/// These options control the thresholds and timing used to classify touch input into discrete
+/// gesture types. Adjust these values to fine-tune gesture sensitivity for your application.
+///
+///
+///
+public class SKGestureDetectorOptions
+{
+ private float _touchSlop = 8f;
+ private float _doubleTapSlop = 40f;
+ private float _flingThreshold = 200f;
+ private TimeSpan _longPressDuration = TimeSpan.FromMilliseconds(500);
+
+ ///
+ /// Gets or sets the minimum movement distance, in pixels, before a touch is considered a pan gesture.
+ ///
+ /// The touch slop distance in pixels. The default is 8 .
+ /// is negative.
+ ///
+ /// Touches that move less than this distance are classified as taps or long presses rather than pans.
+ ///
+ public float TouchSlop
+ {
+ get => _touchSlop;
+ set
+ {
+ if (value < 0)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "TouchSlop must not be negative.");
+ _touchSlop = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the maximum distance, in pixels, between two taps for them to be recognized
+ /// as a double-tap gesture.
+ ///
+ /// The double-tap slop distance in pixels. The default is 40 .
+ /// is negative.
+ public float DoubleTapSlop
+ {
+ get => _doubleTapSlop;
+ set
+ {
+ if (value < 0)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "DoubleTapSlop must not be negative.");
+ _doubleTapSlop = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the minimum velocity, in pixels per second, required for a pan gesture
+ /// to be classified as a fling upon touch release.
+ ///
+ /// The fling velocity threshold in pixels per second. The default is 200 .
+ /// is negative.
+ public float FlingThreshold
+ {
+ get => _flingThreshold;
+ set
+ {
+ if (value < 0)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "FlingThreshold must not be negative.");
+ _flingThreshold = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the duration a touch must be held stationary before a long press gesture is recognized.
+ ///
+ /// The long press duration. The default is 500 ms . Must be positive.
+ /// is zero or negative.
+ public TimeSpan LongPressDuration
+ {
+ get => _longPressDuration;
+ set
+ {
+ if (value <= TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "LongPressDuration must be positive.");
+ _longPressDuration = value;
+ }
+ }
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKGestureLifecycleEventArgs.cs b/source/SkiaSharp.Extended/Gestures/SKGestureLifecycleEventArgs.cs
new file mode 100644
index 0000000000..6283e7c48b
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKGestureLifecycleEventArgs.cs
@@ -0,0 +1,22 @@
+using System;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Provides data for gesture lifecycle events that indicate when a gesture interaction begins or ends.
+///
+///
+/// This class is used with the and
+/// events, as well as the corresponding events on
+/// .
+/// A gesture starts when the first touch contact occurs and ends when all touches are released.
+/// These events are useful for managing UI state such as cancelling inertia animations when a new
+/// gesture begins, or triggering a redraw when a gesture ends.
+///
+///
+///
+///
+///
+public class SKGestureLifecycleEventArgs : EventArgs
+{
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKGestureTracker.cs b/source/SkiaSharp.Extended/Gestures/SKGestureTracker.cs
new file mode 100644
index 0000000000..36ebefd219
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKGestureTracker.cs
@@ -0,0 +1,948 @@
+using System;
+using System.Threading;
+
+namespace SkiaSharp.Extended;
+
+///
+/// A high-level gesture handler that tracks touch input and maintains an absolute transform
+/// (scale, rotation, and offset) by consuming events from an internal .
+///
+///
+/// The tracker is the primary public API for gesture handling. It accepts raw touch input
+/// via , , ,
+/// and , detects gestures internally, and translates them into
+/// transform state changes and higher-level events such as drag lifecycle and fling animation.
+/// Use the property to apply the current transform when painting.
+/// The matrix uses (0,0) as its origin — no view size configuration is required.
+/// All coordinates are in view (screen) space. The tracker converts screen-space deltas
+/// to content-space deltas internally when updating .
+///
+/// Basic usage with an SkiaSharp canvas:
+///
+/// var tracker = new SKGestureTracker();
+///
+/// // Forward touch events from your platform:
+/// tracker.ProcessTouchDown(id, new SKPoint(x, y));
+/// tracker.ProcessTouchMove(id, new SKPoint(x, y));
+/// tracker.ProcessTouchUp(id, new SKPoint(x, y));
+///
+/// // In your paint handler:
+/// canvas.Save();
+/// canvas.Concat(tracker.Matrix);
+/// // Draw your content...
+/// canvas.Restore();
+///
+/// // Listen for transform changes to trigger redraws:
+/// tracker.TransformChanged += (s, e) => canvasView.InvalidateSurface();
+///
+///
+///
+///
+///
+public sealed class SKGestureTracker : IDisposable
+{
+ private readonly SKGestureDetector _engine;
+ private SynchronizationContext? _syncContext;
+ private bool _disposed;
+
+ // Transform state
+ private float _scale = 1f;
+ private float _rotation;
+ private SKPoint _offset = SKPoint.Empty;
+
+ // Drag lifecycle state
+ private bool _isDragging;
+ private bool _isDragHandled;
+ private SKPoint _lastPanLocation;
+ private SKPoint _prevPanLocation;
+
+ // Fling animation state
+ private Timer? _flingTimer;
+ private int _flingToken;
+ private float _flingVelocityX;
+ private float _flingVelocityY;
+ private bool _isFlinging;
+ private long _flingLastFrameTimestamp; // TimeProvider() ticks at last fling frame
+
+ // Zoom animation state
+ private Timer? _zoomTimer;
+ private int _zoomToken;
+ private bool _isZoomAnimating;
+ private float _zoomStartScale;
+ private float _zoomTargetFactor;
+ private SKPoint _zoomFocalPoint;
+ private long _zoomStartTicks;
+ private SKPoint? _gesturePivotOverride;
+
+ ///
+ /// Initializes a new instance of the class with default options.
+ ///
+ public SKGestureTracker()
+ : this(new SKGestureTrackerOptions())
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with the specified options.
+ ///
+ /// The configuration options for gesture detection and tracking.
+ /// is .
+ public SKGestureTracker(SKGestureTrackerOptions options)
+ {
+ Options = options ?? throw new ArgumentNullException(nameof(options));
+ _engine = new SKGestureDetector(options);
+ SubscribeEngineEvents();
+ }
+
+ ///
+ /// Gets the configuration options for this tracker.
+ ///
+ /// The instance controlling gesture detection
+ /// thresholds, transform limits, and animation parameters.
+ public SKGestureTrackerOptions Options { get; }
+
+ #region Touch Input
+
+ ///
+ /// Processes a touch down event and forwards it to the internal gesture detector.
+ ///
+ /// The unique identifier for this touch pointer.
+ /// The location of the touch in view coordinates.
+ /// Whether this event originates from a mouse device.
+ /// if the event was processed; otherwise, .
+ public bool ProcessTouchDown(long id, SKPoint location, bool isMouse = false)
+ => _engine.ProcessTouchDown(id, location, isMouse);
+
+ ///
+ /// Processes a touch move event and forwards it to the internal gesture detector.
+ ///
+ /// The unique identifier for this touch pointer.
+ /// The new location of the touch in view coordinates.
+ ///
+ /// if the touch is in contact with the surface;
+ /// for hover (mouse move without button pressed).
+ ///
+ /// if the event was processed; otherwise, .
+ public bool ProcessTouchMove(long id, SKPoint location, bool inContact = true)
+ => _engine.ProcessTouchMove(id, location, inContact);
+
+ ///
+ /// Processes a touch up event and forwards it to the internal gesture detector.
+ ///
+ /// The unique identifier for this touch pointer.
+ /// The final location of the touch in view coordinates.
+ /// Whether this event originates from a mouse device.
+ /// if the event was processed; otherwise, .
+ public bool ProcessTouchUp(long id, SKPoint location, bool isMouse = false)
+ => _engine.ProcessTouchUp(id, location, isMouse);
+
+ ///
+ /// Processes a touch cancel event and forwards it to the internal gesture detector.
+ ///
+ /// The unique identifier for the cancelled touch pointer.
+ /// if the event was processed; otherwise, .
+ public bool ProcessTouchCancel(long id)
+ => _engine.ProcessTouchCancel(id);
+
+ ///
+ /// Processes a mouse wheel (scroll) event and forwards it to the internal gesture detector.
+ ///
+ /// The position of the mouse cursor in view coordinates.
+ /// The horizontal scroll delta.
+ /// The vertical scroll delta.
+ /// if the event was processed; otherwise, .
+ public bool ProcessMouseWheel(SKPoint location, float deltaX, float deltaY)
+ => _engine.ProcessMouseWheel(location, deltaX, deltaY);
+
+ #endregion
+
+ #region Detection Config (forwarded to engine)
+
+ ///
+ /// Gets or sets a value indicating whether gesture detection is enabled.
+ ///
+ ///
+ /// if the tracker processes touch events; otherwise, .
+ /// The default is .
+ ///
+ public bool IsEnabled
+ {
+ get => _engine.IsEnabled;
+ set => _engine.IsEnabled = value;
+ }
+
+ ///
+ /// Gets or sets the time provider function used to obtain the current time in ticks.
+ ///
+ ///
+ /// A that returns the current time in .
+ /// Override this for deterministic testing.
+ ///
+ public Func TimeProvider
+ {
+ get => _engine.TimeProvider;
+ set => _engine.TimeProvider = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ #endregion
+
+ #region Transform State (read-only)
+
+ ///
+ /// Gets the current zoom scale factor.
+ ///
+ ///
+ /// The absolute scale factor. A value of 1.0 represents the original (unscaled) view.
+ /// Values greater than 1.0 are zoomed in; values less than 1.0 are zoomed out.
+ /// The value is clamped between and
+ /// .
+ ///
+ public float Scale => _scale;
+
+ ///
+ /// Gets the current rotation angle in degrees.
+ ///
+ /// The cumulative rotation in degrees. Positive values are clockwise.
+ public float Rotation => _rotation;
+
+ ///
+ /// Gets the current pan offset in content coordinates.
+ ///
+ /// An representing the translation offset applied after scale and rotation.
+ ///
+ /// The offset is in content (post-scale, post-rotation) coordinate space, not screen space.
+ /// Screen-space deltas are converted to content-space internally by accounting for the
+ /// current and .
+ ///
+ public SKPoint Offset => _offset;
+
+ ///
+ /// Gets the composite transform matrix that combines scale, rotation, and offset,
+ /// using (0,0) as the transform origin.
+ ///
+ ///
+ /// An that can be applied to an to render content
+ /// with the current gesture transform. The matrix applies transformations in the order:
+ /// scale, rotate, translate.
+ ///
+ public SKMatrix Matrix
+ {
+ get
+ {
+ var m = SKMatrix.CreateScale(_scale, _scale);
+ m = m.PreConcat(SKMatrix.CreateRotationDegrees(_rotation));
+ m = m.PreConcat(SKMatrix.CreateTranslation(_offset.X, _offset.Y));
+ return m;
+ }
+ }
+
+ #endregion
+
+ #region Feature Toggles
+
+ /// Gets or sets a value indicating whether tap detection is enabled.
+ /// to detect single taps; otherwise, . The default is .
+ public bool IsTapEnabled { get => Options.IsTapEnabled; set => Options.IsTapEnabled = value; }
+
+ /// Gets or sets a value indicating whether double-tap detection is enabled.
+ /// to detect double taps; otherwise, . The default is .
+ public bool IsDoubleTapEnabled { get => Options.IsDoubleTapEnabled; set => Options.IsDoubleTapEnabled = value; }
+
+ /// Gets or sets a value indicating whether long press detection is enabled.
+ /// to detect long presses; otherwise, . The default is .
+ public bool IsLongPressEnabled { get => Options.IsLongPressEnabled; set => Options.IsLongPressEnabled = value; }
+
+ /// Gets or sets a value indicating whether pan gestures update the .
+ /// to apply pan deltas to the offset; otherwise, . The default is .
+ public bool IsPanEnabled { get => Options.IsPanEnabled; set => Options.IsPanEnabled = value; }
+
+ /// Gets or sets a value indicating whether pinch-to-zoom gestures update the .
+ /// to apply pinch scale changes; otherwise, . The default is .
+ public bool IsPinchEnabled { get => Options.IsPinchEnabled; set => Options.IsPinchEnabled = value; }
+
+ /// Gets or sets a value indicating whether rotation gestures update the .
+ /// to apply rotation changes; otherwise, . The default is .
+ public bool IsRotateEnabled { get => Options.IsRotateEnabled; set => Options.IsRotateEnabled = value; }
+
+ /// Gets or sets a value indicating whether fling (inertia) animation is enabled after a pan gesture.
+ /// to run fling animations; otherwise, . The default is .
+ public bool IsFlingEnabled { get => Options.IsFlingEnabled; set => Options.IsFlingEnabled = value; }
+
+ /// Gets or sets a value indicating whether double-tap triggers an animated zoom.
+ /// to enable double-tap zoom; otherwise, . The default is .
+ public bool IsDoubleTapZoomEnabled { get => Options.IsDoubleTapZoomEnabled; set => Options.IsDoubleTapZoomEnabled = value; }
+
+ /// Gets or sets a value indicating whether scroll-wheel events trigger zoom.
+ /// to enable scroll-wheel zoom; otherwise, . The default is .
+ public bool IsScrollZoomEnabled { get => Options.IsScrollZoomEnabled; set => Options.IsScrollZoomEnabled = value; }
+
+ /// Gets or sets a value indicating whether hover (mouse move without contact) detection is enabled.
+ /// to detect hover events; otherwise, . The default is .
+ public bool IsHoverEnabled { get => Options.IsHoverEnabled; set => Options.IsHoverEnabled = value; }
+
+ #endregion
+
+ #region Animation State
+
+ /// Gets a value indicating whether an animated zoom (from double-tap or ) is in progress.
+ /// if a zoom animation is running; otherwise, .
+ public bool IsZoomAnimating => _isZoomAnimating;
+
+ /// Gets a value indicating whether a fling (inertia) animation is in progress.
+ /// if a fling animation is running; otherwise, .
+ public bool IsFlinging => _isFlinging;
+
+ /// Gets a value indicating whether any gesture is currently active (touch contact in progress).
+ /// if the internal detector is tracking an active gesture; otherwise, .
+ public bool IsGestureActive => _engine.IsGestureActive;
+
+ #endregion
+
+ #region Gesture Events (forwarded from engine)
+
+ /// Occurs when a single tap is detected. Forwarded from the internal .
+ public event EventHandler? TapDetected;
+
+ /// Occurs when a double tap is detected. Forwarded from the internal .
+ public event EventHandler? DoubleTapDetected;
+
+ /// Occurs when a long press is detected. Forwarded from the internal .
+ public event EventHandler? LongPressDetected;
+
+ /// Occurs when a pan gesture is detected. Forwarded from the internal .
+ public event EventHandler? PanDetected;
+
+ /// Occurs when a pinch (scale) gesture is detected. Forwarded from the internal .
+ public event EventHandler? PinchDetected;
+
+ /// Occurs when a rotation gesture is detected. Forwarded from the internal .
+ public event EventHandler? RotateDetected;
+
+ /// Occurs when a fling gesture is detected (once, with initial velocity). Forwarded from the internal .
+ public event EventHandler? FlingDetected;
+
+ /// Occurs when a hover is detected. Forwarded from the internal .
+ public event EventHandler? HoverDetected;
+
+ /// Occurs when a scroll (mouse wheel) event is detected. Forwarded from the internal .
+ public event EventHandler? ScrollDetected;
+
+ /// Occurs when a gesture interaction begins (first touch contact). Forwarded from the internal .
+ public event EventHandler? GestureStarted;
+
+ /// Occurs when a gesture interaction ends (last touch released). Forwarded from the internal .
+ public event EventHandler? GestureEnded;
+
+ #endregion
+
+ #region Tracker Events
+
+ ///
+ /// Occurs when the transform state ( , ,
+ /// , or ) changes.
+ ///
+ ///
+ /// Subscribe to this event to trigger canvas redraws when the user interacts with the view.
+ /// This event fires for pan, pinch, rotation, fling animation frames, zoom animation frames,
+ /// and programmatic transform changes via , ,
+ /// , , or .
+ ///
+ public event EventHandler? TransformChanged;
+
+ ///
+ /// Occurs when a drag operation starts (first pan movement after touch down).
+ ///
+ ///
+ /// Set to to prevent the
+ /// tracker from updating for this drag (useful for custom object dragging).
+ ///
+ public event EventHandler? DragStarted;
+
+ ///
+ /// Occurs on each movement during a drag operation.
+ ///
+ public event EventHandler? DragUpdated;
+
+ ///
+ /// Occurs when a drag operation ends (all touches released).
+ ///
+ public event EventHandler? DragEnded;
+
+ ///
+ /// Occurs each animation frame during a fling deceleration.
+ ///
+ ///
+ /// The
+ /// property contains the per-frame displacement. The velocity decays each frame according to
+ /// .
+ ///
+ public event EventHandler? FlingUpdated;
+
+ ///
+ /// Occurs when a fling animation completes (velocity drops below
+ /// ).
+ ///
+ public event EventHandler? FlingCompleted;
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ /// Sets the transform to the specified values, clamping scale to
+ /// / .
+ ///
+ /// The desired zoom scale factor.
+ /// The desired rotation angle in degrees.
+ /// The desired pan offset in content coordinates.
+ public void SetTransform(float scale, float rotation, SKPoint offset)
+ {
+ _scale = Clamp(scale, Options.MinScale, Options.MaxScale);
+ _rotation = rotation;
+ _offset = offset;
+ TransformChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Sets the zoom scale, clamping to / ,
+ /// and raises .
+ ///
+ /// The desired zoom scale factor.
+ /// Optional pivot point in view coordinates. When provided, the offset is
+ /// adjusted so the pivot point remains stationary after the scale change.
+ public void SetScale(float scale, SKPoint? pivot = null)
+ {
+ var newScale = Clamp(scale, Options.MinScale, Options.MaxScale);
+ if (pivot.HasValue)
+ AdjustOffsetForPivot(pivot.Value, _scale, newScale, _rotation, _rotation);
+ _scale = newScale;
+ TransformChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Sets the rotation angle and raises .
+ ///
+ /// The desired rotation angle in degrees.
+ /// Optional pivot point in view coordinates. When provided, the offset is
+ /// adjusted so the pivot point remains stationary after the rotation change.
+ public void SetRotation(float rotation, SKPoint? pivot = null)
+ {
+ if (pivot.HasValue)
+ AdjustOffsetForPivot(pivot.Value, _scale, _scale, _rotation, rotation);
+ _rotation = rotation;
+ TransformChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Sets the pan offset and raises .
+ ///
+ /// The desired pan offset in content coordinates.
+ public void SetOffset(SKPoint offset)
+ {
+ _offset = offset;
+ TransformChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Starts an animated zoom by the given multiplicative factor around a focal point.
+ ///
+ /// The scale multiplier to apply (e.g., 2.0 to double the current zoom).
+ /// The point in view coordinates to zoom towards.
+ ///
+ /// The animation uses a cubic-out easing curve and runs for
+ /// milliseconds. Any previously
+ /// running zoom animation is stopped before the new one begins.
+ ///
+ public void ZoomTo(float factor, SKPoint focalPoint)
+ {
+ if (_disposed) throw new ObjectDisposedException(GetType().FullName);
+
+ if (factor <= 0 || float.IsNaN(factor) || float.IsInfinity(factor))
+ throw new ArgumentOutOfRangeException(nameof(factor), factor, "Factor must be a positive finite number.");
+
+ StopZoomAnimation();
+ _syncContext ??= SynchronizationContext.Current;
+
+ _zoomStartScale = _scale;
+ _zoomTargetFactor = factor;
+ _zoomFocalPoint = focalPoint;
+ _zoomStartTicks = TimeProvider();
+ _isZoomAnimating = true;
+
+ var token = Interlocked.Increment(ref _zoomToken);
+ _zoomTimer = new Timer(
+ OnZoomTimerTick,
+ token,
+ (int)Options.ZoomAnimationInterval.TotalMilliseconds,
+ (int)Options.ZoomAnimationInterval.TotalMilliseconds);
+ }
+
+ /// Stops any active zoom animation immediately.
+ public void StopZoomAnimation()
+ {
+ if (!_isZoomAnimating)
+ return;
+
+ _isZoomAnimating = false;
+ Interlocked.Increment(ref _zoomToken);
+ var timer = _zoomTimer;
+ _zoomTimer = null;
+ timer?.Change(Timeout.Infinite, Timeout.Infinite);
+ timer?.Dispose();
+ }
+
+ ///
+ /// Stops any active fling animation and raises .
+ ///
+ public void StopFling()
+ {
+ if (!_isFlinging)
+ return;
+
+ CancelFlingInternal();
+ FlingCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ /// Cancels any active fling animation without raising .
+ private void CancelFlingInternal()
+ {
+ if (!_isFlinging)
+ return;
+
+ _isFlinging = false;
+ _flingVelocityX = 0;
+ _flingVelocityY = 0;
+ Interlocked.Increment(ref _flingToken);
+ var timer = _flingTimer;
+ _flingTimer = null;
+ timer?.Change(Timeout.Infinite, Timeout.Infinite);
+ timer?.Dispose();
+ }
+
+ ///
+ /// Resets the tracker to an identity transform (scale 1, rotation 0, offset zero), stops all
+ /// animations, and raises .
+ ///
+ public void Reset()
+ {
+ StopFling();
+ StopZoomAnimation();
+ _scale = 1f;
+ _rotation = 0f;
+ _offset = SKPoint.Empty;
+ _isDragging = false;
+ _engine.Reset();
+ TransformChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Releases all resources used by this instance, including
+ /// stopping all animations and disposing the internal .
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ StopFling();
+ StopZoomAnimation();
+ UnsubscribeEngineEvents();
+ _engine.Dispose();
+ }
+
+ #endregion
+
+ #region Engine Event Subscriptions
+
+ private void SubscribeEngineEvents()
+ {
+ _engine.TapDetected += OnEngineTapDetected;
+ _engine.DoubleTapDetected += OnEngineDoubleTapDetected;
+ _engine.LongPressDetected += OnEngineLongPressDetected;
+ _engine.PanDetected += OnEnginePanDetected;
+ _engine.PinchDetected += OnEnginePinchDetected;
+ _engine.RotateDetected += OnEngineRotateDetected;
+ _engine.FlingDetected += OnEngineFlingDetected;
+ _engine.HoverDetected += OnEngineHoverDetected;
+ _engine.ScrollDetected += OnEngineScrollDetected;
+ _engine.GestureStarted += OnEngineGestureStarted;
+ _engine.GestureEnded += OnEngineGestureEnded;
+ }
+
+ private void UnsubscribeEngineEvents()
+ {
+ _engine.TapDetected -= OnEngineTapDetected;
+ _engine.DoubleTapDetected -= OnEngineDoubleTapDetected;
+ _engine.LongPressDetected -= OnEngineLongPressDetected;
+ _engine.PanDetected -= OnEnginePanDetected;
+ _engine.PinchDetected -= OnEnginePinchDetected;
+ _engine.RotateDetected -= OnEngineRotateDetected;
+ _engine.FlingDetected -= OnEngineFlingDetected;
+ _engine.HoverDetected -= OnEngineHoverDetected;
+ _engine.ScrollDetected -= OnEngineScrollDetected;
+ _engine.GestureStarted -= OnEngineGestureStarted;
+ _engine.GestureEnded -= OnEngineGestureEnded;
+ }
+
+ #endregion
+
+ #region Engine Event Handlers
+
+ private void OnEngineTapDetected(object? s, SKTapGestureEventArgs e)
+ {
+ if (!IsTapEnabled)
+ return;
+ TapDetected?.Invoke(this, e);
+ }
+
+ private void OnEngineDoubleTapDetected(object? s, SKTapGestureEventArgs e)
+ {
+ if (!IsDoubleTapEnabled)
+ return;
+
+ DoubleTapDetected?.Invoke(this, e);
+
+ // If the consumer handled the event (e.g. sticker selection), skip zoom
+ if (e.Handled)
+ return;
+
+ if (!IsDoubleTapZoomEnabled)
+ return;
+
+ if (_scale >= Options.MaxScale - 0.01f)
+ {
+ // At max zoom — animate reset to 1.0
+ ZoomTo(1f / _scale, e.Location);
+ }
+ else
+ {
+ var factor = Math.Min(Options.DoubleTapZoomFactor, Options.MaxScale / _scale);
+ ZoomTo(factor, e.Location);
+ }
+ }
+
+ private void OnEngineLongPressDetected(object? s, SKLongPressGestureEventArgs e)
+ {
+ if (!IsLongPressEnabled)
+ return;
+ LongPressDetected?.Invoke(this, e);
+ }
+
+ private void OnEnginePanDetected(object? s, SKPanGestureEventArgs e)
+ {
+ if (IsPanEnabled)
+ PanDetected?.Invoke(this, e);
+
+ // Track last pan position for DragEnded
+ _prevPanLocation = _lastPanLocation;
+ _lastPanLocation = e.Location;
+
+ // Derive drag lifecycle
+ SKDragGestureEventArgs? dragArgs = null;
+ if (!_isDragging)
+ {
+ _isDragging = true;
+ _isDragHandled = false;
+ _lastPanLocation = e.Location;
+ dragArgs = new SKDragGestureEventArgs(e.Location, e.PreviousLocation);
+ DragStarted?.Invoke(this, dragArgs);
+ }
+ else
+ {
+ dragArgs = new SKDragGestureEventArgs(e.Location, e.PreviousLocation);
+ DragUpdated?.Invoke(this, dragArgs);
+ }
+
+ // Track whether the consumer is handling this drag (e.g. sticker drag)
+ if (dragArgs?.Handled ?? false)
+ _isDragHandled = true;
+
+ // Skip offset update if consumer handled the pan or drag (e.g. sticker drag)
+ if (!IsPanEnabled || e.Handled || _isDragHandled)
+ return;
+
+ // Update offset
+ var d = ScreenToContentDelta(e.Delta.X, e.Delta.Y);
+ _offset = new SKPoint(_offset.X + d.X, _offset.Y + d.Y);
+ TransformChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnEnginePinchDetected(object? s, SKPinchGestureEventArgs e)
+ {
+ if (IsPinchEnabled)
+ PinchDetected?.Invoke(this, e);
+
+ // Apply center movement as pan
+ if (IsPanEnabled)
+ {
+ var panDelta = ScreenToContentDelta(
+ e.FocalPoint.X - e.PreviousFocalPoint.X,
+ e.FocalPoint.Y - e.PreviousFocalPoint.Y);
+ _offset = new SKPoint(_offset.X + panDelta.X, _offset.Y + panDelta.Y);
+ }
+
+ if (IsPinchEnabled)
+ {
+ var pivot = GetEffectiveGesturePivot(e.FocalPoint);
+ var newScale = Clamp(_scale * e.ScaleDelta, Options.MinScale, Options.MaxScale);
+ AdjustOffsetForPivot(pivot, _scale, newScale, _rotation, _rotation);
+ _scale = newScale;
+ }
+
+ // TransformChanged is deferred to OnEngineRotateDetected, which always fires
+ // immediately after this handler for the same gesture frame, to avoid two
+ // notifications per two-finger move frame.
+ }
+
+ private void OnEngineRotateDetected(object? s, SKRotateGestureEventArgs e)
+ {
+ if (IsRotateEnabled)
+ {
+ RotateDetected?.Invoke(this, e);
+
+ var pivot = GetEffectiveGesturePivot(e.FocalPoint);
+ var newRotation = _rotation + e.RotationDelta;
+ AdjustOffsetForPivot(pivot, _scale, _scale, _rotation, newRotation);
+ _rotation = newRotation;
+ }
+
+ // Fire TransformChanged once per two-finger frame (batched with pinch changes above)
+ if (IsPinchEnabled || IsRotateEnabled || IsPanEnabled)
+ TransformChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnEngineFlingDetected(object? s, SKFlingGestureEventArgs e)
+ {
+ if (!IsFlingEnabled || _isDragHandled)
+ return;
+
+ FlingDetected?.Invoke(this, e);
+ StartFlingAnimation(e.Velocity.X, e.Velocity.Y);
+ }
+
+ private void OnEngineHoverDetected(object? s, SKHoverGestureEventArgs e)
+ {
+ if (!IsHoverEnabled)
+ return;
+ HoverDetected?.Invoke(this, e);
+ }
+
+ private void OnEngineScrollDetected(object? s, SKScrollGestureEventArgs e)
+ {
+ ScrollDetected?.Invoke(this, e);
+
+ if (!IsScrollZoomEnabled || e.Delta.Y == 0)
+ return;
+
+ var scaleDelta = Math.Max(0.01f, 1f + e.Delta.Y * Options.ScrollZoomFactor);
+ var newScale = Clamp(_scale * scaleDelta, Options.MinScale, Options.MaxScale);
+ AdjustOffsetForPivot(e.Location, _scale, newScale, _rotation, _rotation);
+ _scale = newScale;
+ TransformChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnEngineGestureStarted(object? s, SKGestureLifecycleEventArgs e)
+ {
+ _syncContext ??= SynchronizationContext.Current;
+ CancelFlingInternal(); // Don't fire FlingCompleted — fling was interrupted by new touch
+ StopZoomAnimation();
+ GestureStarted?.Invoke(this, new SKGestureLifecycleEventArgs());
+ }
+
+ private void OnEngineGestureEnded(object? s, SKGestureLifecycleEventArgs e)
+ {
+ _gesturePivotOverride = null;
+
+ if (_isDragging)
+ {
+ _isDragging = false;
+ _isDragHandled = false;
+ DragEnded?.Invoke(this, new SKDragGestureEventArgs(_lastPanLocation, _prevPanLocation));
+ }
+ GestureEnded?.Invoke(this, new SKGestureLifecycleEventArgs());
+ }
+
+ #endregion
+
+ #region Transform Helpers
+
+ private SKPoint ScreenToContentDelta(float dx, float dy)
+ {
+ var inv = SKMatrix.CreateRotationDegrees(-_rotation);
+ var mapped = inv.MapVector(dx, dy);
+ return new SKPoint(mapped.X / _scale, mapped.Y / _scale);
+ }
+
+ private SKPoint GetEffectiveGesturePivot(SKPoint focalPoint)
+ {
+ if (!IsPanEnabled)
+ {
+ _gesturePivotOverride ??= focalPoint;
+ return _gesturePivotOverride.Value;
+ }
+
+ _gesturePivotOverride = null;
+ return focalPoint;
+ }
+
+ private void AdjustOffsetForPivot(SKPoint pivot, float oldScale, float newScale, float oldRotDeg, float newRotDeg)
+ {
+ // Matrix model: P_screen = S * R * (P_content + offset)
+ // To keep the same content point at screen position 'pivot':
+ // new_offset = R(-newRot).MapVector(pivot / newScale)
+ // - R(-oldRot).MapVector(pivot / oldScale)
+ // + old_offset
+ var rotOld = SKMatrix.CreateRotationDegrees(-oldRotDeg);
+ var rotNew = SKMatrix.CreateRotationDegrees(-newRotDeg);
+ var oldMapped = rotOld.MapVector(pivot.X / oldScale, pivot.Y / oldScale);
+ var newMapped = rotNew.MapVector(pivot.X / newScale, pivot.Y / newScale);
+ _offset = new SKPoint(newMapped.X - oldMapped.X + _offset.X, newMapped.Y - oldMapped.Y + _offset.Y);
+ }
+
+ private static float Clamp(float value, float min, float max)
+ => float.IsNaN(value) ? min : value < min ? min : value > max ? max : value;
+
+ #endregion
+
+ #region Fling Animation
+
+ private void StartFlingAnimation(float velocityX, float velocityY)
+ {
+ StopFling();
+ _syncContext ??= SynchronizationContext.Current;
+
+ _flingVelocityX = velocityX;
+ _flingVelocityY = velocityY;
+ _isFlinging = true;
+ _flingLastFrameTimestamp = TimeProvider();
+
+ var token = Interlocked.Increment(ref _flingToken);
+ _flingTimer = new Timer(
+ OnFlingTimerTick,
+ token,
+ (int)Options.FlingFrameInterval.TotalMilliseconds,
+ (int)Options.FlingFrameInterval.TotalMilliseconds);
+ }
+
+ private void OnFlingTimerTick(object? state)
+ {
+ if (state is not int token || token != Volatile.Read(ref _flingToken))
+ return;
+
+ if (!_isFlinging || _disposed)
+ return;
+
+ var ctx = _syncContext;
+ if (ctx != null)
+ {
+ ctx.Post(_ =>
+ {
+ if (token == Volatile.Read(ref _flingToken))
+ HandleFlingFrame();
+ }, null);
+ }
+ else
+ {
+ HandleFlingFrame();
+ }
+ }
+
+ private void HandleFlingFrame()
+ {
+ if (!_isFlinging || _disposed)
+ return;
+
+ // Use actual elapsed time for frame-rate-independent deceleration
+ var now = TimeProvider();
+ var actualDtMs = Math.Max(1f, (float)((now - _flingLastFrameTimestamp) / (double)TimeSpan.TicksPerMillisecond));
+ _flingLastFrameTimestamp = now;
+
+ var dt = actualDtMs / 1000f;
+ var deltaX = _flingVelocityX * dt;
+ var deltaY = _flingVelocityY * dt;
+
+ FlingUpdated?.Invoke(this, new SKFlingGestureEventArgs(new SKPoint(_flingVelocityX, _flingVelocityY), new SKPoint(deltaX, deltaY)));
+
+ // Apply as pan offset
+ var d = ScreenToContentDelta(deltaX, deltaY);
+ _offset = new SKPoint(_offset.X + d.X, _offset.Y + d.Y);
+ TransformChanged?.Invoke(this, EventArgs.Empty);
+
+ // Apply time-scaled friction so deceleration is consistent regardless of frame rate
+ var nominalDtMs = (float)Options.FlingFrameInterval.TotalMilliseconds;
+ var decay = nominalDtMs > 0
+ ? (float)Math.Pow(1.0 - Options.FlingFriction, actualDtMs / nominalDtMs)
+ : 1f - Options.FlingFriction;
+ _flingVelocityX *= decay;
+ _flingVelocityY *= decay;
+
+ var speed = (float)Math.Sqrt(_flingVelocityX * _flingVelocityX + _flingVelocityY * _flingVelocityY);
+ if (speed < Options.FlingMinVelocity)
+ {
+ StopFling();
+ }
+ }
+
+ #endregion
+
+ #region Zoom Animation
+
+ private void OnZoomTimerTick(object? state)
+ {
+ if (state is not int token || token != Volatile.Read(ref _zoomToken))
+ return;
+
+ if (!_isZoomAnimating || _disposed)
+ return;
+
+ var ctx = _syncContext;
+ if (ctx != null)
+ {
+ ctx.Post(_ =>
+ {
+ if (token == Volatile.Read(ref _zoomToken))
+ HandleZoomFrame();
+ }, null);
+ }
+ else
+ {
+ HandleZoomFrame();
+ }
+ }
+
+ private void HandleZoomFrame()
+ {
+ if (!_isZoomAnimating || _disposed)
+ return;
+
+ var elapsed = TimeProvider() - _zoomStartTicks;
+ var duration = Options.ZoomAnimationDuration.Ticks;
+ var t = duration > 0 ? Math.Min(1.0, (double)elapsed / duration) : 1.0;
+
+ // CubicOut easing: 1 - (1 - t)^3
+ var eased = 1.0 - Math.Pow(1.0 - t, 3);
+
+ // Log-space interpolation: cumulative = factor^eased(t)
+ var cumulative = (float)Math.Pow(_zoomTargetFactor, eased);
+
+ // Apply scale change
+ var oldScale = _scale;
+ var newScale = Clamp(_zoomStartScale * cumulative, Options.MinScale, Options.MaxScale);
+ AdjustOffsetForPivot(_zoomFocalPoint, oldScale, newScale, _rotation, _rotation);
+ _scale = newScale;
+ TransformChanged?.Invoke(this, EventArgs.Empty);
+
+ if (t >= 1.0)
+ {
+ _isZoomAnimating = false;
+ Interlocked.Increment(ref _zoomToken);
+ var timer = _zoomTimer;
+ _zoomTimer = null;
+ timer?.Change(Timeout.Infinite, Timeout.Infinite);
+ timer?.Dispose();
+ }
+ }
+
+ #endregion
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKGestureTrackerOptions.cs b/source/SkiaSharp.Extended/Gestures/SKGestureTrackerOptions.cs
new file mode 100644
index 0000000000..79cd60245b
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKGestureTrackerOptions.cs
@@ -0,0 +1,253 @@
+using System;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Configuration options for . Inherits gesture detection thresholds
+/// from and adds tracker-specific settings for transform
+/// limits, animation, and feature toggles.
+///
+///
+///
+///
+///
+public class SKGestureTrackerOptions : SKGestureDetectorOptions
+{
+ private float _minScale = 0.1f;
+ private float _maxScale = 10f;
+ private float _doubleTapZoomFactor = 2f;
+ private TimeSpan _zoomAnimationDuration = TimeSpan.FromMilliseconds(250);
+ private float _scrollZoomFactor = 0.1f;
+ private float _flingFriction = 0.08f;
+ private float _flingMinVelocity = 5f;
+ private TimeSpan _flingFrameInterval = TimeSpan.FromMilliseconds(16);
+ private TimeSpan _zoomAnimationInterval = TimeSpan.FromMilliseconds(16);
+
+ ///
+ /// Gets or sets the minimum allowed zoom scale.
+ ///
+ /// The minimum scale factor. The default is 0.1 . Must be positive.
+ /// is zero or negative.
+ public float MinScale
+ {
+ get => _minScale;
+ set
+ {
+ if (value <= 0)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "MinScale must be positive.");
+ if (value > _maxScale)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "MinScale must not be greater than MaxScale.");
+ _minScale = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the maximum allowed zoom scale.
+ ///
+ /// The maximum scale factor. The default is 10 . Must be positive and greater than or equal to .
+ /// is zero, negative, or less than .
+ public float MaxScale
+ {
+ get => _maxScale;
+ set
+ {
+ if (value <= 0)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "MaxScale must be positive.");
+ if (value < _minScale)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "MaxScale must not be less than MinScale.");
+ _maxScale = value;
+ }
+ }
+
+ ///
+ /// Sets both and atomically,
+ /// avoiding ordering-dependent validation errors when the desired range lies
+ /// entirely outside the current default range of [0.1, 10].
+ ///
+ /// The minimum scale value. Must be positive and less than .
+ /// The maximum scale value. Must be positive and greater than .
+ ///
+ /// Thrown when is less than or equal to zero, is less than
+ /// or equal to zero, or is greater than or equal to .
+ ///
+ public void SetScaleRange(float minScale, float maxScale)
+ {
+ if (minScale <= 0)
+ throw new ArgumentOutOfRangeException(nameof(minScale), minScale, "MinScale must be positive.");
+ if (maxScale <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxScale), maxScale, "MaxScale must be positive.");
+ if (minScale >= maxScale)
+ throw new ArgumentOutOfRangeException(nameof(minScale), minScale, "MinScale must be less than MaxScale.");
+
+ _minScale = minScale;
+ _maxScale = maxScale;
+ }
+
+ ///
+ /// Gets or sets the multiplicative zoom factor applied when a double-tap is detected.
+ ///
+ /// The zoom multiplier per double-tap. The default is 2.0 . Must be positive.
+ /// is zero or negative.
+ ///
+ /// When the current scale is at or near , a double-tap animates
+ /// the scale back to 1.0 instead of zooming further.
+ ///
+ public float DoubleTapZoomFactor
+ {
+ get => _doubleTapZoomFactor;
+ set
+ {
+ if (value <= 0)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "DoubleTapZoomFactor must be positive.");
+ _doubleTapZoomFactor = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the duration of the double-tap zoom animation.
+ ///
+ /// The animation duration. The default is 250 ms . A value of applies the zoom instantly.
+ /// is negative.
+ public TimeSpan ZoomAnimationDuration
+ {
+ get => _zoomAnimationDuration;
+ set
+ {
+ if (value < TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "ZoomAnimationDuration must not be negative.");
+ _zoomAnimationDuration = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the scale sensitivity for mouse scroll-wheel zoom.
+ ///
+ ///
+ /// A multiplier applied to each scroll tick's .Y
+ /// to compute the scale change. The default is 0.1 . Must be positive.
+ ///
+ /// is zero or negative.
+ public float ScrollZoomFactor
+ {
+ get => _scrollZoomFactor;
+ set
+ {
+ if (value <= 0)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "ScrollZoomFactor must be positive.");
+ _scrollZoomFactor = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the fling friction coefficient that controls deceleration speed.
+ ///
+ ///
+ /// A value between 0 (no friction, fling continues indefinitely) and 1 (full friction,
+ /// fling stops immediately). The default is 0.08 .
+ ///
+ /// is less than 0 or greater than 1 .
+ public float FlingFriction
+ {
+ get => _flingFriction;
+ set
+ {
+ if (value < 0 || value > 1)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "FlingFriction must be between 0 and 1.");
+ _flingFriction = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the minimum velocity, in pixels per second, below which the fling animation stops.
+ ///
+ /// The minimum fling velocity threshold in pixels per second. The default is 5 .
+ /// is negative.
+ public float FlingMinVelocity
+ {
+ get => _flingMinVelocity;
+ set
+ {
+ if (value < 0)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "FlingMinVelocity must not be negative.");
+ _flingMinVelocity = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the fling animation frame interval.
+ ///
+ ///
+ /// The timer interval between fling animation frames.
+ /// The default is 16 ms (approximately 60 FPS). Must be positive.
+ ///
+ /// is zero or negative.
+ public TimeSpan FlingFrameInterval
+ {
+ get => _flingFrameInterval;
+ set
+ {
+ if (value <= TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "FlingFrameInterval must be positive.");
+ _flingFrameInterval = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the zoom animation frame interval.
+ ///
+ ///
+ /// The timer interval between zoom animation frames.
+ /// The default is 16 ms (approximately 60 FPS). Must be positive.
+ ///
+ /// is zero or negative.
+ public TimeSpan ZoomAnimationInterval
+ {
+ get => _zoomAnimationInterval;
+ set
+ {
+ if (value <= TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "ZoomAnimationInterval must be positive.");
+ _zoomAnimationInterval = value;
+ }
+ }
+
+ /// Gets or sets a value indicating whether tap detection is enabled.
+ /// to detect single taps; otherwise, . The default is .
+ public bool IsTapEnabled { get; set; } = true;
+
+ /// Gets or sets a value indicating whether double-tap detection is enabled.
+ /// to detect double taps; otherwise, . The default is .
+ public bool IsDoubleTapEnabled { get; set; } = true;
+
+ /// Gets or sets a value indicating whether long press detection is enabled.
+ /// to detect long presses; otherwise, . The default is .
+ public bool IsLongPressEnabled { get; set; } = true;
+
+ /// Gets or sets a value indicating whether pan gestures update the tracker's offset.
+ /// to apply pan deltas to the offset; otherwise, . The default is .
+ public bool IsPanEnabled { get; set; } = true;
+
+ /// Gets or sets a value indicating whether pinch-to-zoom gestures update the tracker's scale.
+ /// to apply pinch scale changes; otherwise, . The default is .
+ public bool IsPinchEnabled { get; set; } = true;
+
+ /// Gets or sets a value indicating whether rotation gestures update the tracker's rotation.
+ /// to apply rotation changes; otherwise, . The default is .
+ public bool IsRotateEnabled { get; set; } = true;
+
+ /// Gets or sets a value indicating whether fling (inertia) animation is enabled after pan gestures.
+ /// to run fling animations; otherwise, . The default is .
+ public bool IsFlingEnabled { get; set; } = true;
+
+ /// Gets or sets a value indicating whether double-tap triggers an animated zoom.
+ /// to enable double-tap zoom; otherwise, . The default is .
+ public bool IsDoubleTapZoomEnabled { get; set; } = true;
+
+ /// Gets or sets a value indicating whether scroll-wheel events trigger zoom.
+ /// to enable scroll-wheel zoom; otherwise, . The default is .
+ public bool IsScrollZoomEnabled { get; set; } = true;
+
+ /// Gets or sets a value indicating whether hover (mouse move without contact) detection is enabled.
+ /// to detect hover events; otherwise, . The default is .
+ public bool IsHoverEnabled { get; set; } = true;
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKHoverGestureEventArgs.cs b/source/SkiaSharp.Extended/Gestures/SKHoverGestureEventArgs.cs
new file mode 100644
index 0000000000..5dfb9a842b
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKHoverGestureEventArgs.cs
@@ -0,0 +1,32 @@
+using System;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Provides data for a hover (mouse move without contact) gesture event.
+///
+///
+/// Hover events are raised when a mouse cursor moves over the surface without any buttons
+/// pressed (i.e., inContact is in ).
+/// This is a mouse-only gesture that has no equivalent on touch devices.
+///
+///
+///
+public class SKHoverGestureEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The current position of the mouse cursor in view coordinates.
+ public SKHoverGestureEventArgs(SKPoint location)
+ {
+ Location = location;
+ }
+
+ ///
+ /// Gets the current position of the mouse cursor in view coordinates.
+ ///
+ /// An representing the hover position.
+ public SKPoint Location { get; }
+
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKLongPressGestureEventArgs.cs b/source/SkiaSharp.Extended/Gestures/SKLongPressGestureEventArgs.cs
new file mode 100644
index 0000000000..8c71acefa3
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKLongPressGestureEventArgs.cs
@@ -0,0 +1,39 @@
+using System;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Provides data for a long press gesture event.
+///
+///
+/// A long press is detected when a touch is held stationary (within the
+/// threshold) for at least
+/// milliseconds.
+///
+///
+///
+public class SKLongPressGestureEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The location of the long press in view coordinates.
+ /// The duration the touch was held before the long press was recognized.
+ public SKLongPressGestureEventArgs(SKPoint location, TimeSpan duration)
+ {
+ Location = location;
+ Duration = duration;
+ }
+
+ ///
+ /// Gets the location of the long press in view coordinates.
+ ///
+ /// An representing the position where the long press occurred.
+ public SKPoint Location { get; }
+
+ ///
+ /// Gets the duration the touch was held before the long press was recognized.
+ ///
+ /// A representing the elapsed time from touch-down to long press detection.
+ public TimeSpan Duration { get; }
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKPanGestureEventArgs.cs b/source/SkiaSharp.Extended/Gestures/SKPanGestureEventArgs.cs
new file mode 100644
index 0000000000..bf77362d4f
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKPanGestureEventArgs.cs
@@ -0,0 +1,70 @@
+using System;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Provides data for a pan (single-finger drag) gesture event.
+///
+///
+/// Pan events are raised continuously as a single touch moves beyond the
+/// threshold. Each event provides both
+/// the incremental and the instantaneous .
+///
+///
+///
+public class SKPanGestureEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The current touch location in view coordinates.
+ /// The touch location from the previous pan event.
+ /// The current velocity of the touch in pixels per second.
+ public SKPanGestureEventArgs(SKPoint location, SKPoint previousLocation, SKPoint velocity)
+ {
+ Location = location;
+ PreviousLocation = previousLocation;
+ Velocity = velocity;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the event has been handled.
+ ///
+ ///
+ /// if the event has been handled by a consumer and default processing
+ /// should be skipped; otherwise, . The default is .
+ ///
+ ///
+ /// Set this to in a handler
+ /// to prevent the from updating .
+ ///
+ public bool Handled { get; set; }
+
+ ///
+ /// Gets the current touch location in view coordinates.
+ ///
+ /// An representing the current position of the touch.
+ public SKPoint Location { get; }
+
+ ///
+ /// Gets the touch location from the previous pan event.
+ ///
+ /// An representing the previous position of the touch.
+ public SKPoint PreviousLocation { get; }
+
+ ///
+ /// Gets the displacement from to .
+ ///
+ /// An where X and Y represent the change in position, in pixels.
+ /// Calculated as Location - PreviousLocation .
+ public SKPoint Delta => new SKPoint(Location.X - PreviousLocation.X, Location.Y - PreviousLocation.Y);
+
+ ///
+ /// Gets the current velocity of the touch movement.
+ ///
+ ///
+ /// An where X and Y represent the velocity components
+ /// in pixels per second. Positive X is rightward; positive Y is downward.
+ ///
+ public SKPoint Velocity { get; }
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKPinchGestureEventArgs.cs b/source/SkiaSharp.Extended/Gestures/SKPinchGestureEventArgs.cs
new file mode 100644
index 0000000000..61134d1d43
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKPinchGestureEventArgs.cs
@@ -0,0 +1,54 @@
+using System;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Provides data for a pinch (scale) gesture event detected from two or more simultaneous touches.
+///
+///
+/// Pinch events are raised continuously as two or more touches move relative to each other.
+/// The is a relative (per-event) multiplier, not an absolute scale. To
+/// compute the cumulative scale, multiply successive values together, or
+/// use the property which maintains the absolute value.
+///
+///
+///
+///
+public class SKPinchGestureEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The current center point between the pinch fingers, in view coordinates.
+ /// The center point between the pinch fingers from the previous event.
+ /// The relative scale change factor since the previous event.
+ public SKPinchGestureEventArgs(SKPoint focalPoint, SKPoint previousFocalPoint, float scaleDelta)
+ {
+ FocalPoint = focalPoint;
+ PreviousFocalPoint = previousFocalPoint;
+ ScaleDelta = scaleDelta;
+ }
+
+ ///
+ /// Gets the current center point between the pinch fingers in view coordinates.
+ ///
+ /// An representing the midpoint of all active touches.
+ public SKPoint FocalPoint { get; }
+
+ ///
+ /// Gets the center point between the pinch fingers from the previous event.
+ ///
+ /// An representing the previous midpoint of all active touches.
+ public SKPoint PreviousFocalPoint { get; }
+
+ ///
+ /// Gets the relative scale change factor since the previous pinch event.
+ ///
+ ///
+ /// A value of 1.0 means no change. Values greater than 1.0 indicate zooming in
+ /// (fingers spreading apart), and values less than 1.0 indicate zooming out
+ /// (fingers pinching together). This is a per-event delta, not a cumulative scale.
+ ///
+ public float ScaleDelta { get; }
+
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKRotateGestureEventArgs.cs b/source/SkiaSharp.Extended/Gestures/SKRotateGestureEventArgs.cs
new file mode 100644
index 0000000000..03fd809a76
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKRotateGestureEventArgs.cs
@@ -0,0 +1,53 @@
+using System;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Provides data for a rotation gesture event detected from two simultaneous touches.
+///
+///
+/// Rotation events are raised simultaneously with when
+/// two or more touches are active. The is a per-event incremental
+/// angle change in degrees. To compute cumulative rotation, sum successive deltas or use the
+/// property.
+///
+///
+///
+///
+public class SKRotateGestureEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The current center point between the rotation fingers, in view coordinates.
+ /// The center point between the rotation fingers from the previous event.
+ /// The incremental rotation angle in degrees since the previous event.
+ public SKRotateGestureEventArgs(SKPoint focalPoint, SKPoint previousFocalPoint, float rotationDelta)
+ {
+ FocalPoint = focalPoint;
+ PreviousFocalPoint = previousFocalPoint;
+ RotationDelta = rotationDelta;
+ }
+
+ ///
+ /// Gets the current center point between the rotation fingers in view coordinates.
+ ///
+ /// An representing the midpoint of the two touches.
+ public SKPoint FocalPoint { get; }
+
+ ///
+ /// Gets the center point between the rotation fingers from the previous event.
+ ///
+ /// An representing the previous midpoint of the two touches.
+ public SKPoint PreviousFocalPoint { get; }
+
+ ///
+ /// Gets the incremental rotation angle change since the previous event, in degrees.
+ ///
+ ///
+ /// A positive value indicates clockwise rotation; a negative value indicates counter-clockwise
+ /// rotation. The value is normalized to the range (-180, 180] .
+ ///
+ public float RotationDelta { get; }
+
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKScrollGestureEventArgs.cs b/source/SkiaSharp.Extended/Gestures/SKScrollGestureEventArgs.cs
new file mode 100644
index 0000000000..6af6c20e37
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKScrollGestureEventArgs.cs
@@ -0,0 +1,48 @@
+using System;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Provides data for a mouse scroll (wheel) gesture event.
+///
+///
+/// Scroll events are raised when the mouse wheel is rotated or a trackpad scroll gesture
+/// is performed. The uses .Y for scroll-wheel zoom
+/// when is .
+/// Platform note: The sign convention for scroll deltas may vary by platform
+/// and input device. Typically, positive .Y indicates scrolling up (or zooming in),
+/// but this depends on the platform's scroll event normalization.
+///
+///
+///
+///
+public class SKScrollGestureEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The position of the mouse cursor when the scroll occurred, in view coordinates.
+ /// The scroll delta.
+ public SKScrollGestureEventArgs(SKPoint location, SKPoint delta)
+ {
+ Location = location;
+ Delta = delta;
+ }
+
+ ///
+ /// Gets the position of the mouse cursor when the scroll occurred, in view coordinates.
+ ///
+ /// An representing the mouse position at the time of the scroll.
+ public SKPoint Location { get; }
+
+ ///
+ /// Gets the scroll delta.
+ ///
+ ///
+ /// An where X is the horizontal scroll amount and Y is the
+ /// vertical scroll amount. Positive Y typically indicates scrolling up or zooming in.
+ /// When is , Y
+ /// is multiplied by to determine the zoom change.
+ ///
+ public SKPoint Delta { get; }
+}
diff --git a/source/SkiaSharp.Extended/Gestures/SKTapGestureEventArgs.cs b/source/SkiaSharp.Extended/Gestures/SKTapGestureEventArgs.cs
new file mode 100644
index 0000000000..94b64e8e92
--- /dev/null
+++ b/source/SkiaSharp.Extended/Gestures/SKTapGestureEventArgs.cs
@@ -0,0 +1,72 @@
+using System;
+
+namespace SkiaSharp.Extended;
+
+///
+/// Provides data for tap gesture events, including single and multi-tap interactions.
+///
+///
+/// This event argument is used by both and
+/// events. For single taps,
+/// is 1 . For double taps, it is 2 or greater.
+///
+/// The following example shows how to handle tap events:
+///
+/// var tracker = new SKGestureTracker();
+/// tracker.TapDetected += (sender, e) =>
+/// {
+/// Console.WriteLine($"Tapped at ({e.Location.X}, {e.Location.Y}), count: {e.TapCount}");
+/// };
+/// tracker.DoubleTapDetected += (sender, e) =>
+/// {
+/// // Set Handled to true to prevent the default double-tap zoom behavior.
+/// e.Handled = true;
+/// Console.WriteLine($"Double-tapped at ({e.Location.X}, {e.Location.Y})");
+/// };
+///
+///
+///
+///
+///
+public class SKTapGestureEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The location of the tap in view coordinates.
+ /// The number of consecutive taps detected.
+ public SKTapGestureEventArgs(SKPoint location, int tapCount)
+ {
+ Location = location;
+ TapCount = tapCount;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the event has been handled.
+ ///
+ ///
+ /// if the event has been handled by a consumer and default processing
+ /// should be skipped; otherwise, . The default is .
+ ///
+ ///
+ /// Set this to in a
+ /// handler to prevent the from applying its default
+ /// double-tap zoom behavior.
+ ///
+ public bool Handled { get; set; }
+
+ ///
+ /// Gets the location of the tap in view coordinates.
+ ///
+ /// An representing the position where the tap occurred.
+ public SKPoint Location { get; }
+
+ ///
+ /// Gets the number of consecutive taps detected.
+ ///
+ ///
+ /// 1 for a single tap, 2 or greater for multi-tap gestures.
+ ///
+ public int TapCount { get; }
+
+}
diff --git a/source/SkiaSharp.Extended/Polyfills/IsExternalInit.cs b/source/SkiaSharp.Extended/Polyfills/IsExternalInit.cs
new file mode 100644
index 0000000000..f89bfb3aae
--- /dev/null
+++ b/source/SkiaSharp.Extended/Polyfills/IsExternalInit.cs
@@ -0,0 +1,7 @@
+#if NETSTANDARD2_0
+// Polyfill required for record types on netstandard2.0
+namespace System.Runtime.CompilerServices
+{
+ internal static class IsExternalInit { }
+}
+#endif
diff --git a/source/SkiaSharp.Extended/SkiaSharp.Extended.csproj b/source/SkiaSharp.Extended/SkiaSharp.Extended.csproj
index 5e76557727..88820773e5 100644
--- a/source/SkiaSharp.Extended/SkiaSharp.Extended.csproj
+++ b/source/SkiaSharp.Extended/SkiaSharp.Extended.csproj
@@ -5,6 +5,8 @@
SkiaSharp.Extended
SkiaSharp.Extended
+ true
+ $(NoWarn);CS1591
diff --git a/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorFlingTests.cs b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorFlingTests.cs
new file mode 100644
index 0000000000..ce332c768a
--- /dev/null
+++ b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorFlingTests.cs
@@ -0,0 +1,167 @@
+using SkiaSharp;
+using System;
+using Xunit;
+
+namespace SkiaSharp.Extended.Tests.Gestures;
+
+/// Tests for fling detection and animation in .
+public class SKGestureDetectorFlingTests
+{
+ private long _testTicks = 1000000;
+
+ private SKGestureDetector CreateEngine()
+ {
+ var engine = new SKGestureDetector
+ {
+ TimeProvider = () => _testTicks
+ };
+ return engine;
+ }
+
+ private void AdvanceTime(long milliseconds)
+ {
+ _testTicks += milliseconds * TimeSpan.TicksPerMillisecond;
+ }
+
+
+ [Fact]
+ public void FastSwipe_RaisesFlingDetected()
+ {
+ var engine = CreateEngine();
+ var flingRaised = false;
+ engine.FlingDetected += (s, e) => flingRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(120, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(500, 100)); // Fast movement
+ AdvanceTime(10);
+ engine.ProcessTouchUp(1, new SKPoint(500, 100));
+
+ Assert.True(flingRaised);
+ }
+
+ [Fact]
+ public void FlingDetected_VelocityIsCorrect()
+ {
+ var engine = CreateEngine();
+ float? velocityX = null;
+ engine.FlingDetected += (s, e) => velocityX = e.Velocity.X;
+
+ // Start and immediately make fast movements
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(200, 100)); // Move 100 px
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(600, 100)); // Move 400 px in 10ms = fast
+ AdvanceTime(10);
+ engine.ProcessTouchUp(1, new SKPoint(600, 100));
+
+ Assert.NotNull(velocityX);
+ // Movement should be fast enough to trigger fling
+ Assert.True(velocityX.Value > 200, $"VelocityX should be > 200, was {velocityX.Value}");
+ }
+
+ [Fact]
+ public void SlowSwipe_DoesNotRaiseFling()
+ {
+ var engine = CreateEngine();
+ var flingRaised = false;
+ engine.FlingDetected += (s, e) => flingRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(500);
+ engine.ProcessTouchMove(1, new SKPoint(110, 100));
+ AdvanceTime(500);
+ engine.ProcessTouchUp(1, new SKPoint(110, 100));
+
+ Assert.False(flingRaised);
+ }
+
+
+
+ [Fact]
+ public void Fling_PauseBeforeRelease_NoFling()
+ {
+ var engine = CreateEngine();
+ var flingRaised = false;
+ engine.FlingDetected += (s, e) => flingRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(500, 100)); // Fast move
+ AdvanceTime(500); // Long pause — velocity decays past threshold window
+ engine.ProcessTouchUp(1, new SKPoint(500, 100));
+
+ Assert.False(flingRaised, "Fling should not fire after a long pause");
+ }
+
+ [Fact]
+ public void Fling_VerticalDirection_CorrectVelocity()
+ {
+ var engine = CreateEngine();
+ float? velocityX = null, velocityY = null;
+ engine.FlingDetected += (s, e) => { velocityX = e.Velocity.X; velocityY = e.Velocity.Y; };
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(100, 150));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(100, 400));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(100, 700));
+ AdvanceTime(10);
+ engine.ProcessTouchUp(1, new SKPoint(100, 700));
+
+ Assert.NotNull(velocityY);
+ Assert.True(velocityY.Value > 200, $"VelocityY should be > 200, was {velocityY.Value}");
+ Assert.True(Math.Abs(velocityX!.Value) < Math.Abs(velocityY.Value),
+ "Horizontal velocity should be less than vertical for vertical fling");
+ }
+
+ [Fact]
+ public void Fling_DiagonalDirection_BothAxesHaveVelocity()
+ {
+ var engine = CreateEngine();
+ float? velocityX = null, velocityY = null;
+ engine.FlingDetected += (s, e) => { velocityX = e.Velocity.X; velocityY = e.Velocity.Y; };
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(200, 200));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(400, 400));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(700, 700));
+ AdvanceTime(10);
+ engine.ProcessTouchUp(1, new SKPoint(700, 700));
+
+ Assert.NotNull(velocityX);
+ Assert.NotNull(velocityY);
+ Assert.True(velocityX.Value > 200);
+ Assert.True(velocityY.Value > 200);
+ }
+
+
+
+ [Fact]
+ public void FlingDetected_StillFiresOnceAtStart()
+ {
+ var engine = CreateEngine();
+ var flingDetectedCount = 0;
+ engine.FlingDetected += (s, e) => flingDetectedCount++;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(200, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(500, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchUp(1, new SKPoint(500, 100));
+
+ Assert.Equal(1, flingDetectedCount);
+ }
+
+
+}
diff --git a/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorHoverScrollTests.cs b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorHoverScrollTests.cs
new file mode 100644
index 0000000000..27b23aca4b
--- /dev/null
+++ b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorHoverScrollTests.cs
@@ -0,0 +1,137 @@
+using SkiaSharp;
+using System;
+using Xunit;
+
+namespace SkiaSharp.Extended.Tests.Gestures;
+
+/// Tests for hover and mouse wheel detection in .
+public class SKGestureDetectorHoverScrollTests
+{
+ private long _testTicks = 1000000;
+
+ private SKGestureDetector CreateEngine()
+ {
+ var engine = new SKGestureDetector
+ {
+ TimeProvider = () => _testTicks
+ };
+ return engine;
+ }
+
+ private void AdvanceTime(long milliseconds)
+ {
+ _testTicks += milliseconds * TimeSpan.TicksPerMillisecond;
+ }
+
+
+ [Fact]
+ public void MoveWithoutContact_RaisesHoverDetected()
+ {
+ var engine = CreateEngine();
+ var hoverRaised = false;
+ engine.HoverDetected += (s, e) => hoverRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(150, 150), inContact: false);
+
+ Assert.True(hoverRaised);
+ }
+
+ [Fact]
+ public void HoverDetected_LocationIsCorrect()
+ {
+ var engine = CreateEngine();
+ SKPoint? location = null;
+ engine.HoverDetected += (s, e) => location = e.Location;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(175, 225), inContact: false);
+
+ Assert.NotNull(location);
+ Assert.Equal(175, location.Value.X);
+ Assert.Equal(225, location.Value.Y);
+ }
+
+ [Fact]
+ public void HoverWithoutPriorTouchDown_StillRaisesEvent()
+ {
+ var engine = CreateEngine();
+ var hoverRaised = false;
+ engine.HoverDetected += (s, e) => hoverRaised = true;
+
+ // Mouse hover without any prior click/touch
+ engine.ProcessTouchMove(99, new SKPoint(200, 200), inContact: false);
+
+ Assert.True(hoverRaised, "Hover should work without a prior touch down");
+ }
+
+ [Fact]
+ public void HoverWithoutPriorTouchDown_HasCorrectLocation()
+ {
+ var engine = CreateEngine();
+ SKPoint? location = null;
+ engine.HoverDetected += (s, e) => location = e.Location;
+
+ engine.ProcessTouchMove(99, new SKPoint(300, 400), inContact: false);
+
+ Assert.NotNull(location);
+ Assert.Equal(300, location.Value.X);
+ Assert.Equal(400, location.Value.Y);
+ }
+
+
+
+ [Fact]
+ public void ProcessMouseWheel_RaisesScrollDetected()
+ {
+ var engine = CreateEngine();
+ var scrollRaised = false;
+ engine.ScrollDetected += (s, e) => scrollRaised = true;
+
+ engine.ProcessMouseWheel(new SKPoint(200, 200), 0, 1f);
+
+ Assert.True(scrollRaised);
+ }
+
+ [Fact]
+ public void ProcessMouseWheel_HasCorrectData()
+ {
+ var engine = CreateEngine();
+ SKScrollGestureEventArgs? args = null;
+ engine.ScrollDetected += (s, e) => args = e;
+
+ engine.ProcessMouseWheel(new SKPoint(150, 250), 0, -3f);
+
+ Assert.NotNull(args);
+ Assert.Equal(150, args.Location.X);
+ Assert.Equal(250, args.Location.Y);
+ Assert.Equal(0, args.Delta.X);
+ Assert.Equal(-3f, args.Delta.Y);
+ }
+
+ [Fact]
+ public void ProcessMouseWheel_WhenDisabled_ReturnsFalse()
+ {
+ var engine = CreateEngine();
+ engine.IsEnabled = false;
+
+ var result = engine.ProcessMouseWheel(new SKPoint(100, 100), 0, 1f);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void ProcessMouseWheel_WhenDisposed_ReturnsFalse()
+ {
+ var engine = CreateEngine();
+ engine.Dispose();
+
+ var result = engine.ProcessMouseWheel(new SKPoint(100, 100), 0, 1f);
+
+ Assert.False(result);
+ }
+
+
+}
diff --git a/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorPanTests.cs b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorPanTests.cs
new file mode 100644
index 0000000000..edec9af836
--- /dev/null
+++ b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorPanTests.cs
@@ -0,0 +1,74 @@
+using SkiaSharp;
+using System;
+using Xunit;
+
+namespace SkiaSharp.Extended.Tests.Gestures;
+
+/// Tests for pan detection in .
+public class SKGestureDetectorPanTests
+{
+ private long _testTicks = 1000000;
+
+ private SKGestureDetector CreateEngine()
+ {
+ var engine = new SKGestureDetector
+ {
+ TimeProvider = () => _testTicks
+ };
+ return engine;
+ }
+
+ private void AdvanceTime(long milliseconds)
+ {
+ _testTicks += milliseconds * TimeSpan.TicksPerMillisecond;
+ }
+
+
+ [Fact]
+ public void MoveBeyondTouchSlop_RaisesPanDetected()
+ {
+ var engine = CreateEngine();
+ var panRaised = false;
+ engine.PanDetected += (s, e) => panRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(120, 100)); // Move 20 pixels
+
+ Assert.True(panRaised);
+ }
+
+ [Fact]
+ public void PanDetected_DeltaIsCorrect()
+ {
+ var engine = CreateEngine();
+ SKPoint? delta = null;
+ engine.PanDetected += (s, e) => delta = e.Delta;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(120, 100)); // First move starts pan
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(130, 110)); // Second move has delta
+
+ Assert.NotNull(delta);
+ Assert.Equal(10, delta.Value.X, 0.1);
+ Assert.Equal(10, delta.Value.Y, 0.1);
+ }
+
+ [Fact]
+ public void MoveWithinTouchSlop_DoesNotRaisePan()
+ {
+ var engine = CreateEngine();
+ var panRaised = false;
+ engine.PanDetected += (s, e) => panRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(102, 101)); // Move 2 pixels
+
+ Assert.False(panRaised);
+ }
+
+
+}
diff --git a/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorPinchRotationTests.cs b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorPinchRotationTests.cs
new file mode 100644
index 0000000000..19f284fd46
--- /dev/null
+++ b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorPinchRotationTests.cs
@@ -0,0 +1,285 @@
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using Xunit;
+
+namespace SkiaSharp.Extended.Tests.Gestures;
+
+/// Tests for pinch, rotation, and multi-touch detection in .
+public class SKGestureDetectorPinchRotationTests
+{
+ private long _testTicks = 1000000;
+
+ private SKGestureDetector CreateEngine()
+ {
+ var engine = new SKGestureDetector
+ {
+ TimeProvider = () => _testTicks
+ };
+ return engine;
+ }
+
+ private void AdvanceTime(long milliseconds)
+ {
+ _testTicks += milliseconds * TimeSpan.TicksPerMillisecond;
+ }
+
+
+ [Fact]
+ public void TwoFingerGesture_RaisesPinchDetected()
+ {
+ var engine = CreateEngine();
+ var pinchRaised = false;
+ engine.PinchDetected += (s, e) => pinchRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(90, 100));
+ engine.ProcessTouchMove(2, new SKPoint(210, 100));
+
+ Assert.True(pinchRaised);
+ }
+
+ [Fact]
+ public void PinchDetected_ScaleIsCorrect()
+ {
+ var engine = CreateEngine();
+ float? scale = null;
+ engine.PinchDetected += (s, e) => scale = e.ScaleDelta;
+
+ // Initial position: fingers 100 apart (100,100) and (200,100), center at (150,100), radius = 50
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ // Move to increase distance
+ engine.ProcessTouchMove(1, new SKPoint(50, 100));
+ engine.ProcessTouchMove(2, new SKPoint(250, 100));
+
+ Assert.NotNull(scale);
+ // Scale should be > 1 when zooming out
+ Assert.True(scale.Value > 1.0f, $"Scale should be > 1.0, was {scale.Value}");
+ }
+
+
+
+ [Fact]
+ public void TwoFingerRotation_RaisesRotateDetected()
+ {
+ var engine = CreateEngine();
+ var rotateRaised = false;
+ engine.RotateDetected += (s, e) => rotateRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(100, 150));
+ engine.ProcessTouchMove(2, new SKPoint(200, 50));
+
+ Assert.True(rotateRaised);
+ }
+
+ [Fact]
+ public void RotateDetected_RotationDeltaIsNormalized()
+ {
+ var engine = CreateEngine();
+ float? rotation = null;
+ engine.RotateDetected += (s, e) => rotation = e.RotationDelta;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ // Rotate 45 degrees
+ engine.ProcessTouchMove(1, new SKPoint(79.3f, 120.7f));
+ engine.ProcessTouchMove(2, new SKPoint(220.7f, 79.3f));
+
+ Assert.NotNull(rotation);
+ // Rotation should be normalized to -180 to 180 range
+ Assert.True(rotation.Value >= -180 && rotation.Value <= 180);
+ }
+
+
+
+ [Fact]
+ public void PinchDetected_CenterIsMidpointOfTouches()
+ {
+ var engine = CreateEngine();
+ SKPoint? center = null;
+ engine.PinchDetected += (s, e) => center = e.FocalPoint;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(80, 100));
+ engine.ProcessTouchMove(2, new SKPoint(220, 100));
+
+ Assert.NotNull(center);
+ Assert.Equal(150, center.Value.X, 0.1);
+ Assert.Equal(100, center.Value.Y, 0.1);
+ }
+
+ [Fact]
+ public void PinchDetected_PreviousCenterIsProvided()
+ {
+ var engine = CreateEngine();
+ SKPinchGestureEventArgs? lastArgs = null;
+ engine.PinchDetected += (s, e) => lastArgs = e;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ // Move both fingers right — events fire per-finger, so check last event
+ engine.ProcessTouchMove(1, new SKPoint(120, 100));
+ engine.ProcessTouchMove(2, new SKPoint(220, 100));
+
+ Assert.NotNull(lastArgs);
+ // PreviousCenter should be from the intermediate state (after finger1 moved)
+ Assert.NotEqual(default, lastArgs!.PreviousFocalPoint);
+ // Center should be midpoint of final positions
+ Assert.Equal(170, lastArgs.FocalPoint.X, 0.1);
+ }
+
+ [Fact]
+ public void PinchDetected_EqualDistanceMove_ScaleIsOne()
+ {
+ var engine = CreateEngine();
+ var scales = new List();
+ engine.PinchDetected += (s, e) => scales.Add(e.ScaleDelta);
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ // Move both outward equally — each finger fires separately
+ engine.ProcessTouchMove(1, new SKPoint(50, 100));
+ engine.ProcessTouchMove(2, new SKPoint(250, 100));
+
+ Assert.True(scales.Count >= 1);
+ // The net product of scales should show zoom-out (> 1)
+ var totalScale = 1f;
+ foreach (var s in scales) totalScale *= s;
+ Assert.True(totalScale > 1.0f, $"Total scale should be > 1 for zoom-out, was {totalScale}");
+ }
+
+ [Fact]
+ public void PinchDetected_FingersCloser_ScaleLessThanOne()
+ {
+ var engine = CreateEngine();
+ float? scale = null;
+ engine.PinchDetected += (s, e) => scale = e.ScaleDelta;
+
+ // Initial: 100 apart
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ // Pinch in: 50 apart
+ engine.ProcessTouchMove(1, new SKPoint(125, 100));
+ engine.ProcessTouchMove(2, new SKPoint(175, 100));
+
+ Assert.NotNull(scale);
+ Assert.True(scale.Value < 1.0f, $"Scale should be < 1 (zoom in), was {scale.Value}");
+ }
+
+
+
+ [Fact]
+ public void RotateDetected_PreviousCenterIsProvided()
+ {
+ var engine = CreateEngine();
+ SKRotateGestureEventArgs? lastArgs = null;
+ engine.RotateDetected += (s, e) => lastArgs = e;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(100, 150));
+ engine.ProcessTouchMove(2, new SKPoint(200, 50));
+
+ Assert.NotNull(lastArgs);
+ Assert.NotEqual(default, lastArgs!.PreviousFocalPoint);
+ // Center should be midpoint of final positions
+ Assert.Equal(150, lastArgs.FocalPoint.X, 0.1);
+ Assert.Equal(100, lastArgs.FocalPoint.Y, 0.1);
+ }
+
+ [Fact]
+ public void RotateDetected_CenterMovesWithFingers()
+ {
+ var engine = CreateEngine();
+ SKPoint? center = null;
+ engine.RotateDetected += (s, e) => center = e.FocalPoint;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ // Move both fingers down while rotating
+ engine.ProcessTouchMove(1, new SKPoint(100, 200));
+ engine.ProcessTouchMove(2, new SKPoint(200, 200));
+
+ Assert.NotNull(center);
+ Assert.Equal(150, center.Value.X, 0.1);
+ Assert.Equal(200, center.Value.Y, 0.1);
+ }
+
+ [Fact]
+ public void RotateDetected_NoRotation_DeltaIsZero()
+ {
+ var engine = CreateEngine();
+ float? rotationDelta = null;
+ engine.RotateDetected += (s, e) => rotationDelta = e.RotationDelta;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ // Move both outward horizontally — no angle change
+ engine.ProcessTouchMove(1, new SKPoint(50, 100));
+ engine.ProcessTouchMove(2, new SKPoint(250, 100));
+
+ Assert.NotNull(rotationDelta);
+ Assert.Equal(0f, rotationDelta.Value, 0.1);
+ }
+
+
+
+ [Fact]
+ public void ThreeFingers_DoesNotCrash()
+ {
+ var engine = CreateEngine();
+ var pinchRaised = false;
+ engine.PinchDetected += (s, e) => pinchRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ engine.ProcessTouchDown(3, new SKPoint(300, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(90, 100));
+ engine.ProcessTouchMove(2, new SKPoint(210, 100));
+ engine.ProcessTouchMove(3, new SKPoint(310, 100));
+
+ // Should not crash; pinch fires for >= 2 touches
+ Assert.True(pinchRaised, "Pinch should fire with 3+ touches using first 2");
+ }
+
+ [Fact]
+ public void ThreeFingers_LiftOneToTwo_ResumesPinch()
+ {
+ var engine = CreateEngine();
+ var pinchCount = 0;
+ engine.PinchDetected += (s, e) => pinchCount++;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ engine.ProcessTouchDown(3, new SKPoint(300, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(90, 100)); // 3 fingers, no pinch
+
+ // Lift third finger → back to 2
+ engine.ProcessTouchUp(3, new SKPoint(300, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(80, 100));
+ engine.ProcessTouchMove(2, new SKPoint(220, 100));
+
+ Assert.True(pinchCount > 0, "Pinch should resume after lifting to 2 fingers");
+ }
+
+
+}
diff --git a/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorTapTests.cs b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorTapTests.cs
new file mode 100644
index 0000000000..cd69c21be3
--- /dev/null
+++ b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorTapTests.cs
@@ -0,0 +1,233 @@
+using SkiaSharp;
+using System;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace SkiaSharp.Extended.Tests.Gestures;
+
+/// Tests for tap, double-tap, and long-press detection in .
+public class SKGestureDetectorTapTests
+{
+ private long _testTicks = 1000000;
+
+ private SKGestureDetector CreateEngine()
+ {
+ var engine = new SKGestureDetector
+ {
+ TimeProvider = () => _testTicks
+ };
+ return engine;
+ }
+
+ private void AdvanceTime(long milliseconds)
+ {
+ _testTicks += milliseconds * TimeSpan.TicksPerMillisecond;
+ }
+
+
+ [Fact]
+ public void QuickTouchAndRelease_RaisesTapDetected()
+ {
+ var engine = CreateEngine();
+ var tapRaised = false;
+ engine.TapDetected += (s, e) => tapRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50); // Quick tap
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.True(tapRaised);
+ }
+
+ [Fact]
+ public void TapDetected_LocationIsCorrect()
+ {
+ var engine = CreateEngine();
+ SKPoint? location = null;
+ engine.TapDetected += (s, e) => location = e.Location;
+
+ engine.ProcessTouchDown(1, new SKPoint(150, 250));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(150, 250));
+
+ Assert.NotNull(location);
+ Assert.Equal(150, location.Value.X);
+ Assert.Equal(250, location.Value.Y);
+ }
+
+ [Fact]
+ public void TapDetected_TapCountIsOne()
+ {
+ var engine = CreateEngine();
+ int? tapCount = null;
+ engine.TapDetected += (s, e) => tapCount = e.TapCount;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.Equal(1, tapCount);
+ }
+
+
+
+ [Fact]
+ public void TwoQuickTaps_RaisesDoubleTapDetected()
+ {
+ var engine = CreateEngine();
+ var doubleTapRaised = false;
+ engine.DoubleTapDetected += (s, e) => doubleTapRaised = true;
+
+ // First tap
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ // Wait a bit but not too long
+ AdvanceTime(100);
+
+ // Second tap
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.True(doubleTapRaised);
+ }
+
+ [Fact]
+ public void DoubleTap_TapCountIsTwo()
+ {
+ var engine = CreateEngine();
+ int? tapCount = null;
+ engine.DoubleTapDetected += (s, e) => tapCount = e.TapCount;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+ AdvanceTime(100);
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.Equal(2, tapCount);
+ }
+
+
+
+ [Fact]
+ public async Task LongTouch_RaisesLongPressDetected()
+ {
+ var engine = new SKGestureDetector();
+ engine.Options.LongPressDuration = TimeSpan.FromMilliseconds(100); // Short duration for testing
+ var longPressRaised = false;
+ engine.LongPressDetected += (s, e) => longPressRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ await Task.Delay(200); // Wait for timer to fire
+
+ Assert.True(longPressRaised);
+ engine.Dispose();
+ }
+
+ [Fact]
+ public async Task LongPress_DoesNotRaiseTapOnRelease()
+ {
+ var engine = new SKGestureDetector();
+ engine.Options.LongPressDuration = TimeSpan.FromMilliseconds(100);
+ var tapRaised = false;
+ var longPressRaised = false;
+ engine.TapDetected += (s, e) => tapRaised = true;
+ engine.LongPressDetected += (s, e) => longPressRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ await Task.Delay(200);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.True(longPressRaised);
+ Assert.False(tapRaised);
+ engine.Dispose();
+ }
+
+ [Fact]
+ public async Task LongPressDuration_CanBeCustomized()
+ {
+ var engine = new SKGestureDetector();
+ engine.Options.LongPressDuration = TimeSpan.FromMilliseconds(300);
+ var longPressRaised = false;
+ engine.LongPressDetected += (s, e) => longPressRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ await Task.Delay(100);
+ Assert.False(longPressRaised);
+
+ await Task.Delay(300);
+ Assert.True(longPressRaised);
+ engine.Dispose();
+ }
+
+
+
+ [Fact]
+ public void MouseClick_BeyondShortClickDuration_DoesNotFireTap()
+ {
+ var engine = CreateEngine();
+ var tapRaised = false;
+ engine.TapDetected += (s, e) => tapRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100), isMouse: true);
+ AdvanceTime(300); // Beyond ShortClickTicks (250ms)
+ engine.ProcessTouchUp(1, new SKPoint(100, 100), isMouse: true);
+
+ Assert.False(tapRaised, "Mouse click held too long should not fire tap");
+ }
+
+ [Fact]
+ public void TouchHeld_WithSmallMoves_BeyondLongPressDuration_DoesNotFireTap()
+ {
+ var engine = CreateEngine();
+ var tapRaised = false;
+ engine.TapDetected += (s, e) => tapRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ // Small moves within slop over a long time
+ AdvanceTime(200);
+ engine.ProcessTouchMove(1, new SKPoint(101, 101));
+ AdvanceTime(200);
+ engine.ProcessTouchMove(1, new SKPoint(100, 100));
+ AdvanceTime(200); // Total 600ms > LongPressTicks (500ms)
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.False(tapRaised, "Touch held too long should not fire tap");
+ }
+
+ [Fact]
+ public void TapThenPinchThenTap_DoesNotFireDoubleTap()
+ {
+ // Bug fix: _tapCount was not cleared when entering Pinching state, causing
+ // a false DoubleTapDetected on the subsequent single tap.
+ var engine = CreateEngine();
+ var doubleTapRaised = false;
+ engine.DoubleTapDetected += (s, e) => doubleTapRaised = true;
+
+ // First tap
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ // Immediately start a pinch (within double-tap time window)
+ AdvanceTime(100); // < 300ms double-tap delay
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ engine.ProcessTouchMove(1, new SKPoint(80, 100));
+ engine.ProcessTouchMove(2, new SKPoint(220, 100));
+ engine.ProcessTouchUp(2, new SKPoint(220, 100));
+ engine.ProcessTouchUp(1, new SKPoint(80, 100));
+
+ // Third tap shortly after pinch completes (still within original 300ms window from first tap)
+ AdvanceTime(50);
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.False(doubleTapRaised, "DoubleTapDetected must not fire after tap → pinch → single tap sequence");
+ }
+
+}
diff --git a/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorTests.cs b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorTests.cs
new file mode 100644
index 0000000000..90dd92a1eb
--- /dev/null
+++ b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureDetectorTests.cs
@@ -0,0 +1,1061 @@
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace SkiaSharp.Extended.Tests.Gestures;
+
+///
+/// Tests for .
+///
+public class SKGestureDetectorTests
+{
+ private long _testTicks = 1000000;
+
+ private SKGestureDetector CreateEngine()
+ {
+ var engine = new SKGestureDetector
+ {
+ TimeProvider = () => _testTicks
+ };
+ return engine;
+ }
+
+ private void AdvanceTime(long milliseconds)
+ {
+ _testTicks += milliseconds * TimeSpan.TicksPerMillisecond;
+ }
+
+
+ [Fact]
+ public void ProcessTouchDown_WhenEnabled_ReturnsTrue()
+ {
+ var engine = CreateEngine();
+
+ var result = engine.ProcessTouchDown(1, new SKPoint(100, 100));
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void ProcessTouchDown_WhenDisabled_ReturnsFalse()
+ {
+ var engine = CreateEngine();
+ engine.IsEnabled = false;
+
+ var result = engine.ProcessTouchDown(1, new SKPoint(100, 100));
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void ProcessTouchMove_WithoutTouchDown_ReturnsFalse()
+ {
+ var engine = CreateEngine();
+
+ var result = engine.ProcessTouchMove(1, new SKPoint(110, 110));
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void ProcessTouchUp_WithoutTouchDown_ReturnsFalse()
+ {
+ var engine = CreateEngine();
+
+ var result = engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.False(result);
+ }
+
+
+
+ [Fact]
+ public void TouchDown_RaisesGestureStarted()
+ {
+ var engine = CreateEngine();
+ var gestureStarted = false;
+ engine.GestureStarted += (s, e) => gestureStarted = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+
+ Assert.True(gestureStarted);
+ }
+
+ [Fact]
+ public void TouchDown_SetsGestureActive()
+ {
+ var engine = CreateEngine();
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+
+ Assert.True(engine.IsGestureActive);
+ }
+
+ [Fact]
+ public void TouchUp_RaisesGestureEnded()
+ {
+ var engine = CreateEngine();
+ var gestureEnded = false;
+ engine.GestureEnded += (s, e) => gestureEnded = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(150, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchUp(1, new SKPoint(150, 100));
+
+ Assert.True(gestureEnded);
+ }
+
+ [Fact]
+ public void TouchUp_ClearsGestureActive()
+ {
+ var engine = CreateEngine();
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(150, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchUp(1, new SKPoint(150, 100));
+
+ Assert.False(engine.IsGestureActive);
+ }
+
+
+ [Fact]
+ public void Reset_ClearsState()
+ {
+ var engine = CreateEngine();
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+
+ engine.Reset();
+
+ Assert.False(engine.IsGestureActive);
+ }
+
+
+
+ [Fact]
+ public void TouchSlop_CanBeCustomized()
+ {
+ var engine = CreateEngine();
+ engine.Options.TouchSlop = 20;
+ var panRaised = false;
+ engine.PanDetected += (s, e) => panRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(115, 100)); // Move 15 pixels (less than 20)
+
+ Assert.False(panRaised);
+ }
+
+ [Fact]
+ public void FlingThreshold_CanBeCustomized()
+ {
+ var engine = CreateEngine();
+ engine.Options.FlingThreshold = 1000; // Very high threshold
+ var flingRaised = false;
+ engine.FlingDetected += (s, e) => flingRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchMove(1, new SKPoint(200, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(200, 100));
+
+ Assert.False(flingRaised);
+ }
+
+
+
+ [Fact]
+ public void ProcessTouchCancel_ResetsGestureState()
+ {
+ var engine = CreateEngine();
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ Assert.True(engine.IsGestureActive);
+
+ engine.ProcessTouchCancel(1);
+ Assert.False(engine.IsGestureActive);
+ }
+
+ [Fact]
+ public void ProcessTouchCancel_ClearsGestureActive()
+ {
+ var engine = CreateEngine();
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(150, 100));
+ engine.ProcessTouchCancel(1);
+
+ Assert.False(engine.IsGestureActive);
+ }
+
+ [Fact]
+ public void ProcessTouchCancel_RaisesGestureEnded()
+ {
+ var engine = CreateEngine();
+ var gestureEnded = false;
+ engine.GestureEnded += (s, e) => gestureEnded = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(150, 100));
+ engine.ProcessTouchCancel(1);
+
+ Assert.True(gestureEnded);
+ }
+
+ [Fact]
+ public void ProcessTouchCancel_DuringPinch_WithOneFingerRemaining_TransitionsToPanning()
+ {
+ // When one finger is cancelled during a two-finger pinch, the remaining finger
+ // should continue generating pan events rather than the gesture freezing.
+ var engine = CreateEngine();
+ var panRaised = false;
+ engine.PanDetected += (s, e) => panRaised = true;
+
+ // Start a two-finger pinch
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(90, 100));
+ engine.ProcessTouchMove(2, new SKPoint(210, 100));
+
+ // Cancel one finger — should transition to Panning
+ engine.ProcessTouchCancel(2);
+
+ // Subsequent moves with the remaining finger should produce pan events
+ panRaised = false;
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(80, 100));
+
+ Assert.True(panRaised, "Pan should fire after cancelling one finger during a pinch");
+ }
+
+ [Fact]
+ public void ProcessTouchCancel_DuringPinch_WithOneFingerRemaining_GestureStaysActive()
+ {
+ var engine = CreateEngine();
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(90, 100));
+ engine.ProcessTouchMove(2, new SKPoint(210, 100));
+
+ // Cancel one finger
+ engine.ProcessTouchCancel(2);
+
+ // Gesture should still be active (remaining finger is panning)
+ Assert.True(engine.IsGestureActive, "Gesture should remain active after one finger cancelled during pinch");
+ }
+
+
+
+ [Fact]
+ public void DoubleTap_FarApart_DoesNotTriggerDoubleTap()
+ {
+ var engine = CreateEngine();
+ var doubleTapRaised = false;
+ engine.DoubleTapDetected += (s, e) => doubleTapRaised = true;
+
+ // First tap at (100, 100)
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ AdvanceTime(100);
+
+ // Second tap far away at (500, 500)
+ engine.ProcessTouchDown(1, new SKPoint(500, 500));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(500, 500));
+
+ Assert.False(doubleTapRaised);
+ }
+
+ [Fact]
+ public void DoubleTap_TooSlow_DoesNotTriggerDoubleTap()
+ {
+ var engine = CreateEngine();
+ var doubleTapRaised = false;
+ engine.DoubleTapDetected += (s, e) => doubleTapRaised = true;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ AdvanceTime(500); // Longer than DoubleTapDelayTicks (300ms)
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.False(doubleTapRaised);
+ }
+
+ [Fact]
+ public void SecondFingerDown_DoesNotBreakFirstFingerTap()
+ {
+ var engine = CreateEngine();
+ engine.TapDetected += (s, e) => { };
+
+ // First finger down
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ // Second finger touches briefly
+ engine.ProcessTouchDown(2, new SKPoint(200, 200));
+ engine.ProcessTouchUp(2, new SKPoint(200, 200));
+ AdvanceTime(50);
+ // First finger up — should not trigger tap (state changed to pinching)
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ // After multi-touch, tap detection is naturally suppressed since state transitions away
+ // This tests that we don't crash and state is consistent
+ Assert.False(engine.IsGestureActive);
+ }
+
+ [Fact]
+ public void FlingWithMultipleMoveEvents_ProducesReasonableVelocity()
+ {
+ var engine = CreateEngine();
+ float? velocityX = null;
+ engine.FlingDetected += (s, e) => velocityX = e.Velocity.X;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(150, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(250, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(400, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(600, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchUp(1, new SKPoint(600, 100));
+
+ Assert.NotNull(velocityX);
+ Assert.True(velocityX.Value > 200, $"VelocityX should be > 200, was {velocityX.Value}");
+ }
+
+
+
+ [Fact]
+ public void CancelDuringPinch_ResetsState()
+ {
+ var engine = CreateEngine();
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ Assert.True(engine.IsGestureActive);
+
+ engine.ProcessTouchCancel(1);
+ engine.ProcessTouchCancel(2);
+ Assert.False(engine.IsGestureActive);
+ }
+
+
+
+ [Fact]
+ public void MultipleSequentialTaps_EachFiresSeparately()
+ {
+ var engine = CreateEngine();
+ var tapCount = 0;
+ engine.TapDetected += (s, e) => tapCount++;
+
+ for (int i = 0; i < 5; i++)
+ {
+ engine.ProcessTouchDown(1, new SKPoint(100 + i * 50, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100 + i * 50, 100));
+ AdvanceTime(500); // Wait long enough to not trigger double-tap
+ }
+
+ Assert.Equal(5, tapCount);
+ }
+
+ [Fact]
+ public void PanThenTap_TapFiresAfterPanEnds()
+ {
+ var engine = CreateEngine();
+ var tapRaised = false;
+ var panRaised = false;
+ engine.TapDetected += (s, e) => tapRaised = true;
+ engine.PanDetected += (s, e) => panRaised = true;
+
+ // Pan gesture
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(200, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchUp(1, new SKPoint(200, 100));
+ Assert.True(panRaised);
+ Assert.False(tapRaised);
+
+ // New tap gesture after pan is done
+ AdvanceTime(500);
+ engine.ProcessTouchDown(1, new SKPoint(300, 300));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(300, 300));
+ Assert.True(tapRaised);
+ }
+
+ [Fact]
+ public void PinchThenTap_TapFiresAfterPinchEnds()
+ {
+ var engine = CreateEngine();
+ var tapRaised = false;
+ engine.TapDetected += (s, e) => tapRaised = true;
+
+ // Pinch gesture
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(80, 100));
+ engine.ProcessTouchMove(2, new SKPoint(220, 100));
+ engine.ProcessTouchUp(2, new SKPoint(220, 100));
+ engine.ProcessTouchUp(1, new SKPoint(80, 100));
+ Assert.False(tapRaised);
+
+ // New tap
+ AdvanceTime(500);
+ engine.ProcessTouchDown(1, new SKPoint(150, 150));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(150, 150));
+ Assert.True(tapRaised);
+ }
+
+
+
+ [Fact]
+ public void Dispose_DuringGesture_StopsProcessing()
+ {
+ var engine = CreateEngine();
+ var panCount = 0;
+ engine.PanDetected += (s, e) => panCount++;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(120, 100)); // Triggers pan
+ Assert.True(panCount > 0);
+
+ engine.Dispose();
+ var beforeCount = panCount;
+
+ // Further events should be ignored
+ engine.ProcessTouchMove(1, new SKPoint(200, 100));
+ Assert.Equal(beforeCount, panCount);
+ }
+
+ [Fact]
+ public void Reset_DuringPan_AllowsNewGesture()
+ {
+ var engine = CreateEngine();
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(150, 100));
+ Assert.True(engine.IsGestureActive);
+
+ engine.Reset();
+ Assert.False(engine.IsGestureActive);
+
+ // New gesture should work
+ var tapRaised = false;
+ engine.TapDetected += (s, e) => tapRaised = true;
+ engine.ProcessTouchDown(1, new SKPoint(200, 200));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(200, 200));
+ Assert.True(tapRaised);
+ }
+
+ [Fact]
+ public void ProcessEvents_AfterDispose_ReturnsFalse()
+ {
+ var engine = CreateEngine();
+ engine.Dispose();
+
+ Assert.False(engine.ProcessTouchDown(1, new SKPoint(100, 100)));
+ Assert.False(engine.ProcessTouchMove(1, new SKPoint(110, 110)));
+ Assert.False(engine.ProcessTouchUp(1, new SKPoint(110, 110)));
+ }
+
+
+
+ [Fact]
+ public void TouchMove_ToSameLocation_ZeroDelta()
+ {
+ var engine = CreateEngine();
+ SKPoint? lastDelta = null;
+ engine.PanDetected += (s, e) => lastDelta = e.Delta;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(120, 100)); // Start pan
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(120, 100)); // Same location
+
+ Assert.NotNull(lastDelta);
+ Assert.Equal(0, lastDelta.Value.X, 0.01);
+ Assert.Equal(0, lastDelta.Value.Y, 0.01);
+ }
+
+ [Fact]
+ public void Pinch_ZeroRadius_ScaleIsOne()
+ {
+ var engine = CreateEngine();
+ float? scale = null;
+ engine.PinchDetected += (s, e) => scale = e.ScaleDelta;
+
+ // Both fingers at the same point
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(100, 100));
+ engine.ProcessTouchMove(2, new SKPoint(100, 100));
+
+ // With zero initial radius, scale should be 1 (guarded)
+ if (scale != null)
+ Assert.Equal(1.0f, scale.Value, 0.01);
+ }
+
+ [Fact]
+ public void TouchDown_DuplicateId_UpdatesExistingTouch()
+ {
+ var engine = CreateEngine();
+ engine.TapDetected += (s, e) => { };
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(1, new SKPoint(200, 200)); // Same ID
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(200, 200));
+
+ // Should not crash
+ Assert.False(engine.IsGestureActive);
+ }
+
+
+
+ [Fact]
+ public void DoubleTapSlop_FarApartTaps_DoNotTriggerDoubleTap()
+ {
+ var engine = CreateEngine();
+ engine.Options.DoubleTapSlop = 40f;
+ var doubleTapCount = 0;
+ engine.DoubleTapDetected += (s, e) => doubleTapCount++;
+
+ // First tap
+ engine.ProcessTouchDown(1, new SKPoint(50, 50));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(50, 50));
+
+ // Second tap far away (beyond 40px slop)
+ AdvanceTime(100);
+ engine.ProcessTouchDown(1, new SKPoint(200, 200));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(200, 200));
+
+ Assert.Equal(0, doubleTapCount);
+ }
+
+ [Fact]
+ public void DoubleTapSlop_CloseTaps_TriggerDoubleTap()
+ {
+ var engine = CreateEngine();
+ engine.Options.DoubleTapSlop = 40f;
+ var doubleTapCount = 0;
+ engine.DoubleTapDetected += (s, e) => doubleTapCount++;
+
+ // First tap
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ // Second tap within 40px slop
+ AdvanceTime(100);
+ engine.ProcessTouchDown(1, new SKPoint(120, 115));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(120, 115));
+
+ Assert.Equal(1, doubleTapCount);
+ }
+
+ [Fact]
+ public void ProcessTouchCancel_WhenDisposed_DoesNotThrow()
+ {
+ var engine = CreateEngine();
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.Dispose();
+
+ // Should not throw
+ var result = engine.ProcessTouchCancel(1);
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void ProcessTouchCancel_StopsLongPressTimer()
+ {
+ var engine = CreateEngine();
+ var longPressCount = 0;
+ engine.LongPressDetected += (s, e) => longPressCount++;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(200);
+ engine.ProcessTouchCancel(1);
+
+ // Wait for timer to have fired if it wasn't stopped
+ AdvanceTime(600);
+
+ Assert.Equal(0, longPressCount);
+ }
+
+ [Fact]
+ public void ThreeToTwoFinger_NoScaleJump()
+ {
+ var engine = CreateEngine();
+ var scales = new List();
+ engine.PinchDetected += (s, e) => scales.Add(e.ScaleDelta);
+
+ // Start 2-finger pinch
+ engine.ProcessTouchDown(1, new SKPoint(100, 200));
+ engine.ProcessTouchDown(2, new SKPoint(200, 200));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(95, 200));
+ engine.ProcessTouchMove(2, new SKPoint(205, 200));
+
+ // Add third finger
+ engine.ProcessTouchDown(3, new SKPoint(300, 200));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(90, 200));
+ engine.ProcessTouchMove(2, new SKPoint(210, 200));
+ engine.ProcessTouchMove(3, new SKPoint(310, 200));
+
+ scales.Clear(); // Clear history
+
+ // Lift third finger — should recalculate pinch state, no jump
+ engine.ProcessTouchUp(3, new SKPoint(310, 200));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(85, 200));
+ engine.ProcessTouchMove(2, new SKPoint(215, 200));
+
+ // Scale should be close to 1.0 (small incremental change, not a jump)
+ foreach (var scale in scales)
+ {
+ Assert.InRange(scale, 0.8f, 1.2f);
+ }
+ }
+
+ [Fact]
+ public void PinchToPan_DragOriginIsUpdated()
+ {
+ var engine = CreateEngine();
+ var panDeltas = new List();
+ engine.PanDetected += (s, e) => panDeltas.Add(new SKPoint(e.Delta.X, e.Delta.Y));
+
+ // Start pinch with 2 fingers
+ engine.ProcessTouchDown(1, new SKPoint(100, 200));
+ engine.ProcessTouchDown(2, new SKPoint(300, 200));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(105, 200));
+ engine.ProcessTouchMove(2, new SKPoint(295, 200));
+
+ // Lift one finger → transition to pan
+ engine.ProcessTouchUp(2, new SKPoint(295, 200));
+ panDeltas.Clear();
+
+ // Continue moving remaining finger — delta should be small and incremental
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(110, 200));
+
+ if (panDeltas.Count > 0)
+ {
+ var delta = panDeltas[0];
+ // Delta should be small (5px move), not a jump from original touch position
+ Assert.InRange(delta.X, -20f, 20f);
+ Assert.InRange(delta.Y, -20f, 20f);
+ }
+ }
+
+ [Fact]
+ public void Reset_AllowsGesturesAgain()
+ {
+ var engine = CreateEngine();
+ var tapCount = 0;
+ engine.TapDetected += (s, e) => tapCount++;
+
+ // First tap
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+ Assert.Equal(1, tapCount);
+
+ // Reset
+ engine.Reset();
+
+ // Second tap should still work
+ AdvanceTime(500);
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+ Assert.Equal(2, tapCount);
+ }
+
+ [Fact]
+ public void Dispose_PreventsAllFutureGestures()
+ {
+ var engine = CreateEngine();
+ var tapCount = 0;
+ engine.TapDetected += (s, e) => tapCount++;
+
+ engine.Dispose();
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.Equal(0, tapCount);
+ }
+
+
+
+ [Fact]
+ public void Options_TouchSlop_Negative_Throws()
+ {
+ var options = new SKGestureDetectorOptions();
+ Assert.Throws(() => options.TouchSlop = -1f);
+ }
+
+ [Fact]
+ public void Options_DoubleTapSlop_Negative_Throws()
+ {
+ var options = new SKGestureDetectorOptions();
+ Assert.Throws(() => options.DoubleTapSlop = -1f);
+ }
+
+ [Fact]
+ public void Options_FlingThreshold_Negative_Throws()
+ {
+ var options = new SKGestureDetectorOptions();
+ Assert.Throws(() => options.FlingThreshold = -1f);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Options_LongPressDuration_ZeroOrNegative_Throws(int value)
+ {
+ var options = new SKGestureDetectorOptions();
+ Assert.Throws(() => options.LongPressDuration = TimeSpan.FromMilliseconds(value));
+ }
+
+ [Fact]
+ public void Constructor_NullOptions_Throws()
+ {
+ Assert.Throws(() => new SKGestureDetector(null!));
+ }
+
+ [Fact]
+ public void Options_ValidValues_PassThrough()
+ {
+ var options = new SKGestureDetectorOptions
+ {
+ TouchSlop = 16f,
+ DoubleTapSlop = 80f,
+ FlingThreshold = 400f,
+ LongPressDuration = TimeSpan.FromSeconds(1),
+ };
+ var engine = new SKGestureDetector(options);
+
+ Assert.Equal(16f, engine.Options.TouchSlop);
+ Assert.Equal(80f, engine.Options.DoubleTapSlop);
+ Assert.Equal(400f, engine.Options.FlingThreshold);
+ Assert.Equal(TimeSpan.FromSeconds(1), engine.Options.LongPressDuration);
+ }
+
+
+
+ [Fact]
+ public void GestureStarted_OnlyFiresOnce_WhenMultipleFingersTouch()
+ {
+ var engine = CreateEngine();
+ var count = 0;
+ engine.GestureStarted += (s, e) => count++;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ engine.ProcessTouchDown(2, new SKPoint(200, 200));
+ engine.ProcessTouchDown(3, new SKPoint(300, 300));
+
+ Assert.Equal(1, count);
+ }
+
+
+
+ [Fact]
+ public void PanEventArgs_PreviousLocation_IsSetCorrectly()
+ {
+ var engine = CreateEngine();
+ SKPanGestureEventArgs? captured = null;
+ engine.PanDetected += (s, e) => captured = e;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(120, 100));
+
+ Assert.NotNull(captured);
+ Assert.Equal(100, captured.PreviousLocation.X, 1);
+ Assert.Equal(100, captured.PreviousLocation.Y, 1);
+ }
+
+ [Fact]
+ public void PinchEventArgs_ScaleDelta_ProductMatchesCumulativeScale()
+ {
+ var engine = CreateEngine();
+ var scaleDeltas = new List();
+ engine.PinchDetected += (s, e) => scaleDeltas.Add(e.ScaleDelta);
+
+ // Fingers start 100px apart, spread to 200px → cumulative scale ≈ 2.0
+ engine.ProcessTouchDown(1, new SKPoint(100, 200));
+ engine.ProcessTouchDown(2, new SKPoint(200, 200));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(50, 200));
+ engine.ProcessTouchMove(2, new SKPoint(250, 200));
+
+ Assert.NotEmpty(scaleDeltas);
+ var cumulativeScale = 1f;
+ foreach (var delta in scaleDeltas)
+ cumulativeScale *= delta;
+ Assert.Equal(2.0f, cumulativeScale, 2);
+ }
+
+ [Fact]
+ public void FlingEventArgs_HasVelocityAndSpeed()
+ {
+ var engine = CreateEngine();
+ SKFlingGestureEventArgs? captured = null;
+ engine.FlingDetected += (s, e) => captured = e;
+
+ engine.ProcessTouchDown(1, new SKPoint(100, 200));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(300, 200));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(500, 200));
+ AdvanceTime(10);
+ engine.ProcessTouchUp(1, new SKPoint(500, 200));
+
+ Assert.NotNull(captured);
+ Assert.True(captured.Velocity.X > 0, $"VelocityX should be positive for rightward fling, was {captured.Velocity.X}");
+ Assert.True(captured.Speed > 0, $"Speed should be positive, was {captured.Speed}");
+ Assert.Equal((float)Math.Sqrt(captured.Velocity.X * captured.Velocity.X + captured.Velocity.Y * captured.Velocity.Y), captured.Speed, 1);
+ }
+
+
+
+ [Fact]
+ public void Dispose_CalledTwice_DoesNotThrow()
+ {
+ var engine = CreateEngine();
+ engine.Dispose();
+ engine.Dispose(); // should not throw
+ }
+
+
+
+ [Fact]
+ public void TouchIdReuse_AfterTouchUp_StartsNewGesture()
+ {
+ var engine = CreateEngine();
+ var tapCount = 0;
+ engine.TapDetected += (s, e) => tapCount++;
+
+ // First gesture with ID 1
+ engine.ProcessTouchDown(1, new SKPoint(100, 100), false);
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100), false);
+
+ // Wait past double-tap window
+ AdvanceTime(500);
+
+ // Reuse ID 1 at a different location
+ engine.ProcessTouchDown(1, new SKPoint(300, 300), false);
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(300, 300), false);
+ AdvanceTime(500);
+
+ Assert.Equal(2, tapCount);
+ }
+
+
+
+ [Fact]
+ public void ThreeTaps_RapidSequence_FiresDoubleTapAndSingleTap()
+ {
+ var engine = CreateEngine();
+ var tapCount = 0;
+ var doubleTapCount = 0;
+ engine.TapDetected += (s, e) => tapCount++;
+ engine.DoubleTapDetected += (s, e) => doubleTapCount++;
+
+ // Tap 1
+ engine.ProcessTouchDown(1, new SKPoint(100, 100), false);
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100), false);
+ AdvanceTime(100);
+
+ // Tap 2 — triggers double tap
+ engine.ProcessTouchDown(1, new SKPoint(100, 100), false);
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100), false);
+ AdvanceTime(500); // Wait past double-tap window
+
+ // Tap 3 — new single tap
+ engine.ProcessTouchDown(1, new SKPoint(100, 100), false);
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100), false);
+ AdvanceTime(500);
+
+ Assert.True(doubleTapCount >= 1, $"Expected at least 1 double tap, got {doubleTapCount}");
+ Assert.True(tapCount >= 2, $"Expected at least 2 taps, got {tapCount}");
+ }
+
+
+
+ [Fact]
+ public void LongPressTimer_NotRestarted_OnSecondFingerDown()
+ {
+ // Regression: StartLongPressTimer() was called for every ProcessTouchDown,
+ // including the 2nd finger, resetting the long-press timer during pinch start.
+ var engine = CreateEngine();
+ engine.Options.LongPressDuration = TimeSpan.FromMilliseconds(200); // Short for test
+
+ var longPressCount = 0;
+ engine.LongPressDetected += (s, e) => longPressCount++;
+
+ // Put down first finger and hold
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(150); // Almost at long-press threshold
+
+ // Second finger touches — should NOT reset the timer
+ engine.ProcessTouchDown(2, new SKPoint(200, 100));
+ AdvanceTime(10); // Small additional time
+ engine.ProcessTouchUp(2, new SKPoint(200, 100));
+
+ // The first finger has been held for 160ms total — still shouldn't trigger long press
+ // (because state transitioned to Pinching then Panning, not Detecting)
+ // More importantly, the timer should not have been reset such that it would fire 200ms
+ // AFTER the second finger touched (which would be 360ms total, wrong behavior)
+ Assert.Equal(0, longPressCount);
+ }
+
+ [Fact]
+ public void PinchRotation_DoesNotJump_WhenThirdFingerAddedAndRemoved()
+ {
+ // Regression: GetActiveTouchPoints() used Dictionary iteration order (not guaranteed).
+ // Adding/removing a 3rd finger could swap locations[0] and locations[1],
+ // causing the angle to jump ~180°.
+ var engine = CreateEngine();
+ var rotationDeltas = new List();
+ engine.RotateDetected += (s, e) => rotationDeltas.Add(e.RotationDelta);
+
+ // Start 2-finger pinch: finger 1 at left, finger 2 at right (horizontal → 0°)
+ engine.ProcessTouchDown(1, new SKPoint(100, 200));
+ engine.ProcessTouchDown(2, new SKPoint(300, 200));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(1, new SKPoint(100, 200));
+ engine.ProcessTouchMove(2, new SKPoint(300, 200));
+ rotationDeltas.Clear();
+
+ // Add 3rd finger
+ engine.ProcessTouchDown(3, new SKPoint(200, 100));
+ AdvanceTime(10);
+ engine.ProcessTouchMove(3, new SKPoint(200, 100));
+ AdvanceTime(10);
+
+ // Remove 3rd finger — this is where the angle jump was observed
+ engine.ProcessTouchUp(3, new SKPoint(200, 100));
+ AdvanceTime(10);
+
+ // Move fingers slightly to trigger rotation events
+ engine.ProcessTouchMove(1, new SKPoint(100, 200));
+ engine.ProcessTouchMove(2, new SKPoint(300, 200));
+
+ // No rotation delta should be close to ±180° (a jump)
+ foreach (var delta in rotationDeltas)
+ {
+ Assert.True(Math.Abs(delta) < 90f,
+ $"Rotation delta {delta}° is too large — indicates angle jump from unstable ordering");
+ }
+ }
+
+ [Fact]
+ public void TapCount_ResetsAfterFailedTap_DueToMovement()
+ {
+ // Regression: _tapCount was incremented on touch-down but not reset when the
+ // tap failed because the finger moved beyond TouchSlop. A subsequent valid tap
+ // could incorrectly inherit the stale count and fire as a double-tap.
+ var engine = CreateEngine();
+ engine.Options.TouchSlop = 8f;
+
+ var singleTapCount = 0;
+ var doubleTapCount = 0;
+ engine.TapDetected += (s, e) => { if (e.TapCount == 1) singleTapCount++; };
+ engine.DoubleTapDetected += (s, e) => doubleTapCount++;
+
+ // Touch down, then slide beyond slop (failed tap)
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchMove(1, new SKPoint(120, 100)); // 20px > 8px slop
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(120, 100));
+
+ AdvanceTime(100);
+
+ // A clean single tap now — should be count=1, NOT double-tap
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.Equal(1, singleTapCount);
+ Assert.Equal(0, doubleTapCount);
+ }
+
+ [Fact]
+ public void TapCount_ResetsAfterFailedTap_DueToLongHold()
+ {
+ // Regression: _tapCount was not reset when the finger was held too long
+ // (exceeding tap timeout), which could cause a subsequent valid tap to be
+ // counted as a double-tap.
+ var engine = CreateEngine();
+
+ var singleTapCount = 0;
+ var doubleTapCount = 0;
+ engine.TapDetected += (s, e) => { if (e.TapCount == 1) singleTapCount++; };
+ engine.DoubleTapDetected += (s, e) => doubleTapCount++;
+
+ // Touch down and hold beyond the tap timeout
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(600); // Far beyond any tap timeout
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ AdvanceTime(100);
+
+ // A clean single tap — should be count=1, NOT double-tap
+ engine.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ engine.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.Equal(1, singleTapCount);
+ Assert.Equal(0, doubleTapCount);
+ }
+
+ [Fact]
+ public void TimeProvider_SetNull_ThrowsArgumentNullException()
+ {
+ using var engine = new SKGestureDetector();
+ Assert.Throws(() => engine.TimeProvider = null!);
+ }
+
+}
diff --git a/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerDragTests.cs b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerDragTests.cs
new file mode 100644
index 0000000000..b3936522b6
--- /dev/null
+++ b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerDragTests.cs
@@ -0,0 +1,147 @@
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace SkiaSharp.Extended.Tests.Gestures;
+
+/// Tests for drag lifecycle in .
+public class SKGestureTrackerDragTests
+{
+ private long _testTicks = 1000000;
+
+ private SKGestureTracker CreateTracker()
+ {
+ var tracker = new SKGestureTracker
+ {
+ TimeProvider = () => _testTicks
+ };
+ return tracker;
+ }
+
+ private void AdvanceTime(long milliseconds)
+ {
+ _testTicks += milliseconds * TimeSpan.TicksPerMillisecond;
+ }
+
+ private void SimulateFastSwipe(SKGestureTracker tracker, SKPoint start, SKPoint end)
+ {
+ var mid = new SKPoint((start.X + end.X) / 2, (start.Y + end.Y) / 2);
+ tracker.ProcessTouchDown(1, start);
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, mid);
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, end);
+ AdvanceTime(10);
+ tracker.ProcessTouchUp(1, end);
+ }
+
+
+ [Fact]
+ public void FirstPan_FiresDragStarted()
+ {
+ var tracker = CreateTracker();
+ var dragStarted = false;
+ tracker.DragStarted += (s, e) => dragStarted = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+
+ Assert.True(dragStarted);
+ }
+
+ [Fact]
+ public void SubsequentPan_FiresDragUpdated()
+ {
+ var tracker = CreateTracker();
+ var dragUpdatedCount = 0;
+ tracker.DragUpdated += (s, e) => dragUpdatedCount++;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(140, 100));
+
+ Assert.True(dragUpdatedCount > 0);
+ }
+
+ [Fact]
+ public void GestureEnd_FiresDragEnded()
+ {
+ var tracker = CreateTracker();
+ var dragEnded = false;
+ tracker.DragEnded += (s, e) => dragEnded = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+ AdvanceTime(500); // Long pause to avoid fling
+ tracker.ProcessTouchUp(1, new SKPoint(120, 100));
+
+ Assert.True(dragEnded);
+ }
+
+ [Fact]
+ public void DragStarted_HasCorrectStartLocation()
+ {
+ var tracker = CreateTracker();
+ SKPoint? startLocation = null;
+ tracker.DragStarted += (s, e) => startLocation = e.Location;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+
+ Assert.NotNull(startLocation);
+ Assert.Equal(120, startLocation.Value.X, 1);
+ Assert.Equal(100, startLocation.Value.Y, 1);
+ }
+
+ [Fact]
+ public void DragLifecycle_CorrectOrder()
+ {
+ var tracker = CreateTracker();
+ var events = new List();
+ tracker.DragStarted += (s, e) => events.Add("started");
+ tracker.DragUpdated += (s, e) => events.Add("updated");
+ tracker.DragEnded += (s, e) => events.Add("ended");
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(140, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(160, 100));
+ AdvanceTime(500);
+ tracker.ProcessTouchUp(1, new SKPoint(160, 100));
+
+ Assert.Equal("started", events[0]);
+ Assert.Equal("ended", events[^1]);
+ Assert.True(events.Count >= 3);
+ }
+
+
+
+ [Fact]
+ public async Task DragHandled_SuppressesFlingAnimation()
+ {
+ var tracker = CreateTracker();
+ tracker.DragStarted += (s, e) => e.Handled = true;
+ tracker.DragUpdated += (s, e) => e.Handled = true;
+
+ SimulateFastSwipe(tracker, new SKPoint(100, 100), new SKPoint(300, 100));
+ var offsetAfterSwipe = tracker.Offset;
+
+ // Wait for potential fling animation
+ await Task.Delay(100);
+
+ // Offset should not have changed — fling animation was suppressed
+ Assert.Equal(offsetAfterSwipe, tracker.Offset);
+ }
+
+
+}
diff --git a/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerFlingTests.cs b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerFlingTests.cs
new file mode 100644
index 0000000000..4aec43eced
--- /dev/null
+++ b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerFlingTests.cs
@@ -0,0 +1,147 @@
+using SkiaSharp;
+using System;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace SkiaSharp.Extended.Tests.Gestures;
+
+/// Tests for fling animation in .
+public class SKGestureTrackerFlingTests
+{
+ private long _testTicks = 1000000;
+
+ private SKGestureTracker CreateTracker()
+ {
+ var tracker = new SKGestureTracker
+ {
+ TimeProvider = () => _testTicks
+ };
+ return tracker;
+ }
+
+ private void AdvanceTime(long milliseconds)
+ {
+ _testTicks += milliseconds * TimeSpan.TicksPerMillisecond;
+ }
+
+ private void SimulateFastSwipe(SKGestureTracker tracker, SKPoint start, SKPoint end)
+ {
+ var mid = new SKPoint((start.X + end.X) / 2, (start.Y + end.Y) / 2);
+ tracker.ProcessTouchDown(1, start);
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, mid);
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, end);
+ AdvanceTime(10);
+ tracker.ProcessTouchUp(1, end);
+ }
+
+
+ [Fact]
+ public void FastSwipe_FiresFlingDetected()
+ {
+ var tracker = CreateTracker();
+ var flingDetected = false;
+ tracker.FlingDetected += (s, e) => flingDetected = true;
+
+ SimulateFastSwipe(tracker, new SKPoint(100, 200), new SKPoint(500, 200));
+
+ Assert.True(flingDetected);
+ tracker.Dispose();
+ }
+
+ [Fact]
+ public async Task Fling_FiresFlingUpdatedEvents()
+ {
+ var tracker = CreateTracker();
+ tracker.Options.FlingFrameInterval = TimeSpan.FromMilliseconds(16);
+ var flingUpdatedCount = 0;
+ tracker.FlingUpdated += (s, e) => flingUpdatedCount++;
+
+ SimulateFastSwipe(tracker, new SKPoint(100, 200), new SKPoint(500, 200));
+
+ await Task.Delay(200);
+
+ Assert.True(flingUpdatedCount > 0, $"FlingUpdated should have fired, count was {flingUpdatedCount}");
+ tracker.Dispose();
+ }
+
+ [Fact]
+ public async Task Fling_UpdatesOffset()
+ {
+ var tracker = CreateTracker();
+ tracker.Options.FlingFrameInterval = TimeSpan.FromMilliseconds(16);
+ var flingUpdatedFired = false;
+ tracker.FlingUpdated += (s, e) => flingUpdatedFired = true;
+
+ SimulateFastSwipe(tracker, new SKPoint(100, 200), new SKPoint(500, 200));
+ var offsetAfterSwipe = tracker.Offset;
+
+ await Task.Delay(200);
+
+ Assert.True(flingUpdatedFired, "FlingUpdated event should have fired");
+ Assert.True(tracker.Offset.X > offsetAfterSwipe.X || !tracker.IsFlinging,
+ $"Offset should move during fling or fling already completed");
+ tracker.Dispose();
+ }
+
+ [Fact]
+ public void StopFling_StopsAnimation()
+ {
+ var tracker = CreateTracker();
+
+ SimulateFastSwipe(tracker, new SKPoint(100, 200), new SKPoint(500, 200));
+ Assert.True(tracker.IsFlinging);
+
+ tracker.StopFling();
+
+ Assert.False(tracker.IsFlinging);
+ tracker.Dispose();
+ }
+
+ [Fact]
+ public void StopFling_FiresFlingCompleted()
+ {
+ var tracker = CreateTracker();
+ var flingCompleted = false;
+ tracker.FlingCompleted += (s, e) => flingCompleted = true;
+
+ SimulateFastSwipe(tracker, new SKPoint(100, 200), new SKPoint(500, 200));
+ tracker.StopFling();
+
+ Assert.True(flingCompleted);
+ tracker.Dispose();
+ }
+
+ [Fact]
+ public async Task Fling_EventuallyCompletes()
+ {
+ // Use real TimeProvider so fling frame timing advances with wall-clock time
+ var tracker = new SKGestureTracker();
+ tracker.Options.FlingFrameInterval = TimeSpan.FromMilliseconds(16);
+ tracker.Options.FlingFriction = 0.5f;
+ tracker.Options.FlingMinVelocity = 100f;
+ var flingCompleted = false;
+ tracker.FlingCompleted += (s, e) => flingCompleted = true;
+
+ // Use the tracker's own TimeProvider for touch timestamps so velocity is computed correctly
+ var start = new SKPoint(100, 200);
+ var mid = new SKPoint(300, 200);
+ var end = new SKPoint(500, 200);
+ tracker.ProcessTouchDown(1, start);
+ await Task.Delay(20);
+ tracker.ProcessTouchMove(1, mid);
+ await Task.Delay(20);
+ tracker.ProcessTouchMove(1, end);
+ await Task.Delay(20);
+ tracker.ProcessTouchUp(1, end);
+
+ await Task.Delay(2000);
+
+ Assert.True(flingCompleted, "Fling should eventually complete");
+ Assert.False(tracker.IsFlinging);
+ tracker.Dispose();
+ }
+
+
+}
diff --git a/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerTests.cs b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerTests.cs
new file mode 100644
index 0000000000..a4bdf83cef
--- /dev/null
+++ b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerTests.cs
@@ -0,0 +1,1213 @@
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace SkiaSharp.Extended.Tests.Gestures;
+
+///
+/// Tests for .
+///
+public class SKGestureTrackerTests
+{
+ private long _testTicks = 1000000;
+
+ private SKGestureTracker CreateTracker()
+ {
+ var tracker = new SKGestureTracker
+ {
+ TimeProvider = () => _testTicks
+ };
+ return tracker;
+ }
+
+ private void AdvanceTime(long milliseconds)
+ {
+ _testTicks += milliseconds * TimeSpan.TicksPerMillisecond;
+ }
+
+ private void SimulateFastSwipe(SKGestureTracker tracker, SKPoint start, SKPoint end)
+ {
+ var mid = new SKPoint((start.X + end.X) / 2, (start.Y + end.Y) / 2);
+ tracker.ProcessTouchDown(1, start);
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, mid);
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, end);
+ AdvanceTime(10);
+ tracker.ProcessTouchUp(1, end);
+ }
+
+ private void SimulateDoubleTap(SKGestureTracker tracker, SKPoint location)
+ {
+ tracker.ProcessTouchDown(1, location);
+ AdvanceTime(50);
+ tracker.ProcessTouchUp(1, location);
+ AdvanceTime(100);
+ tracker.ProcessTouchDown(1, location);
+ AdvanceTime(50);
+ tracker.ProcessTouchUp(1, location);
+ }
+
+
+ [Fact]
+ public void Pan_UpdatesOffset()
+ {
+ var tracker = CreateTracker();
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+
+ Assert.NotEqual(SKPoint.Empty, tracker.Offset);
+ Assert.True(tracker.Offset.X > 0, $"Offset.X should be > 0, was {tracker.Offset.X}");
+ }
+
+ [Fact]
+ public void Pan_FiresTransformChanged()
+ {
+ var tracker = CreateTracker();
+ var transformChanged = false;
+ tracker.TransformChanged += (s, e) => transformChanged = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+
+ Assert.True(transformChanged);
+ }
+
+ [Fact]
+ public void Pan_OffsetAccumulates()
+ {
+ var tracker = CreateTracker();
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+ var offset1 = tracker.Offset;
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(140, 100));
+ var offset2 = tracker.Offset;
+
+ Assert.True(offset2.X > offset1.X, "Offset should accumulate with continued panning");
+ }
+
+
+
+ [Fact]
+ public void Pinch_UpdatesScale()
+ {
+ var tracker = CreateTracker();
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(200, 200));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(50, 200));
+ tracker.ProcessTouchMove(2, new SKPoint(250, 200));
+
+ Assert.True(tracker.Scale > 1.0f, $"Scale should be > 1 after spreading fingers, was {tracker.Scale}");
+ }
+
+ [Fact]
+ public void Pinch_FiresTransformChanged()
+ {
+ var tracker = CreateTracker();
+ var changeCount = 0;
+ tracker.TransformChanged += (s, e) => changeCount++;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(200, 200));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(50, 200));
+ tracker.ProcessTouchMove(2, new SKPoint(250, 200));
+
+ Assert.True(changeCount > 0);
+ }
+
+ [Fact]
+ public void Pinch_ScaleClampedToMinMax()
+ {
+ var tracker = CreateTracker();
+ tracker.Options.MinScale = 0.5f;
+ tracker.Options.MaxScale = 3f;
+
+ // Pinch fingers very close together
+ tracker.ProcessTouchDown(1, new SKPoint(100, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(200, 200));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(149, 200));
+ tracker.ProcessTouchMove(2, new SKPoint(151, 200));
+
+ Assert.True(tracker.Scale >= 0.5f, "Scale should not go below MinScale");
+ Assert.True(tracker.Scale <= 3f, "Scale should not exceed MaxScale");
+ }
+
+
+
+ [Fact]
+ public void Rotate_UpdatesRotation()
+ {
+ var tracker = CreateTracker();
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(200, 200));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(100, 250));
+ tracker.ProcessTouchMove(2, new SKPoint(200, 150));
+
+ Assert.NotEqual(0f, tracker.Rotation);
+ }
+
+ [Fact]
+ public void Rotate_FiresTransformChanged()
+ {
+ var tracker = CreateTracker();
+ var changeCount = 0;
+ tracker.TransformChanged += (s, e) => changeCount++;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(200, 200));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(100, 250));
+ tracker.ProcessTouchMove(2, new SKPoint(200, 150));
+
+ Assert.True(changeCount > 0);
+ }
+
+
+
+ [Fact]
+ public void IsPanEnabled_False_DoesNotUpdateOffset()
+ {
+ var tracker = CreateTracker();
+ tracker.IsPanEnabled = false;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(150, 100));
+
+ Assert.Equal(SKPoint.Empty, tracker.Offset);
+ }
+
+ [Fact]
+ public void IsDoubleTapZoomEnabled_False_DoesNotZoom()
+ {
+ var tracker = CreateTracker();
+ tracker.IsDoubleTapZoomEnabled = false;
+
+ SimulateDoubleTap(tracker, new SKPoint(200, 200));
+
+ Assert.Equal(1f, tracker.Scale);
+ Assert.False(tracker.IsZoomAnimating);
+ }
+
+ [Fact]
+ public void IsPinchEnabled_False_DoesNotScale()
+ {
+ var tracker = CreateTracker();
+ tracker.IsPinchEnabled = false;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(200, 200));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(50, 200));
+ tracker.ProcessTouchMove(2, new SKPoint(250, 200));
+
+ Assert.Equal(1f, tracker.Scale);
+ }
+
+ [Fact]
+ public void IsRotateEnabled_False_DoesNotRotate()
+ {
+ var tracker = CreateTracker();
+ tracker.IsRotateEnabled = false;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(200, 200));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(100, 250));
+ tracker.ProcessTouchMove(2, new SKPoint(200, 150));
+
+ Assert.Equal(0f, tracker.Rotation);
+ }
+
+ [Fact]
+ public void IsFlingEnabled_False_DoesNotFling()
+ {
+ var tracker = CreateTracker();
+ tracker.IsFlingEnabled = false;
+
+ SimulateFastSwipe(tracker, new SKPoint(100, 200), new SKPoint(500, 200));
+
+ Assert.False(tracker.IsFlinging);
+ }
+
+ [Fact]
+ public void IsScrollZoomEnabled_False_DoesNotZoomOnScroll()
+ {
+ var tracker = CreateTracker();
+ tracker.IsScrollZoomEnabled = false;
+
+ tracker.ProcessMouseWheel(new SKPoint(200, 200), 0, 1f);
+
+ Assert.Equal(1f, tracker.Scale);
+ }
+
+
+
+ [Fact]
+ public void Reset_RestoresDefaultTransform()
+ {
+ var tracker = CreateTracker();
+
+ // Apply pan
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(150, 100));
+ AdvanceTime(500);
+ tracker.ProcessTouchUp(1, new SKPoint(150, 100));
+
+ // Apply scroll zoom
+ AdvanceTime(500);
+ tracker.ProcessMouseWheel(new SKPoint(200, 200), 0, 2f);
+
+ Assert.NotEqual(1f, tracker.Scale);
+ Assert.NotEqual(SKPoint.Empty, tracker.Offset);
+
+ tracker.Reset();
+
+ Assert.Equal(1f, tracker.Scale);
+ Assert.Equal(0f, tracker.Rotation);
+ Assert.Equal(SKPoint.Empty, tracker.Offset);
+ }
+
+ [Fact]
+ public void Reset_MatrixIsIdentity()
+ {
+ var tracker = CreateTracker();
+
+ tracker.ProcessMouseWheel(new SKPoint(200, 200), 0, 2f);
+
+ tracker.Reset();
+
+ var m = tracker.Matrix;
+ Assert.Equal(1f, m.ScaleX, 0.01);
+ Assert.Equal(1f, m.ScaleY, 0.01);
+ Assert.Equal(0f, m.TransX, 0.01);
+ Assert.Equal(0f, m.TransY, 0.01);
+ }
+
+ [Fact]
+ public void Reset_FiresTransformChanged()
+ {
+ var tracker = CreateTracker();
+ tracker.ProcessMouseWheel(new SKPoint(200, 200), 0, 2f);
+
+ var transformChanged = false;
+ tracker.TransformChanged += (s, e) => transformChanged = true;
+
+ tracker.Reset();
+
+ Assert.True(transformChanged);
+ }
+
+
+
+ [Fact]
+ public void TouchSlop_ForwardedToEngine()
+ {
+ var tracker = CreateTracker();
+ tracker.Options.TouchSlop = 50;
+
+ var panRaised = false;
+ tracker.PanDetected += (s, e) => panRaised = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(130, 100)); // 30px < 50px slop
+
+ Assert.False(panRaised, "Pan should not fire when within custom touch slop");
+ }
+
+ [Fact]
+ public void DoubleTapSlop_ForwardedToEngine()
+ {
+ var tracker = CreateTracker();
+ tracker.Options.DoubleTapSlop = 10;
+
+ var doubleTapRaised = false;
+ tracker.DoubleTapDetected += (s, e) => doubleTapRaised = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ tracker.ProcessTouchUp(1, new SKPoint(100, 100));
+ AdvanceTime(100);
+ tracker.ProcessTouchDown(1, new SKPoint(120, 120));
+ AdvanceTime(50);
+ tracker.ProcessTouchUp(1, new SKPoint(120, 120));
+
+ Assert.False(doubleTapRaised, "Double tap should not fire outside slop distance");
+ }
+
+ [Fact]
+ public void FlingThreshold_ForwardedToEngine()
+ {
+ var tracker = CreateTracker();
+ tracker.Options.FlingThreshold = 50000;
+
+ var flingRaised = false;
+ tracker.FlingDetected += (s, e) => flingRaised = true;
+
+ SimulateFastSwipe(tracker, new SKPoint(100, 200), new SKPoint(500, 200));
+
+ Assert.False(flingRaised, "Fling should not fire with very high threshold");
+ }
+
+ [Fact]
+ public void IsEnabled_ForwardedToEngine()
+ {
+ var tracker = CreateTracker();
+ tracker.IsEnabled = false;
+
+ var result = tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void LongPressDuration_ForwardedToEngine()
+ {
+ var tracker = CreateTracker();
+ tracker.Options.LongPressDuration = TimeSpan.FromMilliseconds(200);
+ Assert.Equal(TimeSpan.FromMilliseconds(200), tracker.Options.LongPressDuration);
+ }
+
+
+
+ [Fact]
+ public void Dispose_StopsFlingAnimation()
+ {
+ var tracker = CreateTracker();
+
+ SimulateFastSwipe(tracker, new SKPoint(100, 200), new SKPoint(500, 200));
+ Assert.True(tracker.IsFlinging);
+
+ tracker.Dispose();
+
+ Assert.False(tracker.IsFlinging);
+ }
+
+ [Fact]
+ public void Dispose_StopsZoomAnimation()
+ {
+ var tracker = CreateTracker();
+
+ SimulateDoubleTap(tracker, new SKPoint(200, 200));
+ Assert.True(tracker.IsZoomAnimating);
+
+ tracker.Dispose();
+
+ Assert.False(tracker.IsZoomAnimating);
+ }
+
+
+
+ [Fact]
+ public void TapDetected_ForwardedFromEngine()
+ {
+ var tracker = CreateTracker();
+ var tapRaised = false;
+ tracker.TapDetected += (s, e) => tapRaised = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ tracker.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.True(tapRaised);
+ }
+
+ [Fact]
+ public void PanDetected_ForwardedFromEngine()
+ {
+ var tracker = CreateTracker();
+ var panRaised = false;
+ tracker.PanDetected += (s, e) => panRaised = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+
+ Assert.True(panRaised);
+ }
+
+ [Fact]
+ public void ScrollDetected_ForwardedFromEngine()
+ {
+ var tracker = CreateTracker();
+ var scrollRaised = false;
+ tracker.ScrollDetected += (s, e) => scrollRaised = true;
+
+ tracker.ProcessMouseWheel(new SKPoint(200, 200), 0, 1f);
+
+ Assert.True(scrollRaised);
+ }
+
+ [Fact]
+ public void GestureStarted_ForwardedFromEngine()
+ {
+ var tracker = CreateTracker();
+ var gestureStarted = false;
+ tracker.GestureStarted += (s, e) => gestureStarted = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+
+ Assert.True(gestureStarted);
+ }
+
+ [Fact]
+ public void GestureEnded_ForwardedFromEngine()
+ {
+ var tracker = CreateTracker();
+ var gestureEnded = false;
+ tracker.GestureEnded += (s, e) => gestureEnded = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchUp(1, new SKPoint(120, 100));
+
+ Assert.True(gestureEnded);
+ }
+
+
+
+ [Fact]
+ public void IsTapEnabled_False_SuppressesTap()
+ {
+ var tracker = CreateTracker();
+ tracker.IsTapEnabled = false;
+ var tapFired = false;
+ tracker.TapDetected += (s, e) => tapFired = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ tracker.ProcessTouchUp(1, new SKPoint(100, 100));
+ AdvanceTime(350);
+
+ Assert.False(tapFired);
+ }
+
+ [Fact]
+ public void IsDoubleTapEnabled_False_SuppressesDoubleTap()
+ {
+ var tracker = CreateTracker();
+ tracker.IsDoubleTapEnabled = false;
+ var doubleTapFired = false;
+ tracker.DoubleTapDetected += (s, e) => doubleTapFired = true;
+
+ SimulateDoubleTap(tracker, new SKPoint(200, 200));
+ AdvanceTime(350);
+
+ Assert.False(doubleTapFired);
+ }
+
+ [Fact]
+ public async Task IsLongPressEnabled_False_SuppressesLongPress()
+ {
+ var tracker = CreateTracker();
+ tracker.IsTapEnabled = false;
+ tracker.IsLongPressEnabled = false;
+ var longPressFired = false;
+ tracker.LongPressDetected += (s, e) => longPressFired = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ await Task.Delay(600);
+ tracker.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.False(longPressFired);
+ }
+
+ [Fact]
+ public void IsHoverEnabled_False_SuppressesHover()
+ {
+ var tracker = CreateTracker();
+ tracker.IsHoverEnabled = false;
+ var hoverFired = false;
+ tracker.HoverDetected += (s, e) => hoverFired = true;
+
+ tracker.ProcessTouchMove(1, new SKPoint(100, 100), false);
+
+ Assert.False(hoverFired);
+ }
+
+ [Fact]
+ public void IsTapEnabled_True_AllowsTap()
+ {
+ var tracker = CreateTracker();
+ tracker.IsTapEnabled = true;
+ var tapFired = false;
+ tracker.TapDetected += (s, e) => tapFired = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(50);
+ tracker.ProcessTouchUp(1, new SKPoint(100, 100));
+ AdvanceTime(350);
+
+ Assert.True(tapFired);
+ }
+
+ [Fact]
+ public void IsHoverEnabled_True_AllowsHover()
+ {
+ var tracker = CreateTracker();
+ tracker.IsHoverEnabled = true;
+ var hoverFired = false;
+ tracker.HoverDetected += (s, e) => hoverFired = true;
+
+ tracker.ProcessTouchMove(1, new SKPoint(100, 100), false);
+
+ Assert.True(hoverFired);
+ }
+
+
+
+ [Fact]
+ public void PanDetected_HasVelocity()
+ {
+ var tracker = CreateTracker();
+ SKPoint? velocity = null;
+ tracker.PanDetected += (s, e) => velocity = e.Velocity;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(150, 100));
+
+ Assert.NotNull(velocity);
+ }
+
+
+
+ [Fact]
+ public void ConstructorWithOptions_AppliesValues()
+ {
+ var options = new SKGestureTrackerOptions
+ {
+ MinScale = 0.5f,
+ MaxScale = 5f,
+ DoubleTapZoomFactor = 3f,
+ ScrollZoomFactor = 0.2f,
+ TouchSlop = 16f,
+ DoubleTapSlop = 80f,
+ };
+ var tracker = new SKGestureTracker(options)
+ {
+ TimeProvider = () => _testTicks
+ };
+
+ Assert.Equal(0.5f, tracker.Options.MinScale);
+ Assert.Equal(5f, tracker.Options.MaxScale);
+ Assert.Equal(3f, tracker.Options.DoubleTapZoomFactor);
+ Assert.Equal(0.2f, tracker.Options.ScrollZoomFactor);
+ Assert.Equal(16f, tracker.Options.TouchSlop);
+ Assert.Equal(80f, tracker.Options.DoubleTapSlop);
+ }
+
+ [Fact]
+ public void DefaultOptions_HaveExpectedValues()
+ {
+ var tracker = CreateTracker();
+
+ Assert.Equal(0.1f, tracker.Options.MinScale);
+ Assert.Equal(10f, tracker.Options.MaxScale);
+ Assert.Equal(2f, tracker.Options.DoubleTapZoomFactor);
+ Assert.Equal(0.1f, tracker.Options.ScrollZoomFactor);
+ Assert.Equal(8f, tracker.Options.TouchSlop);
+ Assert.Equal(40f, tracker.Options.DoubleTapSlop);
+ }
+
+
+
+ [Fact]
+ public void Options_MinScale_ZeroOrNegative_Throws()
+ {
+ var options = new SKGestureTrackerOptions();
+ Assert.Throws(() => options.MinScale = 0f);
+ Assert.Throws(() => options.MinScale = -1f);
+ }
+
+ [Fact]
+ public void Options_MaxScale_ZeroOrNegative_Throws()
+ {
+ var options = new SKGestureTrackerOptions();
+ Assert.Throws(() => options.MaxScale = 0f);
+ Assert.Throws(() => options.MaxScale = -1f);
+ }
+
+ [Fact]
+ public void Options_MaxScale_LessThanMinScale_Throws()
+ {
+ var options = new SKGestureTrackerOptions { MinScale = 1f, MaxScale = 5f };
+ Assert.Throws(() => options.MaxScale = 0.5f);
+ }
+
+ [Fact]
+ public void Options_MinScale_GreaterThanMaxScale_Throws()
+ {
+ var options = new SKGestureTrackerOptions { MinScale = 1f, MaxScale = 5f };
+ Assert.Throws(() => options.MinScale = 6f);
+ }
+
+ [Fact]
+ public void Options_DoubleTapZoomFactor_ZeroOrNegative_Throws()
+ {
+ var options = new SKGestureTrackerOptions();
+ Assert.Throws(() => options.DoubleTapZoomFactor = 0f);
+ Assert.Throws(() => options.DoubleTapZoomFactor = -1f);
+ }
+
+ [Fact]
+ public void Options_ScrollZoomFactor_ZeroOrNegative_Throws()
+ {
+ var options = new SKGestureTrackerOptions();
+ Assert.Throws(() => options.ScrollZoomFactor = 0f);
+ Assert.Throws(() => options.ScrollZoomFactor = -1f);
+ }
+
+ [Theory]
+ [InlineData(-0.1f)]
+ [InlineData(1.1f)]
+ public void Options_FlingFriction_OutOfRange_Throws(float value)
+ {
+ var options = new SKGestureTrackerOptions();
+ Assert.Throws(() => options.FlingFriction = value);
+ }
+
+ [Fact]
+ public void Options_FlingMinVelocity_Negative_Throws()
+ {
+ var options = new SKGestureTrackerOptions();
+ Assert.Throws(() => options.FlingMinVelocity = -1f);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Options_FlingFrameInterval_ZeroOrNegative_Throws(int value)
+ {
+ var options = new SKGestureTrackerOptions();
+ Assert.Throws(() => options.FlingFrameInterval = TimeSpan.FromMilliseconds(value));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Options_ZoomAnimationInterval_ZeroOrNegative_Throws(int value)
+ {
+ var options = new SKGestureTrackerOptions();
+ Assert.Throws(() => options.ZoomAnimationInterval = TimeSpan.FromMilliseconds(value));
+ }
+
+ [Fact]
+ public void Options_ZoomAnimationInterval_DefaultIs16()
+ {
+ var options = new SKGestureTrackerOptions();
+ Assert.Equal(TimeSpan.FromMilliseconds(16), options.ZoomAnimationInterval);
+ }
+
+ [Fact]
+ public void Options_ZoomAnimationInterval_AcceptsPositiveValue()
+ {
+ var options = new SKGestureTrackerOptions();
+ options.ZoomAnimationInterval = TimeSpan.FromMilliseconds(33);
+ Assert.Equal(TimeSpan.FromMilliseconds(33), options.ZoomAnimationInterval);
+ }
+
+ [Fact]
+ public void Constructor_NullOptions_Throws()
+ {
+ Assert.Throws(() => new SKGestureTracker(null!));
+ }
+
+
+
+ [Fact]
+ public void Pinch_ScaleDelta_MatchesExpectedRatio()
+ {
+ var tracker = CreateTracker();
+
+ // Fingers start 100px apart, spread to 200px → expected scale ≈ 2.0
+ tracker.ProcessTouchDown(1, new SKPoint(100, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(200, 200));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(50, 200));
+ tracker.ProcessTouchMove(2, new SKPoint(250, 200));
+
+ Assert.Equal(2.0f, tracker.Scale, 2);
+ }
+
+ [Fact]
+ public void Pinch_PinchIn_HalvesScale()
+ {
+ var tracker = CreateTracker();
+
+ // Fingers start 200px apart, pinch to 100px → expected scale ≈ 0.5
+ tracker.ProcessTouchDown(1, new SKPoint(50, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(250, 200));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(100, 200));
+ tracker.ProcessTouchMove(2, new SKPoint(200, 200));
+
+ Assert.Equal(0.5f, tracker.Scale, 2);
+ }
+
+
+
+ [Fact]
+ public void PanEventArgs_PreviousLocation_IsCorrect()
+ {
+ var tracker = CreateTracker();
+ SKPanGestureEventArgs? captured = null;
+ tracker.PanDetected += (s, e) => captured = e;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+
+ Assert.NotNull(captured);
+ Assert.Equal(100, captured.PreviousLocation.X, 1);
+ Assert.Equal(100, captured.PreviousLocation.Y, 1);
+ }
+
+ [Fact]
+ public void PinchEventArgs_ScaleDelta_ProductMatchesCumulativeScale()
+ {
+ var tracker = CreateTracker();
+ var scaleDeltas = new List();
+ tracker.PinchDetected += (s, e) => scaleDeltas.Add(e.ScaleDelta);
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(200, 200));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(50, 200));
+ tracker.ProcessTouchMove(2, new SKPoint(250, 200));
+
+ Assert.NotEmpty(scaleDeltas);
+ var cumulativeScale = 1f;
+ foreach (var delta in scaleDeltas)
+ cumulativeScale *= delta;
+ Assert.Equal(2.0f, cumulativeScale, 2);
+ }
+
+ [Fact]
+ public void FlingEventArgs_SpeedMatchesVelocityMagnitude()
+ {
+ var tracker = CreateTracker();
+ SKFlingGestureEventArgs? captured = null;
+ tracker.FlingDetected += (s, e) => captured = e;
+
+ SimulateFastSwipe(tracker, new SKPoint(100, 200), new SKPoint(500, 200));
+
+ Assert.NotNull(captured);
+ var expectedSpeed = (float)Math.Sqrt(captured.Velocity.X * captured.Velocity.X + captured.Velocity.Y * captured.Velocity.Y);
+ Assert.Equal(expectedSpeed, captured.Speed, 1);
+ tracker.Dispose();
+ }
+
+
+
+ [Fact]
+ public void Dispose_CalledTwice_DoesNotThrow()
+ {
+ var tracker = CreateTracker();
+ tracker.Dispose();
+ tracker.Dispose(); // should not throw
+ }
+
+
+
+ [Theory]
+ [InlineData(0f)]
+ [InlineData(-1f)]
+ [InlineData(-0.001f)]
+ public void ZoomTo_WithNonPositiveFactor_ThrowsArgumentOutOfRangeException(float factor)
+ {
+ var tracker = CreateTracker();
+ Assert.Throws(() => tracker.ZoomTo(factor, SKPoint.Empty));
+ }
+
+ [Theory]
+ [InlineData(float.NaN)]
+ [InlineData(float.PositiveInfinity)]
+ [InlineData(float.NegativeInfinity)]
+ public void ZoomTo_WithNonFiniteFactor_ThrowsArgumentOutOfRangeException(float factor)
+ {
+ var tracker = CreateTracker();
+ Assert.Throws(() => tracker.ZoomTo(factor, SKPoint.Empty));
+ }
+
+ [Fact]
+ public void PinchAndRotate_FiresOnlyOneTransformChangedPerFrame()
+ {
+ // Each two-finger move should fire exactly one TransformChanged, not two
+ // (one from pinch handler + one from rotate handler).
+ var tracker = CreateTracker();
+ var changeCount = 0;
+ tracker.TransformChanged += (s, e) => changeCount++;
+
+ // Set up two fingers in a position that generates both pinch and rotate
+ tracker.ProcessTouchDown(1, new SKPoint(100, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(300, 200));
+ AdvanceTime(10);
+
+ changeCount = 0; // Reset after setup touches
+ tracker.ProcessTouchMove(1, new SKPoint(50, 180));
+ tracker.ProcessTouchMove(2, new SKPoint(350, 220));
+
+ // Two moves → 2 frames, but each frame (triggered by move 1 AND move 2) should fire once
+ // Each ProcessTouchMove call may or may not trigger a frame depending on which finger moves.
+ // The important thing is that for each frame where pinch fires, rotate does NOT fire an extra event.
+ // With the fix: rotate fires TransformChanged (1 per frame), pinch does not.
+ // We can verify by checking the count doesn't double what a pinch-only gesture would produce.
+ Assert.True(changeCount <= 2, $"Expected at most 2 TransformChanged (one per move), got {changeCount}");
+ }
+
+ [Fact]
+ public void ProcessTouchCancel_DuringPinch_WithOneFingerRemaining_TransitionsToPan()
+ {
+ var tracker = CreateTracker();
+ var panDetected = false;
+ tracker.PanDetected += (s, e) => panDetected = true;
+
+ // Start a two-finger pinch
+ tracker.ProcessTouchDown(1, new SKPoint(100, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(300, 200));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(80, 200));
+ tracker.ProcessTouchMove(2, new SKPoint(320, 200));
+
+ // Cancel one finger
+ tracker.ProcessTouchCancel(2);
+
+ // Subsequent move with remaining finger should produce pan
+ panDetected = false;
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(60, 200));
+
+ Assert.True(panDetected, "Pan should be detected after one finger cancelled during pinch");
+ }
+
+
+
+ [Fact]
+ public void DragEnded_ReportsActualEndLocation_NotStartLocation()
+ {
+ // Regression: DragEnded was passing _dragStartLocation as both start and end,
+ // so CurrentLocation and Delta were always wrong.
+ var tracker = CreateTracker();
+ SKDragGestureEventArgs? dragEndedArgs = null;
+ tracker.DragEnded += (s, e) => dragEndedArgs = e;
+
+ var startPoint = new SKPoint(100, 100);
+ var midPoint = new SKPoint(150, 100);
+ var endPoint = new SKPoint(200, 100);
+
+ tracker.ProcessTouchDown(1, startPoint);
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, midPoint);
+ AdvanceTime(500); // Long pause to avoid fling
+ tracker.ProcessTouchMove(1, endPoint);
+ AdvanceTime(500);
+ tracker.ProcessTouchUp(1, endPoint);
+
+ Assert.NotNull(dragEndedArgs);
+ // Location must reflect the final touch position, not the previous
+ Assert.NotEqual(dragEndedArgs!.PreviousLocation, dragEndedArgs.Location);
+ Assert.Equal(endPoint.X, dragEndedArgs.Location.X, 1f);
+ Assert.Equal(endPoint.Y, dragEndedArgs.Location.Y, 1f);
+ }
+
+ [Fact]
+ public void FlingCompleted_DoesNotFire_WhenFlingInterruptedByNewGesture()
+ {
+ // Regression: StopFling() unconditionally raised FlingCompleted, so starting a new
+ // gesture while a fling was in progress incorrectly fired FlingCompleted.
+ var tracker = CreateTracker();
+ tracker.Options.FlingFriction = 0.001f; // Near-zero friction so fling persists
+ tracker.Options.FlingMinVelocity = 1f;
+ tracker.Options.FlingFrameInterval = TimeSpan.FromSeconds(1); // Slow timer — won't fire during test
+
+ var flingCompletedCount = 0;
+ tracker.FlingCompleted += (s, e) => flingCompletedCount++;
+
+ // Start a fling
+ SimulateFastSwipe(tracker, new SKPoint(100, 200), new SKPoint(500, 200));
+ Assert.True(tracker.IsFlinging, "Fling should be active after fast swipe");
+
+ // Interrupt with a new touch — should NOT fire FlingCompleted
+ tracker.ProcessTouchDown(1, new SKPoint(300, 300));
+
+ Assert.Equal(0, flingCompletedCount);
+ }
+
+ [Fact]
+ public void TimeProvider_SetNull_ThrowsArgumentNullException()
+ {
+ var tracker = CreateTracker();
+ Assert.Throws(() => tracker.TimeProvider = null!);
+ }
+
+ [Fact]
+ public void ZoomTo_AfterDispose_ThrowsObjectDisposedException()
+ {
+ var tracker = CreateTracker();
+ tracker.Dispose();
+ Assert.Throws(() => tracker.ZoomTo(2f, new SKPoint(100, 100)));
+ }
+
+ [Fact]
+ public void ScrollZoom_LargeNegativeDelta_ClampsScaleDeltaPositive()
+ {
+ var tracker = CreateTracker();
+ tracker.IsScrollZoomEnabled = true;
+ var transformFired = false;
+ tracker.TransformChanged += (s, e) => transformFired = true;
+
+ // A large negative delta that would make scaleDelta <= 0 without clamping
+ tracker.ProcessMouseWheel(new SKPoint(100, 100), 0, -100f);
+
+ Assert.True(transformFired);
+ Assert.True(tracker.Scale > 0, "Scale must remain positive");
+ }
+
+ [Fact]
+ public void ProcessTouchDown_WithNaNCoordinates_DoesNotCorruptState()
+ {
+ var tracker = CreateTracker();
+ tracker.ProcessTouchDown(1, new SKPoint(float.NaN, float.NaN));
+ tracker.ProcessTouchUp(1, new SKPoint(float.NaN, float.NaN));
+
+ // State should remain valid
+ Assert.False(float.IsNaN(tracker.Scale));
+ Assert.False(float.IsNaN(tracker.Offset.X));
+ Assert.False(float.IsNaN(tracker.Offset.Y));
+ }
+
+ [Fact]
+ public void ProcessTouchDown_WithInfinityCoordinates_DoesNotCorruptState()
+ {
+ var tracker = CreateTracker();
+ tracker.ProcessTouchDown(1, new SKPoint(float.PositiveInfinity, float.NegativeInfinity));
+ tracker.ProcessTouchUp(1, new SKPoint(float.PositiveInfinity, float.NegativeInfinity));
+
+ Assert.False(float.IsInfinity(tracker.Scale));
+ Assert.False(float.IsInfinity(tracker.Offset.X));
+ }
+
+ [Fact]
+ public void Detector_ProcessTouchDown_AfterDispose_ReturnsFalse()
+ {
+ var detector = new SKGestureDetector();
+ detector.Dispose();
+ var result = detector.ProcessTouchDown(1, new SKPoint(100, 100));
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void Detector_ProcessTouchMove_AfterDispose_ReturnsFalse()
+ {
+ var detector = new SKGestureDetector();
+ detector.Dispose();
+ var result = detector.ProcessTouchMove(1, new SKPoint(100, 100));
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void Detector_ProcessTouchUp_AfterDispose_ReturnsFalse()
+ {
+ var detector = new SKGestureDetector();
+ detector.Dispose();
+ var result = detector.ProcessTouchUp(1, new SKPoint(100, 100));
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void ZeroDistanceTouch_DoesNotTriggerPan()
+ {
+ var tracker = CreateTracker();
+ var panFired = false;
+ tracker.PanDetected += (s, e) => panFired = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ tracker.ProcessTouchMove(1, new SKPoint(100, 100)); // same point
+ tracker.ProcessTouchUp(1, new SKPoint(100, 100));
+
+ Assert.False(panFired);
+ }
+
+ [Fact]
+ public void Reset_ClearsAllTransformState()
+ {
+ var tracker = CreateTracker();
+ tracker.SetTransform(2f, 45f, new SKPoint(50, 50));
+
+ Assert.NotEqual(1f, tracker.Scale);
+ Assert.NotEqual(0f, tracker.Rotation);
+
+ tracker.Reset();
+
+ Assert.Equal(1f, tracker.Scale);
+ Assert.Equal(0f, tracker.Rotation);
+ Assert.Equal(SKPoint.Empty, tracker.Offset);
+ }
+
+ [Fact]
+ public void SetScale_ClampsToMinMax()
+ {
+ var tracker = new SKGestureTracker
+ {
+ TimeProvider = () => _testTicks
+ };
+ tracker.Options.MinScale = 0.5f;
+ tracker.Options.MaxScale = 3f;
+
+ tracker.SetScale(10f);
+ Assert.Equal(3f, tracker.Scale);
+
+ tracker.SetScale(0.1f);
+ Assert.Equal(0.5f, tracker.Scale);
+ }
+
+ [Fact]
+ public void PinchAndPan_Simultaneously_BothApply()
+ {
+ var tracker = CreateTracker();
+ var panFired = false;
+ var pinchFired = false;
+ tracker.PanDetected += (s, e) => panFired = true;
+ tracker.PinchDetected += (s, e) => pinchFired = true;
+
+ // Two finger down
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ tracker.ProcessTouchDown(2, new SKPoint(200, 100));
+
+ // Move both fingers apart and to the right (pinch + pan)
+ tracker.ProcessTouchMove(1, new SKPoint(80, 100));
+ tracker.ProcessTouchMove(2, new SKPoint(250, 100));
+
+ Assert.True(pinchFired);
+ // Pan during pinch should also update offset
+ Assert.NotEqual(SKPoint.Empty, tracker.Offset);
+ }
+
+ [Fact]
+ public void IsPinchEnabled_False_PanEnabled_True_PinchDetected_NotRaised()
+ {
+ // Bug fix: PinchDetected was incorrectly raised when only IsPanEnabled=true
+ var tracker = CreateTracker();
+ tracker.IsPinchEnabled = false;
+ tracker.IsPanEnabled = true;
+ var pinchFired = false;
+ tracker.PinchDetected += (s, e) => pinchFired = true;
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ tracker.ProcessTouchDown(2, new SKPoint(200, 100));
+ tracker.ProcessTouchMove(1, new SKPoint(50, 100));
+ tracker.ProcessTouchMove(2, new SKPoint(250, 100));
+
+ Assert.False(pinchFired, "PinchDetected must not fire when IsPinchEnabled is false");
+ }
+
+ [Fact]
+ public void SetScaleRange_SetsMinAndMaxAtomically()
+ {
+ var options = new SKGestureTrackerOptions();
+ options.SetScaleRange(15f, 20f);
+
+ Assert.Equal(15f, options.MinScale);
+ Assert.Equal(20f, options.MaxScale);
+ }
+
+ [Fact]
+ public void SetScaleRange_InvalidOrder_Throws()
+ {
+ var options = new SKGestureTrackerOptions();
+ Assert.Throws(() => options.SetScaleRange(20f, 15f));
+ }
+
+ [Fact]
+ public void SetScaleRange_EqualValues_Throws()
+ {
+ var options = new SKGestureTrackerOptions();
+ Assert.Throws(() => options.SetScaleRange(5f, 5f));
+ }
+
+ [Fact]
+ public void SetScaleRange_NegativeMin_Throws()
+ {
+ var options = new SKGestureTrackerOptions();
+ Assert.Throws(() => options.SetScaleRange(-1f, 10f));
+ }
+ [Fact]
+ public void PinchWithPanDisabled_FingersTranslate_OffsetDoesNotChange()
+ {
+ // Bug fix: when IsPanEnabled=false, moving the finger midpoint during a pinch
+ // (translation, no scale change) should not cause the content to pan.
+ var tracker = CreateTracker();
+ tracker.IsPanEnabled = false;
+ tracker.IsPinchEnabled = true;
+ tracker.IsRotateEnabled = false; // isolate pinch-only behavior
+
+ var initialOffset = tracker.Offset;
+
+ // Both fingers go down 100px apart
+ tracker.ProcessTouchDown(1, new SKPoint(150, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(250, 200));
+
+ // Both fingers translate upward together (same distance = scale delta 1.0)
+ tracker.ProcessTouchMove(1, new SKPoint(150, 100));
+ tracker.ProcessTouchMove(2, new SKPoint(250, 100));
+
+ // Translate further
+ tracker.ProcessTouchMove(1, new SKPoint(150, 50));
+ tracker.ProcessTouchMove(2, new SKPoint(250, 50));
+
+ // Offset must not change -- pure translation with pan disabled should be ignored
+ Assert.Equal(initialOffset.X, tracker.Offset.X, 1f);
+ Assert.Equal(initialOffset.Y, tracker.Offset.Y, 1f);
+ }
+
+ [Fact]
+ public void RotateWithPanDisabled_FingersTranslate_OffsetDoesNotChange()
+ {
+ // Bug fix: when IsPanEnabled=false, moving the finger midpoint during rotation
+ // should not cause content drift -- the pivot must be locked via GetEffectiveGesturePivot.
+ var tracker = CreateTracker();
+ tracker.IsPanEnabled = false;
+ tracker.IsPinchEnabled = false; // isolate rotation-only behavior
+ tracker.IsRotateEnabled = true;
+
+ var initialOffset = tracker.Offset;
+
+ // Both fingers down, equidistant from center
+ tracker.ProcessTouchDown(1, new SKPoint(150, 200));
+ tracker.ProcessTouchDown(2, new SKPoint(250, 200));
+
+ // Both fingers translate upward together (no scale change, some rotation)
+ tracker.ProcessTouchMove(1, new SKPoint(150, 100));
+ tracker.ProcessTouchMove(2, new SKPoint(250, 100));
+
+ // Translate further
+ tracker.ProcessTouchMove(1, new SKPoint(150, 50));
+ tracker.ProcessTouchMove(2, new SKPoint(250, 50));
+
+ // Offset must not change -- translation with pan disabled should be ignored
+ Assert.Equal(initialOffset.X, tracker.Offset.X, 1f);
+ Assert.Equal(initialOffset.Y, tracker.Offset.Y, 1f);
+ }
+
+}
diff --git a/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerTransformTests.cs b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerTransformTests.cs
new file mode 100644
index 0000000000..e3e49f5e4d
--- /dev/null
+++ b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerTransformTests.cs
@@ -0,0 +1,255 @@
+using SkiaSharp;
+using System;
+using Xunit;
+
+namespace SkiaSharp.Extended.Tests.Gestures;
+
+/// Tests for matrix composition, pivot, and SetScale/SetRotation in .
+public class SKGestureTrackerTransformTests
+{
+ private long _testTicks = 1000000;
+
+ private SKGestureTracker CreateTracker()
+ {
+ var tracker = new SKGestureTracker
+ {
+ TimeProvider = () => _testTicks
+ };
+ return tracker;
+ }
+
+ private void AdvanceTime(long milliseconds)
+ {
+ _testTicks += milliseconds * TimeSpan.TicksPerMillisecond;
+ }
+
+
+ [Fact]
+ public void Matrix_AtIdentity_IsIdentity()
+ {
+ var tracker = CreateTracker();
+
+ var m = tracker.Matrix;
+ var pt = m.MapPoint(200, 200);
+ Assert.Equal(200, pt.X, 0.1);
+ Assert.Equal(200, pt.Y, 0.1);
+ }
+
+ [Fact]
+ public void Matrix_AfterScrollAtCenter_CenterUnchanged()
+ {
+ var tracker = CreateTracker();
+
+ // Scroll zoom at center of view
+ tracker.ProcessMouseWheel(new SKPoint(200, 200), 0, 1f);
+
+ var m = tracker.Matrix;
+ var pt = m.MapPoint(200, 200);
+ Assert.Equal(200, pt.X, 5);
+ Assert.Equal(200, pt.Y, 5);
+ }
+
+ [Fact]
+ public void Matrix_AfterPan_PointsShifted()
+ {
+ var tracker = CreateTracker();
+
+ tracker.ProcessTouchDown(1, new SKPoint(100, 100));
+ AdvanceTime(10);
+ tracker.ProcessTouchMove(1, new SKPoint(120, 100));
+ AdvanceTime(500);
+ tracker.ProcessTouchUp(1, new SKPoint(120, 100));
+
+ var m = tracker.Matrix;
+ var origin = m.MapPoint(200, 200);
+ // Pan moved right so the mapped point should shift right
+ Assert.True(origin.X > 200, $"Mapped X should shift right, was {origin.X}");
+ }
+
+
+
+ [Fact]
+ public void SetScale_WithPivot_AdjustsOffset()
+ {
+ var tracker = CreateTracker();
+ var pivot = new SKPoint(100, 100);
+
+ // Map pivot before scale
+ var before = tracker.Matrix.MapPoint(pivot);
+
+ tracker.SetScale(2f, pivot);
+
+ // The pivot point should stay at the same screen location
+ var after = tracker.Matrix.MapPoint(pivot);
+ Assert.Equal(before.X, after.X, 1);
+ Assert.Equal(before.Y, after.Y, 1);
+ }
+
+ [Fact]
+ public void SetScale_WithPivot_NonZeroInitialOffset_PivotRemainsFixed()
+ {
+ // Regression: AdjustOffsetForPivot previously ignored _offset when converting
+ // pivot to content space, causing the pivot point to jump when content was panned.
+ var tracker = CreateTracker();
+ tracker.SetTransform(scale: 1f, rotation: 0f, offset: new SKPoint(50, 30));
+
+ // Choose a content-space point and find its current screen position (the pivot)
+ var contentPoint = new SKPoint(100, 100);
+ var screenPivot = tracker.Matrix.MapPoint(contentPoint);
+
+ tracker.SetScale(2f, screenPivot);
+
+ // The content point should still map to the same screen position
+ var after = tracker.Matrix.MapPoint(contentPoint);
+ Assert.Equal(screenPivot.X, after.X, 1);
+ Assert.Equal(screenPivot.Y, after.Y, 1);
+ }
+
+ [Fact]
+ public void SetRotation_WithPivot_NonZeroInitialOffset_PivotRemainsFixed()
+ {
+ // Regression: AdjustOffsetForPivot previously ignored _offset when converting
+ // pivot to content space, causing the pivot point to drift during rotation.
+ var tracker = CreateTracker();
+ tracker.SetTransform(scale: 1f, rotation: 0f, offset: new SKPoint(50, 30));
+
+ var contentPoint = new SKPoint(100, 100);
+ var screenPivot = tracker.Matrix.MapPoint(contentPoint);
+
+ tracker.SetRotation(45f, screenPivot);
+
+ var after = tracker.Matrix.MapPoint(contentPoint);
+ Assert.Equal(screenPivot.X, after.X, 1);
+ Assert.Equal(screenPivot.Y, after.Y, 1);
+ }
+
+ [Fact]
+ public void SetScale_WithPivot_NonZeroScaleAndRotation_PivotRemainsFixed()
+ {
+ // Verify pivot correctness when both scale and rotation are non-trivial.
+ var tracker = CreateTracker();
+ tracker.SetTransform(scale: 1.5f, rotation: 30f, offset: new SKPoint(20, -10));
+
+ var contentPoint = new SKPoint(80, 60);
+ var screenPivot = tracker.Matrix.MapPoint(contentPoint);
+
+ tracker.SetScale(3f, screenPivot);
+
+ var after = tracker.Matrix.MapPoint(contentPoint);
+ Assert.Equal(screenPivot.X, after.X, 1);
+ Assert.Equal(screenPivot.Y, after.Y, 1);
+ }
+
+ [Fact]
+ public void SetScale_WithoutPivot_ScalesFromOrigin()
+ {
+ var tracker = CreateTracker();
+
+ tracker.SetScale(2f);
+
+ Assert.Equal(2f, tracker.Scale);
+ Assert.Equal(SKPoint.Empty, tracker.Offset);
+ }
+
+ [Fact]
+ public void SetRotation_WithPivot_AdjustsOffset()
+ {
+ var tracker = CreateTracker();
+ var pivot = new SKPoint(100, 100);
+
+ var before = tracker.Matrix.MapPoint(pivot);
+
+ tracker.SetRotation(45f, pivot);
+
+ var after = tracker.Matrix.MapPoint(pivot);
+ Assert.Equal(before.X, after.X, 1);
+ Assert.Equal(before.Y, after.Y, 1);
+ }
+
+ [Fact]
+ public void Matrix_NoViewSize_StillWorks()
+ {
+ var tracker = CreateTracker();
+ tracker.SetTransform(scale: 1f, rotation: 0f, offset: new SKPoint(10, 20));
+
+ var m = tracker.Matrix;
+ // Point (0,0) should map to (10, 20) in screen space at scale 1
+ var origin = m.MapPoint(0, 0);
+ Assert.Equal(10, origin.X, 1);
+ Assert.Equal(20, origin.Y, 1);
+ }
+
+
+
+ [Fact]
+ public void SetScale_NegativeValue_ClampsToMinScale()
+ {
+ var tracker = CreateTracker();
+ tracker.SetScale(-5f);
+ Assert.Equal(tracker.Options.MinScale, tracker.Scale);
+ }
+
+ [Fact]
+ public void SetScale_Zero_ClampsToMinScale()
+ {
+ var tracker = CreateTracker();
+ tracker.SetScale(0f);
+ Assert.Equal(tracker.Options.MinScale, tracker.Scale);
+ }
+
+ [Fact]
+ public void SetScale_AboveMaxScale_ClampsToMaxScale()
+ {
+ var tracker = CreateTracker();
+ tracker.SetScale(999f);
+ Assert.Equal(tracker.Options.MaxScale, tracker.Scale);
+ }
+
+
+
+ [Fact]
+ public void SetScale_RaisesTransformChanged()
+ {
+ var tracker = CreateTracker();
+ var fired = 0;
+ tracker.TransformChanged += (s, e) => fired++;
+
+ tracker.SetScale(2f);
+ Assert.Equal(1, fired);
+ }
+
+ [Fact]
+ public void SetRotation_RaisesTransformChanged()
+ {
+ var tracker = CreateTracker();
+ var fired = 0;
+ tracker.TransformChanged += (s, e) => fired++;
+
+ tracker.SetRotation(45f);
+ Assert.Equal(1, fired);
+ }
+
+ [Fact]
+ public void SetOffset_RaisesTransformChanged()
+ {
+ var tracker = CreateTracker();
+ var fired = 0;
+ tracker.TransformChanged += (s, e) => fired++;
+
+ tracker.SetOffset(new SKPoint(100, 200));
+ Assert.Equal(1, fired);
+ }
+
+ [Fact]
+ public void SetTransform_RaisesTransformChanged()
+ {
+ var tracker = CreateTracker();
+ var fired = 0;
+ tracker.TransformChanged += (s, e) => fired++;
+
+ tracker.SetTransform(2f, 45f, new SKPoint(100, 200));
+ Assert.Equal(1, fired);
+ }
+
+
+}
diff --git a/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerZoomScrollTests.cs b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerZoomScrollTests.cs
new file mode 100644
index 0000000000..424d401df7
--- /dev/null
+++ b/tests/SkiaSharp.Extended.Tests/Gestures/SKGestureTrackerZoomScrollTests.cs
@@ -0,0 +1,160 @@
+using SkiaSharp;
+using System;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace SkiaSharp.Extended.Tests.Gestures;
+
+/// Tests for double-tap zoom and scroll zoom in .
+public class SKGestureTrackerZoomScrollTests
+{
+ private long _testTicks = 1000000;
+
+ private SKGestureTracker CreateTracker()
+ {
+ var tracker = new SKGestureTracker
+ {
+ TimeProvider = () => _testTicks
+ };
+ return tracker;
+ }
+
+ private void AdvanceTime(long milliseconds)
+ {
+ _testTicks += milliseconds * TimeSpan.TicksPerMillisecond;
+ }
+
+ private void SimulateDoubleTap(SKGestureTracker tracker, SKPoint location)
+ {
+ tracker.ProcessTouchDown(1, location);
+ AdvanceTime(50);
+ tracker.ProcessTouchUp(1, location);
+ AdvanceTime(100);
+ tracker.ProcessTouchDown(1, location);
+ AdvanceTime(50);
+ tracker.ProcessTouchUp(1, location);
+ }
+
+
+ [Fact]
+ public void DoubleTap_StartsZoomAnimation()
+ {
+ var tracker = CreateTracker();
+
+ SimulateDoubleTap(tracker, new SKPoint(200, 200));
+
+ Assert.True(tracker.IsZoomAnimating);
+ tracker.Dispose();
+ }
+
+ [Fact]
+ public async Task DoubleTap_ScaleChangesToDoubleTapZoomFactor()
+ {
+ var tracker = CreateTracker();
+ tracker.Options.DoubleTapZoomFactor = 2f;
+ tracker.Options.ZoomAnimationDuration = TimeSpan.FromMilliseconds(100);
+
+ SimulateDoubleTap(tracker, new SKPoint(200, 200));
+
+ // Advance test time past animation duration so timer tick sees it as complete
+ _testTicks += 200 * TimeSpan.TicksPerMillisecond;
+ await Task.Delay(200);
+
+ Assert.Equal(2f, tracker.Scale, 0.1);
+ tracker.Dispose();
+ }
+
+ [Fact]
+ public async Task DoubleTap_AtMaxScale_ResetsToOne()
+ {
+ var tracker = CreateTracker();
+ tracker.Options.DoubleTapZoomFactor = 2f;
+ tracker.Options.MaxScale = 2f;
+ tracker.Options.ZoomAnimationDuration = TimeSpan.FromMilliseconds(100);
+
+ // First double tap: zoom to 2x
+ SimulateDoubleTap(tracker, new SKPoint(200, 200));
+ _testTicks += 200 * TimeSpan.TicksPerMillisecond;
+ await Task.Delay(200);
+ Assert.Equal(2f, tracker.Scale, 0.1);
+
+ // Second double tap at max: should reset to 1.0
+ AdvanceTime(500);
+ SimulateDoubleTap(tracker, new SKPoint(200, 200));
+ _testTicks += 200 * TimeSpan.TicksPerMillisecond;
+ await Task.Delay(200);
+ Assert.Equal(1f, tracker.Scale, 0.1);
+
+ tracker.Dispose();
+ }
+
+ [Fact]
+ public async Task DoubleTap_FiresTransformChanged()
+ {
+ var tracker = CreateTracker();
+ tracker.Options.ZoomAnimationDuration = TimeSpan.FromMilliseconds(100);
+ var changeCount = 0;
+ tracker.TransformChanged += (s, e) => changeCount++;
+
+ SimulateDoubleTap(tracker, new SKPoint(200, 200));
+ _testTicks += 200 * TimeSpan.TicksPerMillisecond;
+ await Task.Delay(200);
+
+ Assert.True(changeCount > 0, "TransformChanged should fire during zoom animation");
+ tracker.Dispose();
+ }
+
+
+
+ [Fact]
+ public void ScrollUp_IncreasesScale()
+ {
+ var tracker = CreateTracker();
+
+ tracker.ProcessMouseWheel(new SKPoint(200, 200), 0, 1f);
+
+ Assert.True(tracker.Scale > 1.0f, $"Scale should increase on scroll up, was {tracker.Scale}");
+ }
+
+ [Fact]
+ public void ScrollDown_DecreasesScale()
+ {
+ var tracker = CreateTracker();
+
+ tracker.ProcessMouseWheel(new SKPoint(200, 200), 0, -1f);
+
+ Assert.True(tracker.Scale < 1.0f, $"Scale should decrease on scroll down, was {tracker.Scale}");
+ }
+
+ [Fact]
+ public void Scroll_FiresTransformChanged()
+ {
+ var tracker = CreateTracker();
+ var transformChanged = false;
+ tracker.TransformChanged += (s, e) => transformChanged = true;
+
+ tracker.ProcessMouseWheel(new SKPoint(200, 200), 0, 1f);
+
+ Assert.True(transformChanged);
+ }
+
+ [Fact]
+ public void Scroll_ScaleClampedToMinMax()
+ {
+ var tracker = CreateTracker();
+ tracker.Options.MinScale = 0.5f;
+ tracker.Options.MaxScale = 3f;
+
+ for (int i = 0; i < 100; i++)
+ tracker.ProcessMouseWheel(new SKPoint(200, 200), 0, -1f);
+
+ Assert.True(tracker.Scale >= 0.5f, "Scale should not go below MinScale");
+
+ for (int i = 0; i < 200; i++)
+ tracker.ProcessMouseWheel(new SKPoint(200, 200), 0, 1f);
+
+ Assert.True(tracker.Scale <= 3f, "Scale should not exceed MaxScale");
+ }
+
+
+}