swiftswiftuihover

How can I make a View detect onHover but let click/tap go to a view below?


With a view that looks like this, I would like the element below to remain clickable / tappable, and the element on top to detect when it is hovered.

If I add .allowsHitTesting(false) to the top element, the text receives click through but onHover stops working.

ZStack {
  Text(content)
    .textSelection(.enabled)
  Rectangle()
    .frame(width: .infinity, height: 10)
    .onHover { isHovered = $0 }
}

I tried using an NSView (which I would prefer not have to do) to detect the hover effect (it can let the hit go through with override func hitTest(_: NSPoint) -> NSView? { nil }). It was mostly working, but for some reason the frame where the hovering was detected didn't line up perfectly with the view:


class HoverNSView: AnyNSView {
  var onHover: ((Bool) -> Void)?

  override func updateTrackingAreas() {
    super.updateTrackingAreas()

    if let oldArea = trackingArea {
      removeTrackingArea(oldArea)
    }

    let options: NSTrackingArea.Options = [
      .mouseEnteredAndExited,
      .activeAlways
    ]
    let newArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
    addTrackingArea(newArea)
    trackingArea = newArea
  }

  override func setFrameSize(_ newSize: NSSize) {
    super.setFrameSize(newSize)
    updateTrackingAreas()
  }

  override func mouseEntered(with event: NSEvent) {
    super.mouseEntered(with: event)
    onHover?(true)
  }

  override func mouseExited(with event: NSEvent) {
    super.mouseExited(with: event)
    onHover?(false)
  }

  /// By returning `nil` here, SwiftUI (and AppKit) will send clicks to underlying views
  /// instead of stopping at this view.
  override func hitTest(_: NSPoint) -> NSView? {
    nil
  }

  /// We'll store a reference to our custom NSTrackingArea
  private var trackingArea: NSTrackingArea?

}

Solution

  • One way to solve this is to move the hover gesture to the container (the ZStack). Then:

    struct ContentView: View {
        let content = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
        @State private var isHovered = false
        @State private var rectangleFrame = CGRect.zero
    
        var body: some View {
            ZStack {
                Text(content)
                    .textSelection(.enabled)
                    .font(.title3)
    
                Rectangle()
                    .frame(height: 20) // width: .infinity
                    .foregroundStyle(.yellow.opacity(0.5))
                    .allowsHitTesting(false)
                    .onGeometryChange(for: CGRect.self) { proxy in
                        proxy.frame(in: .global)
                    } action: { frame in
                        rectangleFrame = frame
                    }
            }
            .onContinuousHover(coordinateSpace: .global) { phase in
                switch phase {
                case .active(let point): isHovered = rectangleFrame.contains(point)
                case .ended: isHovered = false
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .overlay(alignment: .bottom) {
                HStack {
                    Text("isHovered:")
                    Text("\(isHovered)")
                        .background(isHovered ? .yellow.opacity(0.5) : .clear)
                }
                .frame(minWidth: 110, alignment: .leading)
            }
            .padding()
        }
    }
    

    Animation


    The approach above works fine when there is only one rectangle, because then you only need one state variable. If there would be more than one rectangle, you would need a state variable for each of them and the gesture callback would not be so simple. In this case, an alternative approach would be to save the hover location in a state variable instead. Then:

    Here is how the example above can be adapted to use this approach instead:

    // @State private var rectangleFrame = CGRect.zero
    @State private var hoverLocation: CGPoint?
    
    ZStack {
        Text(content)
            .textSelection(.enabled)
            .font(.title3)
    
        GeometryReader { proxy in
            Rectangle()
                .onChange(of: hoverLocation) { oldVal, newVal in
                    if let newVal {
                        isHovered = proxy.frame(in: .global).contains(newVal)
                    } else {
                        isHovered = false
                    }
                }
        }
        .frame(height: 20) // width: .infinity
        .foregroundStyle(.yellow.opacity(0.5))
        .allowsHitTesting(false)
    }
    .onContinuousHover(coordinateSpace: .global) { phase in
        switch phase {
        case .active(let point): hoverLocation = point
        case .ended: hoverLocation = nil
        }
    }
    

    Other notes: