iosswiftcore-graphicscgcontext

Draw text inside pie chart section created using CoreGraphics


I created a simple pie chart on Swift that looks like this: Current state

But now I need to add text inside each pie section like this: Expected result

and I'm not sure how to accomplish that.

This is the code I used to create the graph

 override func draw(_ rect: CGRect) {

        let ctx = UIGraphicsGetCurrentContext()
        let radius = min(frame.size.width, frame.size.height) * 0.5
        let viewCenter = CGPoint(x: bounds.size.width * 0.5, y: bounds.size.height * 0.5)
        let valueCount = segments.reduce(0, {$0 + $1.value})
        var startAngle = -CGFloat.pi * 0.5

        for segment in segments { 
            ctx?.setFillColor(segment.color.cgColor)
            let endAngle = startAngle + 2 * .pi * (segment.value / valueCount)
            ctx?.move(to: viewCenter)
            ctx?.addArc(center: viewCenter, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
            ctx?.fillPath()
            startAngle = endAngle
        }
    }

I created labels for each section, but I'm not sure how to calculate the frame position or if there's a property in the context that could help me to place them inside each section like in the image above. I tried using the startAngle/endAngle as a starting point but they just stack one on top of another.


Solution

  • The trick is to first calculate the center of where each segment's label should be positioned. A little trigonometry gives you:

    let midAngle = (startAngle + endAngle) / 2
    let textCenter = CGPoint(x: cos(midAngle) * radius * 0.75 + viewCenter.x, y: sin(midAngle) * radius * 0.75 + viewCenter.y)
    

    where midAngle is the centerline of each segment (halfway between the start and end angles). The 0.75 is the percentage of the radius you want the text to be from the center of the circle. Adjust as desired.

    Then you need to figure out the bounding box of the text to be drawn for a given segment and then adjust that based on the calculated center.

    Finally you can draw the text.

    Here is a version of your code that can be run in an iOS Swift Playground. I guessed at the Segment struct based on your code. What I show below is enough to make it run.

    import UIKit
    import PlaygroundSupport
    
    struct Segment {
        let value: CGFloat
        let color: UIColor
    }
    
    class GraphView: UIView {
        var segments: [Segment] = [
            Segment(value: 40, color: .green),
            Segment(value: 20, color: .yellow),
            Segment(value: 50, color: .blue),
            Segment(value: 70, color: .orange),
            Segment(value: 120, color: .red),
        ]
    
        override func draw(_ rect: CGRect) {
            let ctx = UIGraphicsGetCurrentContext()
            let radius = min(frame.size.width, frame.size.height) * 0.5
            let viewCenter = CGPoint(x: bounds.size.width * 0.5, y: bounds.size.height * 0.5)
            let valueCount = segments.reduce(0, {$0 + $1.value})
            var startAngle = -CGFloat.pi * 0.5
    
            // Attributes for the labels. Adjust as desired
            let textAttrs: [NSAttributedString.Key : Any] = [
                .font: UIFont.preferredFont(forTextStyle: .headline),
                .foregroundColor: UIColor.black,
            ]
    
            for segment in segments {
                ctx?.setFillColor(segment.color.cgColor)
                let endAngle = startAngle + 2 * .pi * (segment.value / valueCount)
                ctx?.move(to: viewCenter)
                ctx?.addArc(center: viewCenter, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
                ctx?.fillPath()
    
                // Calculate center location of label
                // Replace the 0.75 with a desired distance from the center
                let midAngle = (startAngle + endAngle) / 2
                let textCenter = CGPoint(x: cos(midAngle) * radius * 0.75 + viewCenter.x, y: sin(midAngle) * radius * 0.75 + viewCenter.y)
    
                // The text label (adjust as needed)
                let label = "\(segment.value)"
    
                // Calculate the bounding box and adjust for the center location
                var rect = label.boundingRect(with: CGSize(width: 1000, height: 1000), attributes: textAttrs, context: nil)
                rect.origin.x = textCenter.x - rect.size.width / 2
                rect.origin.y = textCenter.y - rect.size.height / 2
    
                label.draw(in: rect, withAttributes: textAttrs)
    
                startAngle = endAngle
            }
        }
    }
    
    let graph = GraphView(frame: CGRect(x: 0, y: 0, width: 400, height: 400))
    

    This gives you the following:

    enter image description here

    Some of the text methods (boundingRect and draw) come from NSString which are convenient methods for drawing into a CGContext. Easier than the text drawing methods found in CGContext.