Search code examples
swiftcalayercabasicanimationcaanimationcagradientlayer

How to animate CAGradientLayer color points?


I have a problem while animating the CAGradientLayer angle.

Angle in CAGradientLayer is represented through start and end point properties.

I want to animate the gradient in a circular fashion.

When I set it inside an animationGroup it doesn't work. No animation is happening. When I am changing the properties in

DispatchQueue.main.asyncAfter(deadline: now() + 1.0) {
    // change properties here
}

it works. But a very fast animation is happening. Which is not good enough.

On the internet the only thing there is is locations and color changes, but no angle change.

Below you can find a Playground project to play with

//: A UIKit based Playground for presenting user interface

import UIKit
import PlaygroundSupport

class MyViewController : UIViewController {

    // Gradient layer specification
    lazy var gradientLayer: CAGradientLayer = {
        let gradientLayer = CAGradientLayer()
        gradientLayer.colors = [UIColor.white.withAlphaComponent(0.5).cgColor, UIColor.orange.withAlphaComponent(0.5).cgColor, UIColor.orange.cgColor]
        gradientLayer.locations = [0, 0.27, 1]
        gradientLayer.frame = view.bounds
        gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.0)
        gradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
        return gradientLayer
    }()

    func animationMapFunction(points: [(CGPoint, CGPoint)], keyPath: String) -> [CABasicAnimation] {
        return points.enumerated().map { (arg) -> CABasicAnimation in
            let (offset, element) = arg
            let gradientStartPointAnimation = CABasicAnimation(keyPath: keyPath)

            gradientStartPointAnimation.fromValue = element.0
            gradientStartPointAnimation.toValue = element.1
            gradientStartPointAnimation.beginTime = CACurrentMediaTime() + Double(offset)

            return gradientStartPointAnimation
        }
    }

    lazy var gradientAnimation: CAAnimation = {
        let startPointAnimationPoints = [(CGPoint(x: 0.0, y: 0.0), CGPoint(x: 1.0, y:0.0)),
                                         (CGPoint(x: 1.0, y:0.0), CGPoint(x: 1.0, y:1.0)),
                                         (CGPoint(x: 1.0, y:1.0), CGPoint(x: 0.0, y:1.0)),
                                         (CGPoint(x: 0.0, y:1.0), CGPoint(x: 0.0, y:0.0))]

        let endPointAnimatiomPoints = [(CGPoint(x: 1.0, y:1.0), CGPoint(x: 0.0, y:1.0)),
                                       (CGPoint(x: 0.0, y:1.0), CGPoint(x: 0.0, y:0.0)),
                                       (CGPoint(x: 0.0, y: 0.0), CGPoint(x: 1.0, y:0.0)),
                                       (CGPoint(x: 1.0, y:0.0), CGPoint(x: 1.0, y:1.0))]

        let startPointAnimations = animationMapFunction(points: startPointAnimationPoints, keyPath: "startPoint")
        let endPointAnimations = animationMapFunction(points: startPointAnimationPoints, keyPath: "endPoint")

        let animationGroup = CAAnimationGroup()
        animationGroup.duration = 5.0
        animationGroup.repeatCount = Float.infinity
        animationGroup.animations = startPointAnimations + endPointAnimations

        return animationGroup
    }()

    override func loadView() {
        let view = UIView(frame: UIScreen.main.bounds)
        self.view = view

        view.layer.addSublayer(gradientLayer)
    }

    func animate() {
        view.layer.removeAllAnimations()
        gradientLayer.add(gradientAnimation, forKey: nil)
    }
}
// Present the view controller in the Live View window
let vc = MyViewController()
PlaygroundPage.current.liveView = vc

vc.animate()

Solution

  • So what I did was a timer, that triggers start and end points change.

    I rotate from 0 to 360 degrees, while incrementing the angle with a given constant (in my case 6)

    I created a function mapping: angle → (startPoint, endPoint)

    func animate() {
        stopAnimation()
    
        timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true, block: { timer in
            self.angle += 6.0
            self.angle.formTruncatingRemainder(dividingBy: 360)
    
            CATransaction.begin()
            // disable implicit animation
            CATransaction.setDisableActions(true)
    
            let pos = points(from: self.angle)
    
            self.gradientLayer.startPoint = pos.0
            self.gradientLayer.endPoint = pos.1
    
            CATransaction.commit()
        })
    }
    
    func stopAnimation() {
        timer?.invalidate()
    }
    

    And here are the utility functions

    extension CGPoint {
        var inverse: CGPoint {
            return CGPoint(x: 1 - x, y: 1 - y)
        }
    }
    
    fileprivate func points(from angle: Double) -> (CGPoint, CGPoint) {
        let start: CGPoint
    
        switch angle {
        case let x where 0 <= x && x < 90:
            start = CGPoint(x: x / 180, y: 0.5 - x / 180)
        case let x where 90 <= x && x < 180:
            start = CGPoint(x: x / 180, y: x / 180 - 0.5)
        case let x where 180 <= x && x < 270:
            start = CGPoint(x: 2.0 - x / 180, y: 0.5 + (x - 180) / 180)
        case let x where 270 <= x && x < 360:
            start = CGPoint(x: 2.0 - x / 180, y: 0.5 + (360 - x) / 180)
        default:
            start = CGPoint.zero
        }
    
        return (start, start.inverse)
    }