NSScrollView: Overriding "scrollWheel(with:)" can cause a never-ending live scroll

Overview

I'm developing an app that depends on NSScrollView to implement a scrollable and zoomable canvas. I need to override NSScrollView.scrollWheel(with:) to implement Option-zooming (holding Option while scrolling up/down to zoom in/out).

I've found that simply adding these three lines of code in my NSScrollView subclass:

override func scrollWheel(with event: NSEvent) {
    super.scrollWheel(with: event)
}

is enough to cause a live scroll to never end, and NSScrollView.didEndLiveScrollNotification to never fire. My app depends on that notification firing so that it can disable some interactions during a live scroll.

Furthermore, while the NSScrollView is "stuck" in the live scroll, it's not possible to perform a live magnify. There are also some other effects, including elevated CPU usage and persistent scroll indicators.

The issue is occurring with Xcode 27.1 (27A9275) and macOS 27.2 (26B123).

I have modified the macOS App project template to showcase the issue. The sample project, along with a screen recording of the issue, can be downloaded from here:

https://drive.google.com/drive/folders/1F5sF2NA230NkGbQdLfbV6vM1XmfY1-uT?usp=sharing

I hope that Apple can fix this issue soon, as it's a frustrating user experience when the canvas gets stuck while scrolling.

I've already filed this in Feedback Assistant under FB25362729.

Steps to Reproduce

To recreate the issue, follow these steps:

  1. Download and extract the "Xcode Project.zip" file from the link above.
  2. Open the extracted "NSScrollViewTest" project in Xcode.
  3. Build and run the "NSScrollViewTest" app.
  4. Scroll all the way to the right edge of the gray canvas using the horizontal scroll gesture on Magic Trackpad.

If you comment out lines 8–10 of ScrollView.swift, the issue goes away.

The "Issue Recording.mov" file from the link above provides a detailed video of how to recreate the issue.

Answered by DTS Engineer in 913319022

Thanks for the reduced test case and for filing FB25362729. I ran your NSScrollViewTest project, built with Xcode 27.1, in macOS 27.0.1 and reproduced the stuck live scroll. After about a dozen horizontal trackpad scrolls, the last live scroll didn't end. When I quit the app 48 seconds later, didEndLiveScrollNotification still hadn't arrived. So the problem isn't limited to macOS 27.2. That might be worth adding to your report.

In a test app of my own with your configuration, two variations of the override also misbehaved. One zooms on Option-scroll and calls super for everything else. The other puts the override in the document view instead of the scroll view. In both, live scrolls kept running across several separate gestures, and some pinches made during them didn't start a live magnify.

Handling Option-scroll in a local event monitor, with no scrollWheel(with:) override at all, avoided the problem in my tests. Trackpad scrolling arrives as scroll-wheel events that carry a phase. The code uses the phase to decide once per gesture, when the gesture begins, whether to zoom, and keeps that decision through momentum:

private var scrollWheelMonitor: Any?

private var isOptionZoomGesture = false

// In viewDidLoad():
scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in
    guard let self, self.zoomIfOptionScroll(event) else { return event }
    return nil
}

/// Zooms for Option-scroll and returns true, so the scroll view never sees the event.
/// A trackpad gesture is decided once, when it begins, and keeps that decision through momentum.
private func zoomIfOptionScroll(_ event: NSEvent) -> Bool {
    guard event.window === scrollView.window else { return false }

    let isOverScrollView = scrollView.bounds.contains(scrollView.convert(event.locationInWindow, from: nil))
    let wantsZoom = isOverScrollView && event.modifierFlags.contains(.option)

    if event.phase.contains(.mayBegin) || event.phase.contains(.began) {
        isOptionZoomGesture = wantsZoom
    }
    let isGestureEvent = !event.phase.isEmpty || !event.momentumPhase.isEmpty
    guard isGestureEvent ? isOptionZoomGesture : wantsZoom else { return false }

    if event.momentumPhase.isEmpty {
        let delta = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY : event.scrollingDeltaY * 8
        let point = scrollView.contentView.convert(event.locationInWindow, from: nil)
        scrollView.setMagnification(scrollView.magnification * pow(1.01, delta), centeredAt: point)
    }
    return true
}

