Michael Enger

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:

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()
}