Search code examples
iosswiftcore-animation

Simple Pie Chart in Core Animation


I'm trying to include a simple pie chart in my app using Core Animation. I found an article on-line to copy and adjust, which seems to be close to what I need.

https://github.com/tomnoda/piechart_ios

The code refers to Nib files (which I don't really understand), but can I do this programmatically instead? I think this is the line of code that needs to change, and maybe I need to add some other coding as well:-

     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)

The let line refers to the Nib file, but how can I get it to refer to my View Controller instead? This obviously results in a series of unresolved identifier errors, as the 2 files aren't linked as they should be. On the View Controller I have the following, as well as a number of other outlets:-

    @IBOutlet weak var  pieChartView: PieChartView!

As I'm new to Xcode hopefully there is a simple fix to this problem.


Solution

  • "I'm trying to include a simple pie chart in my app using Core Animation"

    First, remove the word simple from that statement. Not to sound like a jerk, but if you are a beginner and don't even understand elements laid-out in a nib (xib) vs creating elements via code, you will have a long road ahead of you.

    While the example you linked to "works," it has a lot of limitations and takes some rather odd approaches to the task. For example:

    • it is limited to 5 or fewer segments
    • the sum of the segment values must equal 1.0
    • it has very little in the way of error checking

    That said, it could be a good place for you to start learning.

    Here is the same code, modified to NOT need the xib file. It can be used like this:

    class ViewController: UIViewController {
    
        @IBOutlet var pieChartView: MyPieChartView!
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            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()
        }
    }
    

    This is MyPieChartView.swift ...

    First changes from the original PieChartView.swift file are at the top, between the:

    // MARK: Changes start here
    // MARK: Changes end here
    

    Additional changes to allow "anti-clockwise" ... look for instances of new Bool var drawClockwise

    import UIKit
    
    class MyPieChartView: UIView {
    
        static let ANIMATION_DURATION: CGFloat = 1.4
    
    // MARK: Changes start here
        var canvasView: UIView!
    
        var label1: UILabel!
        var label2: UILabel!
        var label3: UILabel!
        var label4: UILabel!
        var label5: UILabel!
    
        var label1XConst: NSLayoutConstraint!
        var label2XConst: NSLayoutConstraint!
        var label3XConst: NSLayoutConstraint!
        var label4XConst: NSLayoutConstraint!
        var label5XConst: NSLayoutConstraint!
    
        var label1YConst: NSLayoutConstraint!
        var label2YConst: NSLayoutConstraint!
        var label3YConst: NSLayoutConstraint!
        var label4YConst: NSLayoutConstraint!
        var label5YConst: NSLayoutConstraint!
    
        var drawClockwise: Bool = true
    
        var slices: [Slice]?
        var sliceIndex: Int = 0
        var currentPercent: CGFloat = 0.0
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            commonInit()
        }
    
        func commonInit() -> Void {
    
            if canvasView == nil {
    
                let container = UIView()
                addSubview(container)
    
                canvasView = UIView()
                container.addSubview(canvasView)
    
                canvasView.translatesAutoresizingMaskIntoConstraints = false
                NSLayoutConstraint.activate([
                    canvasView.topAnchor.constraint(equalTo: container.topAnchor),
                    canvasView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
                    canvasView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
                    canvasView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
                ])
    
                canvasView.backgroundColor = .yellow
    
                label1 = UILabel()
                label2 = UILabel()
                label3 = UILabel()
                label4 = UILabel()
                label5 = UILabel()
    
                [label1, label2, label3, label4, label5].forEach {
                    guard let v = $0 else { fatalError("Bad Setup!") }
                    v.translatesAutoresizingMaskIntoConstraints = false
                    v.textColor = .white
                    v.textAlignment = .center
                    addSubview(v)
                }
    
                label1XConst = label1.centerXAnchor.constraint(equalTo: canvasView.centerXAnchor)
                label1YConst = label1.centerYAnchor.constraint(equalTo: canvasView.centerYAnchor)
    
                label2XConst = label2.centerXAnchor.constraint(equalTo: canvasView.centerXAnchor)
                label2YConst = label2.centerYAnchor.constraint(equalTo: canvasView.centerYAnchor)
    
                label3XConst = label3.centerXAnchor.constraint(equalTo: canvasView.centerXAnchor)
                label3YConst = label3.centerYAnchor.constraint(equalTo: canvasView.centerYAnchor)
    
                label4XConst = label4.centerXAnchor.constraint(equalTo: canvasView.centerXAnchor)
                label4YConst = label4.centerYAnchor.constraint(equalTo: canvasView.centerYAnchor)
    
                label5XConst = label5.centerXAnchor.constraint(equalTo: canvasView.centerXAnchor)
                label5YConst = label5.centerYAnchor.constraint(equalTo: canvasView.centerYAnchor)
    
                [label1XConst, label2XConst, label3XConst, label4XConst, label5XConst,
                 label1YConst, label2YConst, label3YConst, label4YConst, label5YConst].forEach {
                    $0?.isActive = true
                }
            }
    
        }
    
        override func layoutSubviews() {
            super.layoutSubviews()
            subviews[0].frame = bounds
        }
    
        // don't do this
        //override func draw(_ rect: CGRect) {
        //  subviews[0].frame = bounds
        //}
    // MARK: Changes end here
    
        /// 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 toPercent = currentPercent + (drawClockwise ? slice.percent : -slice.percent)
            let path = UIBezierPath(arcCenter: canvasView.center,
                                    radius: canvasWidth * 3 / 8,
                                    startAngle: percentToRadian(currentPercent),
                                    endAngle: percentToRadian(toPercent),
                                    clockwise: drawClockwise)
    
            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)
        }
    
        /// 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: drawClockwise)
            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 + (drawClockwise ? slice.percent : -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
    
            let label = [label1, label2, label3, label4, label5][sliceIndex]
            label?.isHidden = true
            label?.text = String(format: "%d%%", Int(slice.percent * 100))
        }
    
        /// 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)
            }
        }
    }
    
    extension MyPieChartView: CAAnimationDelegate {
        func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
            if flag {
                currentPercent += (drawClockwise ? slices![sliceIndex].percent : -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
                    }
                }
            }
        }
    }
    

    Example:

    class ViewController: UIViewController {
    
        @IBOutlet var pieChartView: MyPieChartView!
        @IBOutlet var antiPieChartView: MyPieChartView!
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            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(red: 0.0, green: 0.75, blue: 0.0, alpha: 1.0))
            ]
    
            antiPieChartView.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(red: 0.0, green: 0.75, blue: 0.0, alpha: 1.0))
            ]
    
            // draw this pie anti-clockwise
            antiPieChartView.drawClockwise = false
        }
    
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            pieChartView.animateChart()
            antiPieChartView.animateChart()
        }
    }