Search code examples
swiftanimationcadisplaylink

Correct handling / cleanup / etc of CADisplayLink in Swift custom animation?


Consider this trivial sync animation using CADisplayLink,

var link:CADisplayLink?
var startTime:Double = 0.0
let animTime:Double = 0.2
let animMaxVal:CGFloat = 0.4

private func yourAnim()
    {
    if ( link != nil )
        {
        link!.paused = true
        //A:
        link!.removeFromRunLoop(
          NSRunLoop.mainRunLoop(), forMode:NSDefaultRunLoopMode)
        link = nil
        }

    link = CADisplayLink(target: self, selector: #selector(doorStep) )
    startTime = CACurrentMediaTime()
    link!.addToRunLoop(
      NSRunLoop.currentRunLoop(), forMode:NSDefaultRunLoopMode)
    }

func doorStep()
    {
    let elapsed = CACurrentMediaTime() - startTime

    var ping = elapsed
    if (elapsed > (animTime / 2.0)) {ping = animTime - elapsed}

    let frac = ping / (animTime / 2.0)
    yourAnimFunction(CGFloat(frac) * animMaxVal)

    if (elapsed > animTime)
        {
        //B:
        link!.paused = true
        link!.removeFromRunLoop(
          NSRunLoop.mainRunLoop(), forMode:NSDefaultRunLoopMode)
        link = nil
        yourAnimFunction(0.0)
        }
    }

func killAnimation()
    {
    // for example if the cell disappears or is reused
    //C:
    ????!!!!
    }

There seems to be various problems.

At (A:), even though link is not null, it may not be possible to remove it from a run loop. (For example, someone may have initialized it with link = link:CADisplayLink() - try it for a crash.)

Secondly at (B:) it seems to be a mess ... surely there's a better (and more Swift) way, and what if it's nil even though the time just expired?

Finally in (C:) if you want to break the anim ... I got depressed and have no clue what is best.

And really the code at A: and B: should be the same call right, kind of a clean-up call.


Solution

  • Here’s a simple example showing how I’d go about implementing a CADisplayLink (in Swift 5):

    class C { /// your view class or whatever
        
        private var displayLink: CADisplayLink?
        private var startTime = 0.0
        private let animationLength = 5.0
        
        func startDisplayLink() {
            
            stopDisplayLink() /// make sure to stop a previous running display link
            startTime = CACurrentMediaTime() // reset start time
            
            /// create displayLink and add it to the run-loop
            let displayLink = CADisplayLink(target: self, selector: #selector(displayLinkDidFire))
            displayLink.add(to: .main, forMode: .common)
            self.displayLink = displayLink
        }
        
        @objc func displayLinkDidFire(_ displayLink: CADisplayLink) {
            
            var elapsedTime = CACurrentMediaTime() - startTime
            
            if elapsedTime > animationLength {
                stopDisplayLink()
                elapsedTime = animationLength /// clamp the elapsed time to the animation length
            }
            
            /// do your animation logic here
        }
        
        /// invalidate display link if it's non-nil, then set to nil
        func stopDisplayLink() {
            displayLink?.invalidate()
            displayLink = nil
        }
    }
    

    Points to note:

    • We’re using nil here to represent the state in which the display link isn’t running – as there’s no easy way of getting this information from an invalidated display link.
    • Instead of using removeFromRunLoop(), we’re using invalidate(), which will not crash if the display link hasn’t already been added to a run-loop. However this situation should never arise in the first place – as we’re always immediately adding the display link to the run-loop after creating it.
    • We’ve made the displayLink private in order to prevent outside classes from putting it in an unexpected state (e.g invalidating it but not setting it to nil).
    • We have a single stopDisplayLink() method that both invalidates the display link (if it is non-nil) and sets it to nil – rather than copy and pasting this logic.
    • We’re not setting paused to true before invalidating the display link, as this is redundant.
    • Instead of force unwrapping the displayLink after checking for non-nil, we’re using optional chaining e.g displayLink?.invalidate() (which will call invalidate() if the display link isn’t nil). While force unwrapping may be ‘safe’ in your given situation (as you’re checking for nil) – it’s potentially unsafe when it comes to future refactoring, as you may re-structure your logic without considering what impact this has on the force unwraps.
    • We’re clamping the elapsed time to the animation duration in order to ensure that the later animation logic doesn’t produce a value out of the expected range.
    • Our update method displayLinkDidFire(_:) takes a single argument of type CADisplayLink, as required by the documentation.