Skip to content

Add wheel support to iOS/iPadOS and Mac Catalyst MAUI views with v120 normalization #3537

@mattleibow

Description

@mattleibow

Overview

Add scroll wheel support to SkiaSharp's iOS/iPadOS and Mac Catalyst MAUI views using UIPanGestureRecognizer with allowedScrollTypesMask, normalized to the v120 standard (120 = one discrete mouse wheel notch).

Parent issue: #3533

Current State

The Apple SKTouchHandler (source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Platform/Apple/SKTouchHandler.cs) currently:

  • Handles: TouchesBegan, TouchesMoved, TouchesEnded, TouchesCancelled via UIGestureRecognizer
  • Does NOT have any scroll/wheel gesture recognizer
  • Has no wheel delta support

Platform Details

iOS / iPadOS (13.4+)

Native API: UIPanGestureRecognizer with allowedScrollTypesMask

let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handleScroll))
panRecognizer.allowedScrollTypesMask = [.discrete, .continuous]
// Optionally disable touch-based panning:
// panRecognizer.allowedTouchTypes = []
view.addGestureRecognizer(panRecognizer)

@objc func handleScroll(_ gesture: UIPanGestureRecognizer) {
    let translation = gesture.translation(in: view)
    // translation.y: CGFloat in UIKit points (cumulative since gesture began)
    // Must compute incremental delta by tracking previous translation
    gesture.setTranslation(.zero, in: view) // reset for incremental deltas
}

Scroll Type Distinction

⚠️ Limitation: allowedScrollTypesMask is a filter that configures which scroll types the recognizer accepts — it is NOT a per-event classifier. Once configured with [.discrete, .continuous], there is no public UIPanGestureRecognizer property to determine whether the current event came from a discrete mouse wheel or continuous trackpad. This means the normalization formula must work reasonably for both input types. A future enhancement could use heuristics (e.g., velocity magnitude) to distinguish them.

Raw Values

Device Type translation.y per notch Notes
Mouse wheel .discrete Varies by device/OS See calibration note below
Trackpad .continuous ±1-20 points per event Smooth, fractional
Magic Mouse .continuous ±1-20 points per event Same as trackpad

⚠️ Calibration Note: There is no public iOS API that exposes a "points per notch" constant. The allowedScrollTypesMask only classifies input as .discrete vs .continuous — it provides no normalization factor. Developer reports show values ranging from ~22 to ~60 points per notch depending on device, iOS version, and system acceleration settings. This value must be calibrated empirically on real hardware.

Sign Convention

In UIKit, the coordinate origin is at the top-left with Y increasing downward:

  • translation.y < 0 = finger moved toward top of screen (up)
  • translation.y > 0 = finger moved toward bottom of screen (down)

Mapping to scroll semantics:
When a user moves their finger up on a trackpad or scrolls a mouse wheel forward, content scrolls upward (revealing content below). In UIKit terms, this is a negative translation.y. In the v120 standard (matching Windows), "scroll up" = positive.

⚠️ Note on terminology: "Scroll up" is ambiguous — it can mean the gesture direction (finger/wheel moves up) or the content direction (content moves up). SkiaSharp follows the Windows convention: positive WheelDelta = wheel rotated forward/away from user = content moves down = user scrolls "up" through content. Therefore: negate translation.y to match v120.

Mac Catalyst

Mac Catalyst bridges NSEvent scroll wheel events from AppKit. The same UIPanGestureRecognizer approach works, or you can directly use NSEvent via Catalyst bridging for more precise control:

// Mac Catalyst can also receive NSEvent scroll events
// The UIPanGestureRecognizer approach works for both iOS and Catalyst

Official Documentation

Normalization Logic

The challenge on iOS is that translation is in UIKit points, not a standardized unit. The scaling factor needs to map "one mouse notch" to 120.

// Approach: Use a calibration constant for discrete mouse input.
// This MUST be validated on real hardware — Apple provides no API for this.
const double PointsPerNotch = 40.0; // Initial estimate — needs empirical testing!

double translationY = gesture.TranslationInView(view).Y;
gesture.SetTranslation(CGPoint.Empty, view); // reset for incremental

// Negate: UIKit negative-Y (finger up) = v120 positive (scroll up)
int wheelDelta = (int)Math.Round(-translationY * 120.0 / PointsPerNotch);

⚠️ Important: The PointsPerNotch value is an initial estimate. It has been reported as anywhere from 22 to 60 depending on the mouse and iOS version. During development, log raw translation.y values with a known mouse to determine the actual value on your test hardware. Consider making this configurable or using heuristics based on the UIScrollType if it becomes available per-event.

Expected Results (calibration-dependent)

Input Calculation (assuming 40pt/notch) WheelDelta
Mouse notch up (-40pt) round(-(-40) × 3.0) 120
Mouse notch down (40pt) round(-(40) × 3.0) -120
Trackpad micro (-2pt) round(-(-2) × 3.0) 6
Trackpad large (-15pt) round(-(-15) × 3.0) 45

Implementation Notes

  • Minimum iOS version: 13.4 (when allowedScrollTypesMask was introduced). Guard with @available(iOS 13.4, *) or equivalent MAUI version check.
  • The Apple SKTouchHandler is shared between iOS, macOS, and Mac Catalyst — use platform #if guards or separate the scroll handling
  • Add a new UIPanGestureRecognizer specifically for scroll (separate from touch panning)
  • Set allowedTouchTypes = [] on the scroll recognizer to prevent it from also capturing finger touches
  • Fire SKTouchAction.WheelChanged with SKTouchDeviceType.Mouse
  • Use gesture.Location(in: view) for pointer position
  • Reset translation with gesture.SetTranslation(CGPoint.Empty, view) each event to get incremental deltas
  • Gesture states: Handle .began, .changed, .ended — fire WheelChanged on .changed
  • Feed args.Handled back appropriately
  • Empirical calibration required: Test with physical Bluetooth mouse + iPad and Magic Keyboard trackpad. Log raw values to determine actual PointsPerNotch before finalizing.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    New

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions