Search code examples
swiftcharts

iOS Swift circle chart


Any suggestion how to implement the following progress chart for iOS Swift?

enter image description here


Solution

  • Just break this down into individual steps.

    The first question is how to draw the individual tickmarks.

    One way is to draw four strokes using a UIBezierPath:

    • a clockwise arc at the outer radius;
    • a line to the inner radius;
    • a counter-clockwise arc at the inner radius; and
    • a line back out to the outer radius.

    Turns out, you can skip the two lines, and just add those two arcs, and then close the path and you’re done. The UIBezierPath will add the lines between the two arcs for you. E.g.:

    let startAngle: CGFloat = 2 * .pi * (CGFloat(i) - 0.2) / CGFloat(tickCount)
    let endAngle: CGFloat = 2 * .pi * (CGFloat(i) + 0.2) / CGFloat(tickCount)
    
    // create path for individual tickmark
    
    let path = UIBezierPath()
    path.addArc(withCenter: center, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
    path.addArc(withCenter: center, radius: innerRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
    path.close()
    
    // use that path in a `CAShapeLayer`
    
    let shapeLayer = CAShapeLayer()
    shapeLayer.fillColor = …
    shapeLayer.strokeColor = UIColor.clear.cgColor
    shapeLayer.path = path.cgPath
    
    // add it to our view’s `layer`
    
    view.layer.addSublayer(shapeLayer)
    

    Repeat this for i between 0 and tickCount, where tickCount is 90, and you have ninety tickmarks:

    enter image description here

    Obviously, use whatever colors you want, make the ones outside your progress range gray, etc. But hopefully this illustrates the basic idea of how to use UIBezierPath to render two arcs and fill the shape for each respective tick mark with a specified color.


    For example:

    class CircularTickView: UIView {
        var progress: CGFloat = 0.7 { didSet { setNeedsLayout() } }
    
        private var shapeLayers: [CAShapeLayer] = []
        private let startHue: CGFloat = 0.33
        private let endHue: CGFloat = 0.66
        private let outOfBoundsColor: UIColor = .lightGray
        
        override func layoutSubviews() {
            super.layoutSubviews()
            
            shapeLayers.forEach { $0.removeFromSuperlayer() }
            shapeLayers = []
            
            let outerRadius = min(bounds.width, bounds.height) / 2
            let innerRadius = outerRadius * 0.7
            let center = CGPoint(x: bounds.midX, y: bounds.midY)
            let tickCount = 90
            
            for i in 0 ..< tickCount {
                let shapeLayer = CAShapeLayer()
                shapeLayer.fillColor = color(percent: CGFloat(i) / CGFloat(tickCount)).cgColor
                shapeLayer.strokeColor = UIColor.clear.cgColor
                
                let startAngle: CGFloat = 2 * .pi * (CGFloat(i) - 0.2) / CGFloat(tickCount)
                let endAngle: CGFloat = 2 * .pi * (CGFloat(i) + 0.2) / CGFloat(tickCount)
                
                let path = UIBezierPath()
                path.addArc(withCenter: center, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
                path.addArc(withCenter: center, radius: innerRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
                path.close()
                shapeLayer.path = path.cgPath
                
                layer.addSublayer(shapeLayer)
                shapeLayers.append(shapeLayer)
            }
        }
    
        private func color(percent: CGFloat) -> UIColor {
            if percent > progress {
                return outOfBoundsColor
            } 
            
            let hue = (endHue - startHue) * percent + startHue
            return UIColor(hue: hue, saturation: 1, brightness: 1, alpha: 1)
        }
    }
    

    Clearly, you will want to tweak as you see fit. Perhaps change the color algorithm. Perhaps have it start from 12 o’clock rather than 3 o’clock. Etc. The details are less important than groking the basic idea of how you add shape layers with paths to your UI.


    While I prefer to use shape layers, you can also use a rendition that overrides draw(_:), instead:

    class CircularTickView: UIView { 
        var progress: CGFloat = 0.7 { didSet { setNeedsDisplay() } }
    
        private let startHue: CGFloat = 0.33
        private let endHue: CGFloat = 0.66
        private let outOfBoundsColor: UIColor = .lightGray
        
        override func draw(_ rect: CGRect) {
            super.draw(rect)
            
            let outerRadius = min(bounds.width, bounds.height) / 2
            let innerRadius = outerRadius * 0.7
            let center = CGPoint(x: bounds.midX, y: bounds.midY)
            let tickCount = 90
            
            for i in 0 ..< tickCount {
                let startAngle: CGFloat = 2 * .pi * (CGFloat(i) - 0.2) / CGFloat(tickCount)
                let endAngle: CGFloat = 2 * .pi * (CGFloat(i) + 0.2) / CGFloat(tickCount)
                
                let path = UIBezierPath()
                path.addArc(withCenter: center, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
                path.addArc(withCenter: center, radius: innerRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
                path.close()
    
                if path.cgPath.intersects(UIBezierPath(rect: rect).cgPath)) {
                    color(percent: CGFloat(i) / CGFloat(tickCount))
                        .setFill()
                    path.fill() 
                }
            }
        }
    
        private func color(percent: CGFloat) -> UIColor {
            if percent > progress {
                return outOfBoundsColor
            } 
            
            let hue = (endHue - startHue) * percent + startHue
            return UIColor(hue: hue, saturation: 1, brightness: 1, alpha: 1)
        }
    }