Search code examples
iosswiftuibezierpath

Swift UIBezierPath starting offset from where it should


I have a Button that hides when pressed and instead an animation is shown that fills from left to right, indicating a wait time.

I have the following class which handles the animation:

//MARK: - Class: LinearProgressBarButtonView
class LinearProgressBarButtonView: UIView {
private var myWidth: CGFloat!
private var myHeight: CGFloat!

init(frame: CGRect, width: CGFloat, height: CGFloat) {
    super.init(frame: frame)
    myWidth = width
    myHeight = height
}

required init?(coder: NSCoder) {
    super.init(coder: coder)
}
    
private var lineLayer = CAShapeLayer()
private var progressLayer = CAShapeLayer()

func createLinePath() {
    // created linePath for lineLayer and progressLayer
    let rect = CGRect(x: 0, y: 0, width: myWidth, height: myHeight)
    let linePath = UIBezierPath()
    
    linePath.move(to: CGPoint(x: 0.0, y: rect.height/2))
    linePath.addLine(to: CGPoint(x: myWidth, y: rect.height/2))
    
    // lineLayer path defined to circularPath
    lineLayer.path = linePath.cgPath
    // ui edits
    lineLayer.fillColor = UIColor.clear.cgColor
    lineLayer.lineCap = .square
    lineLayer.lineWidth = myHeight
    lineLayer.strokeEnd = 1.0
    lineLayer.strokeColor = colors.Blue.cgColor
    // added circleLayer to layer
    layer.addSublayer(lineLayer)
    
    // progressLayer path defined to circularPath
    progressLayer.path = linePath.cgPath
    // ui edits
    progressLayer.fillColor = UIColor.clear.cgColor
    progressLayer.lineCap = .square
    progressLayer.lineWidth = myHeight
    progressLayer.strokeEnd = 0
    progressLayer.strokeColor = colors.red.cgColor
    // added progressLayer to layer
    layer.addSublayer(progressLayer)
}

func progressAnimation(duration: TimeInterval) {
    // created circularProgressAnimation with keyPath
    let linearProgressAnimation = CABasicAnimation(keyPath: "strokeEnd")
    // set the end time
    linearProgressAnimation.duration = duration
    linearProgressAnimation.toValue = 1.0
    linearProgressAnimation.fillMode = .forwards
    linearProgressAnimation.isRemovedOnCompletion = true
    progressLayer.add(linearProgressAnimation, forKey: "progressAnim")
}

func endAnimation(){
    progressLayer.removeAllAnimations()
}
}

I have set all necessary constraints, which are all correct. Using

linearProgressBarButtonView = LinearProgressBarButtonView(frame: .zero, width: bookButton.frame.width, height: bookButton.frame.height) linearProgressBarButtonView.createLinePath()

I can create the view and later add it as a subview.

I can now use linearProgressBarButtonView.progressAnimation(duration: linearViewDuration) to start the animation, which works exactly as it should. However, the animation does not seem to start at x = 0, but further along the way (somewhere at around 15%). Here is a screenshot of the first second of the animation, which is supposed to last 60 seconds:

Screenshot

I can't seem to figure out why. As far as I understand, it should start from x = 0. And the width should be the exact same width as the view has, which I pass when generating the animated view. Why is it starting with an offset then?


Solution

  • The main problem is:

    lineLayer.lineCap = .square
    // and
    progressLayer.lineCap = .square
    

    Those need to be .butt

    When set to .square one-half the line-width will be "added" on each end. Here, the line path goes from 0,20 to 260,20, with a .lineWidth = 40:

    enter image description here

    You can easily see what's going on by setting layer.borderWidth = 1 on your view ... it will look similar to this:

    enter image description here

    So, that change should fix your issue.

    However, I'd suggest -- instead of saving width/height and having to call createLinePath(), move that code into layoutSubviews(). That will always keep your line path correct, even if you change the frame at a later point:

    class LinearProgressBarButtonView: UIView {
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            layer.addSublayer(lineLayer)
            layer.addSublayer(progressLayer)
        }
        
        private var lineLayer = CAShapeLayer()
        private var progressLayer = CAShapeLayer()
    
        override func layoutSubviews() {
            super.layoutSubviews()
    
            let linePath = UIBezierPath()
            
            linePath.move(to: CGPoint(x: bounds.minX, y: bounds.midY))
            linePath.addLine(to: CGPoint(x: bounds.maxX, y: bounds.midY))
    
            lineLayer.path = linePath.cgPath
            // ui edits
            lineLayer.fillColor = UIColor.clear.cgColor
            lineLayer.lineCap = .butt
            lineLayer.lineWidth = bounds.height
            lineLayer.strokeEnd = 1.0
            lineLayer.strokeColor = UIColor.systemBlue.cgColor
    
            progressLayer.path = linePath.cgPath
            // ui edits
            progressLayer.fillColor = UIColor.clear.cgColor
            progressLayer.lineCap = .butt
            progressLayer.lineWidth = bounds.height
            progressLayer.strokeEnd = 0.0
            progressLayer.strokeColor = UIColor.systemRed.cgColor
        }
        
        func progressAnimation(duration: TimeInterval) {
            
            // created ProgressAnimation with keyPath
            let linearProgressAnimation = CABasicAnimation(keyPath: "strokeEnd")
            // set the end time
            linearProgressAnimation.duration = duration
            linearProgressAnimation.fromValue = 0.0
            linearProgressAnimation.toValue = 1.0
            linearProgressAnimation.fillMode = .forwards
            linearProgressAnimation.isRemovedOnCompletion = true
            progressLayer.add(linearProgressAnimation, forKey: "progressAnim")
            
        }
        
        func endAnimation(){
            progressLayer.removeAllAnimations()
        }
    }