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 + + + + 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
+ } +
+ +
+ + + @if (_showSettings) + { +
+
Feature Toggles
+
+ + + + +
+
+ + + + +
+
+ + + + +
+ +
Options
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
Current State
+
+ Scale: @_tracker.Scale.ToString("F2")x  |  + Rotation: @_tracker.Rotation.ToString("F1")°  |  + Offset: (@_tracker.Offset.X.ToString("F0"), @_tracker.Offset.Y.ToString("F0")) +
+ + +
+ } +
+ +@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"); + } + + +}