iOS 动画

动画(Animation) 是属性随着时间的可变变化。变化中的属性可以是位置的: 物体移动或者改变尺寸。但是其它种类的属性也能 Animate。例如, 视图的背景色能从红色变为绿色, 不是马上的, 但是能感觉到从一种颜色渐变到另外一种颜色。或者一个视图可以从不透明变为透明, 不是立即的, 但是肉眼可以看到褪色。

动画花费很多计算和时间, 幸运的是你不是自己来执行动画: 你描述它, 整理它, 它会为你执行。你根据需要来获取动画。

要求一个动画能够像设置属性值那样简单; 有时一行代码就能设置动画:

1
myLayer.backgroundColor = UIColor.redColor().CGColor // animate to red

img 选择模拟器的 Debug -> Slow Animations 菜单可以让动画运行的更慢以帮助你检测动画的执行。

Drawing, Animation, and Threading


当你改变一个可见的视图属性, 那个改变不会立即可见。而是, 系统记录你想做的这个变化(change), 并把这个视图标记成需要重绘(redrawn)。然后, 当你的所有代码都完成而且系统有空闲时, 那么它就会重绘所有需要重绘的视图, 应用它们新的可见属性外貌(features)。我们把这叫做重绘时刻

1
2
3
4
5
// 视图从绿色开始
view.backgroundColor = UIColor.redColor()
// 耗时代码放在这儿
view.backgroundColor = UIColor.greenColor()
// 代码结束, 重绘时刻

系统累计所有想要的变化直到重绘时刻的到来, 并且重绘时刻不会发生直到你的代码完成了, 所以, 当重绘时刻确实发生了, 视图中最后一个积累的颜色变为绿色 — 它已经是它的 color 了。因此, 不管颜色变化之间有多少耗时代码, 用户一点也看不到任何颜色变化。

动画的工作原理也是这样, 处理过程也部分相同。当你要执行一个动画时, 动画不会出现在屏幕上直到下一个重绘时刻来临。(你可以强制动画立刻开始, 但这不常用)。

动画就像一种电影和卡通。所以, 当你让一个视图从位置 1 动画到位置 2, 通常有一个事件序列:

  1. 重新配置视图。该视图现在被放置在位置 2, 但是还没到重绘时刻, 所以它仍然显示在位置 1 处。
  2. 为视图指定一个从位置 1 到位置 2的动画。
  3. 等剩下的代码执行完毕
  4. 现在到重绘的时刻了。如果没有动画, 那么该视图现在就会突然显示在位置 2 上。 但是如果有动画, 那么”动画电影” 就会出现。它从位置 1 开始展示视图, 所以那仍旧是用户所看到的。
  5. 动画继续, 然后在位置 1 和位置 2 中间的位置显示视图。
  6. 动画停止, 在位置 2 处显示结束视图
  7. “动画电影”被移除, 视图显示在位置 2 处 — 在第一步放置它的地方。

了解到”动画电影”和真实视图上所发生的是不同的东西是正确配置动画的关键。 新手经常抱怨的一个地方就是动画会发生跳跃, 就是动画执行的最后会跳到另外一个位置, 这跟他们的期望不符。发生这个的原因是视图没有在”动画电影”中匹配它最后的位置; “跳跃” 的发生是因为视图显示的实际位置和”电影”的最后一帧不匹配。

屏幕前面实际上没有”动画电影” — 实际上, 并不是图层(layer)自身显示到屏幕上; 它是派生出来的一个叫做显示层(presentation layer)。因此, 当你从位置 1 到位置 2 animate 视图或图层的位置的位置变化时, 它名义上的位置会立即变化; 同时, 显示层的位置仍旧保持不变直到重绘时刻, 然后会随着时间变化, 因为那是屏幕上实际上所绘制的, 它也是用户所看到的。

(图层的显示层可以通过它的 presentionLayer 方法访问 — 并且图层自身可以通过显示图层的 modeLayer 方法来访问, 在本章和下一章中, 我们也将访问显示层)。

就像真实的电影(特别是老式的卡通动画), “动画电影” 拥有 “帧”(frames)。Animated 值不会平滑和连续地变化; 它是以很小和独立的增长变化来给我们平滑连续变化的错觉。这种错觉有效, 因为设备自身也经受着周期性的迅速的, 或多或少的常规屏幕刷新, 并且不断增加的变化是在这些刷新之间平息的。Apple 调用系统组件来负责这种 “animation server”。

animation server 在单独的线程上操作。你不必担心细节, 但是你也不能忽略它。代码独立运行也可能和动画同时运行。 — 那就是多线程的意义 — 所以在动画和代码之间的沟通需要某些计划。

动画可以用来执行某些清理工作。其中之一就是对触摸(touches)的处理: 当动画在飞行时(in-flight), 如果代码没有在运行, 那么界面默认响应用户的触摸, 这会导致各种破坏, 因为视图会尝试响应触摸而动画仍旧在发生并且屏幕显示不匹配实际。常用的处理方式就是当你为视图设置了动画, 那就关闭该视图的对触摸的响应, 并在动画结束时把它改回来。

因为代码可以在设置动画之后运行或者在动画飞行过程中开始运行, 你需要关心创建有冲突的动画问题。多个动画可以被同时设置(和执行)。

不可抗力可能会打断动画。用户可能按下 Home 键让 app 进入后台或者在动画飞行的时候来电话。在 app 进入后台时, 系统会取消正在飞行中的动画。当 app 恢复后, 会显示动画最后的状态。但是如果你想把动画恢复到它离开的那个状态, 你需要一些精明的代码。

Image View and Image Animation


UIImageView 提供一个 UIImages 数组, 作为它的 animationImageshighlitedAnimationImages 属性的值。这个数组代表着简单卡通的帧(frames); 当你发送 startAnimating 消息, 图片就会以 animationDuration 属性所定义的帧率轮流显示, 重复由 animationRepeatCount 属性指定的次数。(默认为 0, 意思是一直重复), 或者直到收到 stopAnimating 消息。在动画之前和之后, 该 image view 持续显示它的 image(或 highlightedImage)。

例如, 我们想让一个名为 Mars 的图片出现在屏幕上的某个地方并闪烁 3 次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let mars = UIImage(named: "Mars")!

// 打开图形上下文
UIGraphicsBeginImageContextWithOptions(mars.size, false, 0)
// 获取一个空白图像
let empty = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext() // 关闭图形上下文

let arr = [mars, empty, mars, empty, mars]
let iv = UIImageView(image:empty) // 创建一个不含图片的 UIImageView
iv.frame.origin = CGPointMake(100,100)
self.view.addSubview(iv) // 把 UIImageView 添加到 view 上
iv.animationImages = arr // 设置 UIImageView 的图片序列
iv.animationDuration = 2
iv.animationRepeatCount = 1
iv.startAnimating() // 开始动画

UIImageView 动画可以和其它类型的动画组合使用。例如, 闪烁 Mars 图片的同时向右滑动 UIImageView, 使用下一节描述的视图动画。

image 本身也可以是 animated image。多张图片组成了一个序列作为简单卡通的”帧”(frames)。使用下面的其中之一的 UIImage 类方法来创建一个 animated image:

  • animatedImageWithImages:duration:

​ 就像 UIImageView 的 animationImages, 你提供一个 UIImage 的数组。你还可以提供整个动画的持续时间。

  • animatedImageNamed:duration:

    提供单个图片文件的名字, 就像 init(named:) 那样, 不带文件后缀。运行时会给你提供的图片名追加 “0”(失败则为 ”1“), 并把该图片设置为动画序列中的第一张图片。以此类推, 随后追加的数字自增(直到用光图片或达到 ”1024“)。

  • animatedResizableImageNamed:capInsets:resizingMode:duration:

    ​ 把 animated image 和 resizable image 相结合

animated image 可以像 UIImage 那样作为某个界面对象的属性出现在界面中的任何地方。 下面这个例子, 我构造了一个不同尺寸的红色圆形序列, 然后我在 UIBUtton 中展示这个被 animated 后的图形。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class ViewController: UIViewController {

// 在 storyboard 中拖拽一个 UIButton, 并和该 b 变量连线
@IBOutlet var b: UIButton!

override func viewDidLoad() {
super.viewDidLoad()

// 一个跳动的红点
var arr = [UIImage]()
let w : CGFloat = 18
for i in 0 ..< 6 {
UIGraphicsBeginImageContextWithOptions(CGSizeMake(w,w), false, 0)
let con = UIGraphicsGetCurrentContext()!
CGContextSetFillColorWithColor(con, UIColor.redColor().CGColor)
let ii = CGFloat(i)
CGContextAddEllipseInRect(con, CGRectMake(0+ii,0+ii,w-ii*2,w-ii*2))
CGContextFillPath(con)
let im = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
arr += [im]
}

let im = UIImage.animatedImageWithImages(arr, duration:0.5)
b.setImage(im, forState:.Normal) // b is a button in the interface
}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
}

View Animation


所有的动画最终都是图层动画(layer animation), 稍后我会在本章解释。然而, 对于有些属性, 你可以直接对 UIView 进行动画绘制: 这些属性是 alphaboundscenterframetransform 还有(如果 那个view 没有实现 drawRect:) backgroundColor。 你也可以对 UIView 内容的变化绘制动画。

UIView 绘制动画的语法涉及调用一个 UIView 类方法, 并传递一个用于绘制动画的闭包。 假设我们在界面中有一个 self.v 的 UIView, 背景为黑色, 我们现在把它变红色:

1
2
3
UIView.animateWithDuration(0.4, animations: {
self.v.backgroundColor = UIColor.redColor()
})

同时改变视图的颜色和位置:

1
2
3
4
UIView.animateWithDuration(0.4, animations: {
self.v.backgroundColor = UIColor.redColor()
self.v.center.y += 100
})

我们还可以使用同一个 animations: block 对多个视图绘制动画变化。假设我们想让一个视图溶解到另外一个视图中, 那么先让第二个视图显示在视图层级上, alpha 设置为 0, 以使它不可见。 然后我们把第一个视图的 alpha 设置为 0, 第二个视图的 alpha 设置为 1, 绘制动画。

那种情况下, 我们最好把视图层级中的第二个视图放在动画开始之前(不可见, 因为它的 alpha 从 0 开始)并且在动画结束时(不可见, 因为它的 alpha 以 0 结束)移除第一个视图。还有个额外的参数 completion: 用来指定动画结束后应该做点什么:

1
2
3
4
5
6
7
8
9
10
11
let v2 = UIView(frame: CGRect(x: 100, y: 100, width: 200, height: 50));
v2.alpha = 0;

self.v.superView!.addSubview(v2);
UIView.animateWithDuration(0.4, animations: {
self.v.alpha = 0
v2.alpha = 1
}, completion: {
_ in
self.v.removeFromSuperview()
})

现在界面中拖拽一个 UIView, 然后在控制器中声明一个名为 v , 继承自 UIView 的变量, 将它和界面中的那个视图连线。记住, **界面中的对象想要和控制器中的其它对象交互或者你需要用代码控制界面中的对象, 你需要在控制器中为该对象创建 outlet, 例如

1
@IBOutlet var v: UIView!

img 另外一种从带有动画的层级视图中移除一个视图的方法是调用 performSystemAnimation:onViews:options:animations:completion:, 第一个参数使用 。Delete(仅有的可用的第一个参数)。这会让那个视图变模糊、收缩和褪色, 之后再给它发送 removeFromSuperview 方法。

performWithoutAnimation: 方法会执行不会绘制动画的 block。下面这个伪造的例子中, 视图跳到新的位置上并慢慢变红:

1
2
3
4
5
6
UIView.animateWithDuration(0.4, animations: {
self.v.backgroundColor = UIColor.redColor()
UIView.performWithoutAnimation {
self.v.center.y += 100
}
})

如果你在绘制动画过程中改变了视图的属性, 那么之后不应该再改变这个属性, 否则结果会让人迷惑。下面这段代码就不合逻辑:

1
2
3
4
UIView.animateWithDuration(2, animations: {
self.v.center.y += 100
})
self.v.center.y += 300

实际发生的是这个视图向下跳跃 300 点, 然后向下绘制了 100 点的动画。那通常不是你想要的。你在 animations: block 中安排好将要被绘制动画的可动画视图属性之后, 不要再次更改视图属性的值直到动画执行完之后。

下面这段代码用单个动画使视图向下移动了 400 点:

1
2
3
4
UIView.animateWithDuration(2, animations: {
self.v.center.y += 300
self.v.center.y += 100
})

这是因为基本位置视图动画默认是可以叠加的(iOS 8 及以后)。 这意味着第二个动画和第一个动画是同时运行的, 它俩混合在一块儿。

img 在新的 iOS 9 中, UIVisualEffectView 和普通的 UIView 那样也是可动画的。还有, 设置 UIVisualEffectView 的 effect 也是可动画的! 例如, 你可以在 animations: block 中把 UIVisualEffectView 的 effect 属性设置为 nil 然后设置它的 effect 属性为 UIBlurEffect 来绘制 blur 动画。

View Animation Options


UIView 的类方法 animateWithDuration:animateWithDuration:completion: 都是退化

了的形式。完整的形式是 animateWithDuration:delay:options:animations:completion:

参数有:

  • duration

​ 动画持续的时间: 动画从开始到完成花费了多长时间(以秒为单位 )。 你也可以把这个参数当作动画的速度。显而易见, 如果两个视图在同样的时间移动了不同的距离, 那么移动的更远的那个视图移动的更快。

  • delay

​ 动画开始之前延迟的时间。默认没有延迟。延迟和应用到动画中使用的延迟执行不同; 动画是立即应用的, 但是当它空转的时候, 没有可见的变化, 直到 delay 时间过去。

  • options

​ 组合额外选项的位掩码

  • animations

​ 这个 block 包含了要被绘制动画的视图属性的变化。

  • completion

​ 动画结束(或 nil)的时候运行这个 block。它接收一个 Bool 值来标示动画是否运行结束了。这个 block 含有一个标示 true 的参数以调用, 即使 animations: block 中没有任何触发动画的东西。这个块儿还能进一步指定动画, 结果就是链式动画。

下面是某些简明的 options: 值(UIViewAnimationOptions), 你可能用的到:

Animation curve

​ 动画曲线(animation curve)描述了在动画期间动画的变化速度。术语 “ease” 的意思是在开始或结束时动画的中心速度和零速度之间是逐渐增加或减少的。至多指定一个:

  • .CurveEaseInOut(默认的)
  • .CurveEaseIn
  • .CurveEaseOut
  • .CurveLinear(速度始终不变)

.Repeat

​ 如果包含了这个参数, 那么动画会无限重复。作为这个命令中的一部分, 没有办法去
​ 指定一个明确的重复数字; 要么一直重复, 要么一点儿不重复。这看起来像是疏忽;
​ 我会在之后提供一个变通方案。

.Autoreverse

​ 如果包含了这个参数, 那么动画会从开始运行到结束(在给定的持续时间), 然后从结束运行到开始 (也是在给定持续时间)。文档中要求你只能在 repeat 不能接收时自动反转; 你可以使用其中一个或都使用(或都不使用)。

当使用 .Autoreverse 时, 你会想在结尾进行清除以至于当动画结束时视图能恢复到原来的位置:

1
2
3
4
let opts = UIViewAnimationOptions.Autoreverse
UIView.animateWithDuration(1, delay: 0, options: opts, animations: {
self.v.center.x += 100
}, completion: nil)

这个视图动画先向右移动 100 点在向左移动 100 点回到原来的位置, 最后跳回到右侧100点的位置上。原因是我们赋值给视图的 center 的 x 的最后实际值是 100 点向右, 所以当动画结束时, 视图仍旧显示 100 点向右。解决办法是在 completion: 处理中把视图移动回到它原来的位置上:

1
2
3
4
5
6
7
8
let opts  = UIViewAnimationOptions.Autoreverse
let xorig = self.v.center.x
UIView.animateWithDuration(1, delay: 0, options: opts, animations: {
self.v.center.x += 100
}, completion: {
_ in
self.v.center.x = xorig
})

使用一个过时的语法来设置动画重复的次数:

1
2
3
4
5
6
7
8
let count = 3
UIView.animateWithDuration(1, delay: 0, options: opts, animations: {
UIView.setAnimationRepeatCount(Float(count))
self.v.center.x += 100
}, completion: {
_ in
self.v.center.x = xorig
})

最好添加一个重复次数的参数, 在附录 B 中我会用一个类方法来扩展 UIView 给它添加一个重复次数的参数。

还有一些选项说明了如果另外一个动画已经指定(ordered)或正在飞行中(in-flight)会发生什么:

.BeginFromCurrentState

​ 如果这个动画 animates 了一个之前已经指定好或in-flight 的动画 animated 过的属性, 那么不是取消之前的动画(立即完成所请求的变化), 如果它正常发生, 这个动画会使用显示层来决定从哪儿开始, 并且, 如果可能的话, 它会把它的动画和之前的动画混合在一块儿。

.OverrideInheritedDuration

