iOS 视图控制器转场详解

文章来自:http://www.cocoachina.com/ios/20160309/15605.html

屏幕左边缘右滑返回,TabBar 滑动切换,你是否喜欢并十分依赖这两个操作,甚至觉得没有简直反人类?这两个操作在大屏时代极大提升了操作效率,其背后的技术便是今天的主题:视图控制器转换(View Controller Transition)。

视图控制器中的视图显示在屏幕上有两种方式:内嵌在容器控制器中,比如 UINavigationController,UITabBarController, UISplitController;由另外一个视图控制器显示它,这种方式通常被称为模态显示。View Controller Transition 是什么?在 NavigationController 里 push 或 pop 一个 View Controller,在 TabBarController 中切换到其他 View Controller,以 Modal 方式显示另外一个 View Controller,这些都是 View Controller Transition。在 storyboard 里,每个 View Controller 是一个 Scene,View Controller Transition 便是从一个 Scene 转换到另外一个 Scene;为方便,以下对 View Controller Transition 的中文称呼采用 Objccn.io 中的翻译「转场」。

在 iOS 7 之前,我们只能使用系统提供的转场效果,大部分时候够用,但仅仅是够用而已,总归会有各种不如意的小地方,但我们却无力改变;iOS 7 开放了相关 API 允许我们对转场效果进行全面定制,这太棒了,自定义转场动画以及对交互手段的支持带来了无限可能。

阅读本文需要读者至少要对 ViewController 和 View 的结构以及协议有一定的了解,最好自己亲手实现过一两种转场动画。如果你对此感觉没有信心,推荐观看官方文档:View Controller Programming Guide for iOS ,学习此文档将会让你更容易理解本文的内容。对你想学习的小节,我希望你自己亲手写下这些代码,一步步地看着效果是如何实现的,至少对我而言,看各种相关资料时只有字面意义上的理解,正是一步步的试验才能让我理解每一个步骤。本文涉及的内容较多,为了避免篇幅过长,我只给出关键代码而不是从新建工程开始教你每一个步骤。本文基于 Xcode 7 以及 Swift 2。本文 Demo 合集地址:iOS-ViewController-Transition-Demo。

文章目录:

(一)Transition 解释

(二)阶段一:非交互转场

1.动画控制器协议

2.动画控制器实现

3.特殊的 Modal 转场

(1)Modal 转场的差异

(2)Modal 转场实践

(3)iOS 8 的改进:UIPresentationController

4.转场代理

(三)阶段二:交互式转场

1.实现交互化

2.Transition Coordinator

3.封装交互控制器

4.交互转场的限制

(四)插曲:UICollectionViewController 布局转场

(五)进阶

1.案例分析

2.自定义容器控制器转场

(1)实现分析

(2)协议补完

(3)交互控制

  1)动画控制和 CAMediaTiming 协议

  2)取消转场

  3)最后的封装

(六)尾声:转场动画的设计


(一)Transition 解释

前言里从行为上解释了转场,那在转场时发生了什么?下图是从 WWDC 2013 Session 218 整理的,解释了转场时视图控制器和其对应的视图在结构上的变化:

iOS 视图控制器转场详解_第1张图片

转场过程中,作为容器的父 VC 维护着多个子 VC,但在视图结构上,只保留一个子 VC 的视图,所以转场的本质是下一场景(子 VC)的视图替换当前的场景视图(子 VC)以及相应的控制器的切换,表现为当前视图消失和下一视图出现,基于此进行动画,动画的方式非常多,所以限制最终呈现的效果就只有你的想象力了。图中的 Parent VC 可替换为 UIViewController, UITabbarController 或 UINavigationController 中的任何一种。

目前为止,官方支持以下几种方式的自定义转场:

  • 在 UINavigationController 中 push 和 pop

  • 在 UITabBarController 中切换 Tab

  • Modal 转场:presentation 和 dismissal,俗称视图控制器的模态显示和消失,仅限于modalPresentationStyle属性为 UIModalPresentationFullScreen 或 UIModalPresentationCustom 这两种模式

  • UICollectionViewController 的布局转场:UICollectionViewController 与 UINavigationController 结合的转场方式,实现很简单。

官方的支持包含了 iOS 中的大部分转场方式,还有一种自定义容器中的转场并没有得到系统的直接支持,不过借助协议这种灵活的方式,我们依然能够实现对自定义容器控制器转场的定制,在压轴环节我们将实现这一点。

iOS 7 以协议的方式开放了自定义转场的 API,协议的好处是不再拘泥于具体的某个类,只要是遵守该协议的对象都能参与转场,非常灵活。转场协议由5种协议组成,在实际中只需要我们提供其中的两个或三个便能实现绝大部分的转场动画:

1.转场代理(Transition Delegate):

自定义转场的第一步便是提供转场代理,告诉系统使用我们提供的代理而不是系统的默认代理来执行转场。有如下三种转场代理,对应上面三种类型的转场:

1
2
3
[UINavigationControllerDelegate]  //UINavigationController 的 delegate 属性遵守该协议(因识别问题,这里用方括号替换尖括号)
[UITabBarControllerDelegate]  //UITabBarController 的 delegate 属性该协议
[UIViewControllerTransitioningDelegate]  //UIViewController 的 transitioningDelegate 属性遵守该协议

这里除了是 iOS 7 新增的协议,其他两种在 iOS 2 里就存在了,在 iOS 7 时扩充了这两种协议来支持自定义转场。

转场发生时,UIKit 将要求转场代理将提供转场动画的核心构件:动画控制器和交互控制器(可选的);由我们实现。

2.动画控制器(Animation Controller):

最重要的部分,负责添加视图以及执行动画;遵守协议;由我们实现。

3.交互控制器(Interaction Controller):

通过交互手段,通常是手势来驱动动画控制器实现的动画,使得用户能够控制整个过程;遵守协议;系统已经打包好现成的类供我们使用。

4.转场环境(Transition Context):

提供转场中需要的数据;遵守协议;由 UIKit 在转场开始前生成并提供给我们提交的动画控制器和交互控制器使用。

5.转场协调器(Transition Coordinator):

可在转场动画发生的同时并行执行其他的动画,其作用与其说协调不如说辅助,主要在 Modal 转场和交互转场取消时使用,其他时候很少用到;遵守协议;由 UIKit 在转场时生成,UIViewController 在 iOS 7 中新增了方法transitionCoordinator()返回一个遵守该协议的对象,且该方法只在该控制器处于转场过程中才返回一个此类对象,不参与转场时返回 nil。

总结下,5个协议只需要我们操心3个;实现一个最低限度可用的转场动画,我们只需要提供上面五个组件里的两个:转场代理和动画控制器即可,还有一个转场环境是必需的,不过这由系统提供;当进一步实现交互转场时,还需要我们提供交互控制器,也有现成的类供我们使用。


(二)阶段一:非交互转场

这个阶段要做两件事,提供转场代理并由代理提供动画控制器。在转场代理协议里动画控制器和交互控制器都是可选实现的,没有实现或者返回 nil 的话则使用默认的转场效果。动画控制器是表现转场效果的核心部分,代理部分非常简单,我们先搞定动画控制器吧。

转场 API 是协议的好处是不限制具体的类,只要对象实现该协议便能参与转场过程,这也带来另外一个好处:封装便于复用,尽管三大转场代理协议的方法不尽相同,但它们返回的动画控制器遵守的是同一个协议,因此可以将动画控制器封装作为第三方动画控制器在其他控制器的转场过程中使用。

1.动画控制器协议

动画控制器负责添加视图以及执行动画,遵守UIViewControllerAnimatedTransitioning协议,该协议要求实现以下方法:

1
2
3
4
5
6
//执行动画的地方,最核心的方法
(Required)func animateTransition(_ transitionContext: UIViewControllerContextTransitioning)
//返回动画时间,"return 0.5" 已足够,非常简单,出于篇幅考虑不贴出这个方法的代码实现。
(Required)func transitionDuration(_ transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval
//如果实现了,会在转场动画结束后调用,可以执行一些收尾工作
(Optional)func animationEnded(_ transitionCompleted: Bool)

最重要的是第一个方法,该方法接受一个遵守协议的转场环境对象,上一节的 API 解释里提到这个协议,它提供了转场所需要的重要数据:参与转场的视图控制器和转场过程的状态信息。

UIKit 在转场开始前生成遵守转场环境协议的对象 transitionContext,它有以下几个方法来提供动画控制器需要的信息:

1
2
3
4
5
6
//返回容器视图,转场动画发生的地方
func containerView() -> UIView?
//获取参与转场的视图控制器,有 UITransitionContextFromViewControllerKey 和 UITransitionContextToViewControllerKey 两个 Key 
func viewControllerForKey(_ key: String) -> UIViewController?
//iOS 8新增 API 用于方便获取参与参与转场的视图,有 UITransitionContextFromViewKey 和 UITransitionContextToViewKey 两个 Key。
func viewForKey(_ key: String) -> UIView? AVAILABLE_IOS(8_0)

通过viewForKey:获取的视图是viewControllerForKey:返回的控制器的根视图,或者 nil。viewForKey:方法返回 nil 只有一种情况: UIModalPresentationCustom 模式下的 Modal 转场 ,通过此方法获取 presentingView 时得到的将是 nil,在后面的 Modal 转场里会详细解释。

前面提到转场的本质是下一个场景的视图替换当前场景的视图,从当前场景过渡下一个场景。下面称即将消失的场景的视图为 fromView,对应的视图控制器为 fromVC,即将出现的视图为 toView,对应的视图控制器称之为 toVC。几种转场方式的转场操作都是可逆的,一种操作里的 fromView 和 toView 在逆向操作里的角色互换成对方,fromVC 和 toVC 也是如此。在动画控制器里,参与转场的视图只有 fromView 和 toView 之分,与转场方式无关。转场动画的最终效果只限制于你的想象力。这也是动画控制器在封装后可以被第三方使用的重要原因。

在 iOS 8 中可通过以下方法来获取参与转场的三个重要视图,在 iOS 7 中则需要通过对应的视图控制器来获取,为避免 API 差异导致代码过长,示例代码中直接使用下面的视图变量:

1
2
3
let containerView = transitionContext.containerView()
let fromView = transitionContext.viewForKey(UITransitionContextFromViewKey)
let toView = transitionContext.viewForKey(UITransitionContextToViewKey)

2.动画控制器实现

三种转场方式都有一对可逆的转场,你可以为了每一种操作实现单独的动画控制器,也可以实现通用的动画控制器。处于篇幅的考虑,示范一个比较简单的转场动画效果:Slide left and right,而且该 Slide 动画控制器在三种转场方式中是通用的,不必修改就可以直接在工程中使用。效果示意图:

iOS 视图控制器转场详解_第2张图片

在交互式转场章节里我们将在这个基础上实现文章开头提到的两种效果:NavigationController 右滑返回 和 TabBarController 滑动切换。Modal 转场并没有比较合乎操作直觉的交互手段,而且和前面两种容器控制器的转场在机制上有些不同,我将为 Modal 转场示范另外一个示例。

尽管对动画控制器来说,转场方式并不重要,可以对 fromView 和 toView 进行任何动画。但上面的动画和 Modal 转场风格上有点不配,动画的方向不对;在转场中操作是可逆的,返回操作时的动画应该也是逆向的。对此,Slide 动画控制器需要针对转场的操作类型对动画的方向进行调整。Swift 中 enum 的关联值可以视作有限数据类型的集合体,在这种场景下极其合适。设定操作类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum SDETransitionType{
     //UINavigationControllerOperation 是 UIKit 中定义的标记操作类型的枚举常量,有.None, .Push, .Pop 三种值
     case  NavigationTransition(UINavigationControllerOperation) 
     case  TabTransition(TabOperationDirection)
     case  ModalTransition(ModalOperation)
}
 
enum TabOperationDirection{
     case  Left, Right
}
 
enum ModalOperation{
     case  Presentation, Dismissal
}

使用示例:在 TabBarController 中切换到左边的页面。

1
let transitionType = SDETransitionType.TabTransition(.Left)

Slide 动画控制器的核心代码:

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
class SlideAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
     private  var  transitionType: SDETransitionType
     
     init(type: SDETransitionTye) {
         transitionType = type
         super .init()
     }
     
     ...
     
     func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
         ...
          //1
         containerView.addSubview(toView)
         
         //计算相应的位移 transform,NavigationVC 和 TabBarVC 在水平方向进行动画,Modal 转场在竖直方向进行动画
         var  toViewTransform = ...
         var  fromViewTransform = ...
         
         toView.transform = toViewTransform
         let duration = transitionDuration(transitionContext)
         UIView.animateWithDuration(duration, animations: {
             fromView.transform = fromViewTransform
             toView.transform = CGAffineTransformIdentity
             }, completion: { finished  in
             //考虑到转场中途可能取消的情况,转场结束后,恢复视图状态
                 fromView.transform = CGAffineTransformIdentity
                 toView.transform = CGAffineTransformIdentity
                 //2
                 let isCancelled = transitionContext.transitionWasCancelled()
                 transitionContext.completeTransition(!isCancelled)
         })
     }
}

注意上面的代码有2处标记,是动画控制器必须完成的:

  • 将 toView 添加到容器视图中,使得 toView 在屏幕上显示( Modal 转场中此点稍有不同,下一节细述);

  • 正确地结束转场过程。转场的结果有两种:完成或取消。非交互转场的结果只有完成一种情况,不过交互式转场需要考虑取消的情况。如何结束取决于转场的进度,通过transitionWasCancelled()方法来获取转场的状态,使用completeTransition:来完成或取消转场。

转场结束后,fromView 会从视图结构中移除,UIKit 自动替我们做了这事,你也可以手动处理提前将 fromView 移除,这完全取决于你的需求。UIView的类方法transitionFromView:toView:duration:options:completion:也能做同样的事,我们甚至不需要获得 containerView 以及手动将 toView 添加到视图结构中就能实现一个转场动画:

1
2
3
4
UIView.transitionFromView(fromView, toView: toView, duration: transitionDuration, options: .TransitionCurlDown, completion: { _  in
     let isCancelled = transitionContext.transitionWasCancelled()
     transitionContext.completeTransition(!isCancelled)
})

3.特殊的 Modal 转场

(1)Modal 转场的差异

Modal 转场中需要做的事情和两种容器 VC 的转场一样,但在细节上有些差异。

iOS 视图控制器转场详解_第3张图片

