Elliot Czigány
Elliot Czigány

Reputation: 218

How to animate custom Progress bar properly (swift)?

I made a custom progressbar for my app (following an article on medium), it works as intended but i have one problem, when i change the progress value then it jumps to fast! (dont get confused by the percent values below the bar, they are off, i know that)

enter image description here

i use setNeedsDisplay() to redraw my view.

I want the bar to animate smoothly, so in my case a bit slower.

this is the draw function of the bar:

 override func draw(_ rect: CGRect) {
    backgroundMask.path = UIBezierPath(roundedRect: rect, cornerRadius: rect.height * 0.25).cgPath
    layer.mask = backgroundMask

    let progressRect = CGRect(origin: .zero, size: CGSize(width: rect.width * progress, height: rect.height))

    progressLayer.frame = progressRect
    progressLayer.backgroundColor = UIColor.black.cgColor

    gradientLayer.frame = rect
    gradientLayer.colors = [color.cgColor, gradientColor.cgColor, color.cgColor]
    gradientLayer.endPoint = CGPoint(x: progress, y: 0.5)
}

Here is the whole Class i used:

https://bitbucket.org/mariwi/custom-animated-progress-bars-with-uibezierpaths/src/master/ProgressBars/Bars/GradientHorizontalProgressBar.swift

Anyone with an idea?

EDIT 1: Similar questions helped, but the result is not working properly. I aded this function to set the progress of the bar:

func setProgress(to percent : CGFloat)
{
    progress = percent
    print(percent)
    
    let rect = self.bounds
    let oldBounds = progressLayer.bounds
    let newBounds = CGRect(origin: .zero, size: CGSize(width: rect.width * progress, height: rect.height))
    
    
    let redrawAnimation = CABasicAnimation(keyPath: "bounds")
    redrawAnimation.fromValue = oldBounds
    redrawAnimation.toValue = newBounds

    redrawAnimation.fillMode = .forwards
    redrawAnimation.isRemovedOnCompletion = false
    redrawAnimation.duration = 0.5
    
    progressLayer.bounds = newBounds
    gradientLayer.endPoint = CGPoint(x: progress, y: 0.5)
    
    progressLayer.add(redrawAnimation, forKey: "redrawAnim")

    
}

And now the bar behaves like this: enter image description here

Upvotes: 1

Views: 6625

Answers (2)

Elliot Czigány
Elliot Czigány

Reputation: 218

After digging a while and a ton of testing, i came up with a solution, that suited my needs! Altough the above answer from DonMag was also working great (thanks for your effort), i wanted to fix what halfway worked. So the problem was, that the bar resized itself from the middle of the view. And on top, the position was also off for some reason.

First i set the position back to (0,0) so that the view started at the beginning (where it should). The next thing was the resizing from the middle, because with the position set back, the bar only animated to the half when i set it to 100%. After some tinkering and reading i found out, that changing the anchorPoint of the view would solve my problem. The default value was (0.5,0.5), changing it into (0,0) meant that it would only expand the desired direction.

After that i only needed to re-set the end of the gradient, so that the animation stays consistent between the different values. After all of this my bar worked like I imagined. And here is the result:

enter image description here

Here is the final code, i used to accomplish this:

func setProgress(to percent : CGFloat)
{
    progress = percent
    print(percent)
    let duration = 0.5
    
    let rect = self.bounds
    let oldBounds = progressLayer.bounds
    let newBounds = CGRect(origin: .zero, size: CGSize(width: rect.width * progress, height: rect.height))
    
    
    let redrawAnimation = CABasicAnimation(keyPath: "bounds")
    redrawAnimation.fromValue = oldBounds
    redrawAnimation.toValue = newBounds

    redrawAnimation.fillMode = .both
    redrawAnimation.isRemovedOnCompletion = false
    redrawAnimation.duration = duration
    
    progressLayer.bounds = newBounds
    progressLayer.position = CGPoint(x: 0, y: 0)
    
    progressLayer.anchorPoint = CGPoint(x: 0, y: 0)
    
    progressLayer.add(redrawAnimation, forKey: "redrawAnim")
    
    let oldGradEnd = gradientLayer.endPoint
    let newGradEnd = CGPoint(x: progress, y: 0.5)
    let gradientEndAnimation = CABasicAnimation(keyPath: "endPoint")
    gradientEndAnimation.fromValue = oldGradEnd
    gradientEndAnimation.toValue = newGradEnd
    
    gradientEndAnimation.fillMode = .both
    gradientEndAnimation.isRemovedOnCompletion = false
    gradientEndAnimation.duration = duration
    
    gradientLayer.endPoint = newGradEnd
    
    gradientLayer.add(gradientEndAnimation, forKey: "gradEndAnim")
    
}

Upvotes: 4

DonMag
DonMag

Reputation: 77462

I'm going to suggest a somewhat different approach.

First, instead of adding a sublayer as the gradient layer, we'll make the custom view's layer itself a gradient layer:

private var gradientLayer: CAGradientLayer!

override class var layerClass: AnyClass {
    return CAGradientLayer.self
}

// then, in init
// use self.layer as the gradient layer
gradientLayer = self.layer as? CAGradientLayer

We'll set the gradient animation to the full size of the view... that will give it a consistent width and speed.

Next, we'll add a subview as a mask, instead of a layer-mask. That will allow us to animate its width independently.

class GradProgressView: UIView {

    @IBInspectable var color: UIColor = .gray {
        didSet { setNeedsDisplay() }
    }
    @IBInspectable var gradientColor: UIColor = .white {
        didSet { setNeedsDisplay() }
    }

    // this view will mask the percentage width
    private let myMaskView = UIView()
    
    // so we can calculate the new-progress-animation duration
    private var curProgress: CGFloat = 0.0
    
    public var progress: CGFloat = 0 {
        didSet {
            // calculate the change in progress
            let changePercent = abs(curProgress - progress)
            
            // if the change is 100% (i.e. from 0.0 to 1.0),
            //  we want the animation to take 1-second
            //  so, make the animation duration equal to
            //  1-second * changePercent
            let dur = changePercent * 1.0
            
            // save the new progress
            curProgress = progress
            
            // calculate the new width of the mask view
            var r = bounds
            r.size.width *= progress

            // animate the size of the mask-view
            UIView.animate(withDuration: TimeInterval(dur), animations: {
                self.myMaskView.frame = r
            })
        }
    }

    private var gradientLayer: CAGradientLayer!
    
    override class var layerClass: AnyClass {
        return CAGradientLayer.self
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {

        // use self.layer as the gradient layer
        gradientLayer = self.layer as? CAGradientLayer
        gradientLayer.colors = [color.cgColor, gradientColor.cgColor, color.cgColor]
        gradientLayer.locations =  [0.25, 0.5, 0.75]
        gradientLayer.startPoint = CGPoint(x: 0, y: 0)
        gradientLayer.endPoint = CGPoint(x: 1, y: 0)
        
        let animation = CABasicAnimation(keyPath: "locations")
        animation.fromValue = [-0.3, -0.15, 0]
        animation.toValue = [1, 1.15, 1.3]
        animation.duration = 1.5
        animation.isRemovedOnCompletion = false
        animation.repeatCount = Float.infinity
        gradientLayer.add(animation, forKey: nil)
        
        myMaskView.backgroundColor = .white
        mask = myMaskView

    }

    override func layoutSubviews() {
        super.layoutSubviews()

        // if the mask view frame has not been set at all yet
        if myMaskView.frame.height == 0 {
            var r = bounds
            r.size.width = 0.0
            myMaskView.frame = r
        }
        
        gradientLayer.colors = [color.cgColor, gradientColor.cgColor, color.cgColor]
        layer.cornerRadius = bounds.height * 0.25
    }

}

Here's a sample controller class - each tap will cycle through a list of sample progress percentages:

class ExampleViewController: UIViewController {
    
    let progView = GradProgressView()
    let infoLabel = UILabel()
    
    var idx: Int = 0
    let testVals: [CGFloat] = [
        0.75, 0.3, 0.95, 0.25, 0.5, 1.0,
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .black
    
        [infoLabel, progView].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview($0)
        }

        infoLabel.textColor = .white
        infoLabel.textAlignment = .center
        
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            progView.topAnchor.constraint(equalTo: g.topAnchor, constant: 100.0),
            progView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
            progView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
            progView.heightAnchor.constraint(equalToConstant: 40.0),
            
            infoLabel.topAnchor.constraint(equalTo: progView.bottomAnchor, constant: 8.0),
            infoLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
            infoLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),

        ])
        
        progView.color = #colorLiteral(red: 0.9932278991, green: 0.5762576461, blue: 0.03188031539, alpha: 1)
        progView.gradientColor = #colorLiteral(red: 1, green: 0.8578521609, blue: 0.3033572137, alpha: 1)
        
        // add a tap gesture recognizer
        let t = UITapGestureRecognizer(target: self, action: #selector(didTap(_:)))
        view.addGestureRecognizer(t)
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        didTap(nil)
    }
    
    @objc func didTap(_ g: UITapGestureRecognizer?) -> Void {
        let n = idx % testVals.count
        progView.progress = testVals[n]
        idx += 1
        infoLabel.text = "Auslastung \(Int(testVals[n] * 100))%"
    }
    
}

Upvotes: 3

Related Questions