Skip to content

Add wheel support to macOS MAUI views with v120 normalization #3536

@mattleibow

Description

@mattleibow

Overview

Add scroll wheel support to SkiaSharp's macOS MAUI views by overriding scrollWheel(with:) in the Apple SKTouchHandler and normalizing NSEvent.scrollingDeltaY values 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
  • Does NOT override: scrollWheel(with:)
  • Has no wheel delta support whatsoever

Platform Details

Native API

NSEvent scroll wheel properties:

override func scrollWheel(with event: NSEvent) {
    let deltaY = event.scrollingDeltaY
    let isPrecise = event.hasPreciseScrollingDeltas
    let isInverted = event.isDirectionInvertedFromDevice
    // deltaY: CGFloat
    // isPrecise: Bool — true for trackpad/Magic Mouse, false for mouse wheel
    // isInverted: Bool — true when natural scrolling is enabled
}

Raw Values Per Discrete Mouse Notch

Device hasPreciseScrollingDeltas scrollingDeltaY Notes
Standard mouse wheel false ±1.0 per notch "Line" units
Fast mouse scroll false ±2.0, ±3.0 System acceleration
Trackpad true Fractional, variable "Pixel-like" units — NOT line units
Magic Mouse true Fractional, variable Same as trackpad

⚠️ Important: Apple does not publish official numeric ranges for trackpad scrollingDeltaY. Values are device-dependent and vary with gesture speed. Do not hardcode scaling assumptions — use hasPreciseScrollingDeltas to branch logic.

Sign Convention & Natural Scrolling

  • scrollingDeltaY > 0 = scroll up (content moves down) — when natural scrolling is OFF
  • scrollingDeltaY < 0 = scroll down (content moves up) — when natural scrolling is OFF

⚠️ CRITICAL: scrollingDeltaY does NOT always reflect the physical gesture direction. When the user has "Natural scrolling" enabled in System Preferences, AppKit automatically inverts the delta values before delivering them to your app. Apple's documentation states:

"The user may choose to change the scrolling behavior such that it feels like they are moving the content instead of the scroll bar. To accomplish this, deltaX and deltaY and scrollingDeltaX and scrollingDeltaY values are automatically inverted for NSEventScrollWheel events according to the user's preferences."
NSEvent.isDirectionInvertedFromDevice

Use isDirectionInvertedFromDevice to detect this. If you need the physical hardware direction (for v120 normalization), compensate by negating when isDirectionInvertedFromDevice == true.

Distinguishing Mouse vs Trackpad (REQUIRED)

Using the same scaling formula for both mouse and trackpad will produce wildly incorrect results. Apple's documentation states:

"When hasPreciseScrollingDeltas is false, multiply the value returned by scrollingDeltaY by the line or row height. Otherwise scroll by the returned amount."
NSEvent.scrollingDeltaY

  • hasPreciseScrollingDeltas == falsediscrete mouse wheel (line units: ~1.0 per notch). Multiply by 120 to get v120.
  • hasPreciseScrollingDeltas == truecontinuous trackpad or Magic Mouse (pixel-like units). These are already in a fine-grained unit — do NOT multiply by 120 or you'll get extreme values.

Official Documentation

Normalization Logic

// In scrollWheel(with:) handler:
double deltaY = nsEvent.ScrollingDeltaY;

// Step 1: Undo natural scrolling inversion to get physical direction
if (nsEvent.IsDirectionInvertedFromDevice)
    deltaY = -deltaY;

// Step 2: Scale based on device type
int wheelDelta;
if (!nsEvent.HasPreciseScrollingDeltas)
{
    // Mouse wheel: line units (~1.0 per notch). Direct ×120.
    wheelDelta = (int)Math.Round(deltaY * 120.0);
}
else
{
    // Trackpad/Magic Mouse: pixel-like units.
    // These are already fine-grained. Scale proportionally.
    // Calibration: trackpad values are roughly in "points scrolled"
    // and don't map 1:1 to mouse notches. Pass as sub-notch v120.
    wheelDelta = (int)Math.Round(deltaY * 120.0 / 10.0);
    // A 10-point trackpad swipe → 120 (one notch equivalent)
    // A 1-point micro gesture → 12 (sub-notch)
}

⚠️ Note: The trackpad scaling factor (/ 10.0) is an initial estimate. The exact relationship between trackpad points and mouse notches is not defined by Apple and may vary by device. This should be validated on hardware and may need tuning.

Expected Results

Input hasPrecise isInverted Calculation WheelDelta
Mouse notch up (1.0) false false round(1.0 × 120) 120
Mouse notch up (1.0), natural scrolling ON false true round(-1.0 × -1 × 120) 120
Mouse notch down (-1.0) false false round(-1.0 × 120) -120
Fast mouse (3.0) false false round(3.0 × 120) 360
Trackpad 10pt swipe up true false round(10.0 × 12) 120
Trackpad 1pt micro true false round(1.0 × 12) 12

Implementation Notes

  • Need to add scrollWheel(with:) override to the macOS view or gesture recognizer setup
  • The Apple SKTouchHandler is shared between iOS, macOS, and Mac Catalyst — may need platform-specific #if for macOS scroll wheel
  • Fire SKTouchAction.WheelChanged with SKTouchDeviceType.Mouse
  • Get pointer position from nsEvent.LocationInWindow converted to view coordinates
  • Set inContact = false
  • Feed args.Handled back to the event system
  • Reference: Windows handler in Platform/Windows/SKTouchHandler.cs for the pattern
  • Consider also handling scrollingDeltaX for horizontal scroll (future enhancement)
  • Always handle isDirectionInvertedFromDevice — most Mac users have natural scrolling enabled

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