做我表达的,而非我说的。——Edna Krabappel, The Simpsons
第一部分讲述了所有Core Animation
可以做到的东西,除了动画。动画是Core Animation
框架中一个相当显著的部分。在这一章中,我们将看一下它的工作原理。特别的,我们讲解隐式动画,这是框架自动运行的动画(除非你禁用它)。
事务
Core Animation
假设屏幕上的所有东西将要(至少可能)运动。动画并不是你在Core Animation
中启用的东西。动画必须显示的禁用,否则它们时时刻刻都会发生。
无论何时你改变CALayer
的可动画的属性,改变并不会立即反映在屏幕上。相反,图层属性通过动画平滑地从前一个值过渡到新值。你无需做什么就可以使这一切发生;它们是默认行为。
这看起来有一点太过好了,所以让我们用一个例子来演示它:我们将使用第1章“图层树”中的蓝色方块项目,然后添加一个按钮设置图层为一个随机颜色。表7.1展示了代码。点击按钮你会看见颜色平滑过渡而非跳转到另一个新值(见图7.1)。
表7.1 随机图层颜色
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var layerView: UIView!
var colorLayer: CALayer!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 创建子图层
self.colorLayer = CALayer()
self.colorLayer.frame = CGRectMake(50.0, 50.0, 100.0, 100.0)
self.colorLayer.backgroundColor = UIColor.blueColor().CGColor
// 加到视图中
self.layerView.layer.addSublayer(self.colorLayer)
}
}
@IBAction func changeColor(sender: AnyObject) {
// 随机图层背景色
let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
self.colorLayer.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor
}
}
这种动画被称作隐式动画。它是隐式的,因为我们没有指定我们想要发生的动画类型;我们只是改变了属性,Core Animation
决定如何以及何时去动画改变它。Core Animation
也提供显式动画,这将在下一章中讲解。
当你改变属性时,Core Animation
是如何决定它将显示的动画的类型和时长的?动画时长由当前事务设置,动画类型由图层动作控制。
事务是Core Animation
是用来概括一系列特定属性动画的机制。任何可动画的图层属性有一个给定事务的改变就不会立马改变,相反会在事务执行时开始动画过渡到新值。
事务使用CATransaction
类管理。CATransaction
类有一个奇怪的设计,它并不如名字所示的单一事务,而是管理着一堆事务而没有给你直接的访问。CATransaction
没有属性或实例方法,你不能正常使用+alloc
或-init
来创建一个事务。相反,你使用类方法+begin
和+commit
来使一个新事务入栈顶或使当前事务出栈。
任何可动画的图层属性改变变添加上栈顶的事务。你可以通过使用+setAnimationDuration:
方法设置当前事务动画时长,或者你可以使用+animationDuraion
方法得知当前时长。(默认为0.25秒。)
Core Animation
在每个运行周期迭代中自动开始新事务。(运行周期是iOS用来收集用户输入、处理所有显式定时器或网络事件的,最终重新绘制屏幕。)即使你没有显示使用[CATransaction begin]
开始一次事务,所有你在一个给定运行周期迭代中的属性改变都会组到一起,在0.25秒的阶段里进行动画。
有了这个知识,我们可以很容易地改变我们的颜色动画时长。使用+setAnimationDuration:
方法来改变当前(默认)事务的时长足够了,但我们会先开始一个新事务来防止改变时长带来不想要的副作用。改变当前事务时长可能影响其它同一时间发生的动画(例如屏幕旋转),所以调整动画设置前显式入栈一个事务总是一个好主意。
表7.2显示了修改后的代码。如果你运行应用,你会注意到颜色渐变地比以前慢多了。
表7.2 使用CATransaction控制动画时长
@IBAction func changeColor(sender: AnyObject) {
// 开始新事务
CATransaction.begin()
// 设置动画时长为1秒
CATransaction.setAnimationDuration(1.0)
// 随机图层背景色
let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
self.colorLayer.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor
// 执行事务
CATransaction.commit()
}
如果你曾使用UIView
的动画方法实现任何动画,这个模式看起来应该很熟悉。UIView
有两个方法+beginAnimations:context:
和+commitAnimations
,它们工作原理类似于CATransaction
中的+begin
和+commite
方法。你在+beginAnimations:context:
和+commitAnimations
中改变的所有视图或图层属性会自动产生动画,这是因为这些UIView
动画方法实际上就是在设置一个CATransaction
。
在iOS4中,Apple为UIView
添加了一个新的基于闭包的动画方法+animateWithDuration:animations:
。它在语法上比分离属性动画的开始、结束代码块更为干净,但事实上它在幕后做一样的事情。
CATransaction
的+begin
和+commit
方法会在+animateWithDuration:animations:
方法中间调用,动画闭包中的动画会在其中执行。所以任何你在闭包内做的属性改变会被事务包含。这样可以避免开发者错误的没有将+begin
与+commit
一一对应。
完成闭包
UIVIew
基于闭包的动画允许你提供一个完成闭包在动画结束时调用。同样的特性在CATransaction
接口中也是可以的,通过调用+setCompletionBlock:
方法实现。让我们再次修改先前的例子让颜色改变之后执行一个动作。我们将加上一个完成闭包,使用它触发每二个动画来使图层每当颜色改变后旋转90度。表7.3显示了代码,图7.2显示了结果。
表7.3 当颜色动画结束时增加回调
@IBAction func changeColor(sender: AnyObject) {
// 开始新事务
CATransaction.begin()
// 设置动画时长为1秒
CATransaction.setAnimationDuration(1.0)
// 结束时增加旋转动画
CATransaction.setCompletionBlock({
// 图层旋转90度
var transform = self.colorLayer.affineTransform()
transform = CGAffineTransformRotate(transform, CGFloat(M_PI_2))
self.colorLayer.setAffineTransform(transform)
})
// 随机图层背景色
let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
self.colorLayer.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor
// 执行事务
CATransaction.commit()
}
注意,我们的旋转动画比颜色渐变动画快很多。这是因为施加旋转的完成闭包是有颜色渐变的事务提交后执行并出栈的。因此,会使用默认的事务,以及默认的0.25秒时长。
图层动作
现在让我们做个试验:不给单独子图层施加动画,我们直接给视图的主图层施加动画。表7.4展示了修改后的代码版本,它从表7.2中移除了colorLayer
并直接设置layerView
的主图层颜色。
表7.4 直接设置主图层的属性
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var layerView: UIView!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 直接设置我们layerView主图层的颜色
self.layerView.layer.backgroundColor = UIColor.blueColor().CGColor
}
}
@IBAction func changeColor(sender: AnyObject) {
// 开始新交易
CATransaction.begin()
// 设置动画时长为1秒
CATransaction.setAnimationDuration(1.0)
// 随机图层背景色
let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
self.layerView.layer.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor
// 执行交易
CATransaction.commit()
}
}
如果你运行这个项目,你会注意到当按钮按下后颜色立刻跳转成一个新值而非像之前一样的平滑动画。发生了什么?隐式动画看起来好像是在UIView
的主图层中被禁用了。
想想看,我们可能有注意到如果每当UIView
属性被改变时都会自动有动画。所以,如果UIKit
是构建在Core Animaiton
(它总是默认给任何东西加上动画)之上,UIKit
的隐式动画怎么会默认禁用?
我们知道Core Animaiton
通常会给CALayer
的所有属性变化增加动画(假如它可动画),但UIView
以某种方式将它的主图层关闭了这一行为。为了理解这个怎么发生的,我们首先需要理解隐式动画是如何实现的。
CALayer
在属性改变时自动施加的动画叫动作。当CALayer
的属性被修改时,它会调用-actionForKey:
方法,传递其中的属性。接下来发生的在CALayer
头文件中有完整的文档,但它最终总结如下:
- 图层首先检查它是否有委托,如果委托实现了
CALayerDelegate
协议中指定的-actionForLayer:forKey
方法。如果是,则调用它并返回结果。 - 如果没有委托,或委托没有实现
-actionForLayer:forKey
方法,图层检查它的actions
词典,这个词典包含了属性名与动作的映射。 - 如果
actions
词典没有任何要求的属性入口,图层会搜索它的style
词典层次来找寻任何匹配属性名的动作。 - 最后,如果在
style
层次中找不到适合的动作,图层会回调-defualtActionForKey:
方法,这是给属性定义标准动作的。
全面搜索的结果会是-actionForKey:
或nil
(此时,没有动画发生,属性值会立马改变)或一个遵守CAAction
协议的对象,这是CALayer
会用来在先前值和当前值之间的动画的。
这解释了UIKit
是如何禁用隐式动画的:每个UIView
表现的像是主图层的委托,并提供一个-actionForLayer:forKey
方法的实现。当不在动画闭包中时,UIView
为所有的图层动作返回nil
,但在动画闭包的作用域中返回非空值。我们可以用一个小例子演示这点(如表7.5)。
表7.5 测试UIView的actionForLayer:forKey:实现
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var layerView: UIView!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 测试动画闭包外的图层动作
let outsideAction = self.layerView.actionForLayer(self.layerView.layer, forKey: "backgroundColor")
println("Outside: \(outsideAction)")
// 开始动画闭包
UIView.beginAnimations(nil, context: nil)
// 测试动画闭包内的图层动作
let insideAction = self.layerView.actionForLayer(self.layerView.layer, forKey: "backgroundColor")
println("Inside: \(insideAction)")
// 结果动画闭包
UIView.commitAnimations()
}
}
}
当我们运行项目时,我们会在控制台中看见这个:
$ LayerTest[21215:c07] Outside: <null>
$ LayerTest[21215:c07] Inside: <CABasicAnimation: 0x757f090>
译者测试结果为:
Outside: <CABasicAnimation: 0x7ffa0b11a7b0>
Inside: <CABasicAnimation: 0x7ffa0b08a060>
Outside: <CABasicAnimation: 0x7ffa0ad070c0>
Inside: <CABasicAnimation: 0x7ffa0ad821c0>
正如预测的一样,UIView
当属性在动画闭包外改变并为属性动作返回nil
时会禁用隐式动画。当动画可用时返回的动作取决于属性类型,但在这里,它是CABasicAniamtion
。(你会在第8章“显式动画”中学习这点。)
返回nil
并不是禁用隐式动画的唯一方式;CATransaction
有一个方法叫+setDisableActions:
可以用来同时启用或禁用所有属性的动画。如果我们修改表7.2的代码,添加下面几行在CATransaction.begin()
后面,它会阻止所有动画发生:
CATransaction.setDisableActions(true)
总结一下,我们学了这些东西:
-
UIView
的主图层禁用陷式动画。主图层属性动画的唯一方法是使用UIView
动画方法(而不是依赖CATransaction
),子类化UIView
并且重写-actionForLayer:forKey:
方法,或创建一个显式动画(第8章第详细讲解)。 - 对于有主(即非主图层)图层,我们可以通过实现
-actionForLayer:forKey:
图层代理方法或者提供actions
词典来控制隐式属性动画。
让我们为我们的渐隐例子指定一个不一样的动作。我们会修改表7.1,为colorLayer
设置一个自定义的actions
词典。我们可以使用委托来实现这点,但actions
词典方法需要更少的代码。所以我们如何创建一个合适的动作对象?
动作通常由一个隐式动画对象指定,它会在需要时被Core Animaiton
隐式调用。这里我们用的动画是推过渡,这是由一个CATransition
实例实现的(如表7.6)。
过渡将在第8章详细解释,但这里足够说明CATransition
遵循CAAction
协议,可以因此被当作图层动作使用。结果非常酷炫;无论何时我们改变我们的图层颜色,新的值将从左侧滑入而不是默认的交叉渐变效果(如图7.3)。
表7.6 实现自定义动作
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var layerView: UIView!
var colorLayer: CALayer!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 创建子图层
self.colorLayer = CALayer()
self.colorLayer.frame = CGRectMake(50.0, 50.0, 100.0, 100.0)
self.colorLayer.backgroundColor = UIColor.blueColor().CGColor
// 添加自定义动作
let transition = CATransition()
transition.type = kCATransitionPush
transition.subtype = kCATransitionFromLeft
self.colorLayer.actions = ["backgroundColor": transition]
// 添加进视图中
self.layerView.layer.addSublayer(self.colorLayer)
}
}
@IBAction func changeColor(sender: AnyObject) {
// 随机图层背景色
let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
self.colorLayer.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor
}
}
演示与模型
CALayer
的属性表现并不寻常,改变图层属性并不会有一个立即的效果,但随时间慢慢更新。这是如何做到的呢?
当你改变图层的属性时,属性值本身实际是是立即更新的(如果你尝试读取它,你会发现其值是你刚刚设置的),但改变并没有屏幕上反映出来。这是因为你设置的属性并不直接改变图层的样子;相反,它指定了图层在属性动画完成后将要有的样子。
当你设置CALayer
属性,你实际上是在定义你想当前事务最终显示的模型。Core Animation
会如同控制器般负责更新这些基于图层动作和事务设置的屏幕上的属性的视图状态。
我们在讨论是的高效的微MVC模式。CALayer
是一个你通常会用于MVC(模型-视图-控制器)模式中用户界面(也叫视图)部分的可视化类,但在用户界面自身的上下文中,CALayer
表现的更像是一个模型,它表明在所有动画结束后视图将要展示的样子。事实上,在Apple自身的文档中,图层树有时也指模型图层树。
在iOS中,屏幕每秒重绘60次。如果动画时间长于一秒的1/60,Core Animation
因此会要求在屏幕上重组这个图层多次,次数在你设置可动画属性的新值和新值最终显示在屏幕上之间。这意味着CALayer
必须以一种方法保持除了当前属性的“实际”值(你设置的值)之外的显示值。
每个图层的属性的显示值被存储在一个叫显示层的独立图层,这是通过-presentationLayer
方法访问的。显示层本质上是模型层的副本,但它的属性值通常是当前时刻点的样子
。用另一句话来说,你可以访问展示层的属性来查看相应模型图层属性在屏幕上的当前值(如图7.4)。
我们在第1章有提及到,除了图层树外有一个叫展示树的东西。展示树是一个由图层树中所有图层的展示图层组成的树。注意,展示层只在图层每一次提交(就是当它第一次显示在屏幕上时)时创建,所以在这之前尝试调用-presentationLayer
会返回nil
。
你可能注意到也有一个-modelLayer
方法。在展示图层上调用-modelLayer
会返回其下正在显示的CALayer
。在一个普通图层上调用modelLayer
只会返回-self
。(我们早已说过普通图层实际上就是一种模型。)
大多情况下,你不需要直接访问展示图层;你只需要与模型图层的属性打交道,Core Animation
会帮你更新显示。有两种情况下展示图层确实有用,一种是异步动画,另一种是处理用户交互:
- 如果你在实现基于时间的动画(见第11章“基于时间的动画”)再非普通的基于事务的动画,明确某一时刻点指定图层在屏幕上的显示是十分有用的,这样你就可以在动画时正确放置其它元素。
- 如果你想你的动画图层响应用户输入,而且你在使用
-hitTest:
方法(见第3章“图层几何”)来判定指定图层是否被触摸了,对展示图层而非模型图层调用-hitTest:
方法会更有意义,这是因为展示图层展示了用户当前看见的图层位置,而非当前动画结束后将处于的位置。
我们可以用一个简单的例子(见表7.7)来演示后一个例子。在这个例子中,点击屏幕上的任何位置会让图层以动画的形式移动到点击处。点击图层本身会给它设置一个随机的颜色值。我们于图层的展示层上调用-hitTest:
方法来判定点击是否在图层中。
如果你修改代码来使-hitTest:
方法直接在colorLayer
上而非其展示图层上调用,你会发现在图层移动时将无法正常工作,这样你不得不点击图层将要移动到的位置来触发(这是为什么我们最初用展示层来进行点击测试)。
表7.7 使用presentationLayer来判定当前图层位置
import UIKit
class ViewController: UIViewController {
var colorLayer: CALayer!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 创建一个红色图层
self.colorLayer = CALayer()
self.colorLayer.frame = CGRectMake(0, 0, 100, 100)
self.colorLayer.position = CGPointMake(self.view.bounds.size.width / 2, self.view.bounds.size.height / 2)
self.colorLayer.backgroundColor = UIColor.redColor().CGColor
self.view.layer.addSublayer(self.colorLayer)
}
}
override func touchesBegan(touches: Set<NSObject>, withEvent event: UIEvent) {
// 获得触摸点
let point = (touches as NSSet).anyObject()?.locationInView(self.view) as CGPoint!
// 检测是否点击了移动图层
if ((self.colorLayer.presentationLayer().hitTest(point)) != nil) {
// 随机图层背景色
let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
self.colorLayer.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor
} else {
// 否则(慢慢地)移动图层到新的位置
CATransaction.begin()
CATransaction.setAnimationDuration(4.0)
self.colorLayer.position = point
CATransaction.commit()
}
}
}
总结
这一章讲解了隐匿动画以及Core Animation
为一个指定属性选择合适动画动作的机制。你也学习了UIKit
如何使用Core Animation
的隐式动画机制来扩充其自身的显式系统的,在显示系统中动画是默认禁用的,只有在需要时启用。最后,你学习了展示图层和模型图层,以及它们如何使Core Animation
同时追踪图层当前和将来的位置。
下一章中,我们将讲解Core Animation
提供的显式动画类型,它可以直接用于图层属性动画或复写默认的图层行为。