listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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 }