I'm trying to build a drawing tool on Xcode building for Mac Catalyst, using the UIKit Framework, but I'm running into issues with cursor location accuracy:
After initializing an iOS project configured for Swift and Storyboard, here's an absolutely minimal ViewController.swift that produces the bug:
import UIKit
class CanvasView: UIView {
var drawPoint: CGPoint = .zero
let radius = 12.0
override func draw(_: CGRect) {
UIGraphicsGetCurrentContext()!
.fillEllipse(in: CGRect(x: drawPoint.x - radius,
y: drawPoint.y - radius,
width: radius * 2,
height: radius * 2))
}
override func touchesBegan(_ touches: Set<UITouch>, with _: UIEvent?) {
drawPoint = touches.first!.location(in: self)
setNeedsDisplay()
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view = CanvasView()
view.backgroundColor = .white
let hover = UIHoverGestureRecognizer(target: self, action: #selector(hover))
view.addGestureRecognizer(hover)
}
@objc func hover(_ recognizer: UIHoverGestureRecognizer) {
switch recognizer.state {
case .began, .changed: NSCursor.crosshair.set()
default: NSCursor.arrow.set()
}
}
}
All this does is
True enough, this draws a circle centered at the cursor but it's always off by a pixel or two, and moreover this inaccuracy is inconsistent.
Sometimes the circle drawn in offset by 1 pixel to the right and 1 pixel down, sometimes it's 2, and this is a major issue when it comes to users trying to draw things precisely.
I've tried using CAShapeLayer
and its corresponding func draw(_: CALayer, in: CGContext)
, but this exact same inaccuracy is reproduced there.
I've also tried using preciseLocation(in:)
instead of location(in:)
but again the bug is still there.
Notably, when I tried building for iPhone/iPad and Xcode opens the same code in a Simulator, this bug vanishes, and the circle is centered perfectly on the touch point.
Any help is appreciated!
EDIT:
This time I tried using a minimal working example with Cocoa
only, no Mac Catalyst nor UIKit, and somehow, the problem still persists. Here's the ViewController.swift code:
import Cocoa
class View: NSView {
var point: CGPoint = .zero
let radius: CGFloat = 20
override func draw(_: NSRect) {
NSColor.blue.setStroke()
let cross = NSBezierPath()
let v = radius * 2
cross.move(to: CGPoint(x: point.x - v, y: point.y))
cross.line(to: CGPoint(x: point.x + v, y: point.y))
cross.move(to: CGPoint(x: point.x, y: point.y - v))
cross.line(to: CGPoint(x: point.x, y: point.y + v))
cross.lineWidth = 0.8
cross.stroke()
}
override func mouseDown(with event: NSEvent) {
point = event.locationInWindow
setNeedsDisplay(bounds)
NSCursor.crosshair.set()
}
}
class ViewController: NSViewController {
override func viewDidLoad() {
super.viewDidLoad()
let sub = View(frame: view.frame)
sub.autoresizingMask = [.height, .width]
view.addSubview(sub)
}
}
Only this time I use a cross to show more clearly that the alignment is off.
I thought this might be a result of the build target being Debug, but this bug still shows up in an Archive built for Release.
The MacOS mouse pointers are a little quirky...
With the default Arrow pointer, it appears the cursor's hotSpot
is at the tip of the black arrow -- not the white outline.
With the .crosshair
pointer, it appears the cursor's hotSpot
is a little to the right and below the actual cross-point.
To get precision, you may want to experiment with a custom cursor image.
Quick example:
import UIKit
class CanvasView: UIView {
var drawPoint: CGPoint = .zero
let radius = 12.0
override func draw(_: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
// stroke a 100x100 rectangle so we have some corner-points to click on
ctx.stroke(.init(x: 100.0, y: 100.0, width: 100.0, height: 100.0))
// translucent blue rectangle, with top-left corner at touch-point
ctx.setFillColor(UIColor.blue.withAlphaComponent(0.75).cgColor)
ctx.fill([.init(origin: drawPoint, size: .init(width: radius, height: radius))])
// translucent red ellipse with center at touch-point
ctx.setFillColor(UIColor.red.withAlphaComponent(0.5).cgColor)
ctx.fillEllipse(in: CGRect(x: drawPoint.x - radius,
y: drawPoint.y - radius,
width: radius * 2,
height: radius * 2))
}
override func touchesBegan(_ touches: Set<UITouch>, with _: UIEvent?) {
drawPoint = touches.first!.location(in: self)
// might want to round the point? or floor() or ceil() ?
//drawPoint.x = round(drawPoint.x)
//drawPoint.y = round(drawPoint.y)
setNeedsDisplay()
}
}
class ViewController: UIViewController {
// we'll use a custom cursor
var c: NSCursor!
override func viewDidLoad() {
super.viewDidLoad()
view = CanvasView()
view.backgroundColor = .white
let hover = UIHoverGestureRecognizer(target: self, action: #selector(hover))
view.addGestureRecognizer(hover)
// create a custom cross-hairs cursor image
let r: CGRect = .init(origin: .zero, size: .init(width: 20.0, height: 20.0))
let rn = UIGraphicsImageRenderer(size: r.size)
let img = rn.image { _ in
let bez = UIBezierPath()
bez.move(to: .init(x: r.minX, y: r.midY))
bez.addLine(to: .init(x: r.maxX, y: r.midY))
bez.move(to: .init(x: r.midX, y: r.minY))
bez.addLine(to: .init(x: r.midX, y: r.maxY))
bez.stroke()
}
// set the custom cursor
c = NSCursor(image: img, hotSpot: .init(x: r.midX - 0.5, y: r.midY - 0.5))
}
@objc func hover(_ recognizer: UIHoverGestureRecognizer) {
switch recognizer.state {
case .began, .changed:
c.set()
default:
()
}
}
}
Note: This is Example Code Only
Edit
Another option I was playing around with -- hide the cursor and draw a cross-hairs in CanvasView
:
class CanvasView: UIView {
var cursorPoint: CGPoint = .zero
var drawPoint: CGPoint = .zero
let radius = 12.0
override func draw(_: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
// stroke a 100x100 rectangle so we have some corner-points to click on
ctx.stroke(.init(x: 100.0, y: 100.0, width: 100.0, height: 100.0))
// translucent blue rectangle, with top-left corner at touch-point
ctx.setFillColor(UIColor.blue.withAlphaComponent(0.75).cgColor)
ctx.fill([.init(origin: drawPoint, size: .init(width: radius, height: radius))])
// translucent red ellipse with center at touch-point
ctx.setFillColor(UIColor.red.withAlphaComponent(0.5).cgColor)
ctx.fillEllipse(in: CGRect(x: drawPoint.x - radius,
y: drawPoint.y - radius,
width: radius * 2,
height: radius * 2))
let r: CGRect = CGRect(x: cursorPoint.x - radius,
y: cursorPoint.y - radius,
width: radius * 2,
height: radius * 2)
let bez = UIBezierPath()
bez.move(to: .init(x: r.minX, y: r.midY))
bez.addLine(to: .init(x: r.maxX, y: r.midY))
bez.move(to: .init(x: r.midX, y: r.minY))
bez.addLine(to: .init(x: r.midX, y: r.maxY))
ctx.setFillColor(UIColor.black.cgColor)
bez.stroke()
}
override func touchesBegan(_ touches: Set<UITouch>, with _: UIEvent?) {
drawPoint = touches.first!.location(in: self)
// might want to round the point? or floor() or ceil() ?
drawPoint.x = round(drawPoint.x)
drawPoint.y = round(drawPoint.y)
setNeedsDisplay()
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view = CanvasView()
view.backgroundColor = .white
let hover = UIHoverGestureRecognizer(target: self, action: #selector(hover))
view.addGestureRecognizer(hover)
}
@objc func hover(_ recognizer: UIHoverGestureRecognizer) {
switch recognizer.state {
case .began:
// hide the cursor...
// we will draw our own cross-hairs in CanvasView
NSCursor.hide()
case .changed:
if let v = view as? CanvasView {
var p = recognizer.location(in: v)
p.x = round(p.x)
p.y = round(p.y)
v.cursorPoint = p
v.setNeedsDisplay()
}
default:
if let v = view as? CanvasView {
v.cursorPoint = .zero
v.setNeedsDisplay()
}
// un-hide the cursor when mouse leaves
NSCursor.unhide()
}
}
}
As before, this is Example Code Only!!! -- but it may be worth a look :)