如何在核心图形中进行命中检测

How to do hit detection in core graphics

核心图形对我来说很新,我在检测自定义图形上的点击时遇到了一些问题。

我用 paincode 的演示生成了 som 代码,然后我对其进行了大量修改。它画了一个像这样的“馅饼”:

我为此使用的代码如下所示:

import UIKit

public class DrawTest : NSObject {

    static var hitAreas = [Int:UIBezierPath]()
    
    static func didHit(_ point: CGPoint){
        let res = hitAreas.first{ [=10=].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()
    }

}

我正在从一个简单的 UIView 子类调用绘图代码,如下所示。这也是我附加了 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)
    }
}

绘图看起来像我想要的,但我希望能够 select 每个切片,这是不起作用的部分。我很确定这个问题与我传递给 didHit 的点有关,它是我的视图的本地点,但是我存储在 hitAreas 中并调用 contains 的 UIBezierPath 使用 UIBezierPath 的本地坐标,这是为什么我从来没有受到打击。

我不知道如何解决这个问题,迫切需要帮助。我的猜测是,这应该通过 1) 在 UIView 的坐标系上直接绘制我的切片来解决,但这需要大量的数学运算 2) 在命中测试时以某种方式将每个 UIBezierPath 的局部坐标转换为视图的范围

这一切都非常令人困惑,非常感谢建设性的意见。

有多种方法,具体取决于您的最终目标。

一种方法:

  • 计算“每切片的度数”... 360 / 20 = 18
  • 获取中心点到触摸点的角度
  • 将角度“固定”为切片宽度的 1/2(因为切片不从零开始)
  • 将该角度除以每切片的度数以获得切片数

使用这两个扩展可以轻松获得角度(以度为单位):

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
    }
}

并且,在您发布的代码中,在您的 DrawTest class 中,将 didHit 更改为:

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)

}

并在您从 DartBoardView class 调用它时包含边界,如:

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

缺点包括:

  • 您还需要检查“线长”以确保它不会延伸到圆外
  • 您无法轻松访问切片贝塞尔曲线路径(如果您想对它们执行其他操作)

另一种方法是为每个切片使用形状图层,从而更容易跟踪贝塞尔曲线路径。

从切片的 Struct 开始:

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

DartBoardView class 变为(注意:它使用与上面相同的 CGFloat 扩展名):

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: { [=14=].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

        }

    }
    
}

这里有一个示例控制器 class 来演示:

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
    }

}

编辑

并不像看起来那么复杂。

这是一个完整的 Dart Board 的实现(没有数字 - 我将把它作为练习留给你):

段结构

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: { [=17=].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 CGFloat {
    var degrees: CGFloat {
        return self * CGFloat(180) / .pi
    }
    var radians: CGFloat {
        return self * .pi / 180.0
    }
}

示例视图控制器

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
    }

}

结果:

点击几下即可调试输出:

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