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
}
}
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°.
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()
}
}