skip to Main Content

I have a rounded rectangular progress bar via a UIBezierPath and CAShapeLayer. The progress stroke animated currently draws 360 degrees clockwise beginning from top center.

My current setup has the stroke starting at 315 degrees, but ends at top center, and am admittedly lost. My goal is to start/end the stroke at 315 degrees. Any guidance would be appreciated!

class ProgressBarView: UIView {
    
    let progressLayer = CAShapeLayer()
    let cornerRadius: CGFloat = 20
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupProgressLayer()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupProgressLayer()
    }
    
    private func setupProgressLayer() {
        
        progressLayer.lineWidth = 6
        progressLayer.fillColor = nil
        progressLayer.strokeColor = Constants.style.offWhite.cgColor
        progressLayer.strokeStart = 135 / 360
        progressLayer.lineCap = .round
        
        
        let lineWidth: CGFloat = 6
        let radius = bounds.height / 2 - lineWidth / 2
        let progressPath = UIBezierPath()
        progressPath.move(to: CGPoint(x: lineWidth / 2 + cornerRadius, y: lineWidth / 2))
        progressPath.addLine(to: CGPoint(x: bounds.width - lineWidth / 2 - cornerRadius, y: lineWidth / 2))
        progressPath.addArc(withCenter: CGPoint(x: bounds.width - lineWidth / 2 - cornerRadius, y: lineWidth / 2 + cornerRadius), radius: cornerRadius, startAngle: -CGFloat.pi / 2, endAngle: 0, clockwise: true)
        progressPath.addLine(to: CGPoint(x: bounds.width - lineWidth / 2, y: bounds.height - lineWidth / 2 - cornerRadius))
        progressPath.addArc(withCenter: CGPoint(x: bounds.width - lineWidth / 2 - cornerRadius, y: bounds.height - lineWidth / 2 - cornerRadius), radius: cornerRadius, startAngle: 0, endAngle: CGFloat.pi / 2, clockwise: true)
        progressPath.addLine(to: CGPoint(x: lineWidth / 2 + cornerRadius, y: bounds.height - lineWidth / 2))
        progressPath.addArc(withCenter: CGPoint(x: lineWidth / 2 + cornerRadius, y: bounds.height - lineWidth / 2 - cornerRadius), radius: cornerRadius, startAngle: CGFloat.pi / 2, endAngle: CGFloat.pi, clockwise: true)
        progressPath.addLine(to: CGPoint(x: lineWidth / 2, y: lineWidth / 2 + cornerRadius))
        progressPath.addArc(withCenter: CGPoint(x: lineWidth / 2 + cornerRadius, y: lineWidth / 2 + cornerRadius), radius: cornerRadius, startAngle: CGFloat.pi, endAngle: -CGFloat.pi / 2, clockwise: true)
        progressPath.close()
        
        progressLayer.path = progressPath.cgPath
        
        
        layer.addSublayer(progressLayer)
    }
    
    func setProgress(_ progress: CGFloat) {
        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = progressLayer.strokeStart
        animation.toValue = progress
        animation.duration = 1
        progressLayer.add(animation, forKey: "progressAnimation")
        progressLayer.strokeEnd = progress
    }
}

2