I removed the override from your ScrollView and added this code to your ViewController. In a test run after that, all 40 live scrolls ended, each within 3 seconds. Option-zoom worked with both the trackpad and a mouse wheel, and so did pinching. I haven't tried this on macOS 27.2. The addLocalMonitorForEvents(matching:handler:) documentation says you must call removeMonitor(_:) to stop the monitor. If your view controller can be released before the app quits, remove the monitor at that point.

That's a gesture, not a scroll. This method was designed for a mouse wheel and may or may not be simulated properly on modern systems. There are many historical layers of hacks on top of each other to support mice, mouse wheels, trackpads, elasticity, etc.

Obviously your overridden method does nothing, so you shouldn't override it.

The question you want to answer is what are you trying to accomplish by overriding scrollWheel(with:)? There's probably a better way to do it. The only time I ever override scrollWheel(with:) is to prevent scrolling, or otherwise hack some other system bug that would be worse.

Thanks for the reduced test case and for filing FB25362729. I ran your NSScrollViewTest project, built with Xcode 27.1, in macOS 27.0.1 and reproduced the stuck live scroll. After about a dozen horizontal trackpad scrolls, the last live scroll didn't end. When I quit the app 48 seconds later, didEndLiveScrollNotification still hadn't arrived. So the problem isn't limited to macOS 27.2. That might be worth adding to your report.

In a test app of my own with your configuration, two variations of the override also misbehaved. One zooms on Option-scroll and calls super for everything else. The other puts the override in the document view instead of the scroll view. In both, live scrolls kept running across several separate gestures, and some pinches made during them didn't start a live magnify.

Handling Option-scroll in a local event monitor, with no scrollWheel(with:) override at all, avoided the problem in my tests. Trackpad scrolling arrives as scroll-wheel events that carry a phase. The code uses the phase to decide once per gesture, when the gesture begins, whether to zoom, and keeps that decision through momentum:

private var scrollWheelMonitor: Any?

private var isOptionZoomGesture = false

// In viewDidLoad():
scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in
    guard let self, self.zoomIfOptionScroll(event) else { return event }
    return nil
}

/// Zooms for Option-scroll and returns true, so the scroll view never sees the event.
/// A trackpad gesture is decided once, when it begins, and keeps that decision through momentum.
private func zoomIfOptionScroll(_ event: NSEvent) -> Bool {
    guard event.window === scrollView.window else { return false }

    let isOverScrollView = scrollView.bounds.contains(scrollView.convert(event.locationInWindow, from: nil))
    let wantsZoom = isOverScrollView && event.modifierFlags.contains(.option)

    if event.phase.contains(.mayBegin) || event.phase.contains(.began) {
        isOptionZoomGesture = wantsZoom
    }
    let isGestureEvent = !event.phase.isEmpty || !event.momentumPhase.isEmpty
    guard isGestureEvent ? isOptionZoomGesture : wantsZoom else { return false }

    if event.momentumPhase.isEmpty {
        let delta = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY : event.scrollingDeltaY * 8
        let point = scrollView.contentView.convert(event.locationInWindow, from: nil)
        scrollView.setMagnification(scrollView.magnification * pow(1.01, delta), centeredAt: point)
    }
    return true
}

I removed the override from your ScrollView and added this code to your ViewController. In a test run after that, all 40 live scrolls ended, each within 3 seconds. Option-zoom worked with both the trackpad and a mouse wheel, and so did pinching. I haven't tried this on macOS 27.2. The addLocalMonitorForEvents(matching:handler:) documentation says you must call removeMonitor(_:) to stop the monitor. If your view controller can be released before the app quits, remove the monitor at that point.

So the problem isn't limited to macOS 27.2. That might be worth adding to your report.

