Animating Pie Chart on iOS in Swift

JUNE 18, 2019

Step-by-step tutorial of how to draw an animating pie chart on iOS app by using Core Animation in Swift.

This is a screenshot from the actual iOS game application, Coffee Inc. There are many ways of developing animating pie chart on iOS such as using UIKit and images, I found it easier to render it programatically using Core Animation. In this tutorial, I'd like to share how it's done step by step (simpler gist version).

The Coffee Inc - Business Tycoon Game

1 Create a "Single View App" Project

First, create a new Xcode project: File -> New -> Project -> Single View App. Give a project name, PieChart.

Or feel free to download my Xcode project from Github.

2 Create a UIView subclass called PieChartView

Let's create a custom UIView that renders a pie chart. You can programatically code all sub components, but I'm demonstrating it with Nib file and Auto Layout here.

Add UI sub components to Custom UIView

Adding two new files:

  • PieChartView.swift
  • PieChartView.xib

Add sub UI components

In Nib file, drop the following UI components. Five UILabels are arbitrary numbers (up to five slices). You can have more or create them programatically. You can also use CATextLayer instead of UILabel.

  • CanvasView (UIView rendering Core Animation)
  • Label1 (UILabel displaying a % of the 1st slice)
  • Label2 (UILabel for the 2nd slice)
  • Label3 (UILabel for the 3rd)
  • Label4 (UILabel for the 4th)
  • Label5 (UILabel for the 5th)

Set auto layout

For CanvasView, you can have fixed or dynamic width and height, but make sure to set a ratio, 1:1, which guarantees a square shape. In this example, CanvasView's top, leading and trailing are aligned to the superview.

For Label1 - 5, align horizontal and vertical centers to CanvasView's center. Later on, we will manimulate layout constraints to move labels to appropriate slice's spots.

Setting Label's center to Canvas' Center

Wiring between Nib and Swift

Once Auto Layout was properly configured in Nib file, let's wire Nib components to Swift code. PieChartView class looks like this. UIView and UILabels are obvious. XConsts and YConsts are Auto Layout Constraints for UILabels. Make sure to wire them up as well.

PieChartView.swift

import UIKit

class PieChartView: UIView {

    @IBOutlet var canvasView: UIView!

    @IBOutlet var label1: UILabel!
    @IBOutlet var label2: UILabel!
    @IBOutlet var label3: UILabel!
    @IBOutlet var label4: UILabel!
    @IBOutlet var label5: UILabel!
    
    @IBOutlet var label1XConst: NSLayoutConstraint!
    @IBOutlet var label2XConst: NSLayoutConstraint!
    @IBOutlet var label3XConst: NSLayoutConstraint!
    @IBOutlet var label4XConst: NSLayoutConstraint!
    @IBOutlet var label5XConst: NSLayoutConstraint!

    @IBOutlet var label1YConst: NSLayoutConstraint!
    @IBOutlet var label2YConst: NSLayoutConstraint!
    @IBOutlet var label3YConst: NSLayoutConstraint!
    @IBOutlet var label4YConst: NSLayoutConstraint!
    @IBOutlet var label5YConst: NSLayoutConstraint!

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        let view: UIView = Bundle.main.loadNibNamed("PieChartView", owner: self, options: nil)!.first as! UIView
        addSubview(view)
    }
}
                    

3 Understanding Slice Drawing

Stroke path and width

Before going into the PieChartView code details, let's understand how we plan to draw the chart. Core Animation's CAShapeLayer supports stroke and fill. At a glance, we can fill pie chart slices, but that doesn't work well with circular animation. Instead, use a stroke with very bold line. This way, we can express circular animation in CABasicAnimation with key = strokeEnd

This tutorial shows a "donut" pie chart with a hole in it. However, using the same technique, you can draw a chart nicely without a center hole, if a stroke width is equal to the radius.

4 Draw a Slice with UIBezierPath and CAShapeLayer

This pie chart draws one slice at a time. Drawing slice function looks like this. To hold slice metadata (e.g. percentage, color), Slice struct is added.

PieChartView.swift

import UIKit

struct Slice {
    var percent: CGFloat
    var color: UIColor
}

class PieChartView: UIView {

    static let ANIMATION_DURATION: CGFloat = 1.4

    ...

    var slices: [Slice]?
    var sliceIndex: Int = 0
    var currentPercent: CGFloat = 0.0

    /// Get an animation duration for the passed slice.
    /// If slice share is 40%, for example, it returns 40% of total animation duration.
    ///
    /// - Parameter slice: Slice struct
    /// - Returns: Animation duration
    func getDuration(_ slice: Slice) -> CFTimeInterval {
        return CFTimeInterval(slice.percent / 1.0 * PieChartView.ANIMATION_DURATION)
    }
    
    /// Convert slice percent to radian.
    ///
    /// - Parameter percent: Slice percent (0.0 - 1.0).
    /// - Returns: Radian
    func percentToRadian(_ percent: CGFloat) -> CGFloat {
        //Because angle starts wtih X positive axis, add 270 degrees to rotate it to Y positive axis.
        var angle = 270 + percent * 360
        if angle >= 360 {
            angle -= 360
        }
        return angle * CGFloat.pi / 180.0
    }

