Skip to content

Instantly share code, notes, and snippets.

@importRyan
Last active April 6, 2024 06:54
Show Gist options
  • Star 49 You must be signed in to star a gist
  • Fork 7 You must be signed in to fork a gist
  • Save importRyan/c668904b0c5442b80b6f38a980595031 to your computer and use it in GitHub Desktop.
Save importRyan/c668904b0c5442b80b6f38a980595031 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) }
        }
    }
}
@tyirvine
Copy link

tyirvine commented Jun 9, 2021

This is amazing! Thank you so much for this Gist

Note: for tracking in an inactive NSWindow or NSPanel, simply add .activeAlways or an equivalent to options and remove .activeInKeyWindow ⤵︎

 let options: NSTrackingArea.Options = [
                .mouseEnteredAndExited,
                .inVisibleRect,
                .activeAlways
 ]

@shin
Copy link

shin commented Dec 17, 2021

This is what I need. So helpful 🙌

@timonwimmer-git
Copy link

This is particulary nice because by changing .activeInKeyWindow to .alwaysActive (in the NSTrackingArea options) you can also handle hover states when the app window is out of focus!

Thank you!

@rijieli
Copy link

rijieli commented Feb 15, 2024

Hi Ryan, this worked wonderfully, but it appears to block mouse clicks. Do you know how to resolve this issue? Here's my code:

Button {
    let _ = print("Tapped")
} label: {
    Color.red
        .frame(width: 80, height: 30)
        .contentShape(Rectangle())
}
.whenHovered {
    print("Hovered \($0)")
}

@importRyan
Copy link
Author

@rijieli I've released apps without clicks blocked. If you're targeting the latest OS, in casual use I've seen the new SwiftUI API work fine, unlike the old (that this worked around).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment