Nikunj Acharya
Nikunj Acharya

Reputation: 797

Animate UIView's Layer with constrains (Auto Layout Animations)

I am working on project where I need to animate height of view which consist of shadow, gradient and rounded corners (not all corners). So I have used layerClass property of view.

Below is simple example demonstration. Problem is that, when I change height of view by modifying its constraint, it was resulting in immediate animation of layer class, which is kind of awkward.

Below is my sample code

import UIKit

class CustomView: UIView{

    var isAnimating: Bool = false

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.setupView()
    }

    func setupView(){

        self.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)

        guard let layer = self.layer as? CAShapeLayer else { return }

        layer.fillColor = UIColor.green.cgColor
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
        layer.shadowOpacity = 0.6
        layer.shadowRadius = 5

    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override class var layerClass: AnyClass {
        return CAShapeLayer.self
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        // While animating `myView` height, this method gets called
        // So new bounds for layer will be calculated and set immediately
        // This result in not proper animation

        // check by making below condition always true

        if !self.isAnimating{ //if true{
            guard let layer = self.layer as? CAShapeLayer else { return }

            layer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath
            layer.shadowPath = layer.path
        }
    }

}

class TestViewController : UIViewController {

    // height constraint for animating height
    var heightConstarint: NSLayoutConstraint?

    var heightToAnimate: CGFloat = 200

    lazy var myView: CustomView = {
        let view = CustomView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.backgroundColor = .clear
        return view
    }()

    lazy var mySubview: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.backgroundColor = .yellow
        return view
    }()

    lazy var button: UIButton = {
        let button = UIButton(frame: .zero)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.setTitle("Animate", for: .normal)
        button.setTitleColor(.black, for: .normal)
        button.addTarget(self, action: #selector(self.animateView(_:)), for: .touchUpInside)
        return button
    }()


    override func viewDidLoad() {
        super.viewDidLoad()

        self.view.backgroundColor = .white
        self.view.addSubview(self.myView)
        self.myView.addSubview(self.mySubview)
        self.view.addSubview(self.button)

        self.myView.leadingAnchor.constraint(equalTo: self.view.layoutMarginsGuide.leadingAnchor).isActive = true
        self.myView.trailingAnchor.constraint(equalTo: self.view.layoutMarginsGuide.trailingAnchor).isActive = true
        self.myView.topAnchor.constraint(equalTo: self.view.layoutMarginsGuide.topAnchor, constant: 100).isActive = true
        self.heightConstarint = self.myView.heightAnchor.constraint(equalToConstant: 100)
        self.heightConstarint?.isActive = true

        self.mySubview.leadingAnchor.constraint(equalTo: self.myView.layoutMarginsGuide.leadingAnchor).isActive = true
        self.mySubview.trailingAnchor.constraint(equalTo: self.myView.layoutMarginsGuide.trailingAnchor).isActive = true
        self.mySubview.topAnchor.constraint(equalTo: self.myView.layoutMarginsGuide.topAnchor).isActive = true
        self.mySubview.bottomAnchor.constraint(equalTo: self.myView.layoutMarginsGuide.bottomAnchor).isActive = true

        self.button.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true
        self.button.bottomAnchor.constraint(equalTo: self.view.layoutMarginsGuide.bottomAnchor, constant: -20).isActive = true
    }

    @objc func animateView(_ sender: UIButton){

        CATransaction.begin()
        CATransaction.setAnimationDuration(5.0)
        CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut))

        UIView.animate(withDuration: 5.0, animations: {

            self.myView.isAnimating = true
            self.heightConstarint?.constant = self.heightToAnimate
            // this will call `myView.layoutSubviews()`
            // and layer's new bound will set automatically
            // this causing layer to be directly jump to height 200, instead of smooth animation
            self.view.layoutIfNeeded()

        }) { (success) in
            self.myView.isAnimating = false
        }

        let shadowPathAnimation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.shadowPath))
        let pathAnimation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.path))

        let toValue = UIBezierPath(
            roundedRect:CGRect(x: 0, y: 0, width: self.myView.bounds.width, height: heightToAnimate),
            cornerRadius: 10
        ).cgPath


        shadowPathAnimation.fromValue = self.myView.layer.shadowPath
        shadowPathAnimation.toValue = toValue

        pathAnimation.fromValue = (self.myView.layer as! CAShapeLayer).path
        pathAnimation.toValue = toValue


        self.myView.layer.shadowPath = toValue
        (self.myView.layer as! CAShapeLayer).path = toValue

        self.myView.layer.add(shadowPathAnimation, forKey: #keyPath(CAShapeLayer.shadowPath))
        self.myView.layer.add(pathAnimation, forKey: #keyPath(CAShapeLayer.path))

        CATransaction.commit()

    }

}

While animating view, it will call its layoutSubviews() method, which will result into recalculating bounds of shadow layer.

So I checked if view is currently animating, then do not recalculate bounds of shadow layer.

Is this approach right ? or there is any better way to do this ?

Upvotes: 1

Views: 1077

Answers (2)

Nikunj Acharya
Nikunj Acharya

Reputation: 797

Below code also worked for me, As I want to use layout subviews without any flags.

UIView.animate(withDuration: 5.0, animations: {

            let shadowPathAnimation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.shadowPath))
            let pathAnimation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.path))

            let toValue = UIBezierPath(
                roundedRect:CGRect(x: 0, y: 0, width: self.myView.bounds.width, height: self.heightToAnimate),
                cornerRadius: 10
                ).cgPath


            shadowPathAnimation.fromValue = self.myView.layer.shadowPath
            shadowPathAnimation.toValue = toValue

            pathAnimation.fromValue = (self.myView.layer as! CAShapeLayer).path
            pathAnimation.toValue = toValue

            self.heightConstarint?.constant = self.heightToAnimate
            self.myView.layoutIfNeeded()

            self.myView.layer.shadowPath = toValue
            (self.myView.layer as! CAShapeLayer).path = toValue

            self.myView.layer.add(shadowPathAnimation, forKey: #keyPath(CAShapeLayer.shadowPath))
            self.myView.layer.add(pathAnimation, forKey: #keyPath(CAShapeLayer.path))

            CATransaction.commit()

        })

And overriding layoutSubview as follows

override func layoutSubviews() {
        super.layoutSubviews()

        guard let layer = self.layer as? CAShapeLayer else { return }

        layer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath
        layer.shadowPath = layer.path
    }

Upvotes: 0

E.Coms
E.Coms

Reputation: 11539

I know it's a tricky question. Actually, you don't need to care about layoutSubViews at all. The key here is when you set the shapeLayer. If it's setup well, i.e. after the constraints are all working, you don't need to care that during the animation.

//in CustomView, comment out the layoutSubViews() and add updateLayer()

 func updateLayer(){
    guard let layer = self.layer as? CAShapeLayer else { return }
    layer.path = UIBezierPath(roundedRect: layer.bounds, cornerRadius: 10).cgPath
    layer.shadowPath = layer.path
}

  //    override func layoutSubviews() {
  //        super.layoutSubviews()
  //
  //        // While animating `myView` height, this method gets called
  //        // So new bounds for layer will be calculated and set immediately
  //        // This result in not proper animation
  //
 //        // check by making below condition always true
 //
 //        if !self.isAnimating{ //if true{
//            guard let layer = self.layer as? CAShapeLayer else { return }
//
//            layer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath
//            layer.shadowPath = layer.path
//        }
//    }

in ViewController: add viewDidAppear() and remove other is animation block

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

@objc func animateView(_ sender: UIButton){

    CATransaction.begin()
    CATransaction.setAnimationDuration(5.0)
    CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut))

    UIView.animate(withDuration: 5.0, animations: {

        self.heightConstarint?.constant = self.heightToAnimate
        // this will call `myView.layoutSubviews()`
        // and layer's new bound will set automatically
        // this causing layer to be directly jump to height 200, instead of smooth animation
        self.view.layoutIfNeeded()

    }) { (success) in
        self.myView.isAnimating = false
    } 
   ....

Then you are good to go. Have a wonderful day.

Upvotes: 2

Related Questions