2

I am trying to animate layer path property using CABasicAnimation key path But. not getting exact results

What change of path animation i am looking for is below

Expected Animation

enter image description here

Note:- Please ignore switch.. my focus is just path change from circular to moon animation

What i tried

Here is my code that i have tried to get this path conversion animation

@IBDesignable class CustomView: UIView {

 private lazy var shapeLayer = CAShapeLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
        
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    
    private func commonInit() {
        shapeLayer.frame = bounds
        shapeLayer.fillColor = UIColor.black.cgColor
        shapeLayer.strokeColor = UIColor.red.cgColor
        shapeLayer.lineWidth = 3
        layer.addSublayer(shapeLayer)
        
    }
    
    override func layoutSubviews() {
        
        shapeLayer.path = drawSunShape(bounds)
    }
    private func drawSunShape(_ group:CGRect) -> CGPath {
        let bezierPath = UIBezierPath()
        let radius = group.maxX/2
        let center =  CGPoint(x: group.midX, y: group.midY)
        bezierPath.addArc(withCenter:center, radius: radius, startAngle: -.pi/2, endAngle: 3 * .pi * 0.5 , clockwise: true)
        return bezierPath.cgPath
    }
    
    private func drawMoonShape(_ group:CGRect) -> CGPath {
  
        let rad  : CGFloat = group.maxX/2
        let center =  CGPoint(x: group.midX, y: group.midY)
       
        
        let big = UIBezierPath()
       
        big.addArc(withCenter: center, radius: rad, startAngle:-.pi/2.0, endAngle: .pi/2.0, clockwise: true)
        big.addArc(withCenter: CGPoint(x: rad * cos(.pi/2.0), y: center.y), radius: rad * sin(.pi/2.0), startAngle: .pi/2.0, endAngle: -.pi/2.0, clockwise: false)
        big.close()
 
        
        return big.cgPath
    }
    private func animateToOffShape()  {
      
            let anim = CABasicAnimation(keyPath: "path")
            anim.toValue = drawMoonShape(bounds)
            anim.duration = 5.0
            shapeLayer.add(anim, forKey: nil)
        } 
    }

What i am getting from this code

UPDATE

i am able to achieve this by the help of @sweeper and @max

enter image description here

but i want it from center right

Any pointers how i can achieve the expected result. Thanks

Jawad Ali
  • 13,556
  • 3
  • 32
  • 49
  • 1
    The animation seems to work correctly. You just need to draw a better moon shape. – Sweeper Aug 18 '20 at 12:21
  • Animation in the first view is starting from left side ... but the one i created is started from top ... please guide me how i can draw a better moon please any pointers ? – Jawad Ali Aug 18 '20 at 12:24
  • Why not using mask like in the example gif. The rounding mask is expanding it's size and position during the other rounding which will be much smoother, than having actual transformational 4 anchor points object which will give you an 90deg corner like your example – Jayr Aug 18 '20 at 14:41
  • @jayr i want to achieve it using path animation sir ... – Jawad Ali Aug 18 '20 at 15:27
  • Actually i am creating a switch library .. where i gave on switch path and off switch path to animate – Jawad Ali Aug 18 '20 at 15:28

2 Answers2

6

As Max stated, Bezier Path interpolation works best when both paths have the same number of points.

This may get you close to what you're going for...

The idea is to split the circle into two arcs - keep one arc constant, and modify the other arc:

To calculate the arcs, we start with a full circle and our desired crescent:

enter image description here

enter image description here

To get the crescent points, we can take the original full circle and shift its center up and to the left (1/3 of the radius gives a decent result):

enter image description here

So, we want to split the circle into two arcs...

enter image description here

Our first arc will start at 301° and go clockwise to 149°:

enter image description here

Which means our second arc will start at 149° and go clockwise to 301°:

enter image description here

The next step is to get the "modified" second arc:

enter image description here

Here our second arc will start at 121° and go counter-clockwise to 329°:

enter image description here

The result:

enter image description here

enter image description here

Here is some example code, with hard-coded angles. I'll leave it up to you to do the math to get the precise angles for varying crescent shapes. Each tap will animate back-and-forth between "sun" and "moon" shapes:

// little degrees-to-radians helper
extension BinaryInteger {
    var degreesToRadians: CGFloat { CGFloat(self) * .pi / 180 }
}

class MoonView: UIView {
    
    private lazy var shapeLayer = CAShapeLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        shapeLayer.fillColor = UIColor.cyan.cgColor

