Search code examples
iosswiftuibezierpathcashapelayercabasicanimation

Circular BezierPath fills too much vs. given progress value - iOS Swift


I have a circular UIBezierPath & 2x CAShapeLayers that I use to display the progress towards a specific milestone in one of my apps.

Problem: when I animate the progress via CABasicAnimation, the progress goes beyond what it should be doing.

Context: I display this circularView in a subview of a custom cell inside my milestonesCollectionView.

View Hierarchy: CollectionView > Custom Cell > subview > drawRect to create & add the layers there.

My code:

Custom Cell

Inside my custom cell I setup the currentProgress (hardcoded for now for testing purpose)

let placeHolderForProgressView: CircularTrackerView = {
        let ctv = CircularTrackerView()
        ctv.backgroundColor = UIColor.clear
        ctv.translatesAutoresizingMaskIntoConstraints = false
        ctv.currentProgress = 0.5  //set to 0.5 to get a circle filled at 50%
        return ctv
    }()

Inside CircularTrackerView

// I animate the progress whenever the cell is set and provides the currentProgress to the progressView

var currentProgress: CGFloat = 0.0 {
        didSet {
            animate(to: currentProgress)
            let percent = Int(currentProgress * 100)
            percentLbl.text = "\(percent)%"
        }
    } 


let shapeLayer = CAShapeLayer() //displays the progress 
let trackLayer = CAShapeLayer() //background of the ring

override func draw(_ rect: CGRect) {
    super.draw(rect)

    let center = CGPoint(x: frame.size.width / 2, y: frame.size.height / 2)
    let circularPath = UIBezierPath(arcCenter: center, radius: frame.size.width / 2, startAngle: -CGFloat.pi / 2, endAngle: 2 * CGFloat.pi, clockwise: true)

    trackLayer.path = circularPath.cgPath
    trackLayer.strokeColor = UIColor.white.withAlphaComponent(0.2).cgColor
    trackLayer.lineWidth = 5
    trackLayer.fillColor = UIColor.clear.cgColor
    trackLayer.lineCap = kCALineCapRound
    layer.addSublayer(trackLayer)

    shapeLayer.path = circularPath.cgPath
    shapeLayer.strokeColor = UIColor.white.cgColor
    shapeLayer.lineWidth = 5
    shapeLayer.strokeEnd = 0
    shapeLayer.fillColor = UIColor.clear.cgColor
    shapeLayer.lineCap = kCALineCapRound
    layer.addSublayer(shapeLayer)

}

private func animate(to progress: CGFloat) {

    let animation = CABasicAnimation(keyPath: "strokeEnd")
    animation.fromValue = 0.0
    animation.toValue = progress
    animation.duration = 2
    animation.fillMode = kCAFillModeForwards
    animation.isRemovedOnCompletion = false

    shapeLayer.add(animation, forKey: "randomString")

}

Output

The circle goes beyond the 50% mark ... even tough I think I followed the right steps to create this (slightly adapted from the LetsBuildThatApp tutorial on YouTube: https://www.youtube.com/watch?v=O3ltwjDJaMk)

enter image description here

Thanks in advance if you can give any input that would help.


Solution

  • The problem is your UIBezierPath / the angle you give it:

    let circularPath = UIBezierPath(arcCenter: center, radius: frame.size.width / 2, startAngle: -CGFloat.pi / 2, endAngle: 2 * CGFloat.pi, clockwise: true)
    

    The difference endAngle - startAngle is 2.5 * PI but should only be 2 * PI. When it is 2.5 * PI that means your strokeEnd of 0.5 results in 1.25 * PI to be stroked.

    Solution, reduce the endAngle by 0.5 * PI:

    let circularPath = UIBezierPath(arcCenter: center, radius: frame.size.width / 2, startAngle: -0.5 * CGFloat.pi, endAngle: 1.5 * CGFloat.pi, clockwise: true)