In SwiftUI on macOS, how to make a scroll wheel scroll horizontally?

I have a scroll view that scrolls horizontally and one of my users is asking that it respond to their scroll wheel without them having to use the shift key. Is there some way to do this natively? If not, how can I listen for the scroll wheel events in swiftUI and make my scroll wheel scroll to respond to them?

Answered by msdrigg in 782529022

Fixed here:

import AppKit

struct CaptureVerticalScrollWheelModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .background(ScrollWheelHandlerView())
    }

    struct ScrollWheelHandlerView: NSViewRepresentable {
        func makeNSView(context: Context) -> NSView {
            let view = ScrollWheelReceivingView()
            return view
        }

        func updateNSView(_ nsView: NSView, context: Context) {}
    }

    class ScrollWheelReceivingView: NSView {
        private var scrollVelocity: CGFloat = 0
        private var decelerationTimer: Timer?
        
        override var acceptsFirstResponder: Bool { true }

        override func viewDidMoveToWindow() {
            super.viewDidMoveToWindow()
            window?.makeFirstResponder(self)
        }

        override func scrollWheel(with event: NSEvent) {
            var scrollDist = event.deltaX
            var scrollDelta = event.scrollingDeltaX
            if abs(scrollDist) < abs(event.deltaY) {
                scrollDist = event.deltaY
                scrollDelta = event.scrollingDeltaY
            }
            if event.phase == .began || event.phase == .changed || event.phase.rawValue == 0 {
                // Directly handle scrolling
                handleScroll(with: scrollDist, precise: event.hasPreciseScrollingDeltas)
                
                scrollVelocity = scrollDelta
            } else if event.phase == .ended {
                // Begin decelerating
                decelerationTimer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { [weak self] timer in
                    guard let self = self else { timer.invalidate(); return }
                    self.decelerateScroll()
                }
            } else if event.momentumPhase == .ended {
                // Invalidate the timer if momentum scrolling has ended
                decelerationTimer?.invalidate()
                decelerationTimer = nil
            }
        }

        private func handleScroll(with delta: CGFloat, precise: Bool) {
            var scrollDist = delta
            if !precise {
                scrollDist *= 2
            }

            guard let scrollView = self.enclosingScrollView else { return }
            let contentView = scrollView.contentView
            let contentSize = contentView.documentRect.size
            let scrollViewSize = scrollView.bounds.size

            let currentPoint = contentView.bounds.origin
            var newX = currentPoint.x - scrollDist

            // Calculate the maximum allowable X position (right edge of content)
            let maxX = contentSize.width - scrollViewSize.width
            // Ensure newX does not exceed the bounds
            newX = max(newX, 0) // No less than 0 (left edge)
            newX = min(newX, maxX) // No more than maxX (right edge)

            // Scroll to the new X position if it's within the bounds
            scrollView.contentView.scroll(to: NSPoint(x: newX, y: currentPoint.y))
            scrollView.reflectScrolledClipView(scrollView.contentView)
        }

        private func decelerateScroll() {
            if abs(scrollVelocity) < 0.8 {
                decelerationTimer?.invalidate()
                decelerationTimer = nil
                return
            }

            handleScroll(with: scrollVelocity, precise: true)
            scrollVelocity *= 0.95
        }
    }
}

extension View {
    func captureVerticalScrollWheel() -> some View {
        self.modifier(CaptureVerticalScrollWheelModifier())
    }
}
Accepted Answer

Fixed here:

import AppKit

struct CaptureVerticalScrollWheelModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .background(ScrollWheelHandlerView())
    }

    struct ScrollWheelHandlerView: NSViewRepresentable {
        func makeNSView(context: Context) -> NSView {
            let view = ScrollWheelReceivingView()
            return view
        }

        func updateNSView(_ nsView: NSView, context: Context) {}
    }

    class ScrollWheelReceivingView: NSView {
        private var scrollVelocity: CGFloat = 0
        private var decelerationTimer: Timer?
        
        override var acceptsFirstResponder: Bool { true }

        override func viewDidMoveToWindow() {
            super.viewDidMoveToWindow()
            window?.makeFirstResponder(self)
        }

        override func scrollWheel(with event: NSEvent) {
            var scrollDist = event.deltaX
            var scrollDelta = event.scrollingDeltaX
            if abs(scrollDist) < abs(event.deltaY) {
                scrollDist = event.deltaY
                scrollDelta = event.scrollingDeltaY
            }
            if event.phase == .began || event.phase == .changed || event.phase.rawValue == 0 {
                // Directly handle scrolling
                handleScroll(with: scrollDist, precise: event.hasPreciseScrollingDeltas)
                
                scrollVelocity = scrollDelta
            } else if event.phase == .ended {
                // Begin decelerating
                decelerationTimer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { [weak self] timer in
                    guard let self = self else { timer.invalidate(); return }
                    self.decelerateScroll()
                }
            } else if event.momentumPhase == .ended {
                // Invalidate the timer if momentum scrolling has ended
                decelerationTimer?.invalidate()
                decelerationTimer = nil
            }
        }

        private func handleScroll(with delta: CGFloat, precise: Bool) {
            var scrollDist = delta
            if !precise {
                scrollDist *= 2
            }

            guard let scrollView = self.enclosingScrollView else { return }
            let contentView = scrollView.contentView
            let contentSize = contentView.documentRect.size
            let scrollViewSize = scrollView.bounds.size

            let currentPoint = contentView.bounds.origin
            var newX = currentPoint.x - scrollDist

            // Calculate the maximum allowable X position (right edge of content)
            let maxX = contentSize.width - scrollViewSize.width
            // Ensure newX does not exceed the bounds
            newX = max(newX, 0) // No less than 0 (left edge)
            newX = min(newX, maxX) // No more than maxX (right edge)

            // Scroll to the new X position if it's within the bounds
            scrollView.contentView.scroll(to: NSPoint(x: newX, y: currentPoint.y))
            scrollView.reflectScrolledClipView(scrollView.contentView)
        }

        private func decelerateScroll() {
            if abs(scrollVelocity) < 0.8 {
                decelerationTimer?.invalidate()
                decelerationTimer = nil
                return
            }

            handleScroll(with: scrollVelocity, precise: true)
            scrollVelocity *= 0.95
        }
    }
}

extension View {
    func captureVerticalScrollWheel() -> some View {
        self.modifier(CaptureVerticalScrollWheelModifier())
    }
}

Actually I fixed it better here:

    import AppKit
    import SwiftUI

    struct CaptureVerticalScrollWheelModifier: ViewModifier {
        func body(content: Content) -> some View {
            content
                .background(ScrollWheelHandlerView())
        }

        struct ScrollWheelHandlerView: NSViewRepresentable {
            func makeNSView(context _: Context) -> NSView {
                let view = ScrollWheelReceivingView()
                return view
            }

            func updateNSView(_: NSView, context _: Context) {}
        }

        class ScrollWheelReceivingView: NSView {
            private var scrollVelocity: CGFloat = 0
            private var decelerationTimer: Timer?

            override var acceptsFirstResponder: Bool { true }

            override func viewDidMoveToWindow() {
                super.viewDidMoveToWindow()
                window?.makeFirstResponder(self)
            }
            // Don't capture vertical scroll for precise scrolling (e.g. magic mouse/trackpad), and don't capture if we are already scrolling horizontally (e.g. shift + scroll). If we don't do this, we get very unpredictable behavior
            override func scrollWheel(with event: NSEvent) {
                if event.hasPreciseScrollingDeltas || abs(event.scrollingDeltaX) > 0.000001 || abs(event.deltaX) > 0.000001 {
                    super.scrollWheel(with: event)
                    return
                }

                if let cgEvent = event.cgEvent?.copy() {
                    cgEvent.setDoubleValueField(.scrollWheelEventDeltaAxis2, value: Double(event.scrollingDeltaY / 10))
                    cgEvent.setDoubleValueField(.scrollWheelEventDeltaAxis1, value: Double(0))
                    cgEvent.setDoubleValueField(.scrollWheelEventDeltaAxis3, value: Double(0))
                    cgEvent.setDoubleValueField(.mouseEventDeltaX, value: Double(0))
                    cgEvent.setDoubleValueField(.mouseEventDeltaY, value: Double(0))

                   // Once we flip the scrolling axis to X and set the rest to 0, we can just send the event the same as before. All the deceleration and such will get handled natively by the system!
                    if let nsEvent = NSEvent(cgEvent: cgEvent) {
                        super.scrollWheel(with: nsEvent)
                    } else {
                        super.scrollWheel(with: event)
                    }
                } else {
                    super.scrollWheel(with: event)
                }
            }
        }
    }

    extension View {
        func captureVerticalScrollWheel() -> some View {
            modifier(CaptureVerticalScrollWheelModifier())
        }
    }
In SwiftUI on macOS, how to make a scroll wheel scroll horizontally?
 
 
Q