关于 iOS 中的动画

苹果中的动画采用的是 "按需求播放" 这样的形式, 即不需要自己计算许多参数, 只需要提供如何动画的要求, 系统自动去计算相关的参数.

需要将动画看作是用户交互的一种反馈或提示, 而不是简单的效果而已.

1 绘图, 动画和线程

绘图和动画是相辅相成的, 即当提供绘制指令后, 系统并不立即执行, 而是等到一个绘制时机统一执行, 这个时机称为 redraw moment.

动画和绘图的执行是一个道理. 动画拥有帧(frame)的概念, 即动画是由一张一张的帧组成的.

苹果将执行动画的系统组件称为 animation server.

动画是在用户和真实的屏幕显示中间插入了一段"电影"画面, 当动画结束后, 这个电影画面也就从屏幕上移除了, 然后恢复真实屏幕的显示. 但用户不会察觉到这点, 因为当动画结束后, 真实屏幕上的绘制也会变为和动画结束时的状态一样, 但结束状态需要程序员来保证其正确性.

一个简单 view 动画流程如下:

  1. 将 view 由位置 1 移动到位置 2, 由于没有到 redraw 时机, 故现在屏幕没有任何变化.
  2. 提出一个动画请求, 动画的内容是 view 从位置 1 移动到位置 2. 由于没有到 redraw 时机, 故屏幕也没有任何变化.
  3. 系统将所有代码执行完毕后, 出现空闲时机, 即 redraw 时机.
  4. 在 redraw 时机后, 系统将动画进行播放.
  5. 动画结束后, view 也和动画的最后一帧的状态一致.

需要对动画过程有正确认识: 动画只是在真实屏幕上的一层"电影"效果. 不过实际上并不是真的存在"电影"效果, 只是为了方便理解.

真实的情况是, 在进行动画时, 并非现有的 layer 在进行动画, 而是单独的一个 presentation layer, 在这个图层上显示动画的每一帧效果.

layer 的 presentation layer 可以通 presentation 方法来访问, 而 presentation layer 对应的 layer 可以通过 presentation layer 的 model 属性来获取.

另外 Animation server 组件是自动在单独线程执行的, 所以不用担心线程管理问题. 但需要对自己的界面终态进行操作, 以符合动画终态.

在动画结束事件到达时, 可以在对应的方法中去开始下一个动画, 或者是去做一些清理工作.

2 ImageView 和 Image 动画

绘图也是首先讲的 Image 绘制, 这里就先来看 Image 的动画.

给 UIImageView 的 animationImageshighlightedAnimationImages 属性设置一个 UIImage 数组, 这个数组代表的就是动画的每一帧, 然后向 UIImageView 发送 startAnimating 消息, 它就开始动画. 动画时根据 animationDuration 的设置来决定如何计算帧的出现时间. 默认情况下动画是无限循环的, 可以通过 animationRepeatCount 来设置循环次数. 还可通过 stopAnimating 消息来结束动画. 在动画开始前和结束后, 它都是在显示 image 属性或 highlightedImage 属性对应的图片.

有一个技巧就是通过图片上下文绘制若干张图片保存到数组中, 然后将这个数组赋值给 UIImageView 的动画图片数组, 让 UIImageView 来进行动画.

3 View 动画

所有的动画本质上都是图层动画. 只是在系统中为 UIView 提供了一些属性, 可以方便直接通过 UIView 进行动画.

对 View 进行动画的方式目前有三种:

  • 开始--提交方式: 很少使用
  • 动画块方式: 当前最常用, 除了需要重复进行固定次数循环动画的情况.
  • 属性动画器: iOS 10 之后新增. 它不是来替代动画块的, 而是对动画块的扩展和补充. 这个应该会在未来慢慢推广使用.

下面就来看一些动画基础.

对于 View 而言, 在动画块包裹中的所有内容, 只要是对可动画属性的修改, 就会生成动画. 并且由于是直接在操作诸如位置等属性, 故视图的终态就和动画终态是一致的. 但这里有一个问题, 如果使用约束的话, 约束没有改变, 则在未来的任何时候, 如果重新布局, 则界面中的状态会回到初态, 所以约束布局的情况下需要单独在动画时对约束进行处理, 即重新定义约束后, 再在动画块中调用 view 的 layoutIfNeeded 方法.

如果想要动画块中的某些可动画属性改变不会计入动画, 则可以调用 UIView 的类方法 performWithoutAnimation, 将这些代码写到其中即可.

当进行重复动画时, 指定重复次数需要一些技巧. 默认情况下 UIView 的动画如果指定重复选项, 动画是永远重复的.

不过可以通过在动画块中通过 UIView 的类方法 setAnimationRepeatCount 设置次数. 即不指定 repeat 选项的情况下使用这个方法来指定重复次数.

弹性动画的两个参数:

  • Damping ratio: 取值0到1, 描述的是最终的震荡效果, 值越小, 最终晃动越大, 0.8 是比较合适的数值.
  • Initial velocity: 初速度, 值越大的话, 则动画将要结束时, 到达终点后偏离终点的距离越大. 一般设置为0, 看需要什么效果来定.

4 取消 View 动画

动画在进行过程中如何停止动画?

在 iOS 10 引入属性动画器之前, 往往都是将动画从图层中移除的方式来停止动画.

但这个方法的缺点很明显: 移除动画后, 正在进行的动画就生硬地没了, 视图也直接到达终态.

故取消动画的一个正确做法(或者说是普遍做法)是让动画加速行进至终态. 这正是动画叠加的绝佳应用场合.

但之前的动画是在行进过程中, 只有先将所有动画取消, 但这样会导致视图直接处于终态. 如果可以获取到在取消时刻的动画状态, 然后取消之前动画, 再插入一个新的加速动画, 则可以将这个问题解决.

而上述方法的核心就是利用 presentation layer, 将视图在取消时刻的状态和当前 presentation layer 的状态设置为一致, 然后再开始新加速后的动画.

流程即:

  1. 获取视图根图层的 presentation layer 的当前某个需要的状态, 然后将其赋值给视图的根图层.
  2. 将视图动画(在根图层上的动画)取消掉.
  3. 新建一个动画, 该动画的终态是原来的终态, 但时间缩短很多.
    • 如果动画取消的上下文意味着动画回到原来位置, 则将这个动画的终态设置为视图初始状态即可.
    • 另外如果取消在当前的意思是停在当前位置, 则就不加新建的这个动画.)

这样的效果就可以很平滑了.

    private func cancelSprintAnimation() {
        let presentationLayer = self.aView.layer.presentation()!
        // 将进行动画的视图状态赋值为其 presentationLayer 的当前状态, 这里是 position 
        self.aView.layer.position = presentationLayer.position
        // 移除之前的动画
        self.aView.layer.removeAllAnimations()
        print(aView.center)
        // 开始新的加速动画, 这里是加速到最终位置.
        UIView.animate(withDuration: 0.1) {
            self.aView.center.y = self.view.bounds.height / 2.0
        }
    }

    private func performSpringAnimation() {
        print("进行弹性动画")
        UIView.animate(withDuration: 2.0, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: [], animations: {
            self.aView.center.y = 450
        })
    }

上面设置 layer 的属性为 presentation layer 的当前属性, 因为动画实际是 presentation layer 在进行的.

取消 repeat 动画

重复动画取消的话, 也是采用加速到终态或加速回到初态. 或保持在当前状态.

因为重复动画不会被其他动画叠加, 所以在重复动画上添加动画实际就自动把重复动画取消了. 然后利用 beginFromCurrentState 选项, 即可做到之前的平滑取消动画.

    private func performRepeatAnimation() {
        UIView.animate(withDuration: 2.0, delay: 0, options: [.repeat, .autoreverse], animations: {
            self.aView.center.y = 600
        }, completion: nil)
    }

    private func cancelRepeatAnimation() {// 取消重复动画
        UIView.animate(withDuration: 0.1, delay: 0, options: [.beginFromCurrentState], animations: {
            self.aView.center.y = self.view.frame.height / 2.0
        }, completion: nil)
    }

取消重复动画时, 如果下一个加速动画的终态是和重复终态相同的话, 则有 bug... 取消动画没有被执行, 故需要加速动画的终态不和重复终态一样, 比如下面的本来需要 600, 这里设置 600.000001:

    private func cancelRepeatAnimation() {
        UIView.animate(withDuration: 0.1, delay: 0, options: [.beginFromCurrentState], animations: {
            self.aView.center.y = 600.000001
        }, completion: nil)
    }

如果想要的效果是在当前位置停下, 则也是获取 presentation layer 的当前参数, 然后在动画块中赋值给 layer 即可.

可以使用一个视图属性将若干可动画属性进行改变, 然后进行动画, 这个先了解一下.

5 帧动画

原理都一样, 不过可以通过帧动画来组装一些复杂的动画效果.

6 转变动画

transition 动画表示的是视图内容的改变, 有两种:

  • transition(with:duration:options:animations:completion:): 对一个视图的内容改变进行动画, 提供的选项详见文档.
  • transition(from:to:duration:options:completion:): 将 fromView 替换为 toView.

7 隐式 Layer 动画

只有当 Layer 布局并显示到界面上后, 改变它的可动画属性才会出现动画. 另外根 layer 只能进行显式动画.

layer 的隐式动画都是在 CATransaction 的上下文中执行的. 另外总是有一个不可见的 CATransaction "包围着"代码, 故可以不用调用 CATransaction 的 begin 和 commit 也能改变动画的一些属性, 比如:

CATransaction.setAnimationDuration(0.8)
arrow.transform = CATransform3DRotate(arrow.transform, .pi/4.0, 0, 0, 1)

8 核心动画

下面正式进入 Core Animation 的内容.

CA 动画就是指的显式图层动画, 主要使用 CAAnimation 和它的子类来实现动画.

在使用 CA 进行动画时, 视图的终态不会自动设置, 需要手动设. 不然视图在动画完成后会回到原位置, 因为动画是在 layer 上进行的.

8.1 CABasicAnimation

CA 的使用方式是: 创建动画对象(CAAnimation子类型), 然后将它添加到 layer 上即可. 添加时需要一个 key, 这个key用于标志唯一的动画.

添加了动画对象后, layer 就开始动画(redraw 时机), 但动画结束后被移除掉, 此时 layer 的状态又会恢复到动画前, 故需要手动修改终态.

使用 CA 动画的基本模式是:

  1. 获取 layer 的某个当前动画对应的属性的初始值和终值.
  2. 将 layer 的属性改变为终值, 如果不想隐式动画起作用, 需要调用 setDisableActions(true).
  3. 创建 CA 动画, 在其中指定需要动画的属性
  4. 将动画添加到 layer 上.

显式动画被添加到 layer 时, 实际添加的是它的不可变副本.

8.2 CAKeyFrameAnimation

这个是图层帧动画类

8.3 CASpringAnimation

这个是图层弹性动画类.

9 动画组

可以把一组动画通过 CAAnimationGroup 组合在一起, 每个单独的动画都添加到它的 animation 属性中, 通过动画的延迟和时长来决定动画的执行顺序, 从而可以完成许多复杂的效果.

CAAnimationGroup 本身就是 CAAnimation 的子类, 可以把它当成是一个父动画, 可以在其中添加子动画. 其中的子动画会继承它的一些默认属性值(如果子动画没有设置的话).

下面来实现一个动船的动画:

第一个动画: 图层沿指定曲线路径运动.

func createAnim1() -> CAAnimation {
    let areaHeight: CGFloat = 200
    let verticalSpace: CGFloat = 7
    let path = CGMutablePath()
    var leftright: CGFloat = 1 // 表示现在是朝左还是朝右, 左为 -右为 1.
    // 以下代码生成需要的路径
    var next: CGPoint = self.view.layer.position   // 路径起点
    var pos: CGPoint
    path.move(to: CGPoint(next.x, next.y))
    for _ in 0 ..< 4 {
        pos = next
        leftright *= -1
        next = CGPoint(pos.x + areaHeight * leftright, pos.y verticalSpace)
        path.addCurve(to: CGPoint(next.x, next.y),
                      control1: CGPoint(pos.x, pos.y + 30),
                      control2: CGPoint(next.x, next.y - 30))
    }
    endPoint = next // 记录最终点的位置.
    // 动画1: 将图层的 position 沿着曲线运动. 这个动画添加到任何图层上都适用.
    let anim1 = CAKeyframeAnimation(keyPath: #keyPa(CALayer.position))
    anim1.path = path
    anim1.calculationMode = kCAAnimationPaced
    return anim1
  }

第二个动画: 船需要在转弯的时候同时翻转, 否则看起来就不正常了. 翻转时直接沿着 Y 轴旋转即可. 第二个动画需要和第一个动画配合, 当第一个动画中小船每次处于曲线的顶点时, 都需要对其进行翻转, 如果遇到比较复杂的情况, 则需要定义 keyTimes 数组, 让两个动画可以协作.

func createAnim2() -> CAAnimation {
    let revs = [0.0, .pi, 0.0, .pi]
    let anim2 = CAKeyframeAnimation(keyPath:#keyPath(CALayer.transform))
    anim2.values = revs
    anim2.valueFunction = CAValueFunction(name:kCAValueFunctionRotateY)
    anim2.calculationMode = kCAAnimationDiscrete
    return anim2
}

第三个动画: 小船的重复震动效果, 模拟的是风雨飘摇.

func createAnim3() -> CAAnimation {
    let pitches = [0.0, .pi/60.0, 0.0, -.pi/60.0, 0.0]
    let anim3 = CAKeyframeAnimation(keyPath:#keyPath(CALayer.transform))
    anim3.values = pitches
    anim3.repeatCount = .infinity
    anim3.duration = 0.5
    anim3.isAdditive = true
    anim3.valueFunction = CAValueFunction(name:kCAValueFunctionRotateZ)
    return anim3
}

最后通过动画组将三个动画组合, 然后将动画组应用到小船图层上.

private func addAnimToBoatImageViewLayer() {
    let animGroup = createAnimGroup()
    boatImageView.layer.add(animGroup, forKey: nil)
    CATransaction.setDisableActions(true)
    boatImageView.layer.position = endPoint ?? .zero
}

利用 CAAnimationGroup 可以实现许多复杂的动画效果, 这里看到的小船动画就是一个.

10 关于动画冻结

可以不把动画取消掉, 而是在某个位置将动画冻结, 这样在未来的某个时候, 可以手动继续开始动画.

由于 CALayer 有一个 speed 属性, 如果将它改为 0, 就可以把动画冻结. 另外还有一个 timeOffset 属性, 可以控制显示动画的任意一帧. 这两个属性结合后, 就可以实现动画按 timeOffset 的值来动态控制显示了.

    func increase() {
        guard shape.timeOffset + 0.1 <= 1 else { return }
        shape.timeOffset += 0.1
    }

    func decrease() {
        guard shape.timeOffset - 0.1 >= 0 else { return }
        shape.timeOffset -= 0.1
    }

    func setupAnims() {
        shape.frame = bounds
        layer.addSublayer(shape)
        shape.fillColor = UIColor.clear.cgColor
        shape.strokeColor = UIColor.red.cgColor

        let path = CGPath(ellipseIn: CGRect(10, 10, 50, 50), transform: nil)
        shape.path = path

        let path2 = CGPath(rect: CGRect(10, 10, 50, 50), transform: nil)

        let basicAnim = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.path))
        basicAnim.duration = 1.0
        basicAnim.fromValue = path
        basicAnim.toValue = path2
        shape.speed = 0
        shape.timeOffset = 0
        shape.add(basicAnim, forKey: nil)
    }

11 关于图层的转变

图层的转变(transition) 指的是被转变的图层拥有两个"拷贝", 通过第二个替换第一个, 从而实现一些转变的效果.

主要是设置转变的类型和子类型来达到效果, 这里由于历史原因, 转变中的 Bottom 和 Top 正好和手机方向是相反的.

    private func rootLayerTransition() {
        let transition = CATransition()
        transition.type = kCATransitionPush
        transition.subtype = kCATransitionFromBottom
        transition.duration = 2.0
        CATransaction.setDisableActions(true)
        layer.contents = UIImage(named: "img_highlighted")?.cgImage
        layer.add(transition, forKey: nil)
    }

这样的动画应用场景主要是父图层的 maskToBounds 属性是 true, 然后子图层从范围外移动到范围内的情况, 这样就可以达到动画效果, 且在父图层外不会看大子图层的移动效果.

12 动画列表

为了了解动画的内部原理, 需要首先看看什么是 Animation List.

显式动画是通过 CALayer 的 add 方法添加到图层上的. 动画对象(CAAnimation)改变的是图层的绘制方式. 当动画添加到图层上后, 剩下的工作都是由图层的绘制机制来完成的.

在图层中维护了一张当前正在或需要进行的动画的列表, 动画通过 add 方法添加到列表中, 当动画时机到达时, 图层就根据动画列表中的所有动画来决定如何将自己绘制出来, 且按照一定的顺序进行, 而绘制时候要进行的任务在文档中称为 rendering tree.

动画的添加顺序就是它们在绘制时候的执行顺序.

动画列表中如果存在某个 key, 当另外一个拥有相同 key 的动画添加进来时, 之前的相同 key 的动画会被移除掉.

如果添加动画时指定 key 为 nil, 则不受 key 值的影响, 即可以多次添加 nil key 的动画.

但是苹果对 keyPath 和 key 搞了一个小动作, 即如果在创建动画时将 keyPath 设置为 nil, 则动画默认指向的属性就是 add 方法中 key 对应的属性, 这个不得不说是个蛋疼的地方. 有时这样的结合规则会被误用.

所以一定要清楚地认识这两个方法参数的作用:

  • 创建动画时一定要指定动画的 keyPath
  • 添加动画时候的 key 一定只是作为不同动画的标记

这样的话, 在某些情况下就可以通过相同 key 的动画将之前的那个替换掉.

另外 CATransition 添加的动画的 key 一直是 "transition"(也就是 kCATransition 代表的字符串), 故同一个 layer 同时只能添加一个 CATransition 动画.

如果没有特殊处理的话, 当动画结束后, 动画就会从动画列表中移除掉. 当然可以设置动画的 isRemovedOnCompletion 来将它保留在动画列表中, 下次动画时机时就会再次被执行.

这里有一个经典的错误实现:

很多例子都是使用 isRemovedOnCompletion 结合 fillMode 设置来把图层动画的最终状态保留在动画的最后状态上, 但这样的错误的, 因为这个时候的图层只是 "看起来" 在最终位置.

而正确的解决办法是: 将图层对应的属性修改为动画的终态一致, 然后进行动画即可, 而非设置 fillMode.

而 kCAFillMode 的用途是在动画组(Animation Group)中, 和子动画相关的.

在代码中无法直接访问动画列表的所有动画, 只能利用 animation(forKey:) 方法来获取某个 key 对应的动画. 且动画完成回调调用时, 该动画已经完成, 故就已经从动画列表中移除了, 所以在完成回调中, 是获取不到该动画的.

可以使用 removeAnimation(forKey:) 或者 removeAllAnimations 移除动画, 当移除 nil key 的动画时, 只有通过 removeAllAnimations 才可以办到.

如果 APP 被暂停(suspended)的时候, 系统会自动在所有图层上调用 removeAllAnimations 方法.

当手动将进行中的动画被移除时, 它会直接停止. 但停止的时机是在下一个重绘时机到达时. 如果想直接停止动画, 需要在 transaction 块中写.

待续...

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,723评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,080评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,604评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,440评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,431评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,499评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,893评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,541评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,751评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,547评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,619评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,320评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,890评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,896评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,137评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,796评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,335评论 2 342

推荐阅读更多精彩内容

  • 在iOS中随处都可以看到绚丽的动画效果,实现这些动画的过程并不复杂,今天将带大家一窥ios动画全貌。在这里你可以看...
    每天刷两次牙阅读 8,455评论 6 30
  • 在iOS中随处都可以看到绚丽的动画效果,实现这些动画的过程并不复杂,今天将带大家一窥iOS动画全貌。在这里你可以看...
    F麦子阅读 5,089评论 5 13
  • 如果想让事情变得顺利,只有靠自己--夏尔·纪尧姆 上一章介绍了隐式动画的概念。隐式动画是在iOS平台创建动态用户界...
    夜空下最亮的亮点阅读 1,910评论 0 1
  • 书写的很好,翻译的也棒!感谢译者,感谢感谢! iOS-Core-Animation-Advanced-Techni...
    钱嘘嘘阅读 2,287评论 0 6
  • 把玫瑰献给青春的火, 把月桂献给盛年的人; 就折一段常春藤给我, 这未老先衰的身。 把紫罗兰献给青春的坟, 把桂冠...
    威廉_爱德华兹阅读 192评论 0 0