iosswiftuikitcabasicanimationcagradientlayer

Gradient animation is not working properly


There is a problem that my gradient animation is not working correctly. If you add more colors to the gradientColors array, it works more or less, but there is a flickering effect.

Tried solving the problem via CATransaction, but it did not solve the problem. When I add more than 2 colors to the array, the animation seems to work, but there is a flickering effect

there are more than two colors in the colors array

there are 2 colors in the array

Maybe you have a solution to the problem ? I would be grateful for your help:)

github project

class ViewController: UIViewController {
private enum Color {
    static var gradientColors: [UIColor] = [
        UIColor(red: 255, green: 255, blue: 255, alpha: 1),
        UIColor(red: 255, green: 255, blue: 255, alpha: 0)
    ]
}

@IBOutlet weak var gradientView: UIView! {
    didSet {
        gradientView.layer.cornerRadius = 16
    }
}

private var timer: Timer?

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    
    animateBorderGradietion()
}

func animateBorderGradietion() {
       let shape1 = CAShapeLayer()
       shape1.path = UIBezierPath(
           roundedRect: gradientView.bounds.insetBy(dx: 1.0, dy: 1.0),
           cornerRadius: gradientView.layer.cornerRadius
       ).cgPath
       
       shape1.lineWidth = 1.0
       shape1.strokeColor = UIColor.white.cgColor
       shape1.fillColor = UIColor.clear.cgColor
       
       let gradient1 = CAGradientLayer()
       gradient1.frame = gradientView.bounds
       gradient1.type = .conic
       gradient1.colors = Color.gradientColors.map { $0.cgColor }
       gradient1.locations = calculateGradientLocation()
       gradient1.startPoint = CGPoint(x: 0.5, y: 0.5)
       gradient1.endPoint = CGPoint(x: 1, y: 1)
       gradient1.mask = shape1
       gradientView.layer.addSublayer(gradient1)
       
       let shape2 = CAShapeLayer()
       shape2.path = UIBezierPath(
           roundedRect: gradientView.bounds.insetBy(dx: 1.0, dy: 1.0),
           cornerRadius: gradientView.layer.cornerRadius
       ).cgPath
       
       shape2.lineWidth = 1.0
       shape2.strokeColor = UIColor.white.cgColor
       shape2.fillColor = UIColor.clear.cgColor
       
       let gradient2 = CAGradientLayer()
       gradient2.frame = gradientView.bounds
       gradient2.type = .conic
       gradient2.colors = Color.gradientColors.map { $0.cgColor }
       gradient2.locations = calculateGradientLocation()
       gradient2.startPoint = CGPoint(x: 1, y: 1)
       gradient2.endPoint = CGPoint(x: 0.5, y: 0.5)
       gradient2.mask = shape2
       gradientView.layer.addSublayer(gradient2)
       
       self.timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in
           CATransaction.begin()
           CATransaction.setAnimationDuration(0.2)
           
           CATransaction.setCompletionBlock {
               gradient1.removeAnimation(forKey: "myAnimation")
               gradient2.removeAnimation(forKey: "myAnimation")
               
               let previous = Color.gradientColors.map { $0.cgColor }
               let last = Color.gradientColors.removeLast()
               Color.gradientColors.insert(last, at: 0)
               let lastColors = Color.gradientColors.map { $0.cgColor }
               
               let colorsAnimation = CABasicAnimation(keyPath: "colors")
               colorsAnimation.fromValue = previous
               colorsAnimation.toValue = lastColors
               colorsAnimation.repeatCount = 1
               colorsAnimation.duration = 0.2
               colorsAnimation.isRemovedOnCompletion = false
               colorsAnimation.fillMode = .both
               
               gradient1.colors = lastColors
               gradient2.colors = lastColors
               gradient1.add(colorsAnimation, forKey: "myAnimation")
               gradient2.add(colorsAnimation, forKey: "myAnimation")
           }
           
           CATransaction.commit()
       }
    }


private func calculateGradientLocation() -> [NSNumber] {
    return Array(stride(from: 0, to: Color.gradientColors.count, by: 1))
        .map { NSNumber(value: Double($0) / Double(Color.gradientColors.count)) }
}

}


Solution

  • Instead of trying to animate the gradient colors and locations, another approach is to rotate the CAGradientLayer.

    So, if we start with a plain view:

    enter image description here

    we can add a CAGradientLayer as a sublayer -- here, it has only a single 1/2 alpha color so we can easily see the framing:

    enter image description here

    Because we will be rotating that layer, we set that layer's frame to be larger than the view so it will completely cover it:

    enter image description here enter image description here

    Next, we set the gradient layer's colors to clear, white, clear:

    enter image description here enter image description here

    enter image description here enter image description here

    and finally, apply a shape layer mask to the view:

    enter image description here enter image description here

    enter image description here enter image description here

    Here's how it looks on a black background:

    enter image description here

    And here are some animated versions (too big to embed here): https://imgur.com/a/JXgyT7b

    Here is some example code for that:

    // couple helpers for CGRect
    extension CGRect {
        public var center: CGPoint { return CGPoint(x: midX, y: midY) }
        public var diagonalExtent: CGFloat { return hypot(width, height) }
    }
    
    // example view
    class AnimatedGradientBorderedView: UIView {
    
        public var lineWidth: CGFloat = 1.0 { didSet { mskLayer.lineWidth = lineWidth } }
        public var cornerRadius: CGFloat = 16.0 { didSet { setNeedsLayout() } }
        
        private let gradLayer = CAGradientLayer()
        private let mskLayer = CAShapeLayer()
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder aDecoder: NSCoder) {
            super.init(coder:aDecoder)
            commonInit()
        }
        
        func commonInit() {
    
            gradLayer.colors = [UIColor.clear.cgColor, UIColor.white.cgColor, UIColor.clear.cgColor]
            layer.addSublayer(gradLayer)
    
            mskLayer.fillColor = UIColor.clear.cgColor
            // any opaque color
            mskLayer.strokeColor = UIColor.black.cgColor
            mskLayer.lineWidth = lineWidth
            
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
    
            // we need the gradient layer frame to cover the entire view
            //  when it rotates, so we make it a square with width/height
            //  equal to the diagonal dimension of self (plus just a little "padding")
            let diag: CGFloat = bounds.diagonalExtent + 2.0
            gradLayer.frame = bounds.insetBy(dx: -(diag - bounds.width) * 0.5, dy: -(diag - bounds.height) * 0.5)
            
            // rounded-corners mask path, inset by lineWidth so it is completely inside of self
            mskLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: lineWidth, dy: lineWidth), cornerRadius: cornerRadius).cgPath
            layer.mask = mskLayer
    
            doAnim()
        }
        
        func doAnim() {
            
            // rotate the mask layer
            let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
            rotateAnimation.fromValue = 0.0
            rotateAnimation.toValue = CGFloat(Double.pi * 2)
            rotateAnimation.isRemovedOnCompletion = false
            // adjust rotation speed as desired
            rotateAnimation.duration = 8.0
            rotateAnimation.repeatCount=Float.infinity
            gradLayer.add(rotateAnimation, forKey: nil)
            
        }
        
    }
    
    // simple example view controller
    class MyViewController: UIViewController {
        
        let testView = AnimatedGradientBorderedView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            testView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(testView)
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                testView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 34.0),
                testView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -34.0),
                testView.heightAnchor.constraint(equalToConstant: 492.0),
                testView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
                
            ])
            
            view.backgroundColor = .black
            testView.backgroundColor = .clear
            
        }
        
    }