Answers


  1. One way to write this, if you’re willing to forgo the rounded line caps, is to use a mask layer. The idea is that your main layer’s path is always the full rounded rect, and the mask layer is a circle with a wedge taken out to hide part of the rounded rect. It looks like this:

    A gray rounded rect, stroked, with a slider below. As I drag the slider, the rect disappears as if being clipped away.

    Here’s my revised ProgressBarView code. Note that the code for drawing the rounded rect is considerably simpler because I’m always drawing the whole thing, and because I used insetBy(dx:dy:) instead of repeatedly adding/subtracting lineWidth / 2.

    class ProgressBarView: UIView {
        var progress: CGFloat = 0.5 {
            didSet {
                setMaskShape()
            }
        }
    
        let maskLayer = CAShapeLayer()
    
        var cornerRadius: CGFloat { 20 }
        var lineWidth: CGFloat { 6 }
    
        override class var layerClass: AnyClass { CAShapeLayer.self }
    
        override func layoutSubviews() {
            super.layoutSubviews()
    
            let layer = self.layer as! CAShapeLayer
    
            if layer.mask != maskLayer {
                layer.lineWidth = lineWidth
                layer.fillColor = nil
                layer.strokeColor = UIColor.gray.cgColor
                // layer.strokeStart = 135 / 360
    
                layer.mask = maskLayer
            }
    
            layer.path = CGPath(
                roundedRect: bounds.insetBy(dx: 0.5 * lineWidth, dy: 0.5 * lineWidth),
                cornerWidth: cornerRadius,
                cornerHeight: cornerRadius,
                transform: nil
            )
            maskLayer.frame = bounds
    
            setMaskShape()
        }
    
        private func setMaskShape() {
            let bounds = maskLayer.bounds
            let center = CGPoint(x: bounds.midX, y: bounds.midY)
    
            let path = CGMutablePath()
            path.move(to: center)
            path.addRelativeArc(
                center: center,
                radius: bounds.size.width + bounds.size.height,
                startAngle: 0.625 * 2 * .pi,
                delta: progress * 2 * .pi
            )
            path.closeSubpath()
            maskLayer.path = path
        }
    }
    

    And here is the SwiftUI code I used to test it out:

    struct ContentView: View {
        @State var progress: CGFloat = 0.5
    
        var body: some View {
            VStack {
                ProgressBarViewRep(progress: progress)
                    .aspectRatio(1, contentMode: .fit)
    
                Slider(value: $progress, in: 0 ... 1)
            }
            .padding()
        }
    }
    
    struct ProgressBarViewRep: UIViewRepresentable {
        var progress: CGFloat
    
        func makeUIView(context: Context) -> ProgressBarView {
            return ProgressBarView()
        }
    
        func updateUIView(_ uiView: ProgressBarView, context: Context) {
            uiView.progress = progress
        }
    }
    
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
                .padding()
        }
    }
    
    Login or Signup to reply.
  2. If you really want rounded line caps, or you want to be able to easily animate the progress using UIView or CALayer animation, you need to draw the path so that it starts and ends at 315°.

    A rounded rectangle, stroked with a thick gray line. Underneath, a activated toggle titled “Complete”. I tap the button. The rounded rect animates away. I tap the button again and the rounded rect animates back in.

    Since 315° is in the middle of one of the rounded corners, you have to start the drawing with a circular arc spanning 45°, and end it with another arc of 45°. However, you can draw the other three corners using a different method of CGMutablePath designed specifically for drawing rounded corners, which simplifies the code.

    class ProgressBarView: UIView {
        var progress: CGFloat {
            get { (layer as! CAShapeLayer).strokeEnd }
            set { (layer as! CAShapeLayer).strokeEnd = newValue }
        }
    
        var cornerRadius: CGFloat { 20 }
        var lineWidth: CGFloat { 6 }
    
        override class var layerClass: AnyClass { CAShapeLayer.self }
    
        override func layoutSubviews() {
            super.layoutSubviews()
    
            let layer = layer as! CAShapeLayer
            layer.lineCap = .round
            layer.lineWidth = lineWidth
            layer.fillColor = nil
            layer.strokeColor = UIColor.gray.cgColor
    
            let rect = layer.bounds.insetBy(dx: 0.5 * lineWidth, dy: 0.5 * lineWidth)
            let (x0, y0, x1, y1) = (rect.minX, rect.minY, rect.maxX, rect.maxY)
    
            let r = cornerRadius
    
            let path = CGMutablePath()
            // No prior move, so path automatically moves to the start of this arc.
            path.addRelativeArc(
                center: CGPoint(x: x0 + r, y: y0 + r),
                radius: r,
                startAngle: 0.625 * 2 * .pi, // 45° above -x axis
                delta: 0.125 * 2 * .pi // 45°
            )
            var current = CGPoint(x: x1, y: y0)
            for next in [
                CGPoint(x: x1, y: y1),
                CGPoint(x: x0, y: y1),
                CGPoint(x: x0, y: y0)
            ] {
                path.addArc(tangent1End: current, tangent2End: next, radius: r)
                current = next
            }
    
            path.addRelativeArc(
                center: CGPoint(x: x0 + r, y: y0 + r),
                radius: r,
                startAngle: 0.5 * 2 * .pi, // -x axis
                delta: 0.125 * 2 * .pi // 45°
            )
            path.closeSubpath()
    
            layer.path = path
        }
    }
    

    Instead of setting up the CAShapeLayer as a sublayer of the view’s layer, I tell the view to use a CAShapeLayer directly. This has the advantage that UIKit will set up animations for changes to the layer’s properties, if we make those changes inside a UIView.animate block. Here’s the demo code. Notice how when I update the view’s progress property, I do it inside an animation block. I don’t have to create a CABasicAnimation directly.

    struct ContentView: View {
        @State var isComplete = true
    
        var body: some View {
            VStack {
                ProgressBarViewRep(progress: isComplete ? 1 : 0)
                    .aspectRatio(1, contentMode: .fit)
    
                Toggle("Complete", isOn: $isComplete)
                    .toggleStyle(.button)
                    .fixedSize()
            }
            .padding()
        }
    }
    
    struct ProgressBarViewRep: UIViewRepresentable {
        var progress: CGFloat
    
        func makeUIView(context: Context) -> ProgressBarView {
            let view = ProgressBarView()
            view.progress = progress
            return view
        }
    
        func updateUIView(_ uiView: ProgressBarView, context: Context) {
            UIView.animate(withDuration: 1, delay: 0, options: .curveLinear) {
                uiView.progress = progress
            }
        }
    }
    
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
                .padding()
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search