0

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

  1. Turn your cursor into a crosshair icon upon hovering
  2. Draw a circle centered at the cursor upon clicking

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.

2
  • Is your Mac Catalyst app setup as "Scaled to Match iPad" or "Optimize for Mac"? Try "Optimize for Mac" if you not already. I'm not sure that's the issue but it's worth a try. Commented Jun 1, 2023 at 5:06
  • @HangarRash Thanks for the tip! Unfortunately the misalignment persists on both settings. Commented Jun 1, 2023 at 5:31

1 Answer 1

0

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 :)

Sign up to request clarification or add additional context in comments.

3 Comments

Thanks for this suggestion. Building a custom cursor really helps with pin-pointing where the hotspot is, but the inconsistency is still there, moreover it is inconsistent by a random offset each time. I'm thinking that this might be a result of anti-aliasing or some form of pixel alignment.
Though, maybe this is the best we can get already. The (0.5, 0.5) really helped to narrow down the error. Thanks again!
@NguyễnVũKhang - I had also played around with hiding the cursor and drawing it in CanvasView ... see the Edit to my answer.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.