Search code examples
ioscore-animationcaanimation

How to avoid rotation animation jumping at 2π boundary?


I am animating an arrowhead along a CGPath using a CAKeyframeAnimation. The arrowhead follows the path, except for when the output of atan2 'flips' from 0 to 2π or vice versa. In this case, the arrowhead does a complete 360° spin before continuing.

Is there any way to compute the values of my CAKeyframeAnimation so that the arrowhead always rotates the smallest amount to get where it's going?

        let turnAnimation = CAKeyframeAnimation(keyPath: "transform.rotation")
        turnAnimation.duration = 5

        var values = [CGFloat]()
        for i in 0..<points.count - 1 {

            let point1 = points[i]
            let point2 = points[i + 1]
            let delta = point2 - point1
            let value = atan2(delta.y, delta.x)

            values.append(value)
        }

        turnAnimation.values = values
        turnAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        turnAnimation.repeatCount = Float.greatestFiniteMagnitude
        arrowPointLayer.transform = CATransform3DMakeRotation(values.first!, 0, 0, 1)
        arrowPointLayer.add(turnAnimation, forKey: "rotateArrow")

EDIT: Updated based on answer, but still not quite working:

let turnAnimation = CAKeyframeAnimation(keyPath: "transform.rotation")
        turnAnimation.duration = 5

        var rawValues = [CGFloat]()
        var values = [CGFloat]()
        for i in 0..<points.count - 1 {

            let point1 = points[i]
            let point2 = points[i + 1]
            let delta = point2 - point1
            let rawValue = atan2(delta.y, delta.x)

            var value: CGFloat

            if i == 0 {
                value = 0 // we will apply this value from rawValues
            } else {
                let lastValue = rawValues.last!
                value = rawValue - lastValue
            }

            if value < 0 {
                value = .pi * 2 + value
            }

            rawValues.append(rawValue)

            values.append(value)
        }

        for (i, p) in values.enumerated() {
            print("\(rawValues[i]): \(p)")
        }

        turnAnimation.values = values
        turnAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        turnAnimation.repeatCount = Float.greatestFiniteMagnitude
        turnAnimation.isAdditive = true

        arrowPointLayer.transform = CATransform3DMakeRotation(rawValues.first!, 0, 0, 1)
        arrowPointLayer.add(turnAnimation, forKey: "rotateArrow")

Solution

  • Instead of making values a list of absolute rotation values, make it a list of rotational differences and set the animation's isAdditive to true. That way, you are telling the arrow which way to turn and by how much.

    I tested like this (in my test, v is just some view sitting in my interface):

    let turnAnimation = CAKeyframeAnimation(keyPath: "transform.rotation")
    turnAnimation.duration = 5
    let values : [CGFloat] = [0,2,-1,2,-1,3,-3]
    turnAnimation.values = values
    turnAnimation.timingFunction = 
        CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
    turnAnimation.isAdditive = true
    self.v.layer.add(turnAnimation, forKey: "rotateArrow")
    

    The view behaved exactly as I expected.