    /// Add a slice CAShapeLayer to the canvas.
    ///
    /// - Parameter slice: Slice to be drawn.
    func addSlice(_ slice: Slice) {
        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = 0
        animation.toValue = 1
        animation.duration = getDuration(slice)
        animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
        animation.delegate = self
        
        let canvasWidth = canvasView.frame.width
        let path = UIBezierPath(arcCenter: canvasView.center,
                                radius: canvasWidth * 3 / 8,
                                startAngle: percentToRadian(currentPercent),
                                endAngle: percentToRadian(currentPercent + slice.percent),
                                clockwise: true)
        
        let sliceLayer = CAShapeLayer()
        sliceLayer.path = path.cgPath
        sliceLayer.fillColor = nil
        sliceLayer.strokeColor = slice.color.cgColor
        sliceLayer.lineWidth = canvasWidth * 2 / 8
        sliceLayer.strokeEnd = 1
        sliceLayer.add(animation, forKey: animation.keyPath)
        
        canvasView.layer.addSublayer(sliceLayer)
    }

    ...
}
                    

5 Drawing Slices Consecutively

The code above only draws a slice. In order to draw multiple slices, we need to call addSlice in sequence. We can achieve this by implementing CAAnimationDelegate. animationDidStop is called when CAAnimation finished animation. In this callback, we draw a next slice until all slices are drawn. Let's add a delegate:

PieChartView.swift

extension PieChartView: CAAnimationDelegate {

    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        if flag {
            currentPercent += slices![sliceIndex].percent
            sliceIndex += 1
            if sliceIndex < slices!.count {
                let nextSlice = slices![sliceIndex]
                addSlice(nextSlice)
            }
        }
    }
}
                    

6 Understanding a Label Position

Label Center

Positioning label (or annotation) is sometimes tricky as slice sizes vary. In this tutorial, we place a label at a center of each slice. To find out a center of slice, we compute a cross section of two lines: stroke path and middle radian. The diagram illustrates it. Fortunately, we can easily spot a center using UIBezierPath again.

7 Adjust Label Positions

Similar to addSlice, add a function that handles a lebel re-position for each slice. This manipulates X and Y constraints for UILabel. Since default offset (0, 0) is canvas's center, constants are delta changes from that center.

PieChartView.swift

import UIKit

...

class PieChartView: UIView {

    ...

    /// Get label's center position based on from and to percentages.
    /// This is always relative to canvasView's center.
    ///
    /// - Parameters:
    ///   - fromPercent: End of previous slice.
    ///   - toPercent: End of current slice.
    /// - Returns: Center point for label.
    func getLabelCenter(_ fromPercent: CGFloat, _ toPercent: CGFloat) -> CGPoint {
        let radius = canvasView.frame.width * 3 / 8
        let labelAngle = percentToRadian((toPercent - fromPercent) / 2 + fromPercent)
        let path = UIBezierPath(arcCenter: canvasView.center,
                                radius: radius,
                                startAngle: labelAngle,
                                endAngle: labelAngle,
                                clockwise: true)
        path.close()
        return path.currentPoint
    }
    
    /// Re-position and draw label such as "43%".
    ///
    /// - Parameter slice: Slice whose label is drawn.
    func addLabel(_ slice: Slice) {
        let center = canvasView.center
        let labelCenter = getLabelCenter(currentPercent, currentPercent + slice.percent)
        let xConst = [label1XConst, label2XConst, label3XConst, label4XConst, label5XConst][sliceIndex]
        let yConst = [label1YConst, label2YConst, label3YConst, label4YConst, label5YConst][sliceIndex]
        xConst?.constant = labelCenter.x - center.x
        yConst?.constant = labelCenter.y - center.y
        canvasView.superview?.setNeedsUpdateConstraints()
        canvasView.superview?.layoutIfNeeded()

        let label = [label1, label2, label3, label4, label5][sliceIndex]
        label?.isHidden = true
        label?.text = String(format: "%d%%", Int(slice.percent * 100))
    }

    ...
}
                    

Update animationDidStop function as well.

PieChartView.swift

extension PieChartView: CAAnimationDelegate {

    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        if flag {
            currentPercent += slices![sliceIndex].percent
            sliceIndex += 1
            if sliceIndex < slices!.count {
                let nextSlice = slices![sliceIndex]
                addLabel(nextSlice)
                addSlice(nextSlice)
            } else {
                //After animation is done, display all labels. Can be animated.
                for label in [label1, label2, label3, label4, label5] {
                    label?.isHidden = false
                }
            }
        }
    }
}
                    

That's all for PieChartView class.

8 Add PieChartView to ViewController

Let's add PieChartView to ViewController. I used Xcode's Storyboard but you can do it programatically as well. Here is ViewController code:

ViewController.swift

import UIKit

class ViewController: UIViewController {

    @IBOutlet var pieChartView: PieChartView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        //Add some random slices.
        pieChartView.slices = [
            Slice(percent: 0.4, color: UIColor.red),
            Slice(percent: 0.3, color: UIColor.blue),
            Slice(percent: 0.2, color: UIColor.purple),
            Slice(percent: 0.1, color: UIColor.green)
        ]
    }

    override func viewDidAppear(_ animated: Bool) {
        pieChartView.animateChart()
    }
}
                

Finally, add animateChart function to PieChartView.

PieChartView.swift

...

class PieChartView: UIView {

    ...

    /// Call this to start pie chart animation.
    func animateChart() {
        sliceIndex = 0
        currentPercent = 0.0
        canvasView.layer.sublayers = nil
        
        if slices != nil && slices!.count > 0 {
            let firstSlice = slices![0]
            addLabel(firstSlice)
            addSlice(firstSlice)
        }
    }
}
                

That's all! If you build and run it, you should be able to see similar pie chart animation. For all codebase, you can download it from Github.