I forgot to add that it behaves the same in Sequoia.

Hi @Etresoft, thanks for your feedback. I'm trying to implement Option-zoom like in Pixelmator Pro: If I hold Option and scroll up or down, it results in a zoom instead of a scroll. The Apple Maps app does something similar: If I hold Option and scroll up or down, it changes the pitch of the camera instead of scrolling.

I already have Option-zoom implemented and working. It's just the live scroll getting stuck that's the issue. I updated my report with your info that it's occurring on Sequoia too.

Hi @DTS Engineer, thanks for your feedback. I tried the "override in the document view instead of the scroll view" variation too and didn't work for me either.

And thank you for your workaround using a local event monitor. That's something I'll try. However, I'm not sure if determining isOverScrollView is enough. I guess I'll also need to keep track of if there is a popover/sheet/something else on top of the NSScrollView that should get priority.

Nonetheless, it's a promising workaround. I still hope Apple will fix the actual bug in the override. I updated my report with your info that it's occurring on macOS 27.0.1 too.

I updated my report with your info that it's occurring on Sequoia too.

But that's actually bad news. It means this is established code that isn't likely to be changed.

The problem here is that this problem is due to old mouse scroll wheel support being used for a trackpad. That was originally designed when trackpads were novel. But now trackpads are the standard and mice are unusual. That means that you need to focus on properly supporting gestures. Then add this fallback logic. That will then support the relatively advanced modifier behaviour that you're seeing in Pixelmator Pro and Maps.

I mentioned before that I only use this method as a crude hack. In once place I have a scroll view embedded inside another scroll view. That tends to result in such crude hacks.

But I wasn't telling you the whole truth. I was thinking about the app I'm currently working on that uses mainly standard controls. I have another app that in development/on hiatus for several years. In that other app, I do use this method:

  // Handle a scroll wheel event.
  override func scrollWheel(with event: NSEvent)
    {
    if event.modifierFlags.intersection(.shift) == .shift
      {
      scrollWheelMagnify(with: event)
      }
    else
      {
      super.scrollWheel(with: event)
      
      delegate?.didChangeFrame()
      }
    }

This is a mapping app, so it's using shift to zoom the way Maps does.

But more importantly, this is stricly for mouse scroll wheel support, or modifier support. The app has much more extensive gesture support at the view controller level. For this operation on iOS, I'm using a subclass of UIPanGestureRecognizer. On macOS, I'm using the older mouseDragged(with:) style of user interaction APIs rather than NSPanGestureRecognizer. I'm not sure why. But perhaps I was just using the APIs I was most comfortable with at the time. But that's irrelevent.

The important part is that I have another layer of API support that properly handles the gesture. The scrollWheel method itself is simply insufficient. You don't need those "liveScroll" notifications at all.

@Etresoft thanks for the insight. The reason I'm using scrollWheel(with:) rather than NSPanGestureRecognizer is because, as far as I'm aware, AppKit's NSPanGestureRecognizer can recognize drags but not scrolls (please correct me if I'm wrong).

This is in contrast to UIKit's UIPanGestureRecognizer, which can recognize both drags and scrolls (by setting UIPanGestureRecognizer.allowedScrollTypesMask).

In the UIKit version of my app, I implemented Option-zoom by adding a UIPanGestureRecognizer to UIScrollView and overriding UIView.gestureRecognizerShouldBegin(_:) to control whether my added UIPanGestureRecognizer or UIScrollView.panGestureRecognizer begins.

I wish I could do the same in the AppKit version, but NSPanGestureRecognizer has no equivalent to UIPanGestureRecognizer.allowedScrollTypesMask.

As for NSScrollView.willStartLiveScrollNotification and NSScrollView.didEndLiveScrollNotification, I don't actually use those for the Option-zoom. I use those mainly to enable/disable a separate zoom slider NSToolbarItem that's in NSWindow.toolbar, because I don't want the user to be able to simultaneously use the NSToolbarItem and the NSScrollView to scroll/zoom the canvas. This simultaneous use is possible when using Sidecar with NSViewGestureRecognizerIsExclusive set to NO (which is the case in my app).

