简体   繁体   中英

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.

Here's a simple example showing how I'd go about implementing a CADisplayLink (in Swift 3):

class C { // your view class or whatever

  private var displayLink: CADisplayLink?
  private var startTime = 0.0
  private let animLength = 5.0

  func startDisplayLink() {

    stopDisplayLink() // make sure to stop a previous running display link
    startTime = CACurrentMediaTime() // reset start time

    // create displayLink & add it to the run-loop
    let displayLink = CADisplayLink(
      target: self, selector: #selector(displayLinkDidFire)
    )
    displayLink.add(to: .main, forMode: .commonModes)
    self.displayLink = displayLink
  }

  @objc func displayLinkDidFire(_ displayLink: CADisplayLink) {

    var elapsed = CACurrentMediaTime() - startTime

    if elapsed > animLength {
      stopDisplayLink()
      elapsed = animLength // clamp the elapsed time to the anim 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 (eg 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 eg 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 .

I realize this question already has a good answer, but here's another slightly different approach that helps in implementing smooth animations independent of the display link frame rate.

**(Link to demo project available at the bottom of this answer - UPDATE: demo project source code now updated to Swift 4)

For my implementation I opted to wrap the display link in it's own class and setup a delegate reference that will get called with the delta time (the time between the last display link call and the current call) so we can perform our animations a little more smoothly.

I'm currently using this method to animate ~60 views around the screen simultaneously in a game.

First we're going to define the delegate protocol that our wrapper will call to notify of update events.

// defines an interface for receiving display update notifications
protocol DisplayUpdateReceiver: class {
    func displayWillUpdate(deltaTime: CFTimeInterval)
}

Next we're going to define our display link wrapper class. This class will take a delegate reference on initialization. When initialized it will automatically start our display link, and clean it up on deinit.

import UIKit

class DisplayUpdateNotifier {

    // **********************************************
    //  MARK: Variables
    // **********************************************

    /// A weak reference to the delegate/listener that will be notified/called on display updates
    weak var listener: DisplayUpdateReceiver?

    /// The display link that will be initiating our updates
    internal var displayLink: CADisplayLink? = nil

    /// Tracks the timestamp from the previous displayLink call
    internal var lastTime: CFTimeInterval = 0.0

    // **********************************************
    //  MARK: Setup & Tear Down
    // **********************************************

    deinit {
        stopDisplayLink()
    }

    init(listener: DisplayUpdateReceiver) {
        // setup our delegate listener reference
        self.listener = listener

        // setup & kick off the display link
        startDisplayLink()
    }

    // **********************************************
    //  MARK: CADisplay Link
    // **********************************************

    /// Creates a new display link if one is not already running
    private func startDisplayLink() {
        guard displayLink == nil else {
            return
        }

        displayLink = CADisplayLink(target: self, selector: #selector(linkUpdate))
        displayLink?.add(to: .main, forMode: .commonModes)
        lastTime = 0.0
    }

    /// Invalidates and destroys the current display link. Resets timestamp var to zero
    private func stopDisplayLink() {
        displayLink?.invalidate()
        displayLink = nil
        lastTime = 0.0
    }

    /// Notifier function called by display link. Calculates the delta time and passes it in the delegate call.
    @objc private func linkUpdate() {
        // bail if our display link is no longer valid
        guard let displayLink = displayLink else {
            return
        }

        // get the current time
        let currentTime = displayLink.timestamp

        // calculate delta (
        let delta: CFTimeInterval = currentTime - lastTime

        // store as previous
        lastTime = currentTime

        // call delegate
        listener?.displayWillUpdate(deltaTime: delta)
    }
}

To use it you simply initialize an instance of the wrapper, passing in the delegate listener reference, then update your animations based on the delta time. In this example, the delegate passes the update call off to the animatable view (this way you could track multiple animating views and have each update their positions via this call).

class ViewController: UIViewController, DisplayUpdateReceiver {

    var displayLinker: DisplayUpdateNotifier?
    var animView: MoveableView?

    override func viewDidLoad() {
        super.viewDidLoad()

        // setup our animatable view and add as subview
        animView = MoveableView.init(frame: CGRect.init(x: 150.0, y: 400.0, width: 20.0, height: 20.0))
        animView?.configureMovement()
        animView?.backgroundColor = .blue
        view.addSubview(animView!)

        // setup our display link notifier wrapper class
        displayLinker = DisplayUpdateNotifier.init(listener: self)
    }

    // implement DisplayUpdateReceiver function to receive updates from display link wrapper class
    func displayWillUpdate(deltaTime: CFTimeInterval) {
        // pass the update call off to our animating view or views
        _ = animView?.update(deltaTime: deltaTime)

        // in this example, the animatable view will remove itself from its superview when its animation is complete and set a flag
        // that it's ready to be used. We simply check if it's ready to be recycled, if so we reset its position and add it to
        // our view again
        if animView?.isReadyForReuse == true {
            animView?.reset(center: CGPoint.init(x: CGFloat.random(low: 20.0, high: 300.0), y: CGFloat.random(low: 20.0, high: 700.0)))
            view.addSubview(animView!)
        }
    }
}

Our moveable views update function looks like this:

func update(deltaTime: CFTimeInterval) -> Bool {
    guard canAnimate == true, isReadyForReuse == false else {
        return false
    }

    // by multiplying our x/y values by the delta time new values are generated that will generate a smooth animation independent of the framerate.
    let smoothVel = CGPoint(x: CGFloat(Double(velocity.x)*deltaTime), y: CGFloat(Double(velocity.y)*deltaTime))
    let smoothAccel = CGPoint(x: CGFloat(Double(acceleration.x)*deltaTime), y: CGFloat(Double(acceleration.y)*deltaTime))

    // update velocity with smoothed acceleration
    velocity.adding(point: smoothAccel)

    // update center with smoothed velocity
    center.adding(point: smoothVel)

    currentTime += 0.01
    if currentTime >= timeLimit {
        canAnimate = false
        endAnimation()
        return false
    }

    return true
}

If you'd like to look through a full demo project you can download it from GitHub here: CADisplayLink Demo Project

The above is the best example for how to use CADisplayLink with efficiency. Thanks to @Fattie and @digitalHound

I could not resist adding my use of CADisplayLink and DisplayUpdater classes by 'digitalHound' in PdfViewer using WKWebView. My requirement was to continue auto scroll the pdf at the user selectable speed.

May be the answer here is not correct place, but I intent to show usage of CADisplayLink here. ( for others like me, who can implement their requirement. )

//
//  PdfViewController.swift
//

import UIKit
import WebKit

class PdfViewController: UIViewController, DisplayUpdateReceiver {

    @IBOutlet var mySpeedScrollSlider: UISlider!    // UISlider in storyboard

    var displayLinker: DisplayUpdateNotifier?

    var myPdfFileName = ""                          
    var myPdfFolderPath = ""
    var myViewTitle = "Pdf View"
    var myCanAnimate = false
    var mySlowSkip = 0.0

    // 0.125<=slow, 0.25=normal, 0.5=fast, 0.75>=faster
    var cuScrollSpeed = 0.25

    fileprivate var myPdfWKWebView = WKWebView(frame: CGRect.zero)

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        self.title = myViewTitle
        let leftItem = UIBarButtonItem(title: "Back", style: .plain, target: self, action: #selector(PdfViewController.PdfBackClick))
        navigationItem.leftBarButtonItem = leftItem

        self.view.backgroundColor = UIColor.white.cgColor
        mySpeedScrollSlider.minimumValue = 0.05
        mySpeedScrollSlider.maximumValue = 4.0
        mySpeedScrollSlider.isContinuous = true
        mySpeedScrollSlider.addTarget(self, action: #selector(PdfViewController.updateSlider), for: [.valueChanged]) 
        mySpeedScrollSlider.setValue(Float(cuScrollSpeed), animated: false)
        mySpeedScrollSlider.backgroundColor = UIColor.white.cgColor

        self.configureWebView()
        let folderUrl = URL(fileURLWithPath: myPdfFolderPath)
        let url = URL(fileURLWithPath: myPdfFolderPath + myPdfFileName)
        myPdfWKWebView.loadFileURL(url, allowingReadAccessTo: folderUrl)
    }

    //MARK: - Button Action

    @objc func PdfBackClick()
    {
        _ = self.navigationController?.popViewController(animated: true)
    }

    @objc func updateSlider()
    {
        if ( mySpeedScrollSlider.value <= mySpeedScrollSlider.minimumValue ) {
            myCanAnimate = false
        } else {
            myCanAnimate = true
        }
        cuScrollSpeed = Double(mySpeedScrollSlider.value)
    }

    fileprivate func configureWebView() {
        myPdfWKWebView.frame = view.bounds
        myPdfWKWebView.translatesAutoresizingMaskIntoConstraints = false
        myPdfWKWebView.navigationDelegate = self
        myPdfWKWebView.isMultipleTouchEnabled = true
        myPdfWKWebView.scrollView.alwaysBounceVertical = true
        myPdfWKWebView.layer.backgroundColor = UIColor.red.cgColor //test
        view.addSubview(myPdfWKWebView)
        myPdfWKWebView.topAnchor.constraint(equalTo: topLayoutGuide.bottomAnchor ).isActive = true
        myPdfWKWebView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
        myPdfWKWebView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
        myPdfWKWebView.bottomAnchor.constraint(equalTo: mySpeedScrollSlider.topAnchor).isActive = true
    }

    //MARK: - DisplayUpdateReceiver delegate

    func displayWillUpdate(deltaTime: CFTimeInterval) {

        guard myCanAnimate == true else {
            return
        }

        var maxSpeed = 0.0

        if cuScrollSpeed < 0.5 {
            if mySlowSkip > 0.25 {
                mySlowSkip = 0.0
            } else {
                mySlowSkip += cuScrollSpeed
                return
            }
            maxSpeed = 0.5
        } else {
            maxSpeed = cuScrollSpeed
        }

        let scrollViewHeight = self.myPdfWKWebView.scrollView.frame.size.height
        let scrollContentSizeHeight = self.myPdfWKWebView.scrollView.contentSize.height
        let scrollOffset = self.myPdfWKWebView.scrollView.contentOffset.y
        let xOffset = self.myPdfWKWebView.scrollView.contentOffset.x

        if (scrollOffset + scrollViewHeight >= scrollContentSizeHeight)
        {
            return
        }

        let newYOffset = CGFloat( max( min( deltaTime , 1 ), maxSpeed ) )
        self.myPdfWKWebView.scrollView.setContentOffset(CGPoint(x: xOffset, y: scrollOffset+newYOffset), animated: false)
    }

}

extension PdfViewController: WKNavigationDelegate {
    // MARK: - WKNavigationDelegate
    public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
        //print("didStartProvisionalNavigation")
    }

    public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        //print("didFinish")
        displayLinker = DisplayUpdateNotifier.init(listener: self)
        myCanAnimate = true
    }

    public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
        //print("didFailProvisionalNavigation error:\(error)")
    }

    public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
        //print("didFail")
    }

}

Example Calling from another view is as under.

To load the PDF file from Document folder.

func callPdfViewController( theFileName:String, theFileParentPath:String){
    if ( !theFileName.isEmpty && !theFileParentPath.isEmpty ) {
        let pdfViewController = self.storyboard!.instantiateViewController(withIdentifier: "PdfViewController") as? PdfViewController
        pdfViewController?.myPdfFileName = theFileName
        pdfViewController?.myPdfFolderPath = theFileParentPath
        self.navigationController!.pushViewController(pdfViewController!, animated: true)
    } else {
        // Show error.
    }
}

This example may be 'modified' to load web page and auto-scroll them at user selected speed.

Regards

Sanjay.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM