iCediCe
iCediCe

Reputation: 1722

How to do hit detection in core graphics

Core graphics is pretty new to me, and I'm facing some issues detecting clicks on my custom graphics.

I generated som code with the demo of paincode which i then heavily modified. It draws a "pie" like this:

Core graphics pie

The code I used for this looks like this:

import UIKit

public class DrawTest : NSObject {

    static var hitAreas = [Int:UIBezierPath]()
    
    static func didHit(_ point: CGPoint){
        let res = hitAreas.first{ $0.value.contains(point) }?.key
        print("HIT: ", res)
    }

    public class func drawDartboard(frame targetFrame: CGRect) {

        let context = UIGraphicsGetCurrentContext()!
    
        context.saveGState()
        let resizedFrame: CGRect = targetFrame
        context.translateBy(x: resizedFrame.minX, y: resizedFrame.minY)
        context.scaleBy(x: resizedFrame.width / 100, y: resizedFrame.height / 100)

        let sliceRect = CGRect(x: 0, y: 0, width: 100, height: 100)
        context.saveGState()
        context.clip(to: sliceRect)
        context.translateBy(x: sliceRect.minX, y: sliceRect.minY)
        context.translateBy(x: 0, y: sliceRect.height)
        context.scaleBy(x: 1, y: -1)

        let dark = UIColor(red: 0.235, green: 0.208, blue: 0.208, alpha: 1.000)
        let light = UIColor(red: 0.435, green: 0.408, blue: 0.408, alpha: 1.000)
        
        var slice = 0

        while slice < 20 {
            
            let sliceColor = slice%2 == 0 ? dark : light
            
            DrawTest.drawSlice(frame: CGRect(origin: .zero, size: sliceRect.size), roration: CGFloat(slice*18), sliceColor: sliceColor,  slice: slice )
            slice += 1
        }
        
        context.restoreGState()
    }

    public class func drawSlice(frame targetFrame: CGRect, roration: CGFloat, sliceColor: UIColor, slice: Int) {
        
        let context = UIGraphicsGetCurrentContext()!

        context.saveGState()
        let resizedFrame: CGRect = targetFrame
        context.translateBy(x: resizedFrame.minX, y: resizedFrame.minY)
        context.scaleBy(x: resizedFrame.width / 100, y: resizedFrame.height / 100)

        context.saveGState()
        context.translateBy(x: 49.99, y: 50)
        context.rotate(by: roration * CGFloat.pi/180)
        
        let sliceFillPath = UIBezierPath()
        sliceFillPath.move(to: CGPoint(x: -7.82, y: 49.38))
        sliceFillPath.addCurve(to: CGPoint(x: 7.83, y: 49.38), controlPoint1: CGPoint(x: -2.63, y: 50.2), controlPoint2: CGPoint(x: 2.65, y: 50.2))
        sliceFillPath.addLine(to: CGPoint(x: 0.01, y: -0))
        sliceFillPath.addLine(to: CGPoint(x: -7.82, y: 49.38))
        sliceFillPath.close()
        sliceColor.setFill()
        sliceFillPath.fill()
        
        hitAreas[slice] = sliceFillPath
    
        context.restoreGState()
    }

}

I'm calling the draw code from a simple UIView subclass like below. This is also were I attach a TapGerstureRecognizer.

import UIKit

class DartBoardView: UIView {
    
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    
        let gesture = UITapGestureRecognizer(target: self, action:  #selector(self.clickAction(sender:)))
        addGestureRecognizer(gesture)
    }
    
    @objc
    func clickAction(sender : UITapGestureRecognizer) {
        if sender.state == .recognized
        {
            let loc = sender.location(in: self)
            DrawTest.didHit(loc)
        }
    }
    
    override func draw(_ rect: CGRect) {
        DrawTest.drawDartboard(frame: bounds)
    }
}

The drawing looks like I want it to, but I want to be able to select each of the slices, this is the part that is not working. I am pretty sure that the issue has to do with the point I pass to didHit is local to my View but the UIBezierPath I store in hitAreas and call contains uses the local coordinates of the UIBezierPath, this is why I never get a hit.

I have no idea how to solve this and desperately need help. My guess is that this should be solved by 1) drawing my slices directy on the UIView´s coordinate system, but that would require a lot af math 2) somehow translate the local coordinates of each UIBezierPath to the scope of the view when hit testing

This is all very confusing at all constructive input is very appreciated.

Upvotes: 0

Views: 170

Answers (1)

DonMag
DonMag

Reputation: 77423

There are various approaches, depending on exactly what your end-goal is.

One approach:

  • calculate the "degrees-per-slice" ... 360 / 20 = 18
  • get the angle from the center point to the touch point
  • "fix" the angle by 1/2 of the slice width (since the slices don't start at zero)
  • divide that angle by degrees-per-slice to get the slice number

Use these two extensions to make it easy to get the angle (in degrees):

extension CGFloat {
    var degrees: CGFloat {
        return self * CGFloat(180) / .pi
    }
    var radians: CGFloat {
        return self * .pi / 180.0
    }
}

extension CGPoint {
    func angle(to otherPoint: CGPoint) -> CGFloat {
        let pX = otherPoint.x - x
        let pY = otherPoint.y - y
        let radians = atan2f(Float(pY), Float(pX))
        var degrees = CGFloat(radians).degrees
        while degrees < 0 {
            degrees += 360
        }
        return degrees
    }
}

And, in the code you posted, in your DrawTest class, change didHit to:

static func didHit(_ point: CGPoint, in bounds: CGRect){
    
    let c: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
    let angle = c.angle(to: point)
    var fixedAngle = Int(angle) + 99    // 90 degrees + 1/2 of slice width
    if fixedAngle >= 360 {
        fixedAngle -= 360
    }
    print("HIT:", fixedAngle / 18)

}

and include the bounds when you call it from DartBoardView class as:

@objc
func clickAction(sender : UITapGestureRecognizer) {
    if sender.state == .recognized
    {
        let loc = sender.location(in: self)
        // include self's bounds
        DrawTest.didHit(loc, in: bounds)
    }
}

Drawbacks include:

  • you'd also need to check the "line length" to make sure it doesn't extend outside the circle
  • you don't have easy access to the slice bezier paths (if you want to do something else with them)

Another approach would be to use shape layers for each slice, making it easier to track the bezier paths.

Start with a Struct for the slices:

struct Slice {
    var color: UIColor = .white
    var path: UIBezierPath = UIBezierPath()
    var shapeLayer: CAShapeLayer = CAShapeLayer()
    var key: Int = 0
}

The DartBoardView class becomes (note: it uses the same CGFloat extension from above):

extension CGFloat {
    var degrees: CGFloat {
        return self * CGFloat(180) / .pi
    }
    var radians: CGFloat {
        return self * .pi / 180.0
    }
}

class DartBoardView: UIView {
    
    // array of slices
    var slices: [Slice] = []

    // slice width in degrees
    let sliceWidth: CGFloat = 360.0 / 20.0
    
    // easy to understand 12 o'clock (3 o'clock is Zero)
    let twelveOClock: CGFloat = 270
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    
    func commonInit() -> Void {
        
        let dark = UIColor(red: 0.235, green: 0.208, blue: 0.208, alpha: 1.000)
        let light = UIColor(red: 0.435, green: 0.408, blue: 0.408, alpha: 1.000)

        for slice in 0..<20 {
            let sliceColor = slice % 2 == 1 ? dark : light
            let s = Slice(color: sliceColor, key: slice)
            s.shapeLayer.fillColor = s.color.cgColor
            layer.addSublayer(s.shapeLayer)
            slices.append(s)
        }
        
        let gesture = UITapGestureRecognizer(target: self, action:  #selector(self.clickAction(sender:)))
        addGestureRecognizer(gesture)

    }
    
    @objc
    func clickAction(sender : UITapGestureRecognizer) {
        if sender.state == .recognized
        {
            let loc = sender.location(in: self)
            if let s = slices.first(where: { $0.path.contains(loc) }) {
                print("HIT:", s.key)
            } else {
                print("Tapped outside the circle!")
            }
        }
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        let c: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
        let radius: CGFloat = bounds.midX
        
        // slice width in radians
        let ww: CGFloat = sliceWidth.radians
        
        // start 1/2 sliceWidth less than 12 o'clock
        var startDegrees: CGFloat = twelveOClock.radians - (ww * 0.5)
        
        for i in 0..<slices.count {

            let endDegrees: CGFloat = startDegrees + ww
            
            let pth: UIBezierPath = UIBezierPath()
            pth.addArc(withCenter: c, radius: radius, startAngle: startDegrees, endAngle: endDegrees, clockwise: true)
            pth.addLine(to: c)
            pth.close()
            
            slices[i].path = pth
            slices[i].shapeLayer.path = pth.cgPath
            
            startDegrees = endDegrees

        }

    }
    
}

And here's an example controller class to demonstrate:

class DartBoardViewController: UIViewController {

    let dartBoard = DartBoardView()
    
    override func viewDidLoad() {
        super.viewDidLoad()

        dartBoard.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(dartBoard)
        
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            dartBoard.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            dartBoard.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            dartBoard.heightAnchor.constraint(equalTo: dartBoard.widthAnchor),
            dartBoard.centerYAnchor.constraint(equalTo: g.centerYAnchor),
        ])
        
        dartBoard.backgroundColor = .black
    }

}

Edit

Not as complex as it may seem.

Here's an implementation of a full Dart Board (without the numbers - I'll leave that as an exercise for you):

Segment Struct

struct Segment {
    var value: Int = 0
    var multiplier: Int = 1
    var color: UIColor = .cyan
    var path: UIBezierPath = UIBezierPath()
    var layer: CAShapeLayer = CAShapeLayer()
}

DartBoardView class

class DartBoardView: UIView {
    
    var doubleSegments: [Segment] = [Segment]()
    var outerSingleSegments: [Segment] = [Segment]()
    var tripleSegments: [Segment] = [Segment]()
    var innerSingleSegments: [Segment] = [Segment]()
    var singleBullSegment: Segment = Segment()
    var doubleBullSegment: Segment = Segment()

    var allSegments: [Segment] = [Segment]()
    
    let boardLayer: CAShapeLayer = CAShapeLayer()
    
    let darkColor: UIColor = UIColor(white: 0.1, alpha: 1.0)
    let lightColor: UIColor = UIColor(red: 0.975, green: 0.9, blue: 0.8, alpha: 1.0)
    let darkRedColor: UIColor = UIColor(red: 0.8, green: 0.1, blue: 0.1, alpha: 1.0)
    let darkGreenColor: UIColor = UIColor(red: 0.0, green: 0.5, blue: 0.3, alpha: 1.0)

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        
        layer.addSublayer(boardLayer)
        boardLayer.fillColor = UIColor.black.cgColor
        
        // points starting at 3 o'clock
        let values: [Int] = [
            6, 10, 15, 2, 17, 3, 19, 7, 16, 8, 11, 14, 9, 12, 5, 20, 1, 18, 4, 13,
        ]

        // local vars for reuse
        var seg: Segment = Segment()
        var c: UIColor = .white
        
        // doubles and triples
        for i in 0..<values.count {
            c = i % 2 == 1 ? darkRedColor : darkGreenColor

            seg = Segment(value: values[i],
                                       multiplier: 2,
                                       color: c,
                                       layer: CAShapeLayer())
            layer.addSublayer(seg.layer)
            doubleSegments.append(seg)

            seg = Segment(value: values[i],
                                       multiplier: 3,
                                       color: c,
                                       layer: CAShapeLayer())
            layer.addSublayer(seg.layer)
            tripleSegments.append(seg)
        }

        // singles
        for i in 0..<values.count {
            c = i % 2 == 1 ? darkColor : lightColor

            seg = Segment(value: values[i],
                                       multiplier: 1,
                                       color: c,
                                       layer: CAShapeLayer())
            layer.addSublayer(seg.layer)
            outerSingleSegments.append(seg)

            seg = Segment(value: values[i],
                                       multiplier: 1,
                                       color: c,
                                       layer: CAShapeLayer())
            layer.addSublayer(seg.layer)
            innerSingleSegments.append(seg)
        }
        
        // bull and double bull
        seg = Segment(value: 25,
                      multiplier: 1,
                      color: darkGreenColor,
                      layer: CAShapeLayer())
        layer.addSublayer(seg.layer)
        singleBullSegment = seg
        
        seg = Segment(value: 25,
                      multiplier: 2,
                      color: darkRedColor,
                      layer: CAShapeLayer())
        layer.addSublayer(seg.layer)
        doubleBullSegment = seg
        
        let gesture = UITapGestureRecognizer(target: self, action:  #selector(self.clickAction(sender:)))
        addGestureRecognizer(gesture)
        
    }
    
    @objc
    func clickAction(sender : UITapGestureRecognizer) {
        if sender.state == .recognized
        {
            let loc = sender.location(in: self)
            if let s = allSegments.first(where: { $0.path.contains(loc) }) {
                print("HIT:", s.multiplier == 3 ? "Triple" : s.multiplier == 2 ? "Double" : "Single", s.value)
            } else {
                print("Tapped outside!")
            }
        }
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // initialize local variables for reuse / readability
        var startAngle: CGFloat = 0
        
        var outerDoubleRadius: CGFloat = 0.0
        var innerDoubleRadius: CGFloat = 0.0
        var outerTripleRadius: CGFloat = 0.0
        var innerTripleRadius: CGFloat = 0.0
        var outerBullRadius: CGFloat = 0.0
        var innerBullRadius: CGFloat = 0.0
        
        // initialize local constants
        let viewCenter: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
        
        // leave 20% for the numbers area
        let diameter = bounds.width * 0.8
        
        // dart board radii in mm
        let specRadii: [CGFloat] = [
            170, 162, 107, 99, 16, 6
        ]
        
        // convert to view size
        let factor: CGFloat = (diameter * 0.5) / specRadii[0]

        outerDoubleRadius = specRadii[0] * factor
        innerDoubleRadius = specRadii[1] * factor
        outerTripleRadius = specRadii[2] * factor
        innerTripleRadius = specRadii[3] * factor
        outerBullRadius = specRadii[4] * factor
        innerBullRadius = specRadii[5] * factor
        
        let wireColor: UIColor = UIColor(white: 0.8, alpha: 1.0)
        
        let wedgeWidth: CGFloat = 360.0 / 20.0
        let incAngle: CGFloat = wedgeWidth.radians
        startAngle = -(incAngle * 0.5)

        var path: UIBezierPath = UIBezierPath()

        // outer board layer
        path = UIBezierPath(ovalIn: bounds)
        boardLayer.path = path.cgPath
        
        for i in 0..<20 {
            let endAngle = startAngle + incAngle
            
            var shape = doubleSegments[i].layer
            path = UIBezierPath()
            path.addArc(withCenter: viewCenter, radius: outerDoubleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
            path.addArc(withCenter: viewCenter, radius: innerDoubleRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
            path.close()
            shape.path = path.cgPath
            
            doubleSegments[i].path = path
            
            shape.fillColor = doubleSegments[i].color.cgColor
            shape.strokeColor = wireColor.cgColor
            shape.borderWidth = 1.0
            shape.borderColor = wireColor.cgColor
            
            shape = outerSingleSegments[i].layer
            path = UIBezierPath()
            path.addArc(withCenter: viewCenter, radius: innerDoubleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
            path.addArc(withCenter: viewCenter, radius: outerTripleRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
            path.close()
            shape.path = path.cgPath
            
            outerSingleSegments[i].path = path
            
            shape.fillColor = outerSingleSegments[i].color.cgColor
            shape.strokeColor = wireColor.cgColor
            shape.borderWidth = 1.0
            shape.borderColor = wireColor.cgColor
            
            shape = tripleSegments[i].layer
            path = UIBezierPath()
            path.addArc(withCenter: viewCenter, radius: outerTripleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
            path.addArc(withCenter: viewCenter, radius: innerTripleRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
            path.close()
            shape.path = path.cgPath
            
            tripleSegments[i].path = path
            
            shape.fillColor = tripleSegments[i].color.cgColor
            shape.strokeColor = wireColor.cgColor
            shape.borderWidth = 1.0
            shape.borderColor = wireColor.cgColor
            
            shape = innerSingleSegments[i].layer
            path = UIBezierPath()
            path.addArc(withCenter: viewCenter, radius: innerTripleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
            path.addArc(withCenter: viewCenter, radius: outerBullRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
            path.close()
            shape.path = path.cgPath
            
            innerSingleSegments[i].path = path
            
            shape.fillColor = innerSingleSegments[i].color.cgColor
            shape.strokeColor = wireColor.cgColor
            shape.borderWidth = 1.0
            shape.borderColor = wireColor.cgColor
            
            startAngle = endAngle
        }

        let singleBullPath = UIBezierPath(ovalIn: CGRect(x: viewCenter.x - outerBullRadius, y: viewCenter.y - outerBullRadius, width: outerBullRadius * 2, height: outerBullRadius * 2))
        let doubleBullPath = UIBezierPath(ovalIn: CGRect(x: viewCenter.x - innerBullRadius, y: viewCenter.y - innerBullRadius, width: innerBullRadius * 2, height: innerBullRadius * 2))

        var shape = singleBullSegment.layer
        singleBullPath.append(doubleBullPath)
        singleBullPath.usesEvenOddFillRule = true
        shape.fillRule = .evenOdd

        shape.path = singleBullPath.cgPath
        
        singleBullSegment.path = singleBullPath
        
        shape.fillColor = singleBullSegment.color.cgColor
        shape.strokeColor = wireColor.cgColor
        shape.borderWidth = 1.0
        shape.borderColor = wireColor.cgColor
        
        shape = doubleBullSegment.layer
        shape.path = doubleBullPath.cgPath
        doubleBullSegment.path = doubleBullPath

        shape.fillColor = doubleBullSegment.color.cgColor
        shape.strokeColor = wireColor.cgColor
        shape.borderWidth = 1.0
        shape.borderColor = wireColor.cgColor

        // append all segments for hit-testing
        allSegments = []
        allSegments.append(contentsOf: tripleSegments)
        allSegments.append(contentsOf: outerSingleSegments)
        allSegments.append(contentsOf: doubleSegments)
        allSegments.append(contentsOf: innerSingleSegments)
        allSegments.append(singleBullSegment)
        allSegments.append(doubleBullSegment)

    }
}

CGFloat extension

extension CGFloat {
    var degrees: CGFloat {
        return self * CGFloat(180) / .pi
    }
    var radians: CGFloat {
        return self * .pi / 180.0
    }
}

Example view controller

class DartBoardViewController: UIViewController {

    let dartBoard = DartBoardView()
    
    override func viewDidLoad() {
        super.viewDidLoad()

        dartBoard.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(dartBoard)
        
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            dartBoard.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            dartBoard.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            dartBoard.heightAnchor.constraint(equalTo: dartBoard.widthAnchor),
            dartBoard.centerYAnchor.constraint(equalTo: g.centerYAnchor),
        ])
        
        dartBoard.backgroundColor = .clear
    }

}

Result:

enter image description here

and debug output from a few taps:

HIT: Double 20
HIT: Single 18
HIT: Triple 2
HIT: Single 25
HIT: Double 25

Upvotes: 2

Related Questions