在Swift自定义动画中,如何正确处理、清理、等待CADisplayLink?

21

考虑使用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:
    ????!!!!
    }

似乎存在各种问题。

在(A:)处,即使link不为空,也可能无法将其从运行循环中删除。(例如,有人可能使用link = link:CADisplayLink()进行初始化-尝试会引起崩溃)

其次,在(B:)处,情况似乎很混乱...肯定有更好(且更符合Swift风格)的方法,如果时间刚好过期,又该怎么办?

最后,在(C:)处,如果想要中断动画...我感到沮丧,不知道什么是最好的选择。

而且,实际上,A:和B:处的代码应该是相同的调用,这是一种清理调用。

3个回答

48
这是一个简单的例子,展示了如何使用Swift 5实现CADisplayLink

以下是具体实现:

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
    }
}

需要注意的要点:

  • 我们在这里使用nil来表示显示链接未运行的状态,因为没有简单的方法可以从无效的显示链接中获取此信息。
  • 我们使用invalidate()而不是removeFromRunLoop(),如果显示链接尚未添加到运行循环中,则不会崩溃。但是,在创建显示链接后,我们总是立即将其添加到运行循环中,因此不应该出现这种情况。
  • 我们将displayLink设置为私有,以防止外部类将其置于意外状态(例如使其失效但未将其设置为nil)。
  • 我们有一个单独的stopDisplayLink()方法,既使显示链接失效(如果非空),又将其设置为nil,而不是复制和粘贴此逻辑。
  • 我们在失效显示链接之前不将paused设置为true,因为这是多余的。
  • 我们没有在检查非空后强制解包displayLink,而是使用可选链接,例如displayLink?.invalidate()(如果显示链接不为空,则调用invalidate())。虽然在您给定的情况下强制解包可能是“安全”的(因为您正在检查nil),但在未来重构时可能是不安全的,因为您可能会重新构造逻辑而没有考虑强制解包的影响。
  • 我们将elapsed时间限制在动画持续时间内,以确保后续动画逻辑不会产生超出预期范围的值。
  • 我们的更新方法displayLinkDidFire(_:)采用CADisplayLink类型的单个参数,根据文档的要求。

谢谢... "因为没有简单的方法从无效的显示链接中获取此信息。" ... 嗯,这似乎是一个关键点。我现在正在仔细阅读你的答案,谢谢。顺便问一下,你认为现在还值得展示Swift2之前的代码吗?也许最好将其删除... :O - Fattie
1
@JoeBlow 虽然如此,根据您的确切用例,如果您希望封装此功能,您可以始终创建显示链接包装器 - 这将确保您有一种与底层显示链接交互的方式,并因此允许您编写自己的自定义逻辑。 - Hamish
1
@JoeBlow 不,invalidate()会从所有运行循环模式中移除给定的显示链接,这将导致它从运行循环中释放。 - Hamish
@Hamish:不用谢!实际上,这段代码没有编译成功,可能是因为它来自Swift 3.0 beta。(注意到你在http://stackoverflow.com/a/44098377/1187415中的回答时发现的。) - Martin R
@MartinR 是的,看起来是这样,或者至少在他们为Swift 3更新API之前是这样。 - Hamish
显示剩余5条评论

5
我知道这个问题已经有了一个很好的答案,但这里提供另一种略微不同的方法,可以帮助实现平滑动画,与显示链接帧速率无关。
(演示项目链接在此答案底部 - 更新:演示项目源代码现已更新为Swift 4)
为了实现我的方案,我选择将显示链接封装在自己的类中,并设置一个代理引用,该引用将使用增量时间(上次显示链接调用和当前调用之间的时间)进行调用,以便我们可以更平滑地执行动画。
目前我正在使用此方法在游戏中同时使约60个视图沿着屏幕移动。
首先,我们将定义代理协议,我们的封装器将调用它以通知更新事件。
// defines an interface for receiving display update notifications
protocol DisplayUpdateReceiver: class {
    func displayWillUpdate(deltaTime: CFTimeInterval)
}

接下来,我们将定义显示链接包装器类。该类将在初始化时接收一个委托引用。在初始化时,它将自动启动我们的显示链接,并在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)
    }
}

要使用它,您只需初始化封装器的实例,并传入委托监听器引用,然后根据增量时间更新动画。在这个示例中,委托将更新调用传递给可动画视图(这样您可以跟踪多个动画视图,并使每个视图通过此调用更新其位置)。

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!)
        }
    }
}

我们的可移动视图更新函数如下所示:
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
}

如果您想查看完整的演示项目,可以从GitHub这里下载:CADisplayLink演示项目

0

以上是如何高效使用CADisplayLink的最佳示例。感谢@Fattie和@digitalHound。

我无法抗拒在PdfViewer中使用WKWebView添加我的CADisplayLink和DisplayUpdater类的用途,由数字猎犬提供。 我的要求是以用户可选择的速度继续自动滚动pdf。

也许这里的答案不是正确的地方,但我打算在这里展示CADisplayLink的用法。(对于像我这样的其他人,可以实现他们的要求。)

//
//  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")
    }

}

从另一个视图调用的示例如下。

从文档文件夹加载PDF文件。

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.
    }
}

这个例子可以被“修改”以在用户选择的速度下加载网页并自动滚动。

敬礼

桑杰。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接