        // to see outline only
        //shapeLayer.fillColor = UIColor.clear.cgColor
        
        shapeLayer.strokeColor = UIColor.red.cgColor
        shapeLayer.lineWidth = 3
        shapeLayer.lineJoin = .round
        
        layer.addSublayer(shapeLayer)
    }
    
    private var isOn: Bool = true
    
    override func layoutSubviews() {
        super.layoutSubviews()
        shapeLayer.frame = bounds
        if isOn {
            shapeLayer.path = drawSunShape(bounds)
        } else {
            shapeLayer.path = drawMoonShape(bounds)
        }
    }
    
    private func drawSunShape(_ group:CGRect) -> CGPath {
        let radius = group.maxX/2
        let center =  CGPoint(x: group.midX, y: group.midY)
        
        let bezierPath = UIBezierPath()
        bezierPath.addArc(withCenter: center, radius: radius, startAngle: 301.degreesToRadians, endAngle: 149.degreesToRadians, clockwise: true)
        bezierPath.addArc(withCenter: center, radius: radius, startAngle: 149.degreesToRadians, endAngle: 301.degreesToRadians, clockwise: true)
        bezierPath.close()
        
        return bezierPath.cgPath
    }
    
    private func drawMoonShape(_ group:CGRect) -> CGPath {
        let radius: CGFloat = group.maxX/2
        
        let centerA =  CGPoint(x: group.midX, y: group.midY)
        let offset = radius * 1.0 / 3.0
        let centerB =  CGPoint(x: centerA.x - offset, y: centerA.y - offset)
        
        let bezierPath = UIBezierPath()
        bezierPath.addArc(withCenter: centerA, radius: radius, startAngle: 301.degreesToRadians, endAngle: 149.degreesToRadians, clockwise: true)
        bezierPath.addArc(withCenter: centerB, radius: radius, startAngle: 121.degreesToRadians, endAngle: 329.degreesToRadians, clockwise: false)
        bezierPath.close()
        
        return bezierPath.cgPath
    }
    func animateShape()  {
        let anim = CABasicAnimation(keyPath: "path")
        if isOn {
            anim.toValue = drawMoonShape(bounds)
        } else {
            anim.toValue = drawSunShape(bounds)
        }
        anim.duration = 0.5
        anim.fillMode = .forwards
        anim.isRemovedOnCompletion = false
        shapeLayer.add(anim, forKey: nil)
        isOn.toggle()
    }
}

class MoonViewController: UIViewController {
    
    let mView = MoonView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .yellow
        
        view.addSubview(mView)
        mView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(mView)
        NSLayoutConstraint.activate([
            mView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            mView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            mView.widthAnchor.constraint(equalToConstant: 200.0),
            mView.heightAnchor.constraint(equalTo: mView.widthAnchor)
        ])
        
        mView.backgroundColor = .white
        
        let t = UITapGestureRecognizer(target: self, action: #selector(self.didTap(_:)))
        view.addGestureRecognizer(t)
    }
    
    @objc func didTap(_ g: UITapGestureRecognizer) -> Void {
        mView.animateShape()
    }

}
DonMag
  • 69,424
  • 5
  • 50
  • 86
4

It matters how you define the two paths. iOS isn't smart enough to automatically create a good-looking transition between two arbitrary paths. The closer the two are in construction the better the transition will be.

For example, if you construct your moon like this

moon path

You could construct your circle like this

circle path

Notice that both shapes have four arcs and the two large arcs that form most of the circle are the same between the two shapes (just rotated a little). Especially if you keep the order of arcs consistent between the two shapes, iOS will do better at creating the animation.

Max
  • 21,123
  • 5
  • 49
  • 71
  • `Especially if you keep the order of arcs consistent between the two shapes` .. did not understand it by seeing the both images ... how i should divide the full circle in four arcs ... mean to say from which angel to the other ... – Jawad Ali Aug 18 '20 at 18:36
  • 1
    @jawadAli if the first arc you draw for the moon is the top-right arc, the first arc you draw for the circle should also be the top-right arc. Hopefully iOS will match up those two arcs and smoothly rotate between them as opposed to deforming some other side of the shape – Max Aug 19 '20 at 12:57
  • i updated my question .. what i achieved so far by your solution ... can you guide me how it can go from center left... currently its animate from bottom to top ... see image in question – Jawad Ali Aug 19 '20 at 16:59