如何你想某事正确,自己动手做吧。——Charles-Guillaume Étienne
前一章介绍了隐式动画的概念。隐式动画是iOS上创建用户界面的一种直接的方式,它们是UIKit
自身动画机制的基础,但它们并不是一个完整的通用的动画方案。这一章,我们将讲解显式动画,这使我们得以对特殊属性指定自定义动画或者创建非线性动画,如沿弧移动。
属性动画
我们将讲解的第一种显式动画类型是属性动画。属性动画针对于图层的一个单一属性,并指定动画属性的目标值的区间。属性动画分为两类:基础和关键帧。
基础动画
基础动画是随时间发生的,是值改变的最简单的方法,它是CABasicAnimation
设计的模型。
CABasicAnimation
是CAPropertyAnimation
这一抽象类的具体子类,它是CAAnimation
的子类,而CAAnimation
是Core Animation
提供的所有动画类型的抽象基类。身为抽象类,CAAnimation
自身并不会明确实现功能。它提供了一个时间函数(正如第10章“缓动”所讲解的),一个委托(用于获得动画状态反馈),以及一个removedOnCompletion
标志用于指示是否在结束后自动释放(这默认为YES
,这会防止内存溢出)。CAAnimation
也实现包括CAAction
(允许任何CAAnimation
子类被作为图层动作支持)以及CAMediaTiming
(将在第9章“图层时间”中细说)等一系列协议。
CAPropertyAnimation
作用于单一属性,由动画的keyPath
值指定。CAAnimation
通常用于某一CALayer
,同样keyPath
是与该图层相关的。事实上这是一个关键路径(一系列由点限定的键,它们指向一个有层次的直接结构对象)而并非仅是一个属性名,keyPath
十分有趣,它意味着动画不仅可以应用于图层自身,还可以应用于成员对象的属性,甚至虚拟属性(稍后细说)。
CABasicAnimation
对CAPropertyAnimation
扩展了三个额外的属性:
id fromValue
id toValue
id byValue
它们的意思如同字面所示:fromValue
表示动画开始时的属性值;toValue
表示动画结束时的属性值;byValue
表示动画期间改变的属性值。
通过这三个属性,你可以用不同的方式改变某个值。它们的类型是id
(而不是具体类),这是因为属性动画可以用于包括数值、向量、变形矩阵以及颜色和图像在内的不同的属性类型。
id
类型的属性可以包含任意派生的NSObject
,但通常你会想要不是继承自NSObject
的属性类型,这意味着你将需要将值包装成一个对象(被称作boxing)或者转型为一个对象(被称作toll-free bredging),这使得任意Core Foundation
类型表现的如同Objective-C
类,即使它们本身并不是。有时如何将想要的数据类型转换成适配id
的值是并不明显的,但表格8.1列举了一些普遍的情况。
表格8.1 用CAPropertyAnimation`封装原始值
类型 | 对象类型 | 代码例子 |
---|---|---|
CGFloat | NSNumber | id obj = @(float); |
CGPoint | NSValue | id obj = [NSValue valueWithCGPoint: point); |
CGSize | NSValue | id obj = [NSValue valueWithCGSize: size); |
CGRect | NSValue | id obj = [NSValue valueWithCGRect: rect); |
CATransform3D | NSValue | id obj = [NSValue valueWithCATransform3D: transform); |
CGImageRef | id | id obj = (__bridge id)imageRef; |
CGColorRef | id | id obj = (__bridge id)colorRef; |
fromValue
,toValue
,和byValue
属性可以被用于不同的组合,但你不能同时指定三个值,这样会引起冲突。例如,如果你指定fromValue
为2
,toValue
为4
,而byValue
为3,Core Animation
并不知道最终值应该为4
(由toValue
指定)还是5
(formValue
+byValue
)。关于这些属性值到底如何使用在CABasicAnimation
的头文件中有良好的文档,所以在这我们并不重复它们。通常,你只需要指定toValue
或byValue
;其它值会根据上下文推断出来。
让我们试一下:我们将会修改第7章“隐式动画”的颜色渐变动画,我们将使用一个明确的CABasicAnimation
而非一个隐式动画。表8.1展示了代码。
表8.1 用CABasicAnimation设置图层背景颜色
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, 50, 100, 100)
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)
let color = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor
// 创建基本动画
let animation = CABasicAnimation()
animation.keyPath = "backgroundColor"
animation.toValue = color
// 对图层应用动画
self.colorLayer.addAnimation(animation, forKey: nil)
}
}
当我们运行程序时,它并不像想象中运行。点击按钮后图层会动画成一个新颜色,但它马上又跳回原值。
其原因是动画并没有修改的模型,而修改了它的展示(见第7章)。一但动画结束,它会从图层移除,图层变回它模型属性定义的样子。我们从未修改过backgroundColor
属性,所以图层返回它的初值。
当我们之前使用隐式动画时,底层动作是使用一个CABasicAnimation
实现的,恰如我们之前用过那个一样。(你可能回忆起在第7章,我们输出了-actionForLayer:forKey:
委托方法的日志记录,然后发现动作类型是CABasicAnimation
。)然而,在那里,我们通过设置属性来触发动画。现在我们用直接使用的画,但我们并没有设置属性(因此引发了闪回问题)。
将我们的动画注册为图层动作(然后简单地通过改变值来触发动画)是目前为止最简单的保持属性值并同步动画状态的方法,但假使我们因为某些原因我们不能使用这个方法(通常因为我们需要动画的图层是一个UIView
的主图层),我们有两个选择来更新属性值:动画开始前或结束后。
相比之下在动画开始前更新属性会更简单点,但这意味着我们无法利用隐式的fromValue
的优势,因此我们需要手动设置动画的fromValue
来匹配图层的当前值。
考虑到这点,在创建动画的代码中,如果我们在将它添加到图层时添加如下两行代码,将可以避免闪回:
animation.fromValue = self.colorLayer.backgroundColor
self.colorLayer.backgroundColor = color
这起作用了,但它并不可靠。我们应该从展示层(如果存在)而非模型层派生出fromValue
,除非进程中早有动画。而且,因为这个图层并不是主图层,在设置属性前我们应该用CATransaction
来禁用隐式动画,否则默认的图层行为会影响我们的显式动画。(事实上,显式动画通常会覆盖隐式动画,但这一行为并没有记录在文档中,所以为了安全起见不要随意使用。)
如果我们做了这些改变,我们以以下这些代码结束:
let layer = (self.colorLayer.presentationLayer() != nil) ? self.colorLayer.presentationLayer() as? CALayer : self.colorLayer
animation.fromValue = layer.backgroundColor
CATransaction.begin()
CATransaction.setDisableActions(true)
self.colorLayer.backgroundColor = color
CATransaction.commit()
这样需要给每个动画加上许多代码。幸运的是,我们可以从CABasicAnimation
对象本身自动派生这些信息,所以我们可以创建一个可复用的方法。表8.2展示了我们第一个例子的改版,它包括一个方法来应用一个CABasicAnimation
而无需重复这些冗长的代码。
表8.2 一个用于修复动画闪回的可复用方法
func applyBasicAnimation(animation: CABasicAnimation, toLayer layer: CALayer) {
// 设置起始值(如果可能使用展示层)
animation.fromValue = ((layer.presentationLayer() != nil) ? layer.presentationLayer() as? CALayer : layer)?.valueForKeyPath(animation.keyPath)
// 提前更新属性
// 注意:这一方法只在toValue != nil时可用
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.setValue(animation.toValue, forKeyPath: animation.keyPath)
CATransaction.commit()
// 给图层应用动画
layer.addAnimation(animation, forKey: nil)
}
@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)
let color = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor
// 创建基本动画
let animation = CABasicAnimation()
animation.keyPath = "backgroundColor"
animation.toValue = color
// 添加无闪回的动画
self.applyBasicAnimation(animation, toLayer: self.colorLayer)
}
这一简单实现只处理有toValue
而非byValue
的动画,但它是面向通用解决方案的好的开始。你可以将其封装成CALayer
的类别(category)方法来使其更加方便及可复用。
这一切看起来好像是用了很复杂的方法解决这一简单的问题,但这一选择其实有更复杂的考虑。如果在我们动画开始前不更新目标属性,我们在其完全结束前都不能更新它,否则我们将会在进程里取消这一CABasicAnimation
。这意味着我们需要在动画结束后的恰当时刻更新属性,但应该在其从图层移除前,否则属性会闪回初始值。我们如何判定这一时间点?
CAAniamtionDelegate
在第7章中,当我们使用隐式动画时,我们可以用CATransaction
闭包来检测动画结束。然而使用显式动画时,这一方法并不适用,这是因为这一动画与事务无关。
为了发现显式动画何时结束,我们需要使用动画的delegate
属性,它遵循CAAnimationDelegate
协议。
CAAnimationDelegate
是一个自助协议,所以你不会在任何头文件中找到CAAnimationDelegate
这一协议的定义,但你可以在Apple的开发者文档的CAAnimation
中发现相应的支持方法。在这里,我们使用-animationDidStop:finished:
方法来在动画结束后立即更新我们的图层backgroundColor
。
我们需要在更新属性时开始新事务并禁用图层动作;否则,动画将发生两次——一次出于我们的显式CABasicAnimation
,再一次因为该属性的隐式动画。见表8.3所示的完整实现。
表8.3 实现背景颜色值一次动画
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, 50, 100, 100)
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)
let color = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor
// 创建基本动画
let animation = CABasicAnimation()
animation.keyPath = "backgroundColor"
animation.toValue = color
animation.delegate = self
// 给图层应用动画
self.colorLayer.addAnimation(animation, forKey: nil)
}
override func animationDidStop(anim: CAAnimation!, finished flag: Bool) {
// 设置backgroundColor属性来匹配动画toValue
CATransaction.begin()
CATransaction.setDisableActions(true)
self.colorLayer.backgroundColor = (anim as? CABasicAnimation)?.toValue as! CGColorRef
CATransaction.commit()
}
}
使用CAAnimation
的委托方法而非闭包带来的问题是你很容易陷入追踪许多动画和图层的麻烦中。当在视图控制器中创建动画时,你通常会使用控制器本身作为动画委托(正如我们在表8.3中所做的一样),但因为所有的动画都会调用同一个委托方法,你需要寻找图层的相应的图层。
考虑第3章“图层几何”中的时钟;我们最初通过简单的非动画地更新指针角度来实现时钟。如果我们让指针像现实一样动画到相应位置看起来会更好。
我们不能使用隐式动画来移动指针,因为指针是用UIView
实例展示的,而隐式动画对它们的主图层是禁用的。我们可以轻松地使用UIView
动画方法来实现动画,但如果使用显式动画,我们将得以控制动画时间(将在第10章细说)。使用CABasicAnimation
移动指针动画十分复杂,因为我们需要在-animaitonDidStop:finished:
方法中检测指针的相应动画(这样我们才能设置它终止的位置)。
动画本身作为委托方法的一个参数传递。你可能认为你可以在控制器中将动画存储成属性然后与委托方法中的参数进行比较,但这并没有用,因为委托返回的动画是原始的一份不可变的拷贝,而非同一对象。
当我们使用-addAnimation:forKey:
来向我们的图层添加动画时,有一个我们至今总设为nil
的key
参数。这个键(key)是一个用于动画的唯一标识的NSString
,可用于在图层在-animationForKey:
方法中使用。当前附加到图层的所以动画的键可以使用animationKeys
取得。如果我们对每个动画使用一个独一无二的键,我们可以遍历每一个动画图层的动画键并与传递给我们委托方法的动画对象调用-animationForKey:
的结果相比较。但这仍不是一个优雅的解决方案。
幸运的是,存在更为简单的方法。像所有NSObject
子类一样,CAAnimation
遵循KVC(Key-Value Coding键值码)自助协议,这使得我们可以使用名字通过-setValue:forKey:
和-valueForKey:
方法来设置或取得属性。但CAAnimation
有一个不一般的特性:它表现的如同NSDictionary
,允许你直接设置键值对,即使他们并不匹配任何你在用的动画类的已声明属性。
这意味着你可以给一个动画加上额外的数据标签供自己使用。这里,我们将给动画加上时钟指针的UIView
,这样我们可以容易的判断每一个动画相应的视图。接下来我们在委托中使用这一信息来更新正确的指针(如表8.4)。
表8.4 使用KVC来给动画添加额外的数据标签
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var hourHand: UIImageView!
@IBOutlet weak var minuteHand: UIImageView!
@IBOutlet weak var secondHand: UIImageView!
var timer: NSTimer!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 调整锚点
self.secondHand.layer.anchorPoint = CGPointMake(0.5, 0.9)
self.minuteHand.layer.anchorPoint = CGPointMake(0.5, 0.9)
self.hourHand.layer.anchorPoint = CGPointMake(0.5, 0.9)
// 启动计时器
self.timer = NSTimer.scheduledTimerWithTimeInterval(1.0, target: self, selector: "tick", userInfo: nil, repeats: true)
// 设置初始指针位置
self.updateHandsAnimated(false)
}
}
func tick() {
self.updateHandsAnimated(true)
}
func updateHandsAnimated(animated: Bool) {
// 将时间转换成小时、分钟和秒
let calendar = NSCalendar(calendarIdentifier: NSCalendarIdentifierChinese)!
let units = NSCalendarUnit.CalendarUnitHour | NSCalendarUnit.CalendarUnitMinute | NSCalendarUnit.CalendarUnitSecond
let components = calendar.components(units, fromDate: NSDate())
// 计算时针角度
let hoursAngle: CGFloat = (CGFloat(components.hour) / 12.0) * CGFloat(M_PI * 2.0)
// 计算分针角度
let minsAngle: CGFloat = (CGFloat(components.minute) / 60.0) * CGFloat(M_PI * 2.0)
// 计算秒针角度
let secsAngle: CGFloat = (CGFloat(components.second) / 60.0) * CGFloat(M_PI * 2.0)
// 旋转指针
self.setAngle(hoursAngle, forHand: hourHand, animated: animated)
self.setAngle(minsAngle, forHand: minuteHand, animated: animated)
self.setAngle(secsAngle, forHand: secondHand, animated: animated)
}
func setAngle(angle: CGFloat, forHand handView: UIView, animated: Bool) {
// 产生形变
let transform = CATransform3DMakeRotation(angle, 0, 0, 1)
if (animated) {
// 创建形变动画
let animation = CABasicAnimation()
animation.keyPath = "transform"
animation.toValue = NSValue(CATransform3D: transform)
animation.duration = 0.5
animation.delegate = self
animation.setValue(handView, forKey: "handView")
handView.layer.addAnimation(animation, forKey: nil)
} else {
// 直接设置形变
handView.layer.transform = transform
}
}
override func animationDidStop(anim: CAAnimation!, finished flag: Bool) {
// 为指针视图设置最终位置
let handView: UIImageView = anim.valueForKey("handView") as! UIImageView
handView.layer.transform = (anim as! CABasicAnimation).toValue.CATransform3DValue
}
}
我们成功地在每一图层结束动画时辨别出它们,并且将它们的值更新成正确的形变值。到这一步,一切都很棒。
不幸的是,即使做了这些步骤,我们仍有另一个问题。表8.4在模拟器上运作正常,但如果我们在一台iOS设备上运行它,我们会看见我们的时钟指针在调用-animationDidStop:finished:
委托方法前会轻微地跳回其初始值。同样的事情发生在表8.3的图层颜色上。
问题在于尽管回调函数是在动画结束后调用的,但并不能保证它在属性重置为先前状态前被调用。这是一个好例子用来说明你总应该在真机上测试动画代码,而不仅是在模拟器上。
我们可以通过使用一个叫fillMode
的属性来解决这个问题,这个将在下一章中讲解,但在本章中我们在应用动画之前设置要动画的属性为最终值,这比尝试在动画结束后更新它更简单。
关键帧动画
CABasicAnimaiton
有趣之处在于它向我们展示了大多数iOS上的隐式动画的底层机制。相比于用隐式动画实现同样动画效果,显式地向一个图层添加CABasicAnimation
常常需要更多的工作才能换来一点小小的收益(无论是有组织图层的隐式动画或视图或主图层的UIView
动画)。
然而CAKeyframeAnimation
是更为强大的,且在UIKit
中没有暴露相应等同接口的。它像CABasicAnimation
一样是CAPropertyAnimation
的子类。它同样作用于单一属性,但不同于CABasicAnimation
它并未限定为唯一的开始或结束值,它可以赋予一系列动画区间的系列值。
术语关键帧来源于传统动画,表示的是首席绘画师只绘制显著发生的帧(关键帧),而技巧差点的艺术家绘制之前的帧(这可以容易地从关键帧中推出)。同样的原则适用于CAKeyframeAnimation:
,你提供显著帧,然后Core Animation
使用一个叫“插值”的过程填充空隙。
我们可以用我们先前的颜色图层演示这个。我们将设置一组颜色然后用一个关键帧动画采用一条命令回放它们(如表8.5)。
表8.5 使用CAKeyframeAnimation应用一系列颜色
@IBAction func changeColor(sender: AnyObject) {
// 创建关键帧动画
let animation = CAKeyframeAnimation()
animation.keyPath = "backgroundColor"
animation.duration = 2.0
animation.values = [
UIColor.blueColor().CGColor,
UIColor.redColor().CGColor,
UIColor.greenColor().CGColor,
UIColor.blueColor().CGColor
]
// 给图层应用动画
self.colorLayer.addAnimation(animation, forKey: nil)
}
注意我们指定序列的开始和结束都是蓝色。这是必须的,因为CAKeyframeAnimation
并没有一个选项来自动使用当前值作为第一帧(因此我们在CABasicAnimation
中将fromValue
设为nil
)。动画在开始后会立即跳到第一个关键帧值,然后在结束后立即退回其初始属性值,为了形成一个平滑的动画,我们需要开始和结束的关键帧都匹配当前的属性值。
当然,也可以创建开始与结束不同值的动画。那样的话,我们需要在触发动画前手动更新属性值来切尔西最后的关键帧,正如我们先前说过的那样。
我们已经使用duration
属性将动画时长从默认的0.25秒增加到2秒,这样动画不至于快得看不清。如果你运行这一动画,你会看见图层依次变换这些颜色,但效果看起来有点奇怪。原因是动画以一个恒定速度运行。在颜色之间过渡时并不会减速,这使得结果有点不真实感。为了使动画看起来更自然,我们需要调整缓动(easing),这将会在第10章讲解。
对于颜色改变的动画来说一系列值显得有意义,但通常对于描述动作来说就十分怪异了。CAKeyframeAnimation
有一个可选方法来指定动画,就是使用CGPath
。path
属性允许你用一种自然的方式定义运行序列,通过使用Core Graphics
函数来绘制你的动画。
让我们用一个沿简单曲线移动的飞船图像的动画来演示这点。为了创建路径,我们将使用一个三次贝塞尔曲线(cubic Bézier curve)
,这是一个用一个起点、一个终点以及两个额外的控制点来描述形状的特定曲线类型。可以使用纯粹基于C的Core Graphics
的命令来创建这一路径,但使用UIKit
提供的高层的UIBezierPath
类会更简单。
尽管对于动画来说并不是必须的,我们将用CAShapeLayer
来在屏幕上绘制这一曲线。这使其很容易视觉化的看出我们的动画走向。在我们画完CGPath
之后,我们用它创建一个CAKeyframeAnimation
,然后将之用于我们的飞船。表8.6展示了相应代码,图8.1展示了相应结果。
表8.6 沿三次贝塞尔曲线运动的图层动画
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var containerView: UIView!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 创建路径
let bezierPath = UIBezierPath()
bezierPath.moveToPoint(CGPointMake(0, 150))
bezierPath.addCurveToPoint(CGPointMake(300, 150), controlPoint1: CGPointMake(75, 0), controlPoint2: CGPointMake(225, 300))
// 使用CAShapeLayer绘制路径
let pathLayer = CAShapeLayer()
pathLayer.path = bezierPath.CGPath
pathLayer.fillColor = UIColor.clearColor().CGColor
pathLayer.strokeColor = UIColor.redColor().CGColor
pathLayer.lineWidth = 3.0
self.containerView.layer.addSublayer(pathLayer)
// 增加飞船
let shipLayer = CALayer()
shipLayer.frame = CGRectMake(0, 0, 64, 64)
shipLayer.position = CGPointMake(0, 150)
// 译者用之前的雪人图像代替飞船,读者理解方法就好
shipLayer.contents = UIImage(named: "Snowman")?.CGImage
self.containerView.layer.addSublayer(shipLayer)
// 创建关键帧动画
let animation = CAKeyframeAnimation()
animation.keyPath = "position"
animation.duration = 4.0
animation.path = bezierPath.CGPath
shipLayer.addAnimation(animation, forKey: nil)
}
}
}
图8.1 沿贝塞尔曲线移动的图像图层
如果你运行这一案例,你会注意到动画看起来有一点不真实,因为它移动时总指向同一个方向而不是随曲线切线改变。你可以调整它的affineTransform
在其移动时改变朝向,但同步其它动画将会十分麻烦。
幸运的是,Apple预料到了这一情况,给CAKeyframeAnimation
增加了一个叫rotationMode
的属性。将rotationMode
设置为固定值kCAAnimationRotateAuto
(如表8.7),图层将会在动画时自动随切线旋转(如图8.2)。
表8.7 使用rotationMode自动对齐图层和曲线
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 创建路径
let bezierPath = UIBezierPath()
bezierPath.moveToPoint(CGPointMake(0, 150))
bezierPath.addCurveToPoint(CGPointMake(300, 150), controlPoint1: CGPointMake(75, 0), controlPoint2: CGPointMake(225, 300))
// 使用CAShapeLayer绘制路径
let pathLayer = CAShapeLayer()
pathLayer.path = bezierPath.CGPath
pathLayer.fillColor = UIColor.clearColor().CGColor
pathLayer.strokeColor = UIColor.redColor().CGColor
pathLayer.lineWidth = 3.0
self.containerView.layer.addSublayer(pathLayer)
// 增加飞船
let shipLayer = CALayer()
shipLayer.frame = CGRectMake(0, 0, 64, 64)
shipLayer.position = CGPointMake(0, 150)
// 译者用之前的雪人图像代替飞船,读者理解方法就好
shipLayer.contents = UIImage(named: "Snowman")?.CGImage
self.containerView.layer.addSublayer(shipLayer)
// 创建关键帧动画
let animation = CAKeyframeAnimation()
animation.keyPath = "position"
animation.duration = 4.0
animation.path = bezierPath.CGPath
animation.rotationMode = kCAAnimationRotateAuto
shipLayer.addAnimation(animation, forKey: nil)
}
}
图8.2 匹配曲线切线旋转的图层
虚拟属性
我们先前说过事实上属性动画作用于关键路径而非键,这意味我们可以对子属性甚至虚拟属性添加动画。但什么是虚拟属性?
想象一个旋转的动画:如果我们想要添加一个旋转对象动画,我们不得不使用transform
,因为CALayer
并没有任何显式的角度/朝向属性。我们可以如表8.8所示这样做。
表8.8 动画于transform属性来旋转图层
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var containerView: UIView!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 增加飞船
let shipLayer = CALayer()
shipLayer.frame = CGRectMake(0, 0, 64, 64)
shipLayer.position = CGPointMake(0, 150)
// 译者用之前的雪人图像代替飞船,读者理解方法就好
shipLayer.contents = UIImage(named: "Snowman")?.CGImage
self.containerView.layer.addSublayer(shipLayer)
// 飞船旋转动画
let animation = CABasicAnimation()
animation.keyPath = "transform"
animation.duration = 2.0
animation.toValue = NSValue(CATransform3D: CATransform3DMakeRotation(CGFloat(M_PI), 0, 0, 1))
shipLayer.addAnimation(animation, forKey: nil)
}
}
}
这生效了,但这看起来更像是运气好而非是设计的。如果我们将旋转角度从M_PI
(180度)变为2 * M_PI
(360度),然后运行动画,我们会发现飞船压根不动。这是因为矩阵展示中360度等同于0度,所以就动画而言,数值并没有改变。
现在尝试再次使用M_PI
,但是赋值给byValue
而非toValue
属性,这表明旋转应该是相对于当前值而言的。你可能以为这会和设置toValue
得到一样的效果,因为0 + 90度==90度,但实际上图像会拉伸而非旋转,因为变形矩阵不能像角度值一样相加。
如果我们想独立于飞船角度移动或缩放飞船会怎么样?因为它们都需要我们修改transform
属性,我们需要重新计算每个时刻下的这些动画的结合效果,然后从这些组合变形数据中创建一个复杂的关键帧动画,即使我们真正想要做的只是对我们独立图层的一些概念上分享的属性添加动画效果。
幸运的是,有一个解决方案:为了旋转图层,我们可以将我们的动画添加到transform.rotation
关键路径上,而非直接添加到transform
属性自身上(如表8.9)。
表8.9 给虚拟的transform.rotation属性添加动画
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var containerView: UIView!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 增加飞船
let shipLayer = CALayer()
shipLayer.frame = CGRectMake(0, 0, 128, 128)
shipLayer.position = CGPointMake(150, 150)
// 译者用之前的雪人图像代替飞船,读者理解方法就好
shipLayer.contents = UIImage(named: "Snowman")?.CGImage
self.containerView.layer.addSublayer(shipLayer)
// 飞船旋转动画
let animation = CABasicAnimation()
animation.keyPath = "transform.rotation"
animation.duration = 2.0
animation.byValue = CGFloat(M_PI * 2)
shipLayer.addAnimation(animation, forKey: nil)
}
}
}
这个方法效果十分好。使用transform.rotation
而非transform
的好处如下:
- 它允许我们在一步中不用关键帧旋转多于180度。
- 它允许我们使用相对值而非绝对值旋转(通过设置
byValue
而非toValue
)。 - 它允许我们用一个简单的数字值指定角度而不用构建一个
CATransform3D
。 - 它不会与
transform.position
或transform.scale
冲突(这些也是使用关键路径的独立动画)。
关于transform.rotation
属性的奇怪事情是它并不是真实存在的。由于CATransform3D
并非对象,所以这个属性不能存在;它是结构体
所以不能有类似KVC(键值码)的属性。transfrom.rotation
实际上是一个虚拟属性,它是CALayer
提供的用来简化动画变形进程的。
你不能直接设置如同transform.rotation
或transform.scale
等属性;它们只用于动画。当你给这些属性添加动画时,Core Animation
通过使用一个叫CAValueFuntion
的类来自动更新你改变的必须的transform
属性。
CAValueFuntion
被用于转换我们赋值给虚拟的transform.rotation
属性的简单浮点数为真正用于移动图层所需要的CATransform3D
矩阵值。你可以通过设置给定的CAPropertyAnimation
的valueFuntion
属性改变值函数。你指定的函数会覆写默认的。
CAValueFunction
看起来像是一个用于给不能自然相加或添加的属性动画(例如变形矩阵)的有用的机制,但因为CAValueFunction
的实现细节是私有的,现在并不能直接继承它来创建一个新的值函数。你只能使用Apple早已提供的可用常量函数(现在全部关联变形矩阵的虚拟属性,因此有点少,因为默认的这些属性的动作早已使用合适的值函数)。
动画组
尽管CABasicAnimation
和CAKeyframeAnimation
只针对独立的属性,多个这种动画可以用CAAnimationGroup
组合在一起。CAAnimationGroup
是另一个CAAnimation
的具体子类,它增加了一个animations
数组属性,用来组合其它动画。让我们在表8.6中组合关键帧动画和另一个改变图层背景色的动画(如表8.10)。表8.3展示了结果。
向图层添加动画组和单独添加多个动画并没有本质上的区别,所以你现在可能还不是很清楚何时以及为什么使用这个类。它为集体设置动画时长,或者通过一条指令添加或移除多个动画提供了一些便利,但对于第9章讲解的层次时间显得并没有什么用。
表8.10 将关键帧动画和基本动画组合起来
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var containerView: UIView!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 创建路径
let bezierPath = UIBezierPath()
bezierPath.moveToPoint(CGPointMake(0, 150))
bezierPath.addCurveToPoint(CGPointMake(300, 150), controlPoint1: CGPointMake(75, 0), controlPoint2: CGPointMake(225, 300))
// 使用CAShapeLayer绘制路径
let pathLayer = CAShapeLayer()
pathLayer.path = bezierPath.CGPath
pathLayer.fillColor = UIColor.clearColor().CGColor
pathLayer.strokeColor = UIColor.redColor().CGColor
pathLayer.lineWidth = 3.0
self.containerView.layer.addSublayer(pathLayer)
// 添加有色图层
let colorLayer = CALayer()
colorLayer.frame = CGRectMake(0, 0, 64, 64)
colorLayer.position = CGPointMake(0, 150)
colorLayer.backgroundColor = UIColor.greenColor().CGColor
self.containerView.layer.addSublayer(colorLayer)
// 创建位置动画
let animation1 = CAKeyframeAnimation()
animation1.keyPath = "position"
animation1.path = bezierPath.CGPath
animation1.rotationMode = kCAAnimationRotateAuto
// 创建颜色动画
let animation2 = CABasicAnimation()
animation2.keyPath = "backgroundColor"
animation2.toValue = UIColor.redColor().CGColor
// 创建组动画
let groupAnimation = CAAnimationGroup()
groupAnimation.animations = [animation1, animation2]
groupAnimation.duration = 4.0
// 给颜色涂层添加这一动画
colorLayer.addAnimation(groupAnimation, forKey: nil)
}
}
}
图8.3 一个关键帧路径和基本颜色属性动画组
过渡
对于iOS应用来说,使用属性动画来改变布局是十分困难的。例如,你可能需要转化某些文本或图像,或一次性替换掉整个网格或表格。属性动画只作用于图层的可动画属性,所以如果你需要改变一个不可动画的属性(例如图像)或从层次中增删图层,属性动画就不起作用了。
这时过渡的作用就体现了。过渡动画并不像属性动画一样尝试在两个值之间平滑的插值;相反的是它被设计为一系列分治策略——用一个动画掩饰内容改变。过渡影响整个图层而非某一指定属性。过渡会留一个旧图层样式的快照,然后一次将之动画过渡到新的样式。
我们使用CATransition
来创建过渡,它是CAAnimation
的另一个子类。它继承了CAAnimation
除了时间函数外的一切,CATransition
有一个type
和一个subtype
用来指定过渡效果。type
属性是一个NSString
,它可以被设置为下列常量之一:
kCATransitionFade
kCATransitionMoveIn
kCATransitionPush
kCATransitionReveal
你现在被限制为这四个基本的CATransition
类型,但还有一些方法让你可以实现额外的过渡效果,这将在本章后面讲解。
默认的过渡类型是kCATransitionFade
,这会在你修改属性或内容后创建先前图层样式和新样式的平滑交替效果。
我们在第7章的自定义行为案例中曾使用过kCATransitionPush
类型;这会从侧面滑入新的图层样式,从另一面推出旧的样式。
kCATransitionMoveIn
和kCATransitionReveal
类似于kCATransitionPush
;它们都实现了一个方向滑动动画,但有些许不同;kCATransitionMoveIn
从先前样式的上移入新的图层样式,但并不像推过渡一样推出旧样式,kCATransitionReveal
移出旧样式来露出新样式而不是移入新样式。
后三个标准类型有自然地内在指向的。默认情况下,它们从左侧滑入,但你可以使用subtype
属性控制它们的方向,它接受如下常量:
kCATransitionFromRight
kCATransitionFromLeft
kCATransitionFromTop
kCATransitionFromBottom
表8.11展示了一个使用CATransition
添加不可动画属性的动画的简单例子。在这我们改变了UIImage
的image
属性,它正常并不能通过隐式动画或CAPropertyAnimation
添加动画,这是因为Core Animation
并不知道如何在图像之间插值。通过对图层应用交替渐变过渡,我们可以无视内容类型创建平滑的动画改变(如图8.4)。试试改变过渡的type
常量来看看其它可用的效果。
表8.11 使用CATransition给UIImageView添加动画
import UIKit
import Foundation
class ViewController: UIViewController {
@IBOutlet weak var imageView: UIImageView!
var images: NSArray!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 设置图像
self.images = NSArray(objects:
UIImage(named: "Snowman")!,
UIImage(named: "Cone")!,
UIImage(named: "Igloo")!
)
}
}
@IBAction func switchImage(sender: AnyObject) {
// 设置交替渐变过渡
let transition = CATransition()
transition.type = kCATransitionFade
// 给imageView的主图层添加过渡
self.imageView.layer.addAnimation(transition, forKey: nil)
// 循环至下一图像
let currentImage = self.imageView.image
var index = self.images.indexOfObject(currentImage!)
index = (index + 1) % self.images.count
self.imageView.image = self.images[index] as? UIImage
}
}
正如你从代码中所见,过渡可以用和属性或动画组一样使用-addAnimation:forKey:
的方式添加到图层上。然而,不同于属性动画,同一时刻只有一个CATransition
用于一个给定图层上。因此,无论你给键指定了什么值,过渡实际上都会附上一个transition
键,用常量kCATransition
表示。
隐式过渡
CATransition
可以平滑的实现图层的任何改变,这使得在其它情况下难以添加动画的属性有了一个理想的候补方法。Apple当然意识了这点,CATransition
被用作设置CALayer contents
属性的默认行为。这在有其它隐式动画行为的视图主图层上是禁用的,但对于你自己创建的图层,这意味着图层contents
图像的改变会自动添加交替渐变动画。
在第7章我们使用CATranstion
作为图层行为来给我们的图层背景色添加动画。backgroundColor
属性可以用一个普通的CAPropertyAnimation
添加动画,但这并不意味着你不能用一个CATransition
来代替。
图层树改变动画
CATransition
并不对指定图层属性进行操作,这意味着你可以用它进行图层改变的动画而不需要明确知道什么改变了。例如,你可以在不知道哪些行被添加或移除的情况下,用一个交替渐变平滑的覆写一个复杂的UITableView
的重载动画,或者改变两个不同的UIViewController
实例间的事务而无需知道任何有关它们内在视图层次的信息。
这两种情况都有别于目前我们所做过的其它例子,因为它们动画执行不仅与图层属性改变有关而且和实际的图层树改变有关——我们需要在动画过程中从图层层次结构中确切地添加或移除图层。
这一技巧是用于保证附加了CATransition
的图层不会自己在事务期间从图层树中移除,因为随后CATransition
会随之一切移除。通常来说,你只需要将事务附加到受它们影响的图层的父图层上。
在表8.12中我们将展示如何在UITabBarController
的选项卡之间实现交替渐变事务。这里我们简单地使用了默认的Tabbed Application
项目模板,使用UITabBarControllerDelegate
中的-tabBarController:didSelectViewController:
方法来施加动画事务。我们将事务附加到UITabBarController
的视图图层上,因为它们在选项卡相互交换时不会被替换。
表8.12 给UITabBarController添加动画
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UITabBarControllerDelegate {
var window: UIWindow?
var tabBarController: UITabBarController?
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
self.window = UIWindow(frame: UIScreen.mainScreen().bounds)
let viewController1 = FirstViewController()
let viewController2 = SecondViewController()
self.tabBarController = UITabBarController()
self.tabBarController?.viewControllers = [viewController1, viewController2]
self.tabBarController?.delegate = self
self.window?.rootViewController = self.tabBarController
self.window?.makeKeyAndVisible()
return true
}
func tabBarController(tabBarController: UITabBarController, didSelectViewController viewController: UIViewController) {
// 设置交替渐变事务
let transition = CATransition()
transition.type = kCATransitionFade
// 向选项卡控制器视图添加事务
self.tabBarController?.view.layer.addAnimation(transition, forKey: nil)
}
}
自定义事务
我们说过事务是一个强大的方式来给那些难以平滑改变的属性添加动画的。但列举CATransition
看起来有点限制。
更奇怪的是Apple通过
UIView +transitionFromView:duration:options:completion:和
+transitionWithView:duration:options:animations:方法来揭露Core Animation
事务,但可用选项与通过CATransition type
属性可访问的常量完全不同。可用于UIView
事务方法options
参数的指定常量如下:
UIViewAnimationOptionTransitionFlipFromLeft
UIViewAnimationOptionTransitionFlipFromRight
UIViewAnimationOptionTransitionCurlUp
UIViewAnimationOptionTransitionCurlDown
UIViewAnimationOptionTransitionCrossDissolve
UIViewAnimationOptionTransitionFlipFromTop
UIViewAnimationOptionTransitionFlipFromBottom
除了UIViewAnimationOptionTransitionCrossDissolve
,其它事务都不符合CATransition
类型。你可以修改我们之前的事务例子来测试这些可选事务(如表8.13)。
表8.13 使用UIKit方法的可选事务实现
import UIKit
import Foundation
class ViewController: UIViewController {
@IBOutlet weak var imageView: UIImageView!
var images: NSArray!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 设置图像
self.images = NSArray(objects:
UIImage(named: "Snowman")!,
UIImage(named: "Cone")!,
UIImage(named: "Igloo")!
)
}
}
@IBAction func switchImage(sender: AnyObject) {
UIView.transitionWithView(self.imageView, duration: 1.0, options: UIViewAnimationOptions.TransitionFlipFromLeft, animations: {
// 循环至下一图像
let currentImage = self.imageView.image
var index = self.images.indexOfObject(currentImage!)
index = (index + 1) % self.images.count
self.imageView.image = self.images[index] as? UIImage
}, completion: nil)
}
}
从iOS 5(从这时候开始引入了Core Image
框架)开始的某些文档似乎在暗示,可能可以使用CIFilter
和CATransition
的filter
属性的组合来创建额外的事务类型。然而,直到iOS 6,还是不可以用。尝试对CATransition
使用Core Image
过滤器并没有任何效果。(但在Mac OS上这个是支持的,这也导致了文档的矛盾。)
因此,你不得不选择使用CATransition
或者UIView
的事务方法,这取决于你想实现的效果。希望iOS将来的版本可以支持Core Image
事务过滤器,这样可以通过CATransition
使得所有的Core Image
事务动画可用(甚至可以创建新的动画)。
然而,这并不意味着不可以在iOS中实现自定义的事务动画效果。它只意味着你们需要多做一些工作。正如先前提及的,事务动画的基本原则是你先取得当前图层状态的快照,然后在你改变场景之后的图层时对这一快照添加动画。如果我们知道如何对取得图层的快照,我们可以使用正常的属性动画来实现动画,这样我们根本就不需要使用CATransiton
或者UIKit
的事务方法。
最终我们不难发现,获得图层快照会相对容易一些。CALayer
有一个叫-renderInContext:
的方法可以把当前内容绘入Core Graphics
上下文中,这样就可以捕获一张当前内容的图像,这个图像可以用来在另一个视图中显示。如果我们将这一快照视图置于原始视图之前,它会遮盖住我们对真正视图内容的所有改变,这允许我们重新实现一个简单事务的效果。
表8.14演示了这一想法的基本实现:我们获得当前视图状态的快照,然后改变原始视图背景色的同时旋转并渐隐快照。图8.5展示了我们进行中的自定义事务。
为了让一切简单化,我们使用UIView
的-animateWithDuration:completion:
方法来实现动画效果。尽管我们也可以使用CABasicAnimation
实现同样的效果,但我们就不得不为图层变形和透明度属性设置分离的动画,并且我们需要实现CAAnimationDelegate
在动画完成后从屏幕中移除coverView
。
表8.14 使用renderInContext:创建自定义事务
import UIKit
class ViewController: UIViewController {
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
}
}
@IBAction func performTransition(sender: AnyObject) {
// 保持当前视图快照
UIGraphicsBeginImageContextWithOptions(self.view.bounds.size, true, 0.0)
self.view.layer.renderInContext(UIGraphicsGetCurrentContext())
let coverImage = UIGraphicsGetImageFromCurrentImageContext()
// 将快照视图插入到当前视图前
let coverView = UIImageView(image: coverImage)
coverView.frame = self.view.bounds
self.view.addSubview(coverView)
// 更新视图(我们将简单地随机图层背景色)
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.view.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0)
// 添加动画效果(任何你喜欢的都行)
UIView.animateWithDuration(1.0, animations: {
// 缩放、旋转并渐隐视图
var transform = CGAffineTransformMakeScale(0.01, 0.01)
transform = CGAffineTransformRotate(transform, CGFloat(M_PI_2))
coverView.transform = transform
coverView.alpha = 0.0
}, completion: {
finished in
// 完成后移除覆盖视图
coverView.removeFromSuperview()
})
}
}
图8.5 使用renderInContext:实现自定义事务
有一点需要注意:-renderInContext:
方法会捕获图层的主图像和子图层,但并不会正确处理这些子图层所应用的变形,而且不会处理视频或OpenGL内容。CATransition
并不受此限制,它大概用了一个私有方法来捕获快照。
取消进行中的动画
正如这章前面所说,你可以使用-addAnimation:forKey:
方法中的key
参数来将应用到图层上的动画撤回。你可以使用如下方法:
SWIFT
func animationForKey(_ key: String) -> CAAnimation?
OBJECTIVE-C
- (CAAnimation * nullable)animationForKey:(NSString * nonnull)key
系统并不支持修改进行中的动画,所以这一参数的主要目的是用来检查动画属性或者检测图层上是否有某一特定动画。
要终止指定动画,你可以使用如下方法将之从图层上移除:
SWIFT
func removeAnimationForKey(_ key: String!)
OBJECTIVE-C
- (void)removeAnimationForKey:(NSString *)key
或者用这个方法移除所有动画:
SWIFT
func removeAllAnimations()
OBJECTIVE-C
- (void)removeAllAnimations
一旦动画被移除,图层样式会更新来匹配当前模型值。动画会在结束后自动移除,除非你将动画的removedOnCompletion
属性设置为NO
。如果你设置动画不自动移除,你要记得在不再需要它时手动移除它;否则,它会在图层自身最终销毁前一直存储在内存中。
让我们再次扩展旋转飞船案例,添加按钮来控制动画的开始和结束。这一次,我们为我们的动画键提供一个非nil
值,这样我们就可以稍后移除它。-animationDidStop:finished:~方法中的
flag参数表示动画是自然结束的还是被中止的,我们将在控制台中输出记录。如何我们用结束按钮中止动画,控制台会输出
NO,但如果让其完成动画,会输出
YES`。
看表8.15中更新后的例子代码。图8.6展示了结果。
表8.15 开始、结束动画
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var containerView: UIView!
var shipLayer: CALayer!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 判断横屏
let screenSize = UIScreen.mainScreen().applicationFrame.size
if (screenSize.width > screenSize.height) {
// 增加飞船
self.shipLayer = CALayer()
self.shipLayer.frame = CGRectMake(0, 0, 128, 128)
self.shipLayer.position = CGPointMake(150, 150)
// 译者用之前的雪人图像代替飞船,读者理解方法就好
self.shipLayer.contents = UIImage(named: "Snowman")?.CGImage
self.containerView.layer.addSublayer(shipLayer)
}
}
@IBAction func start(sender: AnyObject) {
// 飞船旋转动画
let animation = CABasicAnimation()
animation.keyPath = "transform.rotation"
animation.duration = 2.0
animation.byValue = CGFloat(M_PI * 2)
animation.delegate = self
self.shipLayer.addAnimation(animation, forKey: "rotateAnimation")
}
@IBAction func stop(sender: AnyObject) {
self.shipLayer.removeAnimationForKey("rotateAnimation")
}
override func animationDidStop(anim: CAAnimation!, finished flag: Bool) {
// 输出动画停止
NSLog("The animation stopped (finished: %@)", flag ? "YES": "NO");
}
}
图8.6 用开始和结束按钮控制的旋转动画
译者注:不难发现,原图像仍然存在,译者会在解决问题后再次更新此版本。
总结
这一章中,我们讲解了属性动画(这允许你控制独立图层属性动画),动画组(这允许将多个属性动画组合成单一单元),事务(影响整个图层并可用于对图层内容的任何改变添加动画,包括子图层的添加和移除)。
在第9章中,我们将学习CAMediaTiming
协议并解释Core Animation
如何处理时间流逝。
网友评论