Animating circular progress view (timer) in swift

Viewed 6402

I have got this code for a circular progress view, which displays the progress for how far through a timer you are e.g. You set the timer at 60 seconds and the progress bar is halfway when you reach 30 seconds. The code is show below:

import UIKit

class TimerCircularProgressView: UIView {

@IBInspectable var timeSeconds:CGFloat = CGFloat(timers.seconds[timers.timerID]) {
    didSet {
        setNeedsDisplay()
    }
}

@IBInspectable var timeLabelString:String = "" {
    didSet {
        setNeedsDisplay()
    }
}


private var startAngle = CGFloat(-90 * Double.pi / 180)
private var endAngle = CGFloat(270 * Double.pi / 180)

override init(frame: CGRect) {
    super.init(frame: frame)
    setup()
}

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    setup()
}

private func setup() {
    backgroundColor = .clear
}

override func draw(_ rect: CGRect) {
    // General Declarations
    guard let context = UIGraphicsGetCurrentContext() else {
        return
    }

    // Colour Declarations
    let progressColour = UIColor(red: 1, green: 1, blue: 1, alpha: 1)
    let progressBackgroundColour = UIColor(red: 1, green: 1, blue: 1, alpha: 0.4)
    // let titleColour = UIColor(red: 1, green: 1, blue: 1, alpha: 0.8) // let titleColour = UIColor(red: 1, green: 1, blue: 1, alpha: 0.8)

    // Shadow Declarations
    let innerShadow = UIColor.black.withAlphaComponent(0.22)
    let innerShadowOffset = CGSize(width: 3.1, height: 3.1)
    let innerShadowBlurRadius = CGFloat(4)

    // Background Drawing
    let backgroundPath = UIBezierPath(ovalIn: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: rect.height))
    backgroundColor?.setFill()
    backgroundPath.fill()

    // ProgressBackground Drawing
    let progressPadding = CGFloat(timers.deviceWidth / 32 * 1.5) // 15
    print(progressPadding)
    print(timers.deviceWidth)

    let progressBackgroundPath = UIBezierPath(ovalIn: CGRect(x: rect.minX + progressPadding/2, y: rect.minY + progressPadding/2, width: rect.size.width - progressPadding, height: rect.size.height - progressPadding))
    progressBackgroundColour.setStroke()
    progressBackgroundPath.lineWidth = timers.deviceWidth / 32 // 5, 10
    progressBackgroundPath.stroke()

    // Progress Drawing
    let progressRect = CGRect(x: rect.minX + progressPadding/2, y: rect.minY + progressPadding/2, width: rect.size.width - progressPadding, height: rect.size.height - progressPadding)
    let progressPath = UIBezierPath()
    progressPath.addArc(withCenter: CGPoint(x: progressRect.midX, y: progressRect.midY), radius: progressRect.width / 2, startAngle: startAngle, endAngle: (endAngle - startAngle) * (CGFloat(timers.seconds[timers.timerID]/timers.seconds[timers.timerID])  - (timeSeconds / CGFloat(timers.seconds[timers.timerID]))) + startAngle, clockwise: true)
    progressColour.setStroke()
    progressPath.lineWidth = timers.deviceWidth / 32 * 0.8 // Thickness of the progress line
    progressPath.lineCapStyle = CGLineCap.round
    progressPath.stroke()

    // Text Drawing
    let textRect = CGRect(x: rect.minX, y: rect.minY, width: rect.size.width, height: rect.size.height)
    let textContent = NSString(string: timeLabelString)
    let textStyle = NSMutableParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle
    textStyle.alignment = .center

    let textFontAttributes = [
        NSAttributedStringKey.font: UIFont(name: "Helvetica", size: rect.width / timers.timerFontSize)!, // 3, HelveticaNeue-Light
        NSAttributedStringKey.foregroundColor: UIColor.black, // NSAttributedStringKey.foregroundColor: titleColor,
        NSAttributedStringKey.paragraphStyle: textStyle]

    let textHeight = textContent.boundingRect(with: CGSize(width: textRect.width, height: textRect.height), options: .usesLineFragmentOrigin, attributes: textFontAttributes, context: nil).height

    context.saveGState()
    context.clip(to: textRect)
    textContent.draw(in: CGRect(x: textRect.minX, y: textRect.minY + (textRect.height - textHeight) / 2, width: textRect.width, height: textHeight), withAttributes: textFontAttributes)
    context.restoreGState();
}

}

Currently, the circular progress bar updates every seconds, which means that for short time periods, the animation of the progress section moving is jumpy, and not smooth.

I have tried setting up a timer of about 0.05 second intervals, but this can give inaccurate timings, and so I have tried 0.1 and 0.2, which still appear fairly jumpy.

@objc func processSlider() {

    if secondsDDecimal > 0 {
        secondsDDecimal -= 0.2
    }

    circularProgressView.timeSeconds = CGFloat(secondsDDecimal)

}

Is there a way to animate the transition of the circular progress view second by second, so that it is smooth and not jumpy?

2 Answers

You don't actually need a timer for this. If you use CAShapeLayer you can jsut animate the strokeStart or strokeEnd and the animation system will handle everythign for you. I have an example playground showing this on my github, or you can copy and paste below:

//: Playground - noun: a place where people can play

import UIKit
import PlaygroundSupport


class AnimatedRingView: UIView {
    private static let animationDuration = CFTimeInterval(2)
    private let π = CGFloat.pi
    private let startAngle = 1.5 * CGFloat.pi
    private let strokeWidth = CGFloat(8)
    var proportion = CGFloat(0.5) {
        didSet {
            setNeedsLayout()
        }
    }

    private lazy var circleLayer: CAShapeLayer = {
        let circleLayer = CAShapeLayer()
        circleLayer.strokeColor = UIColor.gray.cgColor
        circleLayer.fillColor = UIColor.clear.cgColor
        circleLayer.lineWidth = self.strokeWidth
        self.layer.addSublayer(circleLayer)
        return circleLayer
    }()

    private lazy var ringlayer: CAShapeLayer = {
        let ringlayer = CAShapeLayer()
        ringlayer.fillColor = UIColor.clear.cgColor
        ringlayer.strokeColor = self.tintColor.cgColor
        ringlayer.lineCap = kCALineCapRound
        ringlayer.lineWidth = self.strokeWidth
        self.layer.addSublayer(ringlayer)
        return ringlayer
    }()

    override func layoutSubviews() {
        super.layoutSubviews()
        let radius = (min(frame.size.width, frame.size.height) - strokeWidth - 2)/2
        let circlePath = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: startAngle + 2 * π, clockwise: true)
        circleLayer.path = circlePath.cgPath
        ringlayer.path = circlePath.cgPath
        ringlayer.strokeEnd = proportion
    }

    func animateRing(From startProportion: CGFloat, To endProportion: CGFloat, Duration duration: CFTimeInterval = animationDuration) {
        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.duration = duration
        animation.fromValue = startProportion
        animation.toValue = endProportion
        animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
        ringlayer.strokeEnd = endProportion
        ringlayer.strokeStart = startProportion
        ringlayer.add(animation, forKey: "animateRing")
    }

}

let view = AnimatedRingView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
PlaygroundPage.current.liveView = view
view.animateRing(From: 0, To: 1)
PlaygroundPage.current.needsIndefiniteExecution = true

Some time ago I created SRCountdownTimer which does exactly what you are trying to achieve. This is the way I've implemented a timer there:

private let fireInterval: TimeInterval = 0.01 // ~60 FPS

/**
 * Starts the timer and the animation. If timer was previously runned, it'll invalidate it.
 * - Parameters:
 *   - beginingValue: Value to start countdown from.
 */
public func start(beginingValue: Int) {
    totalTime = TimeInterval(beginingValue)
    elapsedTime = 0

    timer?.invalidate()
    timer = Timer(timeInterval: fireInterval, repeats: true) { [unowned self] _ in
        self.elapsedTime += self.fireInterval

        if self.elapsedTime < self.totalTime {
            self.setNeedsDisplay() // Will invoke "draw" method to update the progress view
        } else {
            self.end()
        }
    }
    RunLoop.main.add(timer!, forMode: .defaultRunLoopMode)

    delegate?.timerDidStart?()
}

So basically every time the timer fires I add the firing interval value to my elapsedTime. This makes the countdown work smoothly with 60 FPS.

There is another option suggested in comments about using the timeIntervalSinceNow method in Date object to calculate the elapsed time by subtracting the timer's beginning timestamp from the current one. Even though this option seems to give you more accurate results I wouldn't recommend you to use it.

The reason is that in this case you gonna calculate the current timestamp every time the timer fires which means every 0.01 second (!). This is way too complex solution and it's much more clever to accept the trade off with not 100% same intervals of timer's fires(however the difference is enough small to not be noticeable for a user).

Related