iosswiftcagradientlayer

Vertical Progress Bar with Gradient Layer


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")
}

}


Solution

  • 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:

    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
        }
    
    }