swiftcocoacore-animation

How to access the calculated small values of animation in Core Animation?


I am using this below code for my animation:

let animation = CABasicAnimation()
animation.keyPath = "position.x"
animation.fromValue = 0
animation.toValue = 300
animation.timingFunction = .init(name: .easeInEaseOut)
animation.duration = 2


nsView.layer?.add(animation, forKey: "basic")

If you look to codes you see in 2sec we are changing from 0 to 300, I want to get access to the calculated values in between like: 0.0, 0.2, 0.3, ..., 300.0 with CABasicAnimation so how can I do this?


Solution

  • For an ongoing animation, you can get its progress through the presentation() layer by accessing the property that you are animating. If you want every frame of the animation, you can hook up a CVDisplayLink (for macOS) or CADisplayLink (for iOS).

    Example code:

    var displayLink: CVDisplayLink!
    //var displayLink: CADisplayLink!
    
    func someFunctionThatTriggersTheAnimation() {
        let animation = CABasicAnimation()
        // let's say we are animating position.x
        animation.keyPath = "position.x"
        animation.fromValue = 0
        animation.toValue = 300
        animation.timingFunction = .init(name: .easeInEaseOut)
        animation.duration = 2
    
        // Please conform self to CAAnimationDelegate!
        animation.delegate = self
        button.layer?.add(animation, forKey: "basic")
    }
    
    // CAAnimationDelegate methods:
    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        CVDisplayLinkStop(displayLink)
    }
    
    func animationDidStart(_ anim: CAAnimation) {
    
        // CVDisplayLink is a bit of a hassle to use in Swift :(
        CVDisplayLinkCreateWithCGDisplay(CGMainDisplayID(), &displayLink)
        withUnsafeMutablePointer(to: &button!) { pointer in
            CVDisplayLinkSetOutputCallback(displayLink, { _, _, _, _, _, buttonPointer in
                DispatchQueue.main.async {
                    print(buttonPointer!.assumingMemoryBound(to: NSButton.self).pointee.layer!.presentation()!.position.x)
                }
                return 1
            }, pointer)
        }
        CVDisplayLinkStart(displayLink)
    }
    
    /*
    CADisplayLink would look like this:
    
    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        displayLink.invalidate()
    }
    
    func animationDidStart(_ anim: CAAnimation) {
        displayLink = CADisplayLink(target: self, selector: #selector(printAnimationProgress))
        displayLink.add(to: .main, forMode: .common)
    }
    
    @objc func printAnimationProgress() {
        print(button.layer.presentation()!.position.x)
    }
    */
    

    That said, CAMediaTimingFunctions are just cubic Bezier curves, so it is possible to calculate the animation progress without actually running the animation too. As you can see from the wikipedia page, the curve are parametrically defined by four control points, which we can get using getControlPoint.

    After that, we can get the equation of the y coordinates of the curve in terms of the parameter t. But t is not time here. Instead, the x coordinate of the curve is the time of the animation. So we need to express t in terms of the x coordinates. We can already express x in terms of t with a cubic equation, using the control points (see equations on Wikipedia), so we just need to solve the equation. This shows you where to start.

    Finally, we just need to plug in the x, which is the time, to get the parameter t, and then plug that into the equation for the y coordinates.

    We can write a function like this:

    func getCallableFunction(fromTimingFunction function: CAMediaTimingFunction) -> (Float) -> Float {
        // Each *pair* of elements in cps store a control point.
        // the elements at even indices store the x coordinates
        // the elements at odd indices store the y coordinates
        var cps = Array(repeating: Float(0), count: 8)
        cps.withUnsafeMutableBufferPointer { pointer in
            for i in 0..<4 {
                function.getControlPoint(at: i, values: &pointer[i * 2])
            }
        }
    
        return { x in
            // assuming the curve doesn't do weird things like loop around on itself, this only has one real root
            // coefficients got from https://pomax.github.io/bezierinfo/#yforx
            // implementation of cubicSolve from https://gist.github.com/kieranb662/b85aada0b1c03a06ad2f83a0291d7243
            let t = Float(cubicSolve(
                a: Double(-cps[0] + cps[2] * 3 - cps[4] * 3 + cps[6]),
                b: Double(cps[0] * 3 - cps[2] * 6 + cps[4] * 3),
                c: Double(-cps[0] * 3 + cps[2] * 3),
                d: Double(cps[0] - x)
            ).first(where: \.isReal)!.real)
            
            // getting y from t, see equation on Wikipedia
            return powf(1 - t, 3) * cps[1] +
                    powf(1 - t, 2) * t * cps[3] * 3 +
                    (1 - t) * t * t * cps[5] * 3 +
                    t * t * t * cps[7]
        }
    }
    

    Note that I used a cubic solver from this gist, which calculates all the roots. Feel free use another, faster, algorithm.

    Example usage for the default timing function:

    let function = getCallableFunction(fromTimingFunction: .init(name: .default))
    for i in stride(from: Float(0), through: 1, by: 0.05) {
        let result = function(i)
        print(result) // this prints a result between 0 and 1
    }
    

    Plotting the results on a graph looks like this:

    enter image description here

    This is very similar to the shape as the graph shown in the documentation of default.

    To apply this to a particular animation, simply linearly scale the time and output of the function. For example, for an animation with duration 2 and animates between 0 and 300:

    let result = function(time / 2) * 300