Home > Net >  Vertical Progress Bar with Gradient Layer
Vertical Progress Bar with Gradient Layer

Time:12-09

I have a vertical progress bar with an animating CAGradientLayer that shows "activity" to the user.

My problem is I can't get the animation to run top-down where the gradient line is parallel to x-axis. It currently animates left to right with the gradient line parallel to the y-axis.

I thought by adjusting the layer's startPoint and endPoint y-value it would do the trick, but the layer continues to animate from left to right.

Any guidance would be appreciated.

class ProgressBarView: UIView {

var color: UIColor = .red {
    didSet { setNeedsDisplay() }
}

var gradientColor: UIColor = .white {
    didSet { setNeedsDisplay() }
}
    
var progress: CGFloat = 0 {
    didSet {
        DispatchQueue.main.async { self.setNeedsDisplay() }
    }
}

private let progressLayer = CALayer()
private let gradientLayer = CAGradientLayer()
private let backgroundMask = CAShapeLayer()

override init(frame: CGRect) {
    super.init(frame: frame)
    setupLayers()
    createAnimation()
}

required init?(coder: NSCoder) {
    super.init(coder: coder)
    setupLayers()
    createAnimation()
}

override func draw(_ rect: CGRect) {
    
    self.backgroundColor = UIColor.lightGray
    
    gradientLayer.frame = rect
    gradientLayer.colors = [color.cgColor, gradientColor.cgColor, color.cgColor]
    gradientLayer.endPoint = CGPoint(x: 0.5, y: progress)
            
    backgroundMask.path = UIBezierPath(roundedRect: rect, cornerRadius: 8).cgPath
    layer.mask = backgroundMask
    
    let progressRect = CGRect(x: 0, y: rect.height, width: rect.width, height: -(rect.height - (rect.height * progress)))
    progressLayer.frame = progressRect
    progressLayer.backgroundColor = UIColor.black.cgColor
}

private func setupLayers() {
    layer.addSublayer(gradientLayer)
            
    gradientLayer.mask = progressLayer
    gradientLayer.locations = [0.35, 0.5, 0.65]
            
    gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
}

private func createAnimation() {
    
    let flowAnimation = CABasicAnimation(keyPath: "locations")
    flowAnimation.fromValue = [-0.3, -0.15, 0]
    flowAnimation.toValue = [1, 1.15, 1.3]

    flowAnimation.isRemovedOnCompletion = false
    flowAnimation.repeatCount = Float.infinity
    flowAnimation.duration = 1

    gradientLayer.add(flowAnimation, forKey: "flowAnimation")
}

}

CodePudding user response:

This should get you started...

On init:

// add the gradient layer
layer.addSublayer(gradientLayer)

// initial locations 
gradientLayer.locations = [0.35, 0.5, 0.65]

// initial colors
gradientLayer.colors = [color.cgColor, gradientColor.cgColor, color.cgColor]
    
// set start and end points
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0)

// set the mask
gradientLayer.mask = backgroundMask

Don't override draw() ... instead, in layoutSubviews():

    var r = bounds

    // make gradient layer progress % of height
    r.size.height *= progress
    
    // update the mask path
    backgroundMask.path = UIBezierPath(roundedRect: r, cornerRadius: 8).cgPath
    
    // update gradient layer frame
    gradientLayer.frame = r
    

When you update the progress property, call setNeedsLayout() to update the layer frames.

Here's a modified version of your class:

class ProgressBarView: UIView {
    
    var color: UIColor = .red {
        didSet {
            gradientLayer.colors = [color.cgColor, gradientColor.cgColor, color.cgColor]
        }
    }
    
    var gradientColor: UIColor = .white {
        didSet {
            gradientLayer.colors = [color.cgColor, gradientColor.cgColor, color.cgColor]
        }
    }
    
    var progress: CGFloat = 0 {
        didSet {
            // trigger layoutSubviews() to
            //  update the layer frames
            setNeedsLayout()
        }
    }
    
    private let gradientLayer = CAGradientLayer()
    private let backgroundMask = CAShapeLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    
    func commonInit() {

        self.backgroundColor = UIColor.lightGray
        
        setupLayers()
        createAnimation()

    }
    
    private func setupLayers() {
        
        // add the gradient layer
        layer.addSublayer(gradientLayer)
        
        // initial locations
        gradientLayer.locations = [0.35, 0.5, 0.65]
        
        // initial colors
        gradientLayer.colors = [color.cgColor, gradientColor.cgColor, color.cgColor]
        
        // set start and end points
        gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
        gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
        
        // set the mask
        gradientLayer.mask = backgroundMask
        
    }
    
    private func createAnimation() {
        
        let flowAnimation = CABasicAnimation(keyPath: "locations")
        flowAnimation.fromValue = [-0.3, -0.15, 0]
        flowAnimation.toValue = [1, 1.15, 1.3]
        
        flowAnimation.isRemovedOnCompletion = false
        flowAnimation.repeatCount = Float.infinity
        flowAnimation.duration = 1
        
        gradientLayer.add(flowAnimation, forKey: "flowAnimation")

    }

    override func layoutSubviews() {
        super.layoutSubviews()

        var r = bounds

        // make gradient layer progress % of height
        r.size.height *= progress
        
        // update the mask path
        backgroundMask.path = UIBezierPath(roundedRect: r, cornerRadius: 8).cgPath
        
        // update gradient layer frame
        gradientLayer.frame = r
        
    }
    
}

and an example controller. Progress will start at 5% and increment by 10% with each tap anywhere on the screen:

class ViewController: UIViewController {
    
    let pbView = ProgressBarView()
    let infoLabel = UILabel()
    
    var progress: CGFloat = 0.05
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        pbView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(pbView)
        infoLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(infoLabel)

        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            
            pbView.centerXAnchor.constraint(equalTo: g.centerXAnchor, constant: 0.0),
            pbView.centerYAnchor.constraint(equalTo: g.centerYAnchor, constant: 0.0),
            pbView.widthAnchor.constraint(equalToConstant: 60.0),
            pbView.heightAnchor.constraint(equalToConstant: 400.0),
            
            infoLabel.topAnchor.constraint(equalTo: pbView.bottomAnchor, constant: 20.0),
            infoLabel.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            
        ])
        
        infoLabel.font = .systemFont(ofSize: 32.0, weight: .light)
        
        pbView.progress = self.progress
        updateInfo()
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        progress  = 0.1
        pbView.progress = min(1.0, progress)
        updateInfo()
    }

    func updateInfo() {
        let pInt = Int(progress * 100.0)
        infoLabel.text = "\(pInt)%"
    }
}

enter image description here

("Percent Label" value can be off due to rounding.)


Edit - to clarify why it wasn't working..

So, why wasn't it working to begin with?

The original code was changing the gradient layer's .endPoint to a percentage of the height.

However, the .locations are percentages of the .endPoint - .startPoint value.

Suppose the view is 400-pts tall... if we're at 25% and we set:

  • .startPoint = CGPoint(x: 0.5, y: 0.0)
  • .endPoint = CGPoint(x: 0.5, y: 0.25)

the gradient will be calculated for 100-pts of height.

The .locations animation goes from [-0.3, -0.15, 0] to [1, 1.15, 1.3] 0 which is a total of 30% of the 100-pts (or 30-points). However, as soon as the location exceeds 1.0 it will fill out the rest of the layer's frame.

Here's how it looks as we animate through:

gradientLayer.locations = [0.10, 0.25, 0.4]
gradientLayer.locations = [0.40, 0.55, 0.7]
gradientLayer.locations = [0.60, 0.75, 0.9]
gradientLayer.locations = [0.85, 1.0, 1.15]

I've adjusted the gray "progress" layer to be only half of the width -- at full width, it covers the beginning of the gradient animation:

enter image description here

Setting aside any discussion of putting the code inside draw() or layoutSubviews(), you can "fix" the issue by commenting out a single line in your draw() func:

//gradientLayer.endPoint = CGPoint(x: 0.5, y: progress)

Now the actual gradient height will remain at 30% of the full height.


It wasn't clear initially what you wanted to do with the gray "progress" layer... here's a modified version using layoutSubviews() instead of draw(). One big benefit is that the entire thing will automatically resize if the view frame changes:

class ProgressBarView: UIView {
    
    var color: UIColor = .red {
        didSet {
            gradientLayer.colors = [color.cgColor, gradientColor.cgColor, color.cgColor]
        }
    }
    
    var gradientColor: UIColor = .white {
        didSet {
            gradientLayer.colors = [color.cgColor, gradientColor.cgColor, color.cgColor]
        }
    }
    
    var progress: CGFloat = 0 {
        didSet {
            // trigger layoutSubviews() to
            //  update the layer frames
            setNeedsLayout()
        }
    }
    
    private let gradientLayer = CAGradientLayer()
    private let progressLayer = CALayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    
    func commonInit() {
        
        self.backgroundColor = UIColor.lightGray
        
        setupLayers()
        createAnimation()
    
        // give the full view rounded corners
        self.layer.cornerRadius = 8
        self.layer.masksToBounds = true
    }
    
    private func setupLayers() {
        
        // initial locations
        gradientLayer.locations = [0.35, 0.5, 0.65]
        
        // initial colors
        gradientLayer.colors = [color.cgColor, gradientColor.cgColor, color.cgColor]
        
        // set start and end points
        gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
        gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
        
        // add the gradient layer
        layer.addSublayer(gradientLayer)
        
        // add the gray "progress" layer
        progressLayer.backgroundColor = UIColor.lightGray.cgColor
        layer.addSublayer(progressLayer)
        
    }
    
    private func createAnimation() {
        
        let flowAnimation = CABasicAnimation(keyPath: "locations")
        flowAnimation.fromValue = [-0.3, -0.15, 0]
        flowAnimation.toValue = [1, 1.15, 1.3]
        
        flowAnimation.isRemovedOnCompletion = false
        flowAnimation.repeatCount = Float.infinity
        flowAnimation.duration = 1
        
        gradientLayer.add(flowAnimation, forKey: "flowAnimation")
        
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        var r = bounds

        // update gradient layer frame
        gradientLayer.frame = bounds

        // make gray progress layer frame height % of height
        r.size.height *= progress
        
        // update the gray progress layer frame
        progressLayer.frame = r
    }

}
  • Related