Search code examples
iosswiftanimationcalayer

Scaling a CAShapeLayer with CABasicAnimation causes it to translate


I have a CAShapeLayer that's drawn as a circle as a given point:

let circlePath = UIBezierPath(arcCenter: point, radius: 20.0, startAngle: 0, endAngle: CGFloat.pi * 2.0, clockwise: true)
let shapeLayer = CAShapeLayer()
shapeLayer.path = circlePath.cgPath
shapeLayer.strokeColor = UIColor.black.cgColor
shapeLayer.fillColor = nil
shapeLayer.lineWidth = 2.0

I want to be able to scale this layer using a CABasicAnimation. I've tried several methods but most give me the same issue: the circle scales appropriately but also seems to translate/move at the same time. The rate at which the circle scales and translates seems to be affected the value of point: the closer point is to the origin, the slower the circle appears to animate. I'm clearly missing something basic here, but I haven't been able to find it. Here's the most simple attempt at building the animation that I've tried so far:

let growthAnimation = CABasicAnimation(keyPath: "transform.scale")
growthAnimation.toValue = 3
growthAnimation.fillMode = .forwards
growthAnimation.isRemovedOnCompletion = false
growthAnimation.duration = 1.0
            
shapeLayer.add(growthAnimation, forKey: nil)
self.view.layer.addSublayer(shapeLayer)

I've tried setting the "transform" (instead of "transform.scale"), I've tried using an NSValue as the toValue of the animation, I've tried adding translation to the transform to try and keep it in the current position but no luck.

My current theory is that the layer is actually larger than just the circle such that as the entire layer scales, the circle just appears to translate. But I'm not able to color the layer's border or backgroundColor and I'm not sure how I would prove it.


Solution

  • "My current theory is that the layer is actually larger than just the circle such that as the entire layer scales..."

    Your current theory is correct.

    Try it like this:

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        let radius: CGFloat = 20.0
        
        let point = CGPoint(x: radius, y: radius)
        let circlePath = UIBezierPath(arcCenter: point, radius: radius, startAngle: 0, endAngle: CGFloat.pi * 2.0, clockwise: true)
        let shapeLayer = CAShapeLayer()
        shapeLayer.path = circlePath.cgPath
        shapeLayer.strokeColor = UIColor.black.cgColor
        shapeLayer.fillColor = nil
        shapeLayer.lineWidth = 2.0
        
        let growthAnimation = CABasicAnimation(keyPath: "transform.scale")
        growthAnimation.toValue = 3
        growthAnimation.fillMode = .forwards
        growthAnimation.isRemovedOnCompletion = false
        growthAnimation.duration = 1.0
        
        shapeLayer.add(growthAnimation, forKey: nil)
    
        // set the layer frame
        shapeLayer.frame = CGRect(x: view.frame.midX - radius, y: view.frame.midY - radius, width: radius * 2.0, height: radius * 2.0)
        
        shapeLayer.backgroundColor = UIColor.green.cgColor
        self.view.layer.addSublayer(shapeLayer)
        
    }
    

    Edit - this may make things more clear...

    If you run this example, it creates 4 shape layers with circles:

    • circle center at 0,0 with no layer frame given
    • circle center at 100,100 with no layer frame given
    • circle center at 0,0 with layer frame x: 100, y: 240, w: 40, h: 40 (40 is radius * 2)... the circle center is top-left corner of the frame
    • circle center at radius,radius (so, 20,20) with layer frame x: 100, y: 380, w: 40, h: 40 (40 is radius * 2)... the circle center is now centered in the layer frame

    When you tap anywhere on the view, all 4 layers will perform the same scaling animation (slowed to 3-seconds to make it easier to watch).

    It should then be clear how the path and layer frame affect the transformation.

    class ViewController: UIViewController {
    
        func test1() -> Void {
    
            let radius: CGFloat = 20.0
    
            let shapeLayer = CAShapeLayer()
            
            // no frame set for the shape layer
    
            // point is at top-left corner of shape layer frame
            //  since we haven't set a frame for the layer, it's top-left corner of the view
            let point = CGPoint(x: 0, y: 0)
    
            let circlePath = UIBezierPath(arcCenter: point, radius: radius, startAngle: 0, endAngle: CGFloat.pi * 2.0, clockwise: true)
            shapeLayer.path = circlePath.cgPath
            shapeLayer.strokeColor = UIColor.red.cgColor
            shapeLayer.fillColor = nil
            shapeLayer.lineWidth = 2.0
            
            self.view.layer.addSublayer(shapeLayer)
            
            shapeLayer.backgroundColor = UIColor.green.cgColor
            
        }
    
        func test2() -> Void {
            
            let radius: CGFloat = 20.0
            
            let shapeLayer = CAShapeLayer()
            
            // no frame set for the shape layer
            
            // set point to 100,100
            let point = CGPoint(x: 100, y: 100)
            
            let circlePath = UIBezierPath(arcCenter: point, radius: radius, startAngle: 0, endAngle: CGFloat.pi * 2.0, clockwise: true)
            shapeLayer.path = circlePath.cgPath
            shapeLayer.strokeColor = UIColor.blue.cgColor
            shapeLayer.fillColor = nil
            shapeLayer.lineWidth = 2.0
            
            self.view.layer.addSublayer(shapeLayer)
            
            shapeLayer.backgroundColor = UIColor.green.cgColor
            
        }
    
        func test3() -> Void {
            
            let radius: CGFloat = 20.0
            
            let shapeLayer = CAShapeLayer()
    
            // set shape layer frame to 40x40 at 100,240
            shapeLayer.frame = CGRect(x: 100.0, y: 240.0, width: radius * 2.0, height: radius * 2.0)
    
            // point is at top-left corner of shape layer frame
            let point = CGPoint(x: 0, y: 0)
            
            let circlePath = UIBezierPath(arcCenter: point, radius: radius, startAngle: 0, endAngle: CGFloat.pi * 2.0, clockwise: true)
            shapeLayer.path = circlePath.cgPath
            shapeLayer.strokeColor = UIColor.orange.cgColor
            shapeLayer.fillColor = nil
            shapeLayer.lineWidth = 2.0
            
            self.view.layer.addSublayer(shapeLayer)
            
            shapeLayer.backgroundColor = UIColor.green.cgColor
            
        }
        
        func test4() -> Void {
            
            let radius: CGFloat = 20.0
    
            let shapeLayer = CAShapeLayer()
    
            // set shape layer frame to 40x40 at 100,380
            shapeLayer.frame = CGRect(x: 100.0, y: 380.0, width: radius * 2.0, height: radius * 2.0)
    
            // set point to center of layer frame
            let point = CGPoint(x: radius, y: radius)
            
            let circlePath = UIBezierPath(arcCenter: point, radius: radius, startAngle: 0, endAngle: CGFloat.pi * 2.0, clockwise: true)
            shapeLayer.path = circlePath.cgPath
            shapeLayer.strokeColor = UIColor.black.cgColor
            shapeLayer.fillColor = nil
            shapeLayer.lineWidth = 2.0
            
            self.view.layer.addSublayer(shapeLayer)
            
            shapeLayer.backgroundColor = UIColor.green.cgColor
            
            
        }
        
        override func viewDidLoad() {
            super.viewDidLoad()
            navigationController?.setNavigationBarHidden(true, animated: false)
            let t = UITapGestureRecognizer(target: self, action: #selector(self.didTap))
            view.addGestureRecognizer(t)
        }
        
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            
            test1()
            test2()
            test3()
            test4()
            
        }
        
        @objc func didTap() -> Void {
            scaleLayers()
        }
        
        func scaleLayers() -> Void {
            
            let growthAnimation = CABasicAnimation(keyPath: "transform.scale")
            growthAnimation.toValue = 3
            growthAnimation.fillMode = .forwards
            growthAnimation.isRemovedOnCompletion = false
            growthAnimation.duration = 3.0
    
            guard let layers = view.layer.sublayers else {
                return
            }
            layers.forEach { layer in
                if let shapeLayer = layer as? CAShapeLayer {
                    shapeLayer.add(growthAnimation, forKey: nil)
                }
            }
    
        }
    }