​ 阻止从周边或运动中(in-flight)的动画继承持续时间(duration), 默认是继承的。

.OverrideInheritedCurve

​ 阻止从周边或运动中(in-flight)的动画继承动画曲线(curve), 默认是继承的。

iOS 7 及之前你或许会用到少量的 .BeginFromCurrentState, 因为 iOS 8 之后, 简单的视图动画默认是可叠加的。例如, 下面这段代码在 iOS 7 中如果你不使用 .BeginFromCurrentState 的话就会发生跳跃(因为我们正指定两个有冲突的视图位置的动画), 但是在 iOS 8 及之后就是单个流畅的对角线动画:

1
2
3
4
5
6
UIView.animatedWithDuration(1, animations: {
self.v.center.x += 100
})
UIView.animatedWithDuration(1, animations: {
self.v.center.y += 100
})

为了看动画叠加的意思是什么, 尝试下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
UIView.animateWithDuration(2, animations: {
self.v.center.x += 100
})

delay(1) {
let opts = UIViewAnimationOptions.BeginFromCurrentState
UIView.animateWithDuration(1, delay: 0, options: opts,
animations: {
self.v.center.y += 100
}, completion: nil)
}

Canceling a View Animation


视图动画正在运行时你怎么取消它? 为了方便解释, 我把视图的原始位置和最终位置保存到了属性中:

1
2
3
4
5
6
7
self.pOrig    = self.v.center
self.pFinal = self.v.center
self.pFinal.x += 100

UIView.animateWithDuration(4, animations: {
self.v.center = self.pFinal
})

现在假想在动画执行期间有一个按钮, 按下按钮会取消动画。 我们怎么做到?

一种可能是我们到 CALayer 层并调用 removeAllAnimations 方法。(如果那个 layer 拥有不止一个动画, 而你只想移除它们中的一个, 你可以调用 removeAnimationForKey:方法; 我会在本章的后面谈论如何通过 key 来区分 layer 动画)。移除所有的动画实在是简明扼要, 但是缺点是它简单地让动画彻底停止了, 视图会跳转到它最后被设定的位置上, 和 app 进入后台系统自动调用的一样:

1
self.v.layer.removeAllAnimations()

如果我们使用 removeAllAnimations 那么视图就会跳到它最后的位置上; 我们想让它在此刻保持在当前位置上 — 即动画的当前位置。那个位置是当前显示层所在的位置。因此我们重新配置位于它的显示层的位置的视图, 然后移除那个动画, 再然后执行最后的”赶快回家”动画:

1
2
3
4
5
6
7
8
9
// unrecognized selector sent to instance 0x7fc288eaf160'
// 要把 Selector 中的方法放在 viewDidLoad 这个方法的外面,还要注意方法后不能有冒号(如果方法中不需要传递按钮本身这个参数)
func cancelAnimate() {
self.v.layer.position = (self.v.layer.presentationLayer() as! CALayer).position
self.v.layer.removeAllAnimations()
UIView.animateWithDuration(0.1, animations: {
self.v.center = self.pFinal
})
}

如果取消动画意味着视图回到原来的位置, 那么把视图的 center 设置为 self.pOrig 而非 self.pFinal。如果你想把动画停在当前它运行到的位置上, 那么就省略最后一个动画。(即 0.1 的那个)

如果我们要取消的动画是无限重复自动反转的呢:

1
2
3
4
5
6
7
self.pOrig = self.v.center
let opts: UIViewAnimationOptions = [.Autoreverse, .Repeat]
UIView.animateWithDuration(1, delay: 0, options: opts,
animations: {
self.v.center.x += 100
}, completion: nil
)

那种情况下显示另外一个动画足够了, 因为新的动画不会和第一个动画叠加。只有简单视图动画是可叠加的, 什么是简单动画? 有一点必须是”非重复的”。因此, 第二个动画取消了第一个动画。下面通过快速把它返回到它原来的位置上来取消第一个动画:

1
2
3
4
UIView.animateWithDuration(0.1, delay: 0, options: .BeginFromCurrentState,
animations: {
self.v.center = self.pOrig
}, completion: nil)

这是 .BeginFromCurrentState 大显身手的地方! 它阻止了视图立即跳到右侧 100点最后的位置, 而是把它设置为初始的重复动画。

Custom Animatable View Properties


你可以为自定义的视图属性绘制动画。