CADisplayLink 的时间动画和空间动画

1,666 阅读4分钟

本文的标题,全文:

CADisplayLink 基于时间的动画效果,和

CADisplayLink 基于位置的动画效果

引子:

小灰面试小白,

问: "以前美团的 tabbar , 有一个点击选项卡,选项卡拱起"

“就是矩形局部拱起的效果,怎么实现”

小白:“Open GL, 研究没这么深,再见”

小灰: "使用 CADisplayLink + 子定制绘图,也可以做 "

小灰: "面试的开发者,把没思路的技术,归结于他没学过的。杯具"

基于时间的 CADisplayLink 动画效果, 局部拱起

例子: 矩形框,分左右。点击哪一边,那边拱起来

如下图: 左边拱起来,效果

截屏2021-09-28 下午5.22.51.png

辅助方法: 识别点击的哪一边

设置背景色为透明

因为动画是基于绘图 drawRect

@objc func tap(with gesture: UITapGestureRecognizer){

        guard !jelly.animating else{ return }

        let point = gesture.location(in: partial)

        let width = partial.frame.width

        let isRight = point.x > width / 2

        let idx = isRight ? 1 : 0
        
        
        partial.backgroundColor = UIColor.clear

        partial.startAnimation(idx)

    }

主要逻辑

两个辅助状态保存类,省略了,

具体见 github repo

  • drawRect 绘图,只可在 bounds 里面,

所以 view 要预先留一个空档

可通过上文的边框线,理解

  • CADisplaylink 使用的间隔时间,是屏幕的帧率

不可手动设置

就要把基于时间的动画,其持续时间,转化为一定的次数

( 从 from 的状态, 到 to 的状态 )

设置精确的 duration ,还要保证动画平顺,有难度

这里 total = time * 20 , 给予了一定的次数,消费完,就结束

具体见 github repo

  • 局部拱起,是基于时间的

( 切题 )

随着时间变化,越拱越高

progress 值,基于时间,

算出控制点的位置

本文的动画,曲线和动画,都不平滑

调参与细节优化,略

class Partial: UIView {

    var animating = false

    var timer: CADisplayLink!


    let selectInfo = SelectUtil()


    let helper: Helper

    // 省略了一个初始化方法
    
    
    // 可看出,一开始,计时器 `CADisplayLink`就一直在跑
    
    // 出于 demo 简化

    required init?(coder: NSCoder) {

        helper = Helper(lasting: 3)

        super.init(coder: coder)

        timer = CADisplayLink(target: self, selector: #selector(Partial.tick))

        timer.add(to: RunLoop.current, forMode: RunLoop.Mode.default)

    }

    
    // 传入的是,拱起哪一边
    func startAnimation(_ idx: Int){
        // 选中的那一边,重复点击无效
        guard selectInfo.selectedIndex != idx else { return }

        if selectInfo.selectedIndex != nil{
            // 已经有一边供起
            // 例如左边拱起
            // 需要先左下,再右上
            selectInfo.needsReset = true

        }
     
        animating = true

        selectInfo.selectedIndex = idx

        helper.reset()

    }

    

    

    // Only override draw() if you perform custom drawing.

    // An empty implementation adversely affects performance during animation.

    override func draw(_ rect: CGRect) {

        // Drawing code



        let height = rect.height

        let topY: CGFloat = 100



        // 绘图部分,控制先下后上
        var progress = helper.progressPositive

        if selectInfo.needsReset == true{

            progress = helper.progressNegative

        }

        let deltaHeight = -1 * topY * progress

        

        // print("delta: \(deltaHeight)")

        
        // 这个 topY 有意思
        // 因为发现,绘制只能在 bounds 里面
        let topLeft = CGPoint(x: 0, y: topY)

        let topMid = CGPoint(x: rect.width / 2, y: topY)

        let topRight = CGPoint(x: rect.width, y: topY)

        

        let fourthLhs = rect.width / 4

        let fourthRhs = rect.width * 3 / 4

        

        

        let bottomLeft = CGPoint(x: 0, y: height)

        let bottomRight = CGPoint(x: rect.width, y: height)

        let path = UIBezierPath()

        UIColor.blue.setFill()

        path.move(to: topLeft)
        
        switch selectInfo.currentIndex{

        case 0:
            // 左边拱起
            path.addQuadCurve(to: topMid, controlPoint: CGPoint(x: fourthLhs, y: deltaHeight))

        case 1:
            // 右边拱起
            path.addLine(to: topMid)

            path.addQuadCurve(to: topRight, controlPoint: CGPoint(x: fourthRhs, y: deltaHeight))

        default:

            ()

        }

        path.addLine(to: topRight)

        path.addLine(to: bottomRight)

        path.addLine(to: bottomLeft)

        path.close()

        path.fill()

        

    }

    

    

    
    // 计时器的方法
    @objc func tick(){

        guard animating else {   return   }

        
        // 例如左下右上,
        // 这里设置了,左下后的一个间隔
        guard selectInfo.intervals <= 0 else {

            selectInfo.intervals -= 1

            return

        }

            

            
        // 判断一个阶段的完成
        guard helper.count >= 0 else {

            if selectInfo.needsReset == true{
                // 第一阶段完成,左下
                selectInfo.needsReset = false

                helper.reset()

            }

            else{
                // 阶段都完成了
                // 只有一个阶段
                // 左下右上的第二个阶段
                animating = false

            }

            selectInfo.lastSelectedIndex = selectInfo.selectedIndex

            return

        }

        helper.count -= 1

        setNeedsDisplay()

    }



}
小节: CADisplayLink 适合搞动画

A CADisplayLink object is a timer object that allows your application to synchronize its drawing to the refresh rate of the display.

系统渲染每一帧的时候, 我们的方法会被调用,

保证动画的流畅性

基于位置的动画, 果冻效果

调用部分

这个就是位移动画,给出 view 的开始位置和结束位置

UIView.animate 配合 CADisplayLink 使用

基于位置,切题

UIView.animate 有初始位置 from 和 结束位置 to

在这个过程中,每一次绘制, progress ( 控制点的位置 ), 都是明确的

每一次绘制, progress ( 控制点的位置 ), 都是与当时的坐标相关

保证了动画完成,变形结束

可理解为,如果位移和果冻变形动画,是一段明确的视频

我们看到的,是那段视频的一些采样帧

@IBAction func toAnimate(_ sender: Any) {

        

        guard !jelly.animating else{ return }

        let from = view.bounds.height - jelly.bounds.height / 2

        let to: CGFloat = 100
        // 同样的背景色,设置
        jelly.backgroundColor = UIColor.clear

        jelly.center = CGPoint(x: jelly.center.x, y: from)

        jelly.startAnimation(from, to)

        UIView.animate(withDuration: 3, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0, options: []) {

            self.jelly.center = CGPoint(x: self.jelly.center.x, y: to)

        } completion: { _ in

            self.jelly.completeAnimation()

        }

    }

实现代码

省略了辅助类,具体见 github

下面的代码,实现逻辑与上面的一致,

较容易理解


class JellyView: UIView {


    // ... 

    var animating = false

    

    var helper: Helper?

    // 开始动画,就初始化
    // 计时器方法,就是不停的刷新界面
    // 调用绘图方法 `drawRect`

    func startAnimation(_ from: CGFloat, _ to: CGFloat){

        animating = true

        helper = Helper(displayLink: CADisplayLink(target: self, selector: #selector(JellyView.tick)), from: from, to: to)

    }

    

    // 完成就销毁

    func completeAnimation(){

        animating = false

        helper?.displayLink.invalidate()

        helper = nil

    }

    

    

    

    // Only override draw() if you perform custom drawing.

    // An empty implementation adversely affects performance during animation.

    override func draw(_ rect: CGRect) {

        // Drawing code

        guard let info = helper, let layer = layer.presentation() else { return }

        

        var progress: CGFloat = 1

        if animating{

            progress = 1 - (layer.position.y - info.to) / (info.len)

        }

        let height = rect.height

        let deltaHeight = height * (1 - abs(progress)) * 0.6

        

        // print("delta: \(deltaHeight)")

        

        let topLeft = CGPoint(x: 0, y: deltaHeight)

        let topRight = CGPoint(x: rect.width, y: deltaHeight)

        let bottomLeft = CGPoint(x: 0, y: height)

        let bottomRight = CGPoint(x: rect.width, y: height)

        let path = UIBezierPath()

        UIColor.blue.setFill()

        path.move(to: topLeft)

        path.addQuadCurve(to: topRight, controlPoint: CGPoint(x: rect.midX, y: 0))

        path.addLine(to: bottomRight)

        path.addQuadCurve(to: bottomLeft, controlPoint: CGPoint(x: rect.midX, y: height - deltaHeight))

        path.close()

        path.fill()

        

    }

    
    @objc func tick(){

        setNeedsDisplay()

    }


}

github repo