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.
To recreate the issue, follow these steps:
- Download and extract the "Xcode Project.zip" file from the link above.
- Open the extracted "NSScrollViewTest" project in Xcode.
- Build and run the "NSScrollViewTest" app.
- 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.
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.