I hope that clarifies the whole story. You are much more experienced than me so I really appreciate your insight.

I'm using scrollWheel(with:) rather than NSPanGestureRecognizer

The scrollWheel(with:) method is really what it says on the tin. It's for supporting the scroll wheel on your mouse. Then when Apple got rid of the physical scroll wheel on the mouse and replaced it with a touch-sensitive mouse, it became a half-way gesture. And as you have seen, it's halfway supported.

as far as I'm aware, AppKit's NSPanGestureRecognizer can recognize drags but not scrolls (please correct me if I'm wrong)

I don't know if you're wrong, because I've never used that API either. I used the older, mouse-based methods. But you don't want to fall into API dependency. That seems pretty common these days. If Apple doesn't provide some clearly named method or enum for something, people assume it's impossible. It's never impossible. A pan gesture requires touching the trackpad. That makes it a drag. You can reposition the view from that.

But that's really getting off-topic. The main point is that you're somehow triggering some odd behaviour by attempting to use only scrollWheel(with:). I've never seen what you're describing.

I took another look at your demo app. This time, I did what I should have done the first time. I ripped out the vast majority of your code and replaced it with the equivalent structures in InterfaceBuilder. I kept the livescroll notificiations. I also kept the custom scroll view with the no-op override. No problem. No scrolling artifacts. All notifications arrive as expected. My best guess is that somehow you didn't setup scrolling elasticity correctly. Or perhaps it was something else about the scroll view. I don't know.

I normally don't setup scrollview hierarchies in code. But it just so happens that I am doing that now, so I'll take another look at the problem your describing once I get to a point where I can test it.

Popovers and sheets are separate windows, so the event.window === scrollView.window check already stops Option-scrolls over them from zooming the canvas. In my tests in macOS 27.0.1, Option-scrolls over a popover and over a sheet scrolled their own content and didn't zoom the canvas.

Within the same window, the isOverScrollView line treats a scroll view nested inside the canvas as part of the canvas. Option-scrolling over a nested scroll view zoomed the canvas. A plain overlapping view is different. In my test, when the monitor returned an Option-scroll over one instead of consuming it, AppKit scrolled the canvas underneath. So zooming there matches what a scroll without Option does. This version of that line defers only to another scroll view under the pointer:

// Defer to any other scroll view under the pointer, such as one nested in the canvas.
var nearestScrollView = scrollView.window?.contentView?.hitTest(event.locationInWindow)
while let candidate = nearestScrollView, !(candidate is NSScrollView) {
    nearestScrollView = candidate.superview
}
let isInBounds = scrollView.bounds.contains(scrollView.convert(event.locationInWindow, from: nil))
let isOverScrollView = isInBounds && (nearestScrollView == nil || nearestScrollView === scrollView)

It builds in your project. In my test app, the same check zoomed over the canvas and over an overlapping view. Over a nested scroll view, isOverScrollView comes out false, so the monitor returns the event and that scroll view gets it.

On NSPanGestureRecognizer, your reading matches mine. The allowedTouchTypes comment in NSGestureRecognizer.h says "Currently, only NSTouchTypeDirect is supported." Setting it to include indirect touches raised an exception: "Gesture recognizers do not support indirect touches." In my tests, two-finger trackpad scrolls over views with pan gesture recognizers never reached the recognizers, while a press-and-drag began a pan. So trackpad scrolls reach your code as scroll-wheel events, not through a pan recognizer.

The Interface Builder rebuild fits what I saw. In my tests, a scroll view with default settings and the same override ended all 22 of its live scrolls. With your settings, live scrolls got stuck, including with elasticity turned off. Removing allowsMagnification stopped them from sticking, and changing any one of your other settings didn't. Turning on allowsMagnification in a default scroll view didn't make them stick either. Those results might be worth adding to FB25362729.