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
2 Answers
Reset to default 1One 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 receivesHoverPhase
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 aGeometryReader
. - 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 thewidth
of a frame and this was causing errors in the console. You could setmaxWidth: .infinity
instead, if you need to. However, you don't need to apply this to aShape
or to aGeometryReader
, 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条)