Search code examples
iosswiftuibezierpathrounded-cornersspacing

UIBezierPath arc to create pie charts with rounded corners and spacing


I wonder how can we create a pie chart with rounded edges and spaces between pie as shown in the photo.

My first approach: I move the pies out of its center point an offset = 10 to make it look like the photo. But It seems like the radius of the biggest pie is smaller than the smaller ones. Then I make a change on Radius, but the spacing a bit weird And since the newCenter point is not in the center of superview, It’s cut off on a side.

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

(Percentage is the proportion of pie in the chart)

My second approach: I calculate the center point for each pie instead of moving it out of its original center point. Imagine there’s an empty middle as a circle and a new center point for each pie is in that circle.

The issues still occur with large pies.

The new center point for each slide on my tries:

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)

Issues with radius and center point | Expected result

Here’s my code https://gist.github.com/phongngo511/dfd416aaad45fc0241cd4526d80d94d6


Solution

  • Hi is this what you're trying to achieve? If so, I think your approach had a couple of issues. Firstly, looking at your code gist, I changed a couple of things from the way you were doing it:

    1. Changed the pie segment sizes (so I could test >180° segments) and colours.
    2. I added a convenience toRadians() function to the CGFloat extension (which is just the opposite of the toRadians() function you'd already added).
    3. I changed the radius variable to be the min (not max as you were doing) of the bounds width / height, so that it fits in view without cropping. This is just personal preference & wouldn't change the overall functioning of the code (you might need it to be bigger & scrollable, for instance, whereas I just wanted to debug this particular problem). I also added padding so that it would still fit the segments when they've been spaced apart.
    4. I went down your original route of solving the problem; draw all segments at the centre of the pie chart, and space them out afterwards, rather than trying to draw each one off centre. You could do either route although keeping them centred while constructing them is simpler and leads to more readable code. The spacing out is achieved by an affine transform at the end of the createPath: function, which spaces them out by a given segment's mid angle. You'd probably want to do it slightly more intelligently than this in real life (it's a bit primitive) as, as per the screenshot, very large segments will appear to be separated further than small segments are from each other (the red segment appears further away from the green and blue than the green and blue are from each other). So you might want to develop an algorithm that not only incorporates a segment's mid angle, but also how big that segment is, in order to determine not only the direction but also the distance to separate it? Or maybe factor in a segment's neighbours' mid angles when determining the direction of separation? Personal taste.
    5. In your layoutSubviews(), you were supplying your createPath() with a different oRadius for each segment. That's why your segments had different radii from each other. I just supplied "radius" for all of them. If you comment out the affine transform in my createPath() function (which spaces them out), you'll see the segments in my version are all the same size radius.
    6. I moved the path.close() into the createPath() function, rather than after calling it. Seems neater.
    7. In terms of drawing a given segment, I've taken a different approach entirely (aside from drawing it centred in the pie chart and then moving it afterwards). I've drawn it with 2 straight lines and an arc for the outer circumference of the pie chart. For the rounded corners, rather than drawing an arc (N.B.: your centre rounded corner for a segment wasn't drawing correctly, causing weird graphical artefacts), I've used quadratic Bézier curves. These take only 1 control point, not 2 control points like a cubic Bézier curve takes. As a result, you can specify the corner of the segment as that control point, and it will give you a rounded corner suitable for the corner of the triangle that you're rounding. Because of this, I only draw the lines / arc up to near each corner, then do a quad Bézier curve to round the corner, then carry on with the rest of the segment.

    Let me know if anything needs clarification, hope this helps!

        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({$0.removeFromSuperview()})
            labels.removeAll()
    
            shapeLayers.forEach({$0.removeFromSuperlayer()})
            shapeLayers.removeAll()
    
            gradientLayers.forEach({$0.removeFromSuperlayer()})
            gradientLayers.removeAll()
    
            let valueCount = segments.reduce(CGFloat(0), {$0 + $1})
            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({ ($0 / 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({$0.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({$0.element.opacity = $0.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)
        }
    }