Home > Back-end >  Adopt CAGradientLayer frame to UIView bounds during animation
Adopt CAGradientLayer frame to UIView bounds during animation

Time:10-24

In my UIViewController I have a UIView with added CAGradientLayer. I also have a height constraint for the view, that I change inside UIView.animate closure. What I need is the gradientLayer also to change its size during view animation. What I get is gradientLayer jumping to final bounds of changed view, skipping transitional values.

class ViewController: UIViewController {
    
    @IBOutlet weak var headerView: UIView!
    @IBOutlet weak var headerViewHeightConstraint: NSLayoutConstraint!

    var gradientLayer: CAGradientLayer?

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

    func applyGradient() {
        let colors: [UIColor] = [.red, .black]
        let gradientLayer = CAGradientLayer()
        gradientLayer.colors = colors.map { $0.cgColor}
        gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.0)
        gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
        
        // headerView bounds here is (0, 0, 393, 200)
        gradientLayer.frame = headerView.bounds
        
        headerView.layer.addSublayer(gradientLayer)
        self.gradientLayer = gradientLayer
    }
    
    func animate() {
        headerViewHeightConstraint.constant = 100
        UIView.animate(withDuration: 0.3, delay: 0, animations: {
            self.view.layoutIfNeeded()
            // headerView bounds here is always (0, 0, 393, 100)
            self.gradientLayer?.frame = self.headerView.bounds
        }, completion: { _ in
            // headerView bounds here is always (0, 0, 393, 100)
            self.gradientLayer?.frame = self.headerView.bounds
        })
    }
    
    @IBAction func didTapAnimateButton() {
        animate()
    }
}

Animation closure is only called once and headerView.bounds has the same value as in the completion closure, while I expect it to have several transitional values before completion closure.

What I also tried:

  • To catch transitional values inside UIViewController' viewDidLayoutSubviews method
  • Observing frame change via KVO.

Nothing worked as expected. All methods only called when animation is finished.

CodePudding user response:

You'll find it much easier to animate (and otherwise manipulate) gradients if you use a custom UIView subclass such as this:

class CustomGradientView: UIView {
     
    public var colors: [UIColor] = [.red, .black] { didSet { setNeedsLayout() } }
    public var startPoint: CGPoint = CGPoint(x: 0.5, y: 0.0) { didSet { setNeedsLayout() } }
    public var endPoint: CGPoint = CGPoint(x: 0.5, y: 1.0) { didSet { setNeedsLayout() } }
    public var locations: [CGFloat] = [] { didSet { setNeedsLayout() } }

    override class var layerClass: AnyClass {
        return CAGradientLayer.self
    }
    private var gLayer: CAGradientLayer {
        return self.layer as! CAGradientLayer
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        gLayer.colors = self.colors.map { $0.cgColor}
        if self.locations.count == self.colors.count {
            gLayer.locations = self.locations.map { $0 as NSNumber }
        }
        gLayer.startPoint = self.startPoint
        gLayer.endPoint = self.endPoint
    }
    
}

I've set the defaults for .colors / .startPoint / .endPoint / .locations to match your code, but they can be changed the same way at run-time.

Here's your controller example -- instead of a button, tapping anywhere will toggle / animate the height constraint between 100 and 300 and back:

class ViewController: UIViewController {
    
    @IBOutlet weak var headerView: CustomGradientView!
    @IBOutlet weak var headerViewHeightConstraint: NSLayoutConstraint!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // change values if we don't want to use the defaults
        //  for example
        //headerView.colors = [.green, .yellow, .red]
        //headerView.locations = [0.1, 0.3, 1.0]
        //headerView.startPoint = CGPoint(x: 0.0, y: 0.0)
        //headerView.endPoint = CGPoint(x: 1.0, y: 1.0)

    }
    func animate() {
        // animate view height between 100 and 300
        headerViewHeightConstraint.constant = headerViewHeightConstraint.constant == 100.0 ? 300.0 : 100.0
        UIView.animate(withDuration: 0.3, delay: 0, animations: {
            self.view.layoutIfNeeded()
        }, completion: { _ in
        })
    }
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        animate()
    }
}

Edit - a little explanation...

Layers do not "auto-size" when the view frame changes.

When using UIView.animate(withDuration:...), UIKit sets the "target" and then generates the animation effect.

In this case, when our code changes the constraint's .constant, UIKit sets the "target frame height" before the animation is generated. So this line self.gradientLayer?.frame = self.headerView.bounds sets the layer frame to the "end frame" size.

It is probably possible to synch animate the layer change with the view's frame change, but from my experience, subclassing the view like this is much easier and more reliable.

  • Related