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?
}
One way to solve this is to move the hover gesture to the container (the ZStack
). Then:
.onGeometryChange
modifier to record the frame of the rectangle in the global coordinate space..onContinuousHover
to apply the hover gesture. This receives HoverPhase
as parameter, which includes the location of the hover gesture.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:
Rectangle
with a GeometryReader
.GeometryReader
to find the .frame
of the rectangle..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.