UINavigationController 和 UITabBarController 这两个容器 VC 的根视图在屏幕上是不可见的(或者说是透明的),可见的只是内嵌在这两者中的子 VC 中的视图,转场是从子 VC 的视图转换到另外一个子 VC 的视图,其根视图并未参与转场;而 Modal 转场,以 presentation 为例,是从 presentingView 转换到 presentedView,根视图 presentingView 也就是 fromView 参与了转场。而且 NavigationController 和 TabBarController 转场中的 containerView 也并非这两者的根视图。

Modal 转场与两种容器 VC 的转场的另外一个不同是:Modal 转场结束后 presentingView 可能依然可见,UIModalPresentationPageSheet 模式就是这样。这种不同导致了 Modal 转场和容器 VC 的转场对 fromView 的处理差异:容器 VC 的转场结束后 fromView 会被主动移出视图结构,这是可预见的结果,我们也可以在转场结束前手动移除;而 Modal 转场中,presentation 结束后 presentingView(fromView) 并未主动被从视图结构中移除。准确来说,是 UIModalPresentationCustom 这种模式下的 Modal 转场结束时 fromView 并未从视图结构中移除;UIModalPresentationFullScreen 模式的 Modal 转场结束后 fromView 依然主动被从视图结构中移除了。这种差异导致在处理 dismissal 转场的时候很容易出现问题,没有意识到这个不同点的话出错时就会毫无头绪。下面来看看 dismissal 转场时的场景。

ContainerView 在转场期间作为 fromView 和 toView 的父视图。三种转场过程中的 containerView 是 UIView 的私有子类,不过我们并不需要关心 containerView 具体是什么。在 dismissal 转场中:

  • UIModalPresentationFullScreen 模式:presentation 后,presentingView 被主动移出视图结构,在 dismissal 中 presentingView 是 toView 的角色,其将会重新加入 containerView 中,实际上,我们不主动将其加入,UIKit 也会这么做,前面的两种容器控制器的转场里不是这样处理的,不过这个差异基本没什么影响。

  • UIModalPresentationCustom 模式:转场时 containerView 并不担任 presentingView 的父视图,后者由 UIKit 另行管理。在 presentation 后,fromView(presentingView) 未被移出视图结构,在 dismissal 中,注意不要像其他转场中那样将 toView(presentingView) 加入 containerView 中,否则本来可见的 presentingView 将会被移除出自身所处的视图结构消失不见。如果你在使用 Custom 模式时没有注意到这点,就很容易掉进这个陷阱而很难察觉问题所在,这个问题曾困扰了我一天。

对于 Custom 模式,我们可以参照其他转场里的处理规则来打理:presentation 转场结束后主动将 fromView(presentingView) 移出它的视图结构,并用一个变量来维护 presentingView 的父视图,以便在 dismissal 转场中恢复;在 dismissal 转场中,presentingView 的角色由原来的 fromView 切换成了 toView,我们再将其重新恢复它原来的视图结构中。测试表明这样做是可行的。但是这样一来,在实现上,需要在转场代理中维护一个动画控制器并且这个动画控制器要维护 presentingView 的父视图,第三方的动画控制器必须为此改造。显然,这样的代价是无法接受的。

小结:经过上面的尝试,建议是,不要干涉官方对 Modal 转场的处理,我们去适应它。在 Custom 模式下,由于 presentingView 不受 containerView 管理,在 dismissal 转场中不要像其他的转场那样将 toView(presentingView) 加入 containerView,否则 presentingView 将消失不见,而应用则也很可能假死;在 presentation 转场中,切记不要手动将 fromView(presentingView) 移出其父视图。

iOS 8 为协议添加了viewForKey:方法以方便获取 fromView 和 toView,但是在 Modal 转场里要注意,从上面可以知道,Custom 模式下,presentingView 并不受 containerView 管理,这时通过viewForKey:方法来获取 presentingView 得到的是 nil,必须通过viewControllerForKey:得到 presentingVC 后来获取。因此在 Modal 转场中,较稳妥的方法是从 fromVC 和 toVC 中获取 fromView 和 toView。

顺带一提,前面提到的UIView的类方法transitionFromView:toView:duration:options:completion:能在 Custom 模式下工作,却与 FullScreen 模式有点不兼容。

(2)Modal 转场实践

UIKit 已经为 Modal 转场实现了多种效果,当 UIViewController 的modalPresentationStyle属性为.Custom 或.FullScreen时,我们就有机会定制转场效果,此时modalTransitionStyle指定的转场动画将会被忽略。

Modal 转场开放自定义功能后最令人感兴趣的是定制 presentedView 的尺寸,下面来我们来实现一个带暗色调背景的小窗口效果。Demo 地址:CustomModalTransition。

iOS 视图控制器转场详解_第4张图片

由于需要保持 presentingView 可见,这里的 Modal 转场应该采用 UIModalPresentationCustom 模式,此时 presentedVC 的modalPresentationStyle属性值应设置为.Custom。而且与容器 VC 的转场的代理由容器 VC 自身的代理提供不同,Modal 转场的代理由 presentedVC 提供。动画控制器的核心代码:

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
35
36
37
38
39
40
41
42
43
44
45
class OverlayAnimationController: NSobject, UIViewControllerAnimatedTransitioning{
     ... 
     func animateTransition(transitionContext: UIViewControllerContextTransitioning) {            
         ...
         //不像容器 VC 转场里需要额外的变量来标记操作类型,UIViewController 自身就有方法跟踪 Modal 状态
         //处理 Presentation 转场
         if  toVC.isBeingPresented(){
             //计算 presentedView 和 dimmingView 的初始位置和尺寸
             let toViewWidth = containerView.frame.width * 2 / 3, toViewHeight = containerView.frame.height * 2 / 3
 
             //1
             containerView.addSubview(toView)
             toView.center = containerView.center
             toView.bounds = CGRect(x: 0, y: 0, width: 1, height: toViewHeight)
 
             let dimmingView = UIView()
             //在 presentedView 后面添加背景视图 dimmingView,注意两者在 containerView 中的位置
             containerView.insertSubview(dimmingView, belowSubview: toView)
             dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
             dimmingView.center = containerView.center
             dimmingView.bounds = CGRect(x: 0, y: 0, width: toViewWidth, height: toViewHeight)
 
             //实现形变动画
             UIView.animateWithDuration(duration, delay: 0, options: .CurveEaseInOut, animations: {
                 toView.bounds = CGRect(x: 0, y: 0, width: toViewWidth, height: toViewHeight)
                 dimmingView.bounds = containerView.bounds
                 }, completion: {_  in
                     //2
                     let isCancelled = transitionContext.transitionWasCancelled()
                     transitionContext.completeTransition(!isCancelled)
             })
         }
         //处理 Dismiss 转场,注意,按照上一小节的结论,.Custom 模式下的 Dismiss 里不要将 toView 添加到 containerView
         if  fromVC.isBeingDismissed(){
             let fromViewHeight = fromView.frame.height
             UIView.animateWithDuration(duration, animations: {
                 fromView.bounds = CGRect(x: 0, y: 0, width: 1, height: fromViewHeight)
                 }, completion: { _  in
                     //2
                     let isCancelled = transitionContext.transitionWasCancelled()
                     transitionContext.completeTransition(!isCancelled)
             })
         }
     }
}

(3)iOS 8的改进:UIPresentationController

iOS 8 针对分辨率日益分裂的 iOS 设备带来了新的适应性布局方案,以往有些专为在 iPad 上设计的控制器也能在 iPhone 上使用了,同时改进了(模态)显示视图控制器的机制,UIKit 在视图控制器的(模态)显示过程,包括转场过程,引入了UIPresentationController类,该类接管了 UIViewController 的显示过程,为其提供转场和视图管理支持。当 UIViewController 的modalPresentationStyle属性设置为.Custom时(不支持.FullScreen),我们有机会通过控制器的转场代理提供UIPresentationController的子类对 Modal 转场进行进一步的定制。官方对该类参与转场的流程和使用方法有非常详细的说明:Creating Custom Presentations。

UIPresentationController类主要给 Modal 转场带来了以下几点变化:

  • 定制 presentedView 的外观:设定 presentedView 的尺寸以及在 containerView 中添加自定义视图并为这些视图添加动画;

  • iOS 8 中的适应性布局

  • 可以在不需要动画控制器的情况下单独工作

  • 可以选择是否移除 presentingView

UIPresentationController类带来的定制外观功能在 iOS 7 中也可以做到,在上一节里我们正是这样做的,这样一来,动画控制器还需要负责管理额外的视图。UIPresentationController类将该功能剥离了出来独立负责,其提供了如下的方法参与转场,对转场过程实现了更加细致的控制,从命名便可以看出与动画控制器里的animateTransition:的关系:

1
2
3
4
func presentationTransitionWillBegin()
func presentationTransitionDidEnd(_ completed: Bool)
func dismissalTransitionWillBegin()
func dismissalTransitionDidEnd(_ completed: Bool)

除了 presentingView,UIPresentationController类拥有转场过程中剩下的角色:

1
2
3
4
5
6
7
//指定初始化方法
init(presentedViewController presentedViewController: UIViewController, presentingViewController presentingViewController: UIViewController)
var  presentingViewController: UIViewController { get }
var  presentedViewController: UIViewController { get }
var  containerView: UIView? { get }
//提供给动画控制器使用的视图,默认返回 presentedVC.view,通过重写该方法返回其他视图,但一定要是 presentedVC.view 的上层视图
func presentedView() -> UIView?

没有 presentingView 是因为 Custom 模式下 presentingView 不受 containerView 管理,UIPresentationController类并没有改变这一点。iOS 8 扩充了转场环境协议,可以通过viewForKey:方便获取转场的视图,而该方法在 Modal 转场中获取的是presentedView()返回的视图。因此我们可以在子类中将 presentedView 包装在其他视图后重写该方法返回包装后的视图当做 presentedView 在动画控制器中使用。

接下来,我用UIPresentationController子类实现上一节「Modal 转场实践」里的效果,presentingView 和 presentedView 的动画由动画控制器负责,剩下的事情可以交给我们实现的子类来完成。

参与角色都准备好了,但有个问题,无法直接访问动画控制器,不知道转场的持续时间,怎么与转场过程同步?这时候前面提到的用处甚少的转场协调器(Transition Coordinator)将在这里派上用场。该对象可通过 UIViewController 的transitionCoordinator()方法获取,这是 iOS 7 为自定义转场新增的 API,该方法只在控制器处于转场过程中才返回一个与当前转场有关的有效对象,其他时候返回 nil。

转场协调器遵守协议,它含有以下几个方法:

1
2
3
4
//与动画控制器中的转场动画同步,执行其他动画
animateAlongsideTransition:completion:
//与动画控制器中的转场动画同步,在指定的视图内执行动画
animateAlongsideTransitionInView:animation:completion:

由于转场协调器的这种特性,动画的同步问题解决了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class OverlayPresentationController: UIPresentationController {
     let dimmingView = UIView()
     
     //presentation 转场开始前将 dimmingView 添加到 containerView,同时添加尺寸变化的动画
     override func presentationTransitionWillBegin() {
         containerView?.addSubview(dimmingView)
         let dimmingViewInitailWidth = containerView!.frame.width * 2 / 3, dimmingViewInitailHeight = containerView!.frame.height * 2 / 3
         dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
         dimmingView.center = containerView!.center
         dimmingView.bounds = CGRect(x: 0, y: 0, width: dimmingViewInitailWidth , height: dimmingViewInitailHeight)
         //使用 transitionCoordinator 与转场动画并行执行 dimmingView 的动画
         presentedViewController.transitionCoordinator()?.animateAlongsideTransition({ _  in
             self.dimmingView.bounds = self.containerView!.bounds
         }, completion: nil)
     }
     //dismissal 转场开始前添加 dimmingView 消失的动画,在上一节中并没有添加这个动画,实际上由于 presentedView 的形变动画,这个动画根本不会被注意到,此处只为示范
     override func dismissalTransitionWillBegin() {
         presentedViewController.transitionCoordinator()?.animateAlongsideTransition({ _  in
         self.dimmingView.alpha = 0.0
         }, completion: nil)
     }    
}

OverlayPresentationController类接手了 dimmingView 的工作后,上一节OverlayAnimationController里处理 presentation 转场的部分就需要修改一下,把 dimmingView 的部分删除:

1
2
3
4
5
6
7
8
9
10
11
12
if  toVC.isBeingPresented(){
     containerView.addSubview(toView)    
     toView.center = ...
     toView.bounds = ...
 
     UIView.animateWithDuration(duration, delay: 0, options: .CurveEaseInOut, animations: {
         toView.bounds = CGRect(x: 0, y: 0, width: toViewWidth, height: toViewHeight)
         }, completion: {_  in
             let isCancelled = transitionContext.transitionWasCancelled()
             transitionContext.completeTransition(!isCancelled)
     })
}

iOS 8 带来了适应性布局,协议用于响应视图尺寸变化和屏幕旋转事件,之前用于处理屏幕旋转的方法都被废弃了。UIViewController 和 UIPresentationController 类都遵守该协议,在 Modal 转场中如果提供了后者,则由后者负责前者的尺寸变化和屏幕旋转,最终的布局机会也在后者里。在OverlayPresentationController中重写以下方法来调整视图布局以及应对屏幕旋转:

1
2
3
4
5
6
7
8
override func containerViewWillLayoutSubviews() {
     dimmingView.center = containerView!.center
     dimmingView.bounds = containerView!.bounds
     
     let width = containerView!.frame.width * 2 / 3, height = containerView!.frame.height * 2 / 3
     presentedView()?.center = containerView!.center
     presentedView()?.bounds = CGRect(x: 0, y: 0, width: width, height: height)
}

然后在 presentedVC 的转场代理属性transitioningDelegate中提供OverlayPresentationController对象就可以达到上一节里的效果。

1
2
3
func presentationControllerForPresentedViewController(_ presented: UIViewController, presentingViewController presenting: UIViewController, sourceViewController source: UIViewController) -> UIPresentationController?{
     return  OverlayPresentationController(presentedViewController: presented, presentingViewController: presenting)
}

除了能与动画控制器配合,UIPresentationController类也能脱离动画控制器独立工作,在转场代理里我们仅仅提供后者也能对 presentedView 的外观进行定制,缺点是无法控制 presentedView 的转场动画,因为这是动画控制器的职责,这种情况下,presentedView 的转场动画采用的是默认的动画效果,转场协调器实现的动画则是采用默认的动画时间。

在 iOS 7 中,Custom 模式的 Modal 转场里,presentingView 不会被移除,如果我们要移除它并妥善恢复会破坏动画控制器的独立性使得第三方动画控制器无法直接使用;在 iOS 8 中,UIPresentationController解决了这点,给予了我们选择的权力,通过重写下面的方法来决定 presentingView 是否在 presentation 转场结束后被移除:

1
func shouldRemovePresentersView() -> Bool

返回 true 时,presentation 结束后 presentingView 被移除,在 dimissal 结束后 UIKit 会自动将 presentingView 恢复到原来的视图结构中。通过UIPresentationController的参与,Custom 模式完全实现了 FullScreen 模式下的全部特性。

4.转场代理

完成动画控制器后,只需要在转场前设置好转场代理便能实现动画控制器中提供的效果。转场代理的实现很简单,但是在设置代理时有不少陷阱,需要注意。

UINavigationControllerDelegate

定制 UINavigationController 这种容器控制器的转场时,很适合实现一个子类,自身集转场代理,动画控制器于一身,也方便使用,不过这样做有时候又限制了它的使用范围,别人也实现了自己的子类时便不能方便使用你的效果,这里采取的是将转场代理封装成一个类。

1
2
3
4
5
6
7
8
class SDENavigationControllerDelegate: NSObject, UINavigationControllerDelegate {
     //在对象里,实现该方法提供动画控制器,返回 nil 则使用系统默认的效果
     func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
         //使用上一节实现的 Slide 动画控制器,需要提供操作类型信息
         let transitionType = SDETransitionTye.NavigationTransition(operation)
         return  SlideAnimationController(type: transitionType)
     }
}

如果你在代码里为你的控制器里这样设置代理:

1
2
//错误的做法,delegate 是弱引用,在离开这行代码所处的方法范围后,delegate 将重新变为 nil,然后什么都不会发生。
self.navigationController?.delegate = SDENavigationControllerDelegate()

可以使用强引用的变量来引用新实例,且不能使用本地变量,在控制器中新增一个变量来维持新实例就可以了。

1
self.navigationController?.delegate = strongReferenceDelegate

解决了弱引用的问题,这行代码应该放在哪里执行呢?很多人喜欢在viewDidLoad()做一些配置工作,但在这里设置无法保证是有效的,因为这时候控制器可能尚未进入 NavigationController 的控制器栈,self.navigationController返回的可能是 nil;如果是通过代码 push 其他控制器,在 push 前设置即可;prepareForSegue:sender:方法是转场前更改设置的最后一次机会,可以在这里设置;保险点,使用UINavigationController子类,自己作为代理,省去到处设置的麻烦。

不过,通过代码设置终究显得很繁琐且不安全,在 storyboard 里设置一劳永逸:在控件库里拖拽一个 NSObject 对象到相关的 UINavigationControler 上,在控制面板里将其类别设置为SDENavigationControllerDelegate,然后拖拽鼠标将其设置为代理。

最后一步,像往常一样触发转场:

1
2
self.navigationController?.pushViewController(toVC, animated:  true )
self.navigationController?.popViewControllerAnimated( true )

在 storyboard 中通过设置 segue 时开启动画也将看到同样的 Slide 动画。Demo 地址:NavigationControllerTransition。

UITabBarControllerDelegate

同样作为容器控制器,UITabBarController 的转场代理和 UINavigationController 类似,通过类似的方法提供动画控制器,不过的代理方法里提供了操作类型,但的代理方法没有提供滑动的方向信息,需要我们来获取滑动的方向。

1
2
3
4
5
6
7
8
9
10
11
12
class SDETabBarControllerDelegate: NSObject, UITabBarControllerDelegate {
     //在对象里,实现该方法提供动画控制器,返回 nil 则没有动画效果
     func tabBarController(tabBarController: UITabBarController, animationControllerForTransitionFromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning?{
         let fromIndex = tabBarController.viewControllers!.indexOf(fromVC)!
         let toIndex = tabBarController.viewControllers!.indexOf(toVC)!
         
         let tabChangeDirection: TabOperationDirection = toIndex < fromIndex ? .Left : .Right
         let transitionType = SDETransitionTye.TabTransition(tabChangeDirection)
         let slideAnimationController = SlideAnimationController(type: transitionType)
         return  slideAnimationController
     }
}

为 UITabBarController 设置代理的方法和陷阱与上面的 UINavigationController 类似,注意delegate属性的弱引用问题。点击 TabBar 的相邻页面进行切换时,将会看到 Slide 动画;通过以下代码触发转场时也将看到同样的效果:

1
2
3
tabBarVC.selectedIndex = ...
//or
tabBarVC.selectedViewController = ...

Demo 地址:ScrollTabBarController。

UIViewControllerTransitioningDelegate

Modal 转场的代理协议是 iOS 7 新增的,其为 presentation 和 dismissal 转场分别提供了动画控制器。在「特殊的 Modal 转场」里实现的OverlayAnimationController类可同时处理 presentation 和 dismissal 转场。UIPresentationController只在 iOS 8中可用,通过available关键字可以解决 API 的版本差异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class SDEModalTransitionDelegate: NSObject, UIViewControllerTransitioningDelegate {
     func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
         return  OverlayAnimationController()
     }
     
     func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
         return  OverlayAnimationController()
     }
     
     @available(iOS 8.0, *)
     func presentationControllerForPresentedViewController(presented: UIViewController, presentingViewController presenting: UIViewController, sourceViewController source: UIViewController) -> UIPresentationController? {
         return  OverlayPresentationController(presentedViewController: presented, presentingViewController: presenting)
     }
}

Modal 转场的代理由 presentedVC 的transitioningDelegate属性来提供,这与前两种容器控制器的转场不一样,不过该属性作为代理同样是弱引用,记得和前面一样需要有强引用的变量来维护该代理,而 Modal 转场需要 presentedVC 来提供转场代理的特性使得 presentedVC 自身非常适合作为自己的转场代理。另外,需要将 presentedVC 的modalPresentationStyle属性设置为.Custom或.FullScreen,只有这两种模式下才支持自定义转场,该属性默认值为.FullScreen。自定义转场时,决定转场动画效果的modalTransitionStyle属性将被忽略。

开启转场动画的方式依然是两种:在 storyboard 里设置 segue 并开启动画,但这里并不支持.Custom模式,不过还有机会挽救,转场前的最后一个环节prepareForSegue:sender:方法里可以动态修改modalPresentationStyle属性;或者全部在代码里设置,示例如下:

1
2
3
4
let presentedVC = ...
presentedVC.transitioningDelegate = strongReferenceSDEModalTransitionDelegate
presentedVC.modalPresentationStyle = .Custom/.FullScreen  //与 UIPresentationController 配合时该值必须为.Custom
presentingVC.presentViewController(presentedVC, animated:  true , completion: nil)

Demo 地址:CustomModalTransition。


阶段二:交互式转场

激动人心的部分来了,好消息是交互转场的实现难度比你想象的要低。

1.实现交互化

在非交互转场的基础上将之交互化需要两个条件:

  • 由转场代理提供交互控制器,这是一个遵守协议的对象,不过系统已经打包好了现成的类UIPercentDrivenInteractiveTransition供我们使用。我们不需要做任何配置,仅仅在转场代理的相应方法中提供一个该类实例便能工作。另外交互控制器必须有动画控制器才能工作。

  • 交互控制器还需要交互手段的配合,最常见的是使用手势,或是其他事件,来驱动整个转场进程。

以上两个条件缺一不可,这使得实现交互转场时容易犯错。

正确地提供交互控制器:

如果在转场代理中提供了交互控制器,而转场发生时并没有方法来驱动转场进程(比如手势),转场过程将一直处于开始阶段无法结束,应用界面也会失去响应:在 NavigationController 中点击 NavigationBar 也能实现 pop 返回操作,但此时没有了交互手段的支持,转场过程卡壳;在 TabBarController 的代理里提供交互控制器存在同样的问题,点击 TabBar 切换页面时也没有实现交互控制。因此仅在确实处于交互状态时才提供交互控制器,可以使用一个变量来标记交互状态,该变量由交互手势来更新状态。

以为 NavigationController 提供交互控制器为例:

1
2
3
4
5
6
7
8
9
class SDENavigationDelegate: NSObject, UINavigationControllerDelegate {
     var  interactive =  false
     let interactionController = UIPercentDrivenInteractiveTransition()
     ...
     
     func navigationController(navigationController: UINavigationController, interactionControllerForAnimationController animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
         return  interactive ? self.interactionController : nil
     }
}

TabBarController 的实现类似,Modal 转场代理分别为 presentation 和 dismissal 提供了各自的交互控制器,也需要注意上面的问题。

问题的根源是交互控制的工作机制导致的,交互过程实际上是由转场环境对象来管理的,它提供了如下几个方法来控制转场的进度:

1
2
3
func updateInteractiveTransition(_ percentComplete: CGFloat) //更新转场进度,进度数值范围为0.0~1.0
func cancelInteractiveTransition() //取消转场,转场动画从当前状态返回至转场发生前的状态
func finishInteractiveTransition() //完成转场,转场动画从当前状态继续直至结束

交互控制协议只有一个必须实现的方法:

1
func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning)

在转场代理里提供了交互控制器后,转场开始时,该方法自动被 UIKit 调用对转场环境进行配置。

系统打包好的UIPercentDrivenInteractiveTransition中的控制转场进度的方法与转场环境对象提供的三个方法同名,实际上只是前者调用了后者的方法而已。系统以一种解耦的方式使得动画控制器,交互控制器,转场环境对象互相协作,我们只需要使用UIPercentDrivenInteractiveTransition的三个同名方法来控制进度就够了。如果你要实现自己的交互控制器,而不是UIPercentDrivenInteractiveTransition的子类,就需要调用转场环境的三个方法来控制进度,压轴环节我们将示范如何做。

交互控制器控制转场的过程就像将动画控制器实现的动画制作成一部视频,我们使用手势或是其他方法来控制转场动画的播放,可以前进,后退,继续或者停止。finishInteractiveTransition()方法被调用后,转场动画从当前的状态将继续进行直到动画结束,转场完成;cancelInteractiveTransition()被调用后,转场动画从当前的状态回拨到初始状态,转场取消。

在 NavigationController 中点击 NavigationBar 的 backBarButtomItem 执行 pop 操作时,由于我们无法介入 backBarButtomItem 的内部流程,就失去控制进度的手段,于是转场过程只有一个开始,永远不会结束。其实我们只需要有能够执行上述几个方法的手段就可以对转场动画进行控制,用户与屏幕的交互手段里,手势是实现这个控制过程的天然手段,我猜这是其被称为交互控制器的原因。

交互手段的配合:

下面使用演示如何利用屏幕边缘滑动手势UIScreenEdgePanGestureRecognizer在 NavigationController 中控制 Slide 动画控制器提供的动画来实现右滑返回的效果,该手势绑定的动作方法如下:

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
func handleEdgePanGesture(gesture: UIScreenEdgePanGestureRecognizer){
     let translationX =  gesture.translationInView(view).x
     let translationBase: CGFloat = view.frame.width / 3
     let translationAbs = translationX > 0 ? translationX : -translationX
     let percent = translationAbs > translationBase ? 1.0 : translationAbs / translationBase
     switch  gesture.state{
     case  .Began:
         //转场开始前获取代理,一旦转场开始,VC 将脱离控制器栈,此后 self.navigationController 返回的是 nil
         self.navigationDelegate = self.navigationController?.delegate as? SDENavigationDelegate
         //更新交互状态
         self.navigationDelegate?.interactive =  true
         //1.交互控制器没有 start 之类的方法,当下面这行代码执行后,转场开始,如果转场代理提供了交互控制器,它将从这时候开始接管转场过程
         self.navigationController?.popViewControllerAnimated( true )
     case  .Changed:
         //2.更新进度
         self.navigationDelegate?.interactionController.updateInteractiveTransition(percent)
     case  .Cancelled, .Ended:
         //3.结束转场
         if  percent > 0.5{
             //完成转场
             self.navigationDelegate?.interactionController.finishInteractiveTransition()
         } else {
             //取消转场
             self.navigationDelegate?.interactionController.cancelInteractiveTransition()
         }
         //无论转场的结果如何,恢复为非交互状态
         self.navigationDelegate?.interactive =  false
     default : self.navigationDelegate?.interactive =  false
     }
}

交互转场的流程就是三处数字标记的代码。不管是什么交互方式,使用什么转场方式,都是在使用这三个方法控制转场的进度。对于交互式转场,交互手段只是表现形式,本质是驱动转场进程。很希望能够看到更新颖的交互手法,比如通过点击页面不同区域来控制一套复杂的流程动画。TabBarController 的 Demo 中也实现了滑动切换 Tab 页面,代码是类似的,就不占篇幅了。

转场交互化后结果有两种:完成和取消。取消后动画将会原路返回到初始状态,但已经变化了的数据怎么恢复?

一种情况是,控制器的系统属性,比如,在 TabBarController 里使用上面的方法实现滑动切换 Tab 页面,中途取消的话,已经变化的selectedIndex属性该怎么恢复为原值;上面的代码里,取消转场的代码执行后,self.navigationController返回的依然还是是 nil,怎么让控制器回到 NavigationController 的控制器栈顶。对于这种情况,UIKit 自动替我们恢复了,不需要我们操心(可能你都没有意识到这回事);

另外一种就是,转场发生的过程中,你可能想实现某些效果,一般是在下面的事件中执行,转场中途取消的话可能需要取消这些效果。

1
2
3
4
func viewWillAppear(_ animated: Bool)
func viewDidAppear(_ animated: Bool)
func viewWillDisappear(_ animated: Bool)
func viewDidDisappear(_ animated: Bool)

交互转场介入后,视图在这些状态间的转换变得复杂,WWDC 上苹果的工程师还表示转场过程中 view 的Will系方法和Did系方法的执行顺序并不能得到保证,虽然几率很小,但如果你依赖于这些方法执行的顺序的话就可能需要注意这点。而且,Did系方法调用时并不意味着转场过程真的结束了。另外,fromView 和 toView 之间的这几种方法的相对顺序更加混乱,具体的案例可以参考这里:The Inconsistent Order of View Transition Events。

如何在转场过程中的任意阶段中断时取消不需要的效果?这时候该转场协调器(Transition Coordinator)再次出场了。

2.Transition Coordinator

转场协调器(Transition Coordinator)的出场机会不多,但却是关键先生。Modal 转场中,UIPresentationController类只能通过转场协调器来与动画控制器同步,并行执行其他动画;这里它可以在交互式转场结束时执行一个闭包:

1
func notifyWhenInteractionEndsUsingBlock(_ handler: (UIViewControllerTransitionCoordinatorContext) -> Void)

当转场由交互状态转变为非交互状态(在手势交互过程中则为手势结束时),无论转场的结果是完成还是被取消,该方法都会被调用;得益于闭包,转场协调器可以在转场过程中的任意阶段搜集动作并在交互中止后执行。闭包中的参数是一个遵守协议的对象,该对象由 UIKit 提供,和前面的转场环境对象作用类似,它提供了交互转场的状态信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
override func viewWillAppear(animated: Bool) {
     super .viewWillDisappear(animated)
     self.doSomeSideEffectsAssumingViewDidAppearIsGoingToBeCalled()
     //只在处于交互转场过程中才可能取消效果
     if  let coordinator = self.transitionCoordinator() where coordinator.initiallyInteractive() ==  true {
         coordinator.notifyWhenInteractionEndsUsingBlock({
             interactionContext  in
             if  interactionContext.isCancelled(){
                 self.undoSideEffects()
             }
         })
     }
}

不过交互状态结束时并非转场过程的终点(此后动画控制器提供的转场动画根据交互结束时的状态继续或是返回到初始状态),而是由动画控制器来结束这一切:

1
optional func animationEnded(_ transitionCompleted: Bool)

如果实现了该方法,将在转场动画结束后调用。

UIViewController 可以通过transitionCoordinator()获取转场协调器,该方法的文档中说只有在 Modal 转场过程中,该方法才返回一个与当前转场相关的有效对象。实际上,NavigationController 的转场中 fromVC 和 toVC 也能返回一个有效对象,TabBarController 有点特殊,fromVC 和 toVC 在转场中返回的是 nil,但是作为容器的 TabBarController 可以使用该方法返回一个有效对象。

转场协调器除了上面的两种关键作用外,也在 iOS 8 中的适应性布局中担任重要角色,可以查看协议中的方法,其中响应尺寸和屏幕旋转事件的方法都包含一个转场协调器对象,视图的这种变化也被系统视为广义上的 transition,参数中的转场协调器也由 UIKit 提供。这个话题有点超出本文的范围,就不深入了,有需要的话可以查看文档和相关 session。

3.封装交互控制器

UIPercentDrivenInteractiveTransition类是一个系统提供的交互控制器,在转场代理的相关方法里提供一个该类实例就够了,还有其他需求的话可以实现其子类来完成,那这里的封装是指什么?系统把交互控制器打包好了,但是交互控制器工作还需要其他的配置。程序员向来很懒,能够自动完成的事绝不肯写一行代码,写一行代码就能搞定的事绝不写第二行,所谓少写一行是一行。能不能顺便把交互控制器的配置也打包好省得写代码啊?当然可以。

热门转场动画库 VCTransitionsLibrary 封装好了多种动画效果,并且自动支持 pop, dismissal 和 tab change 等操作的手势交互,其手法是在转场代理里为 toVC 添加手势并绑定相应的处理方法。

为何没有支持 push 和 presentation 这两种转场?因为 push 和 presentation 这两种转场需要提供 toVC,而库并没有 toVC 的信息,这需要作为使用者的开发者来提供;对于逆操作的 pop 和 dismiss,toVC 的信息已经存在了,所以能够实现自动支持。而 TabBarController 则是个例外,它是在已知的子 VC 之间切换,不存在这个问题。需要注意的是,库这样封装了交互控制器后,那么你将无法再让同一种手势支持 push 或 presentation,要么只支持单向的转场,要么你自己实现双向的转场。当然,如果知道 toVC 是什么类的话,你可以改写这个库让 push 和 present 得到支持。不过,对于在初始化时需要配置额外信息的类,这种简单的封装可能不起作用。VCTransitionsLibrary 库还支持添加自定义的简化版的动画控制器和交互控制器,在封装和灵活之间的平衡控制得很好,代码非常值得学习。

只要愿意,我们还可以变得更懒,不,是效率更高。FDFullscreenPopGesture 通过 category 的方法让所有的 UINavigationController 都支持右滑返回,而且,一行代码都不用写,这是配套的博客:一个丝滑的全屏滑动返回手势。那么也可以实现一个类似的 FullScreenTabScrollGesture 让所有的 UITabBarController 都支持滑动切换,不过,UITabBar 上的 icon 渐变动画有点麻烦,因为其中的 UITabBarItem 并非 UIView 子类,无法进行动画。WXTabBarController 这个项目完整地实现了微信界面的滑动交互以及 TabBar 的渐变动画。不过,它的滑动交互并不是使用转场的方式完成的,而是使用 UIScrollView,好处是兼容性更好。兼容性这方面国内的环境比较差,iOS 9 都出来了,可能还需要兼容 iOS 6,而自定义转场需要至少 iOS 7 的系统。该项目实现的 TabBar 渐变动画是基于 TabBar 的内部结构实时更新相关视图的 alpha 值来实现的(不是UIView 动画),这点非常难得,而且使用 UIScrollView 还可以实现自动控制 TabBar 渐变动画,相比之下,使用转场的方式来实现这个效果会麻烦一点。

一个较好的转场方式需要顾及更多方面的细节,NavigationController 的 NavigationBar 和 TabBarController 的 TabBar 这两者在先天上有着诸多不足需要花费更多的精力去完善,本文就不在这方面深入了,上面提及的几个开源项目都做得比较好,推荐学习。

4.交互转场的限制

如果希望转场中的动画能完美地被交互控制,必须满足2个隐性条件:

  • 使用 UIView 动画的 API。你当然也可以使用 Core Animation 来实现动画,甚至,这种动画可以被交互控制,但是当交互中止时,会出现一些意外情况:如果你正确地用 Core Animation 的方式复现了 UIView 动画的效果(不仅仅是动画,还包括动画结束后的处理),那么手势结束后,动画将直接跳转到最终状态;而更多的一种状况是,你并没有正确地复现 UIView 动画的效果,手势结束后动画会停留在手势中止时的状态,界面失去响应。所以,如果你需要完美的交互转场动画,必须使用 UIView 动画。

  • 在动画控制器的animateTransition:中提交动画。问题和第1点类似,在viewWillDisappear:这样的方法中提交的动画也能被交互控制,但交互停止时,立即跳转到最终状态。

如果你希望制作多阶段动画,在某个动画结束后再执行另外一段动画,可以通过 UIView Block Animation 的 completion 闭包来实现动画链,或者是通过设定动画执行的延迟时间使得不同动画错分开来,但是交互转场不支持这两种形式。UIView 的 keyFrame Animation API 可以帮助你,通过在动画过程的不同时间节点添加关键帧动画就可以实现多阶段动画。

1
2
class func animateKeyframesWithDuration(_ duration: NSTimeInterval, delay delay: NSTimeInterval, options options: UIViewKeyframeAnimationOptions, animations animations: () -> Void, completion completion: ((Bool) -> Void)?)
class func addKeyframeWithRelativeStartTime(_ frameStartTime: Double, relativeDuration frameDuration: Double, animations animations: () -> Void)

我实现过一个这样的多阶段转场动画,Demo 在此:CollectionViewAlbumTransition


(四)插曲:UICollectionViewController 布局转场

前面一直没有提到这种转场方式,与三大主流转场不同,布局转场只针对 CollectionViewController 搭配 NavigationController 的组合,且是作用于布局,而非视图。采用这种布局转场时,NavigationController 将会用布局变化的动画来替代 push 和 pop 的默认动画。苹果自家的照片应用中的「照片」Tab 页面使用了这个技术:在「年度-精选-时刻」几个时间模式间切换时,CollectionViewController 在 push 或 pop 时尽力维持在同一个元素的位置同时进行布局转换。

布局转场的实现比三大主流转场要简单得多,只需要满足四个条件:NavigationController + CollectionViewController, 且要求后者都拥有相同数据源, 并且开启useLayoutToLayoutNavigationTransitions属性为真。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let cvc0 = UICollectionViewController(collectionViewLayout: layout0)
//作为 root VC 的 cvc0 的该属性必须为 false,该属性默认为 false
cvc0.useLayoutToLayoutNavigationTransitions =  false
let nav = UINavigationController(rootViewController: cvc0)
//cvc0, cvc1, cvc2 必须具有相同的数据,如果在某个时刻修改了其中的一个数据源,其他的数据源必须同步,不然会出错。
let cvc1 = UICollectionViewController(collectionViewLayout: layout1)
cvc1.useLayoutToLayoutNavigationTransitions =  true
nav.pushViewController(cvc1, animated:  true )
 
let cvc2 = UICollectionViewController(collectionViewLayout: layout2)
cvc2.useLayoutToLayoutNavigationTransitions =  true
nav.pushViewController(cvc2, animated:  true )
 
nav.popViewControllerAnimated( true )
nav.popViewControllerAnimated( true )

Push 进入控制器栈后,不能更改useLayoutToLayoutNavigationTransitions的值,否则应用会崩溃。

当 CollectionView 的数据源(section 和 cell 的数量)不完全一致时,push 和 pop 时依然会有布局转场动画,但是当 pop 回到 rootVC 时,应用会崩溃。可否共享数据源保持同步来克服这个缺点?测试表明,这样做可能会造成画面上的残缺,以及不稳定。建议不要这么做。

布局转场不支持交互控制。Demo 地址:CollectionViewControllerLayoutTransition

此外,iOS 7 支持 UICollectionView 布局的交互转换(Layout Interactive Transition),过程与控制器的交互转场(ViewController Interactive Transition)类似,这个功能和布局转场(CollectionViewController Layout Transition)容易混淆,前者是在自身布局转换的基础上实现了交互控制,后者是 CollectionViewController 与 NavigationController 结合后在转场的同时进行布局转换。感兴趣的话可以看这个功能的文档。


(五)进阶

是否觉得本文中实现的例子的动画效果太过简单?的确很简单,与 VCTransitionsLibrary 这样的转场动画库提供的十种动画效果相比是很简单的,不过就动画而言,与本文示例的本质是一样的,它们都是针对 fromView 和 toView 的整体进行的动画,但在效果上更加复杂。我在本文中多次强调转场动画的本质是是对即将消失的当前视图和即将出现的下一屏幕的内容进行动画,「在动画控制器里,参与转场的视图只有 fromView 和 toView 之分,与转场方式无关。转场动画的最终效果只限制于你的想象力」,当然,还有你的实现能力。

本文前面的目的是帮助你熟悉转场的整个过程,你也看到了,转场动画其实很简单。那学习了前面的内容能够立马实现 Github 上那些热门的转场动画吗?不能。因为那些转场动画在转场这堆料里还加了些佐料,正是这些佐料才让它们成为热门,而且大部分涉及视图动画的其他方面,与转场本身关系不大,但它们与转场结合后就有了神奇的力量。那学习了作为进阶的本章能立马实现那些热门的转场效果吗?有可能,有些效果其实很简单,一点就透,还有一些效果涉及的技术属于本文主题之外的内容,我会给出相关的提示就不深入了。

本章的进阶分为两个部分:

  • 案例分析:动画的方式非常多,有些并不常见,有些只是简单到令人惊讶的组合,只是你不曾了解过所以不知道如何实现,一旦了解了就不再是难事。尽管这些动画本身并不属于转场技术这个主题,但与转场动画组合后往往有着惊艳的视觉效果,这部分将提供一些实现此类转场动画的思路,技巧和工具来扩展视野。

  • 自定义容器转场:官方支持四种方式的转场,而且这些也足以应付绝大多数需求了,但依然有些地方无法顾及。本文一直通过探索转场的边界的方式来总结使用方法以及陷阱,在本文的压轴部分,我们将挣脱系统的束缚来实现自定义容器控制器的转场效果。

1.案例分析

动画的持续时间一般不超过0.5秒,稍纵即逝,有时候看到一个复杂的转场动画也不容易知道实现的方式,我一般是通过逐帧解析的手法来分析实现的手段:开源的就运行一下,使用系统自带的 QuickPlayer 对 iOS 设备进行录屏,再使用 QuickPlayer 打开视频,按下 cmd+T 打开剪辑功能,这时候就能查看每一帧了;Gif 等格式的原型动画的动图就直接使用系统自带的 Preview 打开看中间帧。

子元素动画

当转场动画涉及视图中的子视图时,往往无法依赖第三方的动画库来实现,你必须为这种效果单独定制,神奇移动就是一个典型的例子。神奇移动是 Keynote 中的一个动画效果,如果某个元素在连续的两页 Keynote 同时存在,在页面切换时,该元素从上一页的位置移动到下一页的位置,非常神奇。在转场中怎么实现这个效果呢?最简单的方法是截图配合移动动画:伪造那个元素的视图添加到 containerView 中,从 fromView 中的位置移动到 toView 中的位置,这期间 fromView 和 toView 中的该元素视图隐藏,等到移动结束恢复 toView 中该元素的显示,并将伪造的元素视图从 containerView 中移除。

UIView 有几个convert方法用于在不同的视图之间转换坐标:

1
2
3
4
func convertPoint(_ point: CGPoint, toView view: UIView?) -> CGPoint
func convertPoint(_ point: CGPoint, fromView view: UIView?) -> CGPoint
func convertPoint(_ point: CGPoint, fromView view: UIView?) -> CGPoint
func convertPoint(_ point: CGPoint, fromView view: UIView?) -> CGPoint

对截图这个需求,iOS 7 提供了趁手的工具,UIView Snapshot API:

1
2
3
func snapshotViewAfterScreenUpdates(_ afterUpdates: Bool) -> UIView
//获取视图的部分内容
func resizableSnapshotViewFromRect(_ rect: CGRect, afterScreenUpdates afterUpdates: Bool, withCapInsets capInsets: UIEdgeInsets) -> UIView

当afterScreenUpdates参数值为true时,这两个方法能够强制视图立刻更新内容,同时返回更新后的视图内容。在 push 或 presentation 中,如果 toVC 是 CollectionViewController 并且需要对 visibleCells 进行动画,此时动画控制器里是无法获取到的,因为此时 collectionView 还未向数据源询问内容,执行此方法后能够达成目的。UIView 的layoutIfNeeded()也能要求立即刷新布局达到同样的效果。

Mask 动画

你可能感兴趣的:(iOS)