UIBezierPath 圆弧创建带圆角和间距的饼图

UIBezierPath arc to create pie charts with rounded corners and spacing

我想知道我们如何创建如图所示的圆形边缘和饼图之间的空间的饼图。

我的第一种方法:我将馅饼从中心点移出 offset = 10,使其看起来像照片。但似乎最大的馅饼的半径比小的要小。 然后我对 Radius 进行了更改,但间距有点奇怪 而且由于新的中心点不在superview的中心,所以在一边被截断了。

outerRadius = outerRadius - offset * 2 * (1 - percentage)

(百分比为饼图在图表中所占的比例)

我的第二种方法:我计算每个饼的中心点,而不是将其移出其原始中心点。想象有一个空心的圆圈,每个馅饼的新中心点都在那个圆圈中。

大馅饼仍然会出现问题。

我尝试过的每张幻灯片的新中心点:

let middleAngle = ((startAngle + endAngle) / 2.0).toRadians()
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let newCenter = CGPoint(x: center.x + cos(middleAngle) * offset, y: center.y + sin(middleAngle) * offset)

关于半径和中心点的问题 |预期结果

这是我的代码 https://gist.github.com/phongngo511/dfd416aaad45fc0241cd4526d80d94d6

嗨is this what you're trying to achieve?如果是这样,我认为您的方法有几个问题。首先,查看您的代码要点,我根据您的操作方式更改了一些内容:

  1. 更改了饼图的分段大小(这样我就可以测试 >180° 的分段)和颜色。
  2. 我向 CGFloat 扩展添加了一个方便的 toRadians() 函数(这与您已经添加的 toRadians() 函数正好相反)。
  3. 我将 radius 变量更改为边界宽度/高度的最小值(不是您所做的最大值),以便它适合视图而无需裁剪。这只是个人偏好,不会改变代码的整体功能(例如,您可能需要它更大且可滚动,而我只是想调试这个特定问题)。我还添加了填充,这样当它们 spaced 分开时它仍然适合这些段。
  4. 我按照你原来的解决问题的方法做了;在饼图的中心绘制所有部分,然后 space 将它们画出来,而不是试图将每个部分都画出中心。尽管在构建它们时保持它们居中更简单并且代码可读性更高,但您可以采用任何一种方法。间隔是通过 createPath: 函数末尾的仿射变换实现的,该函数 space 将它们按给定线段的中角分开。你可能想要比现实生活中的这个更智能一点(它有点原始),因为根据屏幕截图,非常大的段看起来比小段彼此分开得更远(红色段出现绿色和蓝色之间的距离比绿色和蓝色彼此之间的距离更远)。因此,您可能想要开发一种算法,不仅要包含线段的中角,还要考虑线段的大小,以便不仅确定方向,还确定将线段分开的距离?或者在确定分离方向时考虑段的邻居的中角?个人品味。
  5. 在您的 layoutSubviews() 中,您为每个段提供了具有不同 oRadius 的 createPath()。这就是为什么您的线段彼此具有不同的半径。我只是为所有这些提供了“半径”。如果您在我的 createPath() 函数中注释掉仿射变换(space 将它们删除),您会看到我的版本中的段都是相同大小的半径。
  6. 我将 path.close() 移动到 createPath() 函数中,而不是在调用它之后。看起来更整洁了。
  7. 在绘制给定的部分方面,我采用了完全不同的方法(除了将其绘制在饼图的中心然后再移动之外)。我用 2 条直线和圆弧绘制了饼图的外圆周。对于圆角,我没有画弧线(N.B.: 你的线段的中心圆角没有正确绘制,导致奇怪的图形伪像),我使用了二次贝塞尔曲线。这些只需要 1 个控制点,而不是像三次贝塞尔曲线那样需要 2 个控制点。因此,您可以将线段的角指定为该控制点,它会为您提供一个适合您要舍入的三角形角的圆角。正因为如此,我只画线/弧直到每个角附近,然后做一个四边形贝塞尔曲线来圆角,然后继续线段的其余部分。

如果有任何需要澄清的地方,请告诉我,希望这对您有所帮助!

    import UIKit
    
    class PieChartView: UIView {
    
    var onTouchPie: ((_ sliceIndex: Int) -> ())?
    var shouldHighlightPieOnTouch = false

    var shouldShowLabels: Bool = false {
        didSet { setNeedsLayout() }
    }
    var labelTextFont = UIFont.systemFont(ofSize: 12) {
        didSet { setNeedsLayout() }
    }
    var labelTextColor = UIColor.black {
        didSet { setNeedsLayout() }
    }
    
    var shouldShowTextPercentageFromFieFilledFigures = false {
        didSet { setNeedsLayout() }
    }
    
    var pieGradientColors: [[UIColor]] = [[.red,.red], [.cyan,.cyan], [.green,.green]] {
        didSet { setNeedsLayout() }
    }
    
    var pieFilledPercentages:[CGFloat] = [1, 1, 1] {
        didSet { setNeedsLayout() }
    }
    
    //var segments:[CGFloat] = [40, 30, 30] {
    var segments:[CGFloat] = [70, 20, 10] {
        didSet { setNeedsLayout() }
    }
    
    var offset:CGFloat = 15 {
        didSet { setNeedsLayout() }
    }
    
    var spaceLineColor: UIColor = .white {
        didSet { setNeedsLayout() }
    }
    
    private var labels: [UILabel] = []
    private var labelSize = CGSize(width: 100, height: 50)
    private var shapeLayers = [CAShapeLayer]()
    private var gradientLayers = [CAGradientLayer]()
    
    override func layoutSubviews() {
        super.layoutSubviews()

        labels.forEach({[=10=].removeFromSuperview()})
        labels.removeAll()

        shapeLayers.forEach({[=10=].removeFromSuperlayer()})
        shapeLayers.removeAll()

        gradientLayers.forEach({[=10=].removeFromSuperlayer()})
        gradientLayers.removeAll()

        let valueCount = segments.reduce(CGFloat(0), {[=10=] + })
        guard pieFilledPercentages.count >= 3, segments.count >= 3, pieGradientColors.count >= 3 , valueCount > 0 else { return }
        let radius = min(bounds.width / 2, bounds.height / 2) * 0.9 //KEN CHANGED
        var startAngle: CGFloat = 360
        let proportions = segments.map({ ([=10=] / valueCount * 100).rounded()})

        for i in 0..<segments.count {
            let endAngle = startAngle - proportions[i] / 100 * 360

            let path = createPath(from: startAngle, to: endAngle, oRadius: radius, percentage: proportions[i])
            //path.close() //KEN CHANGED
            let shapeLayer = CAShapeLayer()
            shapeLayer.path = path.cgPath
            shapeLayers.append(shapeLayer)

            let gradientLayer = CAGradientLayer()
            gradientLayer.colors = pieGradientColors[i].map({[=10=].cgColor})
            if i == 0 {
                gradientLayer.locations = [0.5, 1]
            } else {
                gradientLayer.locations = [0, 0.5]
            }
            gradientLayer.mask = shapeLayer
            gradientLayer.frame = bounds
            if proportions[i] != 0 && pieFilledPercentages[i] != 0 {
                layer.addSublayer(gradientLayer)
                gradientLayers.append(gradientLayer)
            }

            let label = labelFromPoint(point: getCenterPointOfArc(startAngle: startAngle, endAngle: endAngle), andText: String(format: "%.f", shouldShowTextPercentageFromFieFilledFigures ? pieFilledPercentages[i] * 100 :segments[i]) + "%")
            label.isHidden = !shouldShowLabels
            if proportions[i] != 0 {
                addSubview(label)
                labels.append(label)
            }

            startAngle = endAngle
        }
    }
    
    private func labelFromPoint(point: CGPoint, andText text: String) -> UILabel {
        let label = UILabel(frame: CGRect(origin: point, size: labelSize))
        label.font = labelTextFont
        label.textColor = labelTextColor
        label.text = text
        return label
    }

    private func getCenterPointOfArc(startAngle: CGFloat, endAngle: CGFloat) -> CGPoint {
        let oRadius = max(bounds.width / 2, bounds.height / 2) * 0.8
        let center = CGPoint(x: oRadius, y: oRadius)
        let centerAngle = ((startAngle + endAngle) / 2.0).toRadians()
        let arcCenter = CGPoint(x: center.x + oRadius * cos(centerAngle), y: center.y - oRadius * sin(centerAngle))
        return CGPoint(x: (center.x + arcCenter.x) / 2, y: (center.y + arcCenter.y) / 2)
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        if let touch = touches.first, shouldHighlightPieOnTouch {
            shapeLayers.enumerated().forEach { (item) in
                if let path = item.element.path, path.contains(touch.location(in: self)) {
                    item.element.opacity = 1
                    onTouchPie?(item.offset)
                } else {
                    item.element.opacity = 0.3
                }
            }
        }
        super.touchesBegan(touches, with: event)
    }

    private func highlightLayer(index: Int) {
        shapeLayers.enumerated().forEach({[=10=].element.opacity = [=10=].offset == index ? 1: 0.3 })
    }

    private func createPath(from startAngle: CGFloat, to endAngle: CGFloat, oRadius: CGFloat, cornerRadius: CGFloat = 10, percentage: CGFloat) -> UIBezierPath {

        let radius: CGFloat = min(bounds.width, bounds.height) / 2.0 - (2.0 * offset)
        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        let midPointAngle = ((startAngle + endAngle) / 2.0).toRadians() //used to spread the segment away from its neighbours after creation

        let startAngle = (360.0 - startAngle).toRadians()
        let endAngle = (360.0 - endAngle).toRadians()

        let circumference: CGFloat = CGFloat(2.0 * (Double.pi * Double(radius)))
        let arcLengthPerDegree = circumference / 360.0 //how many pixels long the outer arc is of the pie chart, per 1° of a pie segment
        let pieSegmentOuterCornerRadiusInDegrees: CGFloat = 4.0 //for a given segment (and if it's >4° in size), use up 2 of its outer arc's degrees as rounded corners.
        let pieSegmentOuterCornerRadius = arcLengthPerDegree * pieSegmentOuterCornerRadiusInDegrees
        
        let path = UIBezierPath()
        
        //move to the centre of the pie chart, offset by the corner radius (so the corner of the segment can be rounded in a bit)
        path.move(to: CGPoint(x: center.x + (cos(startAngle - CGFloat(360).toRadians()) * cornerRadius), y: center.y + (sin(startAngle - CGFloat(360).toRadians()) * cornerRadius)))
        //if the size of the pie segment isn't big enough to warrant rounded outer corners along its outer arc, don't round them off
        if ((endAngle - startAngle).toDegrees() <= (pieSegmentOuterCornerRadiusInDegrees * 2.0)) {
            //add line from centre of pie chart to 1st outer corner of segment
            path.addLine(to: CGPoint(x: center.x + (cos(startAngle - CGFloat(360).toRadians()) * radius), y: center.y + (sin(startAngle - CGFloat(360).toRadians()) * radius)))
            //add arc for segment's outer edge on pie chart
            path.addArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
            //move down to the centre of the pie chart, leaving room for rounded corner at the end
            path.addLine(to: CGPoint(x: center.x + (cos(endAngle - CGFloat(360).toRadians()) * cornerRadius), y: center.y + (sin(endAngle - CGFloat(360).toRadians()) * cornerRadius)))
            //add final rounded corner in middle of pie chart
            path.addQuadCurve(to: CGPoint(x: center.x + (cos(startAngle - CGFloat(360).toRadians()) * cornerRadius), y: center.y + (sin(startAngle - CGFloat(360).toRadians()) * cornerRadius)), controlPoint: center)
        } else { //round the corners on the outer arc
            //add line from centre of pie chart to circumference of segment, minus the space needed for the rounded corner
            path.addLine(to: CGPoint(x: center.x + (cos(startAngle - CGFloat(360).toRadians()) * (radius - pieSegmentOuterCornerRadius)), y: center.y + (sin(startAngle - CGFloat(360).toRadians()) * (radius - pieSegmentOuterCornerRadius))))
            //add rounded corner onto start of outer arc
            let firstRoundedCornerEndOnArc = CGPoint(x: center.x + (cos(startAngle + pieSegmentOuterCornerRadiusInDegrees.toRadians() - CGFloat(360).toRadians()) * radius), y: center.y + (sin(startAngle + pieSegmentOuterCornerRadiusInDegrees.toRadians() - CGFloat(360).toRadians()) * radius))
            path.addQuadCurve(to: firstRoundedCornerEndOnArc, controlPoint: CGPoint(x: center.x + (cos(startAngle - CGFloat(360).toRadians()) * radius), y: center.y + (sin(startAngle - CGFloat(360).toRadians()) * radius)))
            //add arc for segment's outer edge on pie chart
            path.addArc(withCenter: center, radius: radius, startAngle: startAngle + pieSegmentOuterCornerRadiusInDegrees.toRadians(), endAngle: endAngle - pieSegmentOuterCornerRadiusInDegrees.toRadians(), clockwise: true)
            //add rounded corner onto end of outer arc
            let secondRoundedCornerEndOnLine = CGPoint(x: center.x + (cos(endAngle - CGFloat(360).toRadians()) * (radius - pieSegmentOuterCornerRadius)), y: center.y + (sin(endAngle - CGFloat(360).toRadians()) * (radius - pieSegmentOuterCornerRadius)))
            path.addQuadCurve(to: secondRoundedCornerEndOnLine, controlPoint: CGPoint(x: center.x + (cos(endAngle - CGFloat(360).toRadians()) * radius), y: center.y + (sin(endAngle - CGFloat(360).toRadians()) * radius)))
            //add line back to centre point of pie chart, leaving room for rounded corner at the end
            path.addLine(to: CGPoint(x: center.x + (cos(endAngle - CGFloat(360).toRadians()) * cornerRadius), y: center.y + (sin(endAngle - CGFloat(360).toRadians()) * cornerRadius)))
            //add final rounded corner in middle of pie chart
            path.addQuadCurve(to: CGPoint(x: center.x + (cos(startAngle - CGFloat(360).toRadians()) * cornerRadius), y: center.y + (sin(startAngle - CGFloat(360).toRadians()) * cornerRadius)), controlPoint: center)
        }
        path.close()
        //spread the segments out around the pie chart centre
        path.apply(CGAffineTransform(translationX: cos(midPointAngle) * offset, y: -sin(midPointAngle) * offset))
        return path
    }
}

extension CGFloat {
    func toRadians() -> CGFloat {
        return self * CGFloat(Double.pi) / 180.0
    }
    func toDegrees() -> CGFloat {
        return self / (CGFloat(Double.pi) / 180.0)
    }
}