swiftuikitmac-catalyst

Mac Catalyst: Cursor position doesn't match touch location on UIView


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.


Solution

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