Skip to content

Instantly share code, notes, and snippets.

@rijieli
Forked from importRyan/whenHovered.md
Created February 15, 2024 09:19
Show Gist options
  • Save rijieli/3d5ef222abe97e84de82ab128a9d1a2d to your computer and use it in GitHub Desktop.
Save rijieli/3d5ef222abe97e84de82ab128a9d1a2d to your computer and use it in GitHub Desktop.
Reliable SwiftUI mouse hover

Reliable mouseEnter/Exit for SwiftUI

Kapture 2021-03-01 at 14 43 39

On Mac, SwiftUI's .onHover closure is not always called on mouse exit, particularly with high cursor velocity. A grid of targets or with finer target shapes will often have multiple targets falsely active after the mouse has moved on.

It is easy to run back to AppKit's safety. Below is a SwiftUI-like modifier for reliable mouse-tracking. You can easily adapt it for other mouse tracking needs.

import SwiftUI

extension View {
    func whenHovered(_ mouseIsInside: @escaping (Bool) -> Void) -> some View {
        modifier(MouseInsideModifier(mouseIsInside))
    }
}

The modifier creates an empty NSView for the SwiftUI view's frame. A bare NSResponder then subscribes to mouse enter and exit events for the frame. Performance is reliable and fast in complex and rapidly changing grid views.

struct MouseInsideModifier: ViewModifier {
    let mouseIsInside: (Bool) -> Void
    
    init(_ mouseIsInside: @escaping (Bool) -> Void) {
        self.mouseIsInside = mouseIsInside
    }
    
    func body(content: Content) -> some View {
        content.background(
            GeometryReader { proxy in
                Representable(mouseIsInside: mouseIsInside,
                              frame: proxy.frame(in: .global))
            }
        )
    }
    
    private struct Representable: NSViewRepresentable {
        let mouseIsInside: (Bool) -> Void
        let frame: NSRect
        
        func makeCoordinator() -> Coordinator {
            let coordinator = Coordinator()
            coordinator.mouseIsInside = mouseIsInside
            return coordinator
        }
        
        class Coordinator: NSResponder {
            var mouseIsInside: ((Bool) -> Void)?
            
            override func mouseEntered(with event: NSEvent) {
                mouseIsInside?(true)
            }
            
            override func mouseExited(with event: NSEvent) {
                mouseIsInside?(false)
            }
        }
        
        func makeNSView(context: Context) -> NSView {
            let view = NSView(frame: frame)
            
            let options: NSTrackingArea.Options = [
                .mouseEnteredAndExited,
                .inVisibleRect,
                .activeInKeyWindow
            ]
            
            let trackingArea = NSTrackingArea(rect: frame,
                                              options: options,
                                              owner: context.coordinator,
                                              userInfo: nil)
            
            view.addTrackingArea(trackingArea)
            
            return view
        }
        
        func updateNSView(_ nsView: NSView, context: Context) {}
        
        static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
            nsView.trackingAreas.forEach { nsView.removeTrackingArea($0) }
        }
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment