Search code examples
iosswiftuiviewcore-graphicscgcontext

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.


Solution

  • 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