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:
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.
There are various approaches, depending on exactly what your end-goal is.
One approach:
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:
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:
and debug output from a few taps:
HIT: Double 20
HIT: Single 18
HIT: Triple 2
HIT: Single 25
HIT: Double 25