Reading Current Mouse Position in SwiftUI
SwiftUI is very pleasant to use. Rather than me specifying where in the X and Y I want something, SwiftUI lets me declare what views I have and it rearranges everything to a (near) perfection. The drawback is that this lack of control means that I have no idea how anything is laid out and for an ongoing project I had the need to determine where in a given view the mouse was located. As I'm not specifying any positions directly, and views can often be nested inside other views, I can't just keep track of the coordinates myself. Getting the mouse position would need to be done declaratively.
Enter NSEvent.addLocalMonitorForEvents
which, if given the correct event, will tell you where in the current window the mouse is located. If you attach it to a view using its onAppear()
callback, you can easily track the mouse position from within that view. The following code block contains a view that just displays the mouse position and is updated as the mouse is moved.
struct MousePositionView: View {
@State var mousePosition = CGPoint(x: -1, y: -1)
@State var eventMonitor: Any? = nil
var body: some View {
ZStack {
Rectangle()
.background(.white)
Text("\(mousePosition.x), \(mousePosition.y)")
.foregroundStyle(.black)
}
.frame(width: 200, height: 200)
.onAppear(perform: {
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [
.mouseMoved
]) { event in
mousePosition = event.locationInWindow
return event
}
})
}
}
However, employing this technique comes with a few considerations:
- This is the mouse position within the current window, not within the view itself.
- The event is triggered when the mouse is moved anywhere in the window, not only when it is inside the view.
- Moving the mouse outside the window results in garbage values.
- The origin point (
0,0
) is on the bottom left of the window, whereas views have their origin to the top left.
To compensate for this we will need to use a GeometryReader
to get the frame of the view as well as find the window size. Conveniently, the NSEvent
which contains the mouse position also has a reference to the current NSWindow
. Even more conveniently, the window reference is nil
when the mouse is outside the window.
Combining it all results in a view which tracks the mouse position and is aware of when the mouse is within its boundaries.
import SwiftUI
/// View which is aware of the current mouse position and whether that is within its frame.
struct MousePositionView: View {
@State var mousePosition = CGPoint(x: -1, y: -1)
@State var isMouseOver = false
@State var eventMonitor: Any? = nil
var body: some View {
GeometryReader { geometry in
ZStack {
Rectangle()
.background(isMouseOver ? .red : .white)
Text("\(mousePosition.x), \(mousePosition.y)")
.foregroundStyle(.black)
}
.onAppear(perform: {
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [
.mouseMoved
]) { event in
updateMousePosition(event: event, geometry: geometry)
return event
}
})
}
}
}
extension MousePositionView {
/// Update the mouse position based on a mouseMoved event.
func updateMousePosition(event: NSEvent, geometry: GeometryProxy) {
if event.window == nil {
return // mouse is outside the window
}
let viewFrame = geometry.frame(in: .global)
let windowFrame = event.window!.frame.size
mousePosition = CGPoint(
x: event.locationInWindow.x - viewFrame.origin.x,
y: windowFrame.height - event.locationInWindow.y - viewFrame.origin.y
)
isMouseOver = mousePosition.x >= 0 && mousePosition.x <= viewFrame.size.width
&& mousePosition.y >= 0 && mousePosition.y <= viewFrame.size.height
}
}
#Preview {
VStack {
HStack {
MousePositionView()
.frame(width: 200, height: 200)
MousePositionView()
.frame(width: 200, height: 200)
MousePositionView()
.frame(width: 200, height: 200)
}
HStack {
MousePositionView()
.frame(width: 200, height: 200)
MousePositionView()
.frame(width: 200, height: 200)
MousePositionView()
.frame(width: 200, height: 200)
}
}
.padding()
}