9
votes

I've made some custom slider views in SwiftUI that change appearance based on hover state, but if the mouse moves out too fast (which is actually a very reasonable speed of moving a cursor), it stays in the hover state until you re-hover and re-leave the component slowly.

Is there a solution for this? The hover code is pretty standard:

struct RulerSlider: View {
  @State var hovering = false

  var body: some View {
    GeometryReader { geometry in
      ZStack {
        // Ruler lines
        if hovering {
          Ruler()
        }
      }
      .onHover { hover in
        withAnimation(.easeOut(duration: 0.1)) {
          self.hovering = hover
        }
      }
    }
  }
}

Here's what the issue looks like:

enter image description here

Sample code for reproducing the bug: https://gist.github.com/rdev/ea0c53448e12835b29faa11fec8e0388

3
Try w/o animation, try to make rows (in parent view) unique. If not helpful please prepare standalone minimal reproducible example for debugging.Asperi
I tried doing it without animation, same result. It looks like the 'mouseleave' event (or whatever is the swift equivalent of it) is not being fired if the mouse doesn't move out of the view slow enoughmax
Added a gist link with reproducible code. It doesn't happen as much in a stripped down example though, but still happens.max
I also see this in my apps. Even with a background that tries to nil out on mouse exit.Ryan

3 Answers

11
votes

I resolved this issue today with a tracking area on an empty NSView. This is tested in a semi-complex and quickly refreshing grid view, which previously had the same behavior you pictured. About 75 views have this modifier applied in the GIF capture in this gist, most with zero border to each other.

Sugar for call site

import SwiftUI

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

Representable with empty tracking view

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) }
        }
    }
}
3
votes

onHover is laggy if you apply it per individual child view. It works as expected though if you apply it in parent container view. Here is an example:

struct ContainerView: View {
    var elements = (0..<10).map { "\($0)" }
    
    @State private var lastHoveredId = ""
    
    var body: some View {
        ScrollView {
            ForEach(elements, id: \.self) { element in
                ChildView(lastHoveredId: $lastHoveredId, id: element)
                    .onHover { isHovered in
                        if isHovered {
                            lastHoveredId = element
                        } else if lastHoveredId == element {
                            lastHoveredId = ""
                        }
                    }
            }
        }
    }
}

struct ChildView: View {
    @Binding var lastHoveredId: String
    var id: String
    
    @State private var isHovered = false
    
    var body: some View {
        Text(id)
            .frame(width: 100, height: 30)
            .background(isHovered ? Color.primary.opacity(0.2) : .clear)
            .animation(.easeIn(duration: 0.2))
            .onChange(of: lastHoveredId) {
                isHovered = $0 == id
            }
    }
}
2
votes

You can fix this super easily without leaving SwiftUI environment (unlike accepted answer) by actually applying onHover in a container view to your RulerSliders. In other words, keep track of which element is currently hovered (if any) in List, instead of list children.

public struct RulersList {
    @State private var hoveredRulerId: RulerModel.ID? = nil
    public let rulers: [RulerModel]
    public var body: some View {
        List {
            ForEach(rulers) { ruler in
                RulerSlider(ruler, ruler.id == hoveredRulerId)
                    .onHover(perform: { isHovering in
                        self.hoveredRulerId = isHovering ? ruler.id : nil
                    })
            }
        }
    }
}

public struct RulerSlider: View {
    public let ruler: RulerModel
    public var isHovered
    public var body: some View {
        Stuff()
            .background(isHovered ? Color.accentColor.opacity(0.10) : Color.clear)
            // DON'T DO onHover HERE, IT HAS SEVERE PERFORMANCE PENALTY
    }
}

Do note RulerSlider(ruler, ruler.id == hoveredRulerId) inside the ForEach, this is how you inform child view whether it's being hovered or not.