编辑:Bison
投稿: Sindri的小巢
在iOS中,普通的动画可以使用 UIKit
提供的方法来实现动画,但如果想要实现复杂的动画效果,使用 CoreAnimation
框架提供的动画效果是最好的选择。那么两种动画方案相比之下,后者存在的主要好处包括不仅下面这些:
在 CoreAnimation
框架下,最主要的两个部分是图层 CALayer
以及动画 CAAnimation
类。前者管理着一个可以被用来实现动画的位图上下文;后者是一个抽象的动画基类,它提供了对 CAMediaTiming
和 CAAction
协议的支持,方便子类实例直接作用于 CALayer
本身来实现动画效果。接下来笔者会分段分别讲述上面提到的类,参考信息来自于 苹果官方文档 以及 objc中国
CALayer类结构
如果你喜欢动画效果,在网上开源的动画实现中总是能看到 CALayer
及其子类的应用,那么了解这个图层类别先从它的结构看起(此处列出了了部分属性并且去除了注释):
public class CALayer : NSObject, NSCoding, CAMediaTiming { public func presentationLayer() -> AnyObject? public func modelLayer() -> AnyObject public var bounds: CGRect public var position: CGPoint public var anchorPoint: CGPoint public var transform: CATransform3D public var frame: CGRect public var hidden: Bool public var superlayer: CALayer? { get } public func removeFromSuperlayer() public func addSublayer(layer: CALayer) public func insertSublayer(layer: CALayer, below sibling: CALayer?) public func insertSublayer(layer: CALayer, above sibling: CALayer?) public func replaceSublayer(layer: CALayer, with layer2: CALayer) public var sublayerTransform: CATransform3D public var mask: CALayer? public var masksToBounds: Bool public func hitTest(p: CGPoint) -> CALayer? public func containsPoint(p: CGPoint) -> Bool public var shadowColor: CGColor? public var shadowOpacity: Float public var shadowOffset: CGSize public var shadowRadius: CGFloat public var contents: AnyObject? public var contentsRect: CGRect public var cornerRadius: CGFloat public var borderWidth: CGFloat public var borderColor: CGColor? public var opacity: Float }
根据 CALayer Class Reference 中的描述,在每一个 UIView
的背后都有一个 CALayer
对象用来协助它显示内容,它自身管理着我们提供给视图显示的位图上下文以及保存这些位图上下文的几何信息。通过上面的代码可以看出:
CALayer
是 NSObject
的子类而非 UIResponder
的子类,因此图层本身无法响应用户操作事件却拥有着 事件响应链 相似的判断方法,所以 CALayer
需要包装成一个 UIView
容器来完成这一功能。 UIView
自身存在一个 CALayer
来显示内容。在后者的属性中我们可以看到存在着多个和 UIView
界面属性对应的变量,因此我们在修改 UIView
的界面属性的时候其实是修改了这个 UIView
对应的 layer
的属性。 CALayer
拥有和 UIView
一样的树状层级关系,也有类似 UIView
添加子视图的 addSublayer
这些类似的方法。 CALayer
可以独立于 UIView
之外显示在屏幕上,但我们需要重写事件方法来完成对它的响应操作 对于苹果为什么要把 UIView
和 CALayer
区分开来,网上已经有了一篇很详(qi)细(pa)的文章讲解这个问题 都有了CALayer,为什么还要UIView
图层树和隐式动画
在每一个 CALayer
中,都有三个重要的层次树,它们负责相互协调完成图层的渲染展示效果。这三个层次树分别是:
layer.modelLayer
获取,当我们修改 CALayer
的可动画属性时,模型树对应的属性就会立刻被修改成对应的数值 layer. presentationLayer
获取,呈现树保存着当前图层状态的显示数据,即会随着动画的过程不断更新图层的状态数据 modelLayer
跟 presentationLayer
中设置的效果来将内容渲染到屏幕上 CALayer
中的显示数据几乎都是可动画属性,这个特性为我们制作核心动画提供了很大的实践基础。在一个单独的 CALayer
中(也就是说这个 layer
并没有和任何 UIView
绑定),我们修改它的显示属性的时候,都会触发一个从 旧值
到 新值
之间的简单动画效果,这种动画我们称之为隐式动画:
class ViewController: UIViewController { let layer = CAShapeLayer() override func viewDidLoad() { super.viewDidLoad() layer.strokeEnd = 0 layer.lineWidth = 6 layer.fillColor = UIColor.clearColor().CGColor layer.strokeColor = UIColor.redColor().CGColor self.view.layer.addSublayer(layer) } @IBAction func actionToAnimate() { layer.path = UIBezierPath(arcCenter: self.view.center, radius: 100, startAngle: 0, endAngle: 2*CGFloat(M_PI), clockwise: true).CGPath layer.strokeEnd = 1 } }
可以看到上面的代码中我单独创建了一个 CALayer
并且将它添加到当前的控制器视图的图层上, strokeEnd
这一属性表示填充百分比。当这个属性发生变化的时候,产生了一个画圈的动画效果:
在隐式动画的实现背后,隐藏着一个最重要的扮演角色 CAAction
协议这一过程会在下面进行详细的介绍。那么在上面这个隐式动画的过程中,模型树和呈现树发生了哪些变化呢?由于系统的动画时长默认为 0.25
秒,我设置一个 0.05
秒的定时器在每次回调的时候查看一下这两个层次树的信息:
@IBAction func actionToAnimate() { timer = NSTimer(timeInterval: 0.05, target: self, selector: #selector(timerCallback), userInfo: nil, repeats: true) NSRunLoop.currentRunLoop().addTimer(timer!, forMode: NSRunLoopCommonModes) layer.path = UIBezierPath(arcCenter: self.view.center, radius: 100, startAngle: 0, endAngle: 2*CGFloat(M_PI), clockwise: true).CGPath layer.strokeEnd = 1 } @objc private func timerCallback() { print("========================/nmodelLayer: /t/(layer.modelLayer().strokeEnd)/ntpresentationLayer: /t/(layer.presentationLayer()!.strokeEnd)") if fabs((layer.presentationLayer()?.strokeEnd)! - 1) < 0.01 { if let _ = timer { timer?.invalidate() timer = nil } } }
控制台的输出结果如下:
======================== modelLayer: 1.0 presentationLayer: 0.294064253568649 ======================== modelLayer: 1.0 presentationLayer: 0.676515340805054 ======================== modelLayer: 1.0 presentationLayer: 0.883405208587646 ======================== modelLayer: 1.0 presentationLayer: 0.974191427230835 ======================== modelLayer: 1.0 presentationLayer: 0.999998211860657
可以看到当一个隐式动画发生的时候, modelLayer
的属性被修改成动画最终的结果值。而系统会根据动画时长和最终效果值计算出动画中每一帧的数值,然后依次更新设置到 presentationLayer
当中。最终这些计算工作都完成之后,渲染树 renderingTree
根据这些值将动画效果渲染到屏幕上。
那么通过层次树我们能制作什么呢?假设我需要制作下面这么一个粘性的弹球动画,那么我在界面的最左侧、最右侧以及中间各自添加了一个 CALayer
,当点击按钮的时候给左右两侧的 layer
添加一个匀速的 position
下移动画,中间的 centerLayer
添加一个弹簧动画。通过使用定时器更新获取这三个 layer
的呈现树 y
轴坐标来绘制区域形成这样一个动画:
其他属性
除了上面重点介绍的属性之外,下面的属性我只进行简单的介绍,详细的使用以及动画作用会在以后对应使用的动画中更详细的讲解:
position
和 anchorPoint
anchorPoint
是一个 x
和 y
值取值范围内在 0~1
之间 CGPoint
类型,它决定了当图层发生几何仿射变换时基于的坐标原点。默认情况下为 0.5, 0.5
,由 anchorPoint
和 frame
经过计算获得图层的 position
这个值。更多介绍这两个属性的文章在 彻底理解position和anchorPoint
mask
和 maskToBounds
maskToBounds
值为 true
时表示超出图层范围外的所有子图层都不会进行渲染,当我们设置 UIView
的 clipsToBounds
时实际上就是在修改 maskToBounds
这个属性。 mask
这个属性表示一个遮罩图层,在这个遮罩之外的内容不予渲染显示,在上一篇动画 碎片动画 中使用的 maskView
实际上也是修改这个属性
cornerRadius
、 borderWidth
和 borderColor
borderWidth
和 borderColor
设置了图层的边缘线条的颜色以及宽度,正常情况下这两个属性在 layer
的层次上不怎么使用。后者 cornerRadius
设置圆角半径,这个半径会影响边缘线条的形状
shadowColor
、 shadowOpacity
、 shadowOffset
和 shadowRadius
这四个属性结合起来可以制作阴影效果。 shadowOpacity
默认情况下值为 0
,这意味着即便你设置了其他三个属性,只要不修改这个值,你的阴影效果就是透明的。其次,不要纠结 shadowOffset
这个决定阴影效果位置偏移的属性为什么会是 CGSize
而不是 CGPoint
。我通过下面这段代码设置的阴影效果如下:
layer.shadowColor = UIColor.grayColor().CGColor layer.shadowOffset = CGSize(width: 2, height: 5) layer.shadowOpacity = 1
其他属性
这里包括了 transform
的仿射变换属性,相比 UIView
的同名属性,它可以设置 z
轴上的值实现更多的几何变换效果。此外还有 bound
和 frame
这些影响图层显示范围的属性,就不再多说
CAAnimation的子类
开头说过, CAAnimation
是一个封装出来的基类,其最重要的目的在于遵循两个重要的动画相关协议,所以解析动画类型要从它的子类依赖关系下手。在苹果文档中, CAAnimation
的直接子类包括这些:
从图中我们可以看到存在这么三个子类:
CAAnimationGroup
动画组对象,其作用是将多个 CAAnimation
动画实例组合在一起,让图层同时执行多个动画效果。在本文中不会进行更多的介绍 CAPropertyAnimation
属性动画,这是很多核心动画类的父类,同时也是一个抽象的 CAAnimation
子类(这两父子都是抽象主义)他提供了对图层关键路径的属性进行动画的重要功能,在其基础上衍生的众多子类是实现动画的重要工具 CATransition
过度动画类,不得不说在现今这个版本这个类的定位非常尴尬。在 CATransform3D
以及自定义转场API大行其道的这个年代,它提供的作用实在太轻微了。另一方面它还可能因为 私有api
的问题导致应用的上架失败,不过了解这个类也是可以的,在这篇 CATransition用法 中可以学习如何使用 CATransition
制作动画 类结构属性
从上面的图中我们可以看到 CAAnimation
遵循了两个协议,在其本身属性中并没有太多的属性。其中大部分的动画相关属性都是在协议中声明的,在实现中动态生成了 setter
和 getter
public class CAAnimation : NSObject, NSCoding, NSCopying, CAMediaTiming, CAAction { public class func defaultValueForKey(key: String) -> AnyObject? public func shouldArchiveValueForKey(key: String) -> Bool public var timingFunction: CAMediaTimingFunction? public var delegate: AnyObject? public var removedOnCompletion: Bool }
通过 CAAnimation
的类结构,可以分为属性和方法两个部分,其中属性是我们需要重点关注的
defaultValueForKey
和 shouldArchiveValueForKey
这两个方法从名字上看就知道跟 NSCoding
协议脱不开干系,后者通过传入一个关键字对动画对象进行序列化本地存储,并且返回成功与否。然后使用相同的关键字调用前者来获取这个持久化的动画对象
timingFunction
这个是个有趣的属性,决定了动画的视觉效果。我在 从UIView动画说起 中提到过动画的视觉效果,包括 淡入
、 淡出
等效果,这些效果用字符串表示:
public let kCAMediaTimingFunctionLinear: String public let kCAMediaTimingFunctionEaseIn: String public let kCAMediaTimingFunctionEaseOut: String public let kCAMediaTimingFunctionEaseInEaseOut: String public let kCAMediaTimingFunctionDefault: String let timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
delegate
在 NSObject
中实现了 CAAnimation
的回调方法,包括不限于 animationDidStart
和 animationDidStop
等方法。因此在iOS中的任何一个对象都能成为 CAAnimation
的代理人。通过这个代理我们可以在动画结束时移除动画效果等操作
removedOnCompletion
决定了动画结束之后是否将动画从相应的图层上移除,默认是 true
。由于图层动画实际上相当于障眼法(使用 CAAnimaiton
实现动画效果的时候并不会真的修改对应的属性),即是在动画结束时图层会回到动画开始的状态。通过设置这个值为 false
以及其他配置,可以避免这种事情发生
动画协议属性
除了 CAAnimation
本身的属性之外,另外两个协议中声明了决定动画时长、前后动画效果等关键属性:
public protocol CAMediaTiming { public var beginTime: CFTimeInterval { get set } public var duration: CFTimeInterval { get set } public var speed: Float { get set } public var timeOffset: CFTimeInterval { get set } public var repeatCount: Float { get set } public var repeatDuration: CFTimeInterval { get set } public var autoreverses: Bool { get set } public var fillMode: String { get set } }
CAMediaTiming
是一个控制动画时间的协议,提供了动画过程中的时间相关的属性,对于这些属性在 控制动画时间 一文中讲解的非常清楚了,笔者在这里就不再一一介绍。此外,还有另一个协议 CAAction
协议:
public protocol CAAction { /* Called to trigger the event named 'path' on the receiver. The object * (e.g. the layer) on which the event happened is 'anObject'. The * arguments dictionary may be nil, if non-nil it carries parameters * associated with the event. */ @available(iOS 2.0, *) public func runActionForKey(event: String, object anObject: AnyObject, arguments dict: [NSObject : AnyObject]?) }
在动画发生之后这个方法会被调用,这个方法把将要发生的事件告诉图层,从而让图层做出对应的操作,比如渲染等。
显式动画
开头笔者提到过隐式动画是单独的 layer
的可动画属性发生改变时自动产生的过度动画,那么肯定就有对应的显式动画。显式动画的制作过程是创建一个动画对象,然后添加到实现动画的图层上。下面这段代码创建了一个修改 layer.position.y
的基础动画对象,然后设置动画结束值为 160
后添加到 layer
层上,动画的默认时长是 0.25
秒:
let animation = CABasicAnimation(keyPath: "position.y") animation.toValue = NSNumber(float: 160) layer.position.y = 160 layer.addAnimation(animation, forKey: nil)
对于动画的更详细讲解不在本篇文章的计划内,在接下来的核心动画中笔者会更加详细的介绍各式各样的 CAAnimation
子类以用于不同的动画场景。这里放上上面粘性弹窗的核心代码:
func startAnimation() { let displayLink = CADisplayLink(target: self, selector: #selector(fluctAnimation(_:))) displayLink.addToRunLoop(NSRunLoop.currentRunLoop(), forMode: NSRunLoopCommonModes) let move = CABasicAnimation(keyPath: "position.y") move.toValue = NSNumber(float: 160) leftLayer.position.y = 160 rightLayer.position.y = 160 leftLayer.addAnimation(move, forKey: nil) rightLayer.addAnimation(move, forKey: nil) let spring = CASpringAnimation(keyPath: "position.y") spring.damping = 15 spring.initialVelocity = 40 spring.toValue = NSNumber(float: 160) centerLayer.position.y = 160 centerLayer.addAnimation(spring, forKey: "spring") }
给三个图层添加了下移的动画之后,创建 CADisplayLink
定时器来同步屏幕刷新频率更新弹出效果:
@objc private func fluctAnimation(link: CADisplayLink) { let path = UIBezierPath() path.moveToPoint(CGPointZero) guard let _ = centerLayer.animationForKey("spring") else { return } let offset = leftLayer.presentationLayer()!.position.y - centerLayer.presentationLayer()!.position.y var controlY: CGFloat = 160 if offset < 0 { controlY = centerLayer.presentationLayer()!.position.y + 30 } else if offset > 0 { controlY = centerLayer.presentationLayer()!.position.y - 30 } path.addLineToPoint(leftLayer.presentationLayer()!.position) path.addQuadCurveToPoint(rightLayer.presentationLayer()!.position, controlPoint: CGPoint(x: centerLayer.position.x, y: controlY)) path.addLineToPoint(CGPoint(x: UIScreen.mainScreen().bounds.width, y: 0)) path.closePath() fluctLayer.path = path.CGPath }
隐式动画发生了什么
上面说过隐式动画发生在单独的 CALayer
对象的可动画属性被改变时。如果我们这个 layer
已经存在与之绑定的 UIView
对象,那么当我们直接修改这个 layer
的属性的时候,只会瞬间从 旧值
变成 新值
的显示效果,不会有额外的效果。在 CoreAnimation
的编程指南中对此做出了解释: UIView默认情况下禁止了layer动画,但在animate block中重新启用了它们
。这是我们看到的行为,但如果认真去挖掘这一机制的内部实现,我们会惊讶于 view
和 layer
之间协同工作的精心设计,这里就要提到 CAAction
当任何一个可动画的 layer
的属性发生改变的时候, layer
通过向它的代理人发送 actionForLayer(layer:event:)
方法来查询一个对应属性变化的 CAAction
对象,这个方法可以返回下面三个结果:
CAAction
的对象,这种情况下 layer
使用这个动作完成动画 nil
,这样 layer
就会到其他地方继续寻找 NSNull
对象,这样 layer
就会停止查找并且不执行动画 正常来说,当一个 CALayer
和 UIView
关联的时候,这个 UIView
对象会成为 layer
的代理人。因此从返回值上来说,当 layer
的属性被我们修改的时候,这个关联的 UIView
对象一般都是直接返回 NSNull
对象,而只有在 animate block
的状态下才会返回实际的动画效果,方便让图层继续查找处理动作的方案。我们通过代码来验证:
print("===========normal call===========") print("/(self.view.actionForLayer(self.view.layer, forKey: "opacity"))") UIView.animateWithDuration(0.25) { print("===========animate block call===========") print("/(self.view.actionForLayer(self.view.layer, forKey: "opacity"))") }
控制台输出结果如下,在动画 block
中确实返回了一个 CABasicAnimation
对象来协同完成这个动画效果
===========normal call=========== Optional(<null>) ===========animate block call=========== Optional(<CABasicAnimation:0x7f8712d30c90; delegate = <UIViewAnimationState: 0x7f8712d304d0>; fillMode = both; timingFunction = easeInEaseOut; duration = 0.25; fromValue = 1; keyPath = opacity>)
通常来说处在动画代码块中的 UIView
都会返回这么一个核心动画对象,但如果返回的是 nil
,图层还有继续查找其他的动作解决方案,整个的查找过程共有四次,这个在 CALayer
的头文件中已经说明了:
当 layer
对象查找到了属性修改动作的动画时,就会调用 addAnimation(_:forKey:)
方法开始执行动画效果。同样的,我们继承 CALayer
对象来重写这个方法:
class LXDActionLayer: CALayer { override func addAnimation(anim: CAAnimation, forKey key: String?) { print("***********************************************") print("Layer will add an animation: /(anim)") super.addAnimation(anim, forKey: key) } } class LXDActionView: UIView { override class func layerClass() -> AnyClass { return LXDActionLayer.classForCoder() } } override func viewDidLoad() { super.viewDidLoad() let actionView = LXDActionView() view.addSubview(actionView) print("===========normal call===========") print("/(self.view.actionForLayer(self.view.layer, forKey: "opacity"))") actionView.layer.opacity = 0.5 UIView.animateWithDuration(0.25) { print("===========animate block call===========") print("/(self.view.actionForLayer(self.view.layer, forKey: "opacity"))") actionView.layer.opacity = 0 }
控制台输出结果如下:
===========normal call=========== Optional(<null>) ===========animate block call=========== Optional(<CABasicAnimation:0x7f8f00eb1850; delegate = <UIViewAnimationState: 0x7f8f00eb0e90>; fillMode = both; timingFunction = easeInEaseOut; duration = 0.25; fromValue = 1; keyPath = opacity>) *********************************************** Layer will add an animation: <CABasicAnimation: 0x7f8f00eb2400>
这里可能会有人有疑惑,为什么两次输出的 CABasicAnimation
的地址不一样。为了保证同一个动画对象可以作用于多个 CALayer
对象执行,在 addAnimation(_:forKey:)
方法调用的时候都会对 CAAnimation
对象进行一次 copy
操作。各位可以继承 CABasicAnimation
对象重写 copy
方法自行测试
本来笔者想要直接使用动画粒子开始讲解核心动画这一框架,但是考虑到如果能够全面的对核心动画进行一次讲解,这对于以后的文章讲解以及动画粒子的制作有很大的帮助。
本文demo
学习iOS开发的app上线啦,点此来围观吧
更多经验请点击
好文推荐: iOS开发之超链接富文本