swift - How can I make a View detect onHover but let clicktap go to a view below? - Stack Overflow

With a view that looks like this, I would like the element below to remain clickabletappable, and th

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?

}

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?

}
Share Improve this question edited Mar 12 at 8:32 Joakim Danielson 52.3k5 gold badges33 silver badges71 bronze badges asked Mar 12 at 5:11 GuigGuig 10.5k8 gold badges75 silver badges146 bronze badges 0
Add a comment  | 

2 Answers 2

Reset to default 1

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

  • Use an .onGeometryChange modifier to record the frame of the rectangle in the global coordinate space.
  • Use .onContinuousHover to apply the hover gesture. This receives HoverPhase as parameter, which includes the location of the hover gesture.
  • Set the flag according to whether the location of the hover gesture is inside the frame of the rectangle.
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()
    }
}


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:

  • Wrap each Rectangle with a GeometryReader.
  • Use the GeometryReader to find the .frame of the rectangle.
  • Use an .onChange handler to detect, when the frame of the rectangle contains the hover location.

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:

  • When there are multiple rectangles, you might also need to record the id of the rectangle that last set the flag to true, so that only this rectangle is allowed to change the flag from true to false. Otherwise, the rectangles will be competing against each other.

  • If the purpose of the rectangle is to highlight the text then it might look better if the rectangle is shown behind the text, instead of over it. This just means, re-arranging the layers of the ZStack. The gestures still work the same.

  • .infinity is not a valid value for the width of a frame and this was causing errors in the console. You could set maxWidth: .infinity instead, if you need to. However, you don't need to apply this to a Shape or to a GeometryReader, because these views are greedy and use as much space as possible anyway.

You could try a different approach, using .allowsHitTesting(false) and a background(...) for the hover.

The hover works and you can select the text as well.

Example code:


struct ContentView: View {
    let content = "Mickey Mouse"
    @State private var isHovered: Bool = false

    var body: some View {
        VStack {
            Text(isHovered ? "hovering" : "NOT hovering")

            ZStack {
                Text(content)
                    .textSelection(.enabled)

                Rectangle()
                    .stroke(style: StrokeStyle(lineWidth: 1))
                    .frame(width: 345, height: 345)
                    .background(Color.pink.opacity(0.3))
                    .allowsHitTesting(false)
            }
            .background(
                Color.clear
                    .contentShape(Rectangle())
                    .onHover { isHovered = $0 }
            )
        }
    }
}

发布者:admin,转转请注明出处:http://www.yc00.com/questions/1744769354a4592661.html

相关推荐

发表回复

评论列表(0条)

  • 暂无评论

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信