BackgroundClickMonitor.swift (1976B)
1 import AppKit 2 import SwiftUI 3 4 /// Monitors for mouse clicks in empty scroll view space (below content). 5 /// 6 /// SwiftUI's `.contentShape(Rectangle()).onTapGesture` on a `ScrollView` 7 /// does not reliably fire for clicks in the area below the document view 8 /// on macOS, because `NSScrollView`'s clip view handles the event before 9 /// SwiftUI's gesture system. This representable installs a local event 10 /// monitor that detects those clicks and forwards them to a handler. 11 struct BackgroundClickMonitor: NSViewRepresentable { 12 let onClick: () -> Void 13 14 func makeNSView(context: Context) -> ClickMonitorNSView { 15 let view = ClickMonitorNSView() 16 view.onClick = onClick 17 return view 18 } 19 20 func updateNSView(_ nsView: ClickMonitorNSView, context: Context) { 21 nsView.onClick = onClick 22 } 23 } 24 25 final class ClickMonitorNSView: NSView { 26 var onClick: (() -> Void)? 27 private var monitor: Any? 28 29 override func viewDidMoveToWindow() { 30 super.viewDidMoveToWindow() 31 if window != nil { 32 installMonitor() 33 } else { 34 removeMonitor() 35 } 36 } 37 38 private func installMonitor() { 39 guard monitor == nil else { return } 40 monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { 41 [weak self] event in 42 self?.handleClick(event) 43 return event 44 } 45 } 46 47 private func removeMonitor() { 48 if let monitor { 49 NSEvent.removeMonitor(monitor) 50 self.monitor = nil 51 } 52 } 53 54 private func handleClick(_ event: NSEvent) { 55 guard let window, event.window == window else { return } 56 57 let pointInSelf = convert(event.locationInWindow, from: nil) 58 guard bounds.contains(pointInSelf) else { return } 59 60 guard let hitView = window.contentView?.hitTest(event.locationInWindow) 61 else { return } 62 63 if hitView is NSClipView { 64 onClick?() 65 } 66 } 67 68 }