译者:教程和相关代码已经更新至兼容Swift 3.0,原教程中Bug已经清扫完毕,升级到Xcode 8的程序猴们可以放心观看。Swift3对CoreAnimation的API进行了大量更新,让代码简洁了不少。关于Swift3的更新,可以参考我的Swift3的变化。
推入、弹出、翻转….iOS内置了不少视图间的过渡动画,但自己动手显然更有趣些。自定义的过渡动画不但可以大大增强用户体验,也可以让你的App“鹤立鸡群”。如果你是一名曾经被自定义动画的复杂吓跑过的老司机,不妨也停下来看看,现在的实现方法比你想象的要简单的多。
本篇教程里,我们将给一个简单的猜猜看游戏添加过渡动画。教程结束时你将获得以下技能:
- 了解过渡动画API的结构
- 了解如何通过自定义过渡动画打开/关闭视图控制器
- 了解如何创建交互式过渡
提示:本篇教程会用到UIView的动画方法,你应该具备基本的知识储备。如果没有接触过的话建议提前看一下这这篇关于iOS动画的文章,速成一下。
入门
老规矩,Clone或下载初始项目,编译运行一下,效果如下所示:
我们在一个Page View Controller里展示了不同的卡片。每张卡片上有一段关于宠物的描述,点击卡片会显示对应的宠物照片。
我们的任务是根据描述猜出宠物!它是喵星人,汪星人还是条咸鱼?自己把玩一下初始App看看你猜的准不准。
主要的导航逻辑为你已经写好了,但现在这个App太过普通了,没什么意思。我们通过自定义的过渡动画给它增添点色彩。
探索过渡动画API
过渡动画API大量地使用协议,而非实体对象。看完这一部分内容,你会了解每一个相关协议的职责,以及它们之间是如何互相联系的。下图展示了API中的一些重要主体:
相关主体
上图看起来挺复杂的,但当你了解了不同部分之间是如何协同工作之后,你会发现它的逻辑其实非常直接。
过渡协议
每一个视图控制器(View Controller)都包含一个transitioningDelegate
对象,它服从UIViewControllerDelegate
协议。
每当你打开或关闭一个视图控制器时,UIKit会向这个协议索取应该使用的动画控制器(Animation Controller)。如果想让协议获得的是我们自定义的对象,只需将它赋值给视图控制器的transitioningDelegate
。
动画控制器
具体实现UIViewControllerAnimatedTransitioning
协议的对象,用来实现过渡动画。
过渡上下文
上下文负责实现UIViewControllerContextTransitioning
协议,它在过渡过程中至关重要:它负责封装所有与过渡相关的视图控制器(过渡前和过渡后的)。
实际上我们并不需要自己实现这个协议。每当过渡发生时,动画控制器会自动从UIKit那里接收到已经设置好的上下文对象。
过渡的步骤
打开一个视图控制器时要经历以下几步:
1. 通过代码或者Segue触发过渡。
2. UIKit尝试从目标视图控制器(即将展示的)那里获取它的过渡协议。如果为空,则使用内置的标准协议。
3. UIKit通过animationController(forPresented:presenting:source:)
方法向过渡协议请求动画控制器。如果方法返回nil
,则使用默认动画。
4. 如果上述方法返回有效,UIKit会创建过渡上下文。
5. UIKit通过transitionDuration(using:)
方法从动画控制器那里获取动画时长。
6. UIKit在动画控制器上调用animateTransition(using:)
方法,实际播放过渡动画。
7. 最后,动画控制器会调用过渡上下文的completeTransition(using:)
方法,标志动画的完成。
自定义打开动画
是时候把刚刚新学的知识应用到实际中了!
我们的目标是实现下面的效果:
- 当用户点击卡片,卡片翻转过来显示缩小版的Modal视图(和卡片一样大)
- 随后放大至充满屏幕
创建Animator
首先需要创建动画控制器。
创建一个新的Cocoa Touch Class文件,命名为FlipPresentAnimationController,让它继承自NSObject,语言设置为Swift。点击下一步,把分组设置为Animation Controllers,然后完成创建。
动画控制器需要遵从UIViewControllerAnimatedTransitioning协议。打开FlipPresentAnimationController.swift,更新类的声明:
1
2
3
4
|
import
UIKit
class
FlipPresentAnimationController
:
NSObject
,
UIViewControllerAnimatedTransitioning
{
}
|
修改完声明后,编辑器会弹出血红的Error,提示缺失代理方法;淡定淡定,我们这不是啥都还没添加呢么,现在就来修正。
编译错误….淡定淡定我们把卡片的框架(Frame)作为动画起点,并添加变量存储这个值:
1
|
var
originFrame
=
CGRect
.
zero
|
根据协议要求,我们需要添加两个方法。
把下面的方法添加到类里:
1
2
3
|
func
transitionDuration
(
using
transitionContext
:
UIViewControllerContextTransitioning
?
)
->
TimeInterval
{
return
2.0
}
|
正如名称提示的一样,该方法用于设定动画时长。暂且把它设定为2秒,以便在开发过程中观察动画效果。
接着把下面的方法声明添加到类里:
1
2
|
func
animateTransition
(
using
transitionContext
:
UIViewControllerContextTransitioning
)
{
}
|
我们需要在这个方法里真正实现动画。
首先添加下面代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// 1
let
containerView
=
transitionContext
.
containerView
guard
let
fromVC
=
transitionContext
.
viewController
(
forKey
:
.
from
)
,
let
toVC
=
transitionContext
.
viewController
(
forKey
:
.
to
)
else
{
return
}
// 2
let
initialFrame
=
originFrame
let
finalFrame
=
transitionContext
.
finalFrame
(
for
:
toVC
)
// 3
let
snapshot
=
toVC
.
view
.
snapshotView
(
afterScreenUpdates
:
true
)
!
snapshot
.
frame
=
initialFrame
snapshot
.
layer
.
cornerRadius
=
25
snapshot
.
layer
.
masksToBounds
=
true
|
解释一下:
1. 过渡上下文负责提供与过渡相关的视图控制器,通过对应的键获取。
2. 设置“to”视图的起始帧和终止帧。过渡动画中,它从卡片大小开始,逐渐扩充并填满整个屏幕。
3. UIView捕捉“to”视图快照,并把它渲染成轻量级的视图;这样就可以在动画中同时显示当前的视图和它的父视图。快照同样从卡片的边框大小开始。此外我们把快照的边角弧度改成和卡片一样。
译者:在iOS10(Swift3)环境下,
snaphotView(afterScreenUpdates:)
方法无法正常获取快照。为了临时解决这个Bug,我们需要自己编写一个辅助方法,以图片的形式返回快照。
创建一个新的Swift文件,命名为Util,用来存放我们编写的扩展方法。添加下面代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
import
UIKit
extension
UIImage
{
class
func
renderImage
(
from
view
:
UIView
)
->
UIImage
?
{
UIGraphicsBeginImageContextWithOptions
(
view
.
frame
.
size
,
true
,
0
)
let
context
=
UIGraphicsGetCurrentContext
(
)
view
.
layer
.
render
(
in
:
context
!
)
let
renderedImage
=
UIGraphicsGetImageFromCurrentImageContext
(
)
UIGraphicsEndImageContext
(
)
return
renderedImage
}
}
|
然后回到之前的animateTransition(using:)
方法,把注释3下面的第一行替换成下面的代码:
1
2
3
4
|
// let snapshot = toVC.view.snapshotView(afterScreenUpdates: true)!
// Ed: snapshot在iOS 10(swift3)下无法正常获取
// 这里用图片代替快照
let
snapshot
=
UIImageView
(
image
:
UIImage
.
renderImage
(
from
:
toVC
.
view
)
)
|
打完补丁我们接着往下进行。
往animateTransition(using:)
方法里继续添加下面的代码:
1
2
3
4
5
6
|
containerView
.
addSubview
(
toVC
.
view
)
containerView
.
addSubview
(
snapshot
)
toVC
.
view
.
isHidden
=
true
AnimationHelper
.
perspectiveTransformForContainerView
(
containerView
)
snapshot
.
layer
.
transform
=
AnimationHelper
.
yRotation
(
M_PI_2
)
|
这里出现了一个新家伙:容器视图(Container View),我们可以把它想象成过渡动画的舞台。容器视图自动包含了“from”视图,我们需要自行添加“to”视图。
此外,我们还需要把快照添加到容器视图里,并隐藏实际视图。动画结束时,快照也会旋转至消失。
提示:不要被
AnimationHelper
唬住了,它只是一个工具类,负责给视图添加透视效果以及旋转变换。感兴趣的话可以看看它的具体实现。
现在,与实现动画相关的对象已经准备完毕。在方法的最后添加下面代码,具体实现动画效果:
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
|
// 1
let
duration
=
transitionDuration
(
using
:
transitionContext
)
UIView
.
animateKeyframes
(
withDuration
:
duration
,
delay
:
0
,
options
:
.
calculationModeCubic
,
animations
:
{
// 2
UIView
.
addKeyframe
(
withRelativeStartTime
:
0.0
,
relativeDuration
:
1
/
3
,
animations
:
{
fromVC
.
view
.
layer
.
transform
=
AnimationHelper
.
yRotation
(
-
M_PI_2
)
}
)
// 3
UIView
.
addKeyframe
(
withRelativeStartTime
:
1
/
3
,
relativeDuration
:
1
/
3
,
animations
:
{
snapshot
.
layer
.
transform
=
AnimationHelper
.
yRotation
(
0.0
)
}
)
// 4
UIView
.
addKeyframe
(
withRelativeStartTime
:
2
/
3
,
relativeDuration
:
1
/
3
,
animations
:
{
snapshot
.
frame
=
finalFrame
}
)
}
,
completion
:
{
_
in
// 5
toVC
.
view
.
isHidden
=
false
fromVC
.
view
.
layer
.
transform
=
AnimationHelper
.
yRotation
(
0.0
)
snapshot
.
removeFromSuperview
(
)
transitionContext
.
completeTransition
(
!
transitionContext
.
transitionWasCancelled
)
}
)
|
解释一下:
1. 首先,我们设置了动画时长。注意这里transitionDuration(using:)
的方法,我们在类的最开始实现了它。我们需要让动画时长和整个过渡的时长相同,以便UIKit进行同步。
2. 我们先把“from”视图沿着y轴翻转一半,让它离开画面(变成一条线了)。
3. 接着用同样的方法逐渐显示快照。
4. 然后我们让快照逐渐填满整个屏幕。
5. 一切准备完毕,我们可以放心地显示“to”视图了。快照的任务已经完成,可以把它删除了。此外我们还需要把“from”视图翻转回去,不然返回上级视图的时候就看不到它了。最后,调用completeTransition
方法通知上下文,宣告动画已经完成。UIKit会确保最终状态的一致性,然后从容器里删除“from”视图。
我们的动画控制器现在可以投入使用了!
连接Animator
打开CardViewController.swift,在类外面添加下面的属性:
1
|
private
let
flipPresentAnimationController
=
FlipPresentAnimationController
(
)
|
UIKit需要一个可以提供动画控制器的代理对象。因此,我们必须提供一个服从UIViewControllerTransitioningDelegate协议的对象。
在这里,我们把CardViewController当做过渡代理。在源文件末尾添加协议扩展:
1
2
|
extension
CardViewController
:
UIViewControllerTransitioningDelegate
{
}
|
在扩展里添加下面的方法:
1
2
3
4
5
|
func
animationController
(
forPresented
presented
:
UIViewController
,
presenting
:
UIViewController
,
source
:
UIViewController
)
->
UIViewControllerAnimatedTransitioning
?
{
flipPresentAnimationController
.
originFrame
=
cardView
.
frame
return
flipPresentAnimationController
}
|
上面的方法返回了自定义的动画控制器,同时也确保了过渡动画从正确的帧开始。
最后一步是把CardViewController设置为过渡代理。视图控制器包含一个transitioningDelegate
属性,UIKit正是通过它来确定是否使用自定义过渡。
在prepare(for segue:sender:)
方法里添加下面的代码,就在给卡片赋值的下面:
1
|
destinationViewController
.
transitioningDelegate
=
self
|
注意,需要代理协议的是即将展示的视图控制器,而不是负责展示的。
编译运行一下我们的项目,点击卡片应该会显示如下的效果:
搞定!你的一个自定义过渡动画。别着急得意,这仅仅是一半而已:我们需要用同样浮夸的方式关闭视图。
自定义关闭动画
创建一个新的Cocoa Touch类,命名为FlipDismissAnimationController,确保它继承自NSObject并隶属于Animation Controllers组下。
往新文件里添加下面的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
import
UIKit
class
FlipDismissAnimationController
:
NSObject
,
UIViewControllerAnimatedTransitioning
{
var
destinationFrame
=
CGRect
.
zero
func
transitionDuration
(
using
transitionContext
:
UIViewControllerContextTransitioning
?
)
->
TimeInterval
{
return
0.6
}
func
animateTransition
(
using
transitionContext
:
UIViewControllerContextTransitioning
)
{
}
}
|
这个类需要实现打开动画的逆转版:
- 把当前页面缩小为卡片大小;我们用
destinationFrame
存储这个值。 - 翻转视图以显示原始卡片。
在animateTransition(_:)
方法里添加下面的代码:
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
|
let
containerView
=
transitionContext
.
containerView
guard
let
fromVC
=
transitionContext
.
viewController
(
forKey
:
.
from
)
,
let
toVC
=
transitionContext
.
viewController
(
forKey
:
.
to
)
else
{
return
}
// 1
let
finalFrame
=
destinationFrame
// 2
let
snapshot
=
UIImageView
(
image
:
UIImage
.
renderImage
(
from
:
fromVC
.
view
)
)
snapshot
.
layer
.
cornerRadius
=
25
snapshot
.
layer
.
masksToBounds
=
true
// 3
containerView
.
addSubview
(
toVC
.
view
)
containerView
.
addSubview
(
snapshot
)
fromVC
.
view
.
isHidden
=
true
AnimationHelper
.
perspectiveTransformForContainerView
(
containerView
)
// 4
toVC
.
view
.
layer
.
transform
=
AnimationHelper
.
yRotation
(
-
M_PI_2
)
let
duration
=
transitionDuration
(
using
:
transitionContext
)
|
你应该已经熟悉这些步骤了,这里还是再重复解释一下:
1. 因为在这个动画里,我们需要缩小视图,所以初始帧和终止帧和之前正好相反。
2. 这次我们操作的是“from”视图,所以快照应该通过它来创建。
3. 和之前一样,我们把“to”视图以及快照添加到容器视图里,并隐藏“from”视图以免和快照冲突。
4. 最后,我们通过翻转隐藏“to”视图。
剩下的就是添加动画本身了。
紧接着之前的代码,在animateTransition(_:)
里添加下面的代码:
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
|
let
duration
=
transitionDuration
(
using
:
transitionContext
)
UIView
.
animateKeyframes
(
withDuration
:
duration
,
delay
:
0
,
options
:
.
calculationModeCubic
,
animations
:
{
// 1
UIView
.
addKeyframe
(
withRelativeStartTime
:
0.0
,
relativeDuration
:
1
/
3
,
animations
:
{
snapshot
.
frame
=
finalFrame
}
)
UIView
.
addKeyframe
(
withRelativeStartTime
:
1
/
3
,
relativeDuration
:
1
/
3
,
animations
:
{
snapshot
.
layer
.
transform
=
AnimationHelper
.
yRotation
(
M_PI_2
)
}
)
UIView
.
addKeyframe
(
withRelativeStartTime
:
2
/
3
,
relativeDuration
:
1
/
3
,
animations
:
{
toVC
.
view
.
layer
.
transform
=
AnimationHelper
.
yRotation
(
0.0
)
}
)
}
,
completion
:
{
_
in
// 2
fromVC
.
view
.
isHidden
=
false
snapshot
.
removeFromSuperview
(
)
transitionContext
.
completeTransition
(
!
transitionContext
.
transitionWasCancelled
)
}
)
|
这就是之前动画的逆转版本:
1. 首先缩放视图,然后通过翻转隐藏快照。接着通过反方向旋转逐渐显示“to”视图。
2. 最后,我们删除快照并通知上下文,过渡动画已经完成。这样UIKit就知道可以开始更新视图控制器的层级结构,并删除过渡时使用的视图了。
打开CardViewController.swift,在动画控制器下面添加一个新的属性:
1
|
private
let
flipDismissAnimationController
=
FlipDismissAnimationController
(
)
|
接着,往协议扩展里添加下面的方法:
1
2
3
4
|
func
animationController
(
forDismissed
dismissed
:
UIViewController
)
->
UIViewControllerAnimatedTransitioning
?
{
flipDismissAnimationController
.
destinationFrame
=
cardView
.
frame
return
flipDismissAnimationController
}
|
和之前类似,负责把正确的视图框架传递给动画控制器,并返回这个控制器。
最后一步,修改FlipPresentAnimationController里的transitionDuration(using:)
方法,把它的速度改成和关闭动画一致:
1
2
3
|
func
transitionDuration
(
using
transitionContext
:
UIViewControllerContextTransitioning
?
)
->
TimeInterval
{
return
0.6
}
|
编译运行你的App,点击卡片看看现在的打开/关闭动画:
非常犀利的自定义动画!但为了追求完美,我们更进一步,给动画添加交互。
添加交互
译者:如果你看过我之前几篇教程,这部分内容可以跳过。不妨回忆一下思路,尝试独立完成。
iOS自带的设置软件是交互式过渡动画的典范。这一节我们的任务是,利用左边缘滑动手势,退回卡片朝下的状态,过渡动画跟随用户手势。
交互式过渡的原理
交互控制器(Interaction Controller)可以响应触摸事件以及编码控制,比如加速、减速,甚至反转过渡动画。为了添加交互,过渡代理必须负责额外提供一个交互控制器。它可以是任何实现了UIViewControllerInteractiveTransitioning协议的对象。我们已经编写好了过渡动画,交互控制器只是负责让这个动画跟随你的手势,而不是像播放视频一样,从头到尾直接放完。
Apple提供了一个现成的UIPercentDrivenInteractiveTransition类,它是一个具体的交互控制器的实现。我们正好可以利用它,给我们的过渡添加交互。
创建交互式过渡
首先我们需要创建一个交互控制器。新建一个Cocoa Touch Class文件,命名为SwipeInteractionController,让它继承自UIPercentDrivenInteractiveTransition。确保语言为Swift,添加到Interaction Controllers组里。
打开SwipeInteractionController.swift,在类定义的最开始添加下面这些属性:
1
2
3
|
var
interactionInProgress
=
false
private
var
shouldCompleteTransition
=
false
private
weak
var
viewController
:
UIViewController
!
|
这些属性的作用显而易见:
- 正如其名,
interactionInProgress
用于指示交互是否在进行中。 - 我们需要在内部使用
shouldCompleteTransition
来控制过渡,随后你会看到使用方法。 - 交互控制器直接负责打开/关闭视图控制器,所以我们需要把当前的视图控制器存储在
viewController
里,便于引用。
把下面的方法添加到类里:
1
2
3
4
|
func
wire
(
to
viewController
:
UIViewController
!
)
{
self
.
viewController
=
viewController
prepareGestureRecognizer
(
in
:
viewController
.
view
)
}
|
我们需要通过手势控制过渡动画。在上面的方法里,我们获得了视图控制器的引用,并给它的视图添加了手势识别器。
如下添加prepareGestureRecognizerInView(_:)
方法:
1
2
3
4
5
|
private
func
prepareGestureRecognizer
(
in
view
:
UIView
)
{
let
gesture
=
UIScreenEdgePanGestureRecognizer
(
target
:
self
,
action
:
#
selector
(
handleGesture
(
gestureRecognizer
:
)
)
)
gesture
.
edges
=
.
left
view
.
addGestureRecognizer
(
gesture
)
}
|
我们定义了一个手势识别器,通过左边缘滑动手势触发,并把它添加到视图上。
最后一步是添加handleGesture(_:)
方法,如下所示:
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
33
34
|
func
handleGesture
(
gestureRecognizer
:
UIScreenEdgePanGestureRecognizer
)
{
// 1
let
translation
=
gestureRecognizer
.
translation
(
in
:
gestureRecognizer
.
view
?
.
superview
)
var
progress
=
Float
(
translation
.
x
/
200
)
progress
=
fminf
(
fmaxf
(
progress
|