Skip to content

ViewController Transition

seedante edited this page Mar 15, 2017 · 42 revisions

iOS 视图控制器转场详解

前言的前言

唐巧前辈在微信公众号「iOSDevTips」以及其博客上推送了我的文章后,我的 Github 各项指标有了大幅度的增长,多谢唐巧前辈的推荐。有些人问我相关的问题,好吧,目前为止就几个,由于没有评论系统,实在不方便交流,但我也没把博客好好整理,一直都在简书上写博客,大家有问题请移步我的简书本文章的页面。关于交流,我想说这么几点:

1.问问题就好,不要加上大神大牛之类的称呼,与本文有关的问题我尽量回答;不负责解析转场动画,看心情回答。

2.去我的简书下留言是最有效的交流方式,要加我好友就免了。

3.本文有一定的阅读门槛,并非适合新手的手把手入门教程,更适合照着教程写过几次转场动画过了几个月又忘了整个流程的人回顾学习精进。本文的结构以及相关知识点能让你回忆起当初亲手写出转场动画时的那股激动,除此之外,本文能满足你希望彻底搞懂转场的求知欲,我相信后者更重要,那种把分支技能树升满的感觉......

4.怎么提问?新手如果觉得本文的范例啃不下去,去看源码,很简单。如果是关于转场动画中关键流程的地方,我相信本文已经做出了很好的解释,多读几遍;如果 Demo 里出了 Bug,请自己先确认好,然后在 Demo issue 这里提交 issue 并给出你的详细测试环境;如果对本文中探讨机制以及缺陷的地方有疑问,欢迎留言交流。

前言

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

视图控制器中的视图显示在屏幕上有两种方式:最主要的方式是内嵌在容器控制器中,比如 UINavigationController,UITabBarController, UISplitController;由另外一个视图控制器显示它,这种方式通常被称为模态(Modal)显示。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

文章越来越长了,分成三个部分:

第一部分:

第二部分:PartII Link(以下目录无法跳转,请点击该链接查看内容)

第三部分:PartIII Link(以下目录无法跳转,请点击该链接查看内容)

前奏:触发转场的方式

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

  1. 在 UINavigationController 中 push 和 pop;
  2. 在 UITabBarController 中切换 Tab;
  3. Modal 转场:presentation 和 dismissal,俗称视图控制器的模态显示和消失,仅限于modalPresentationStyle属性为 UIModalPresentationFullScreen 或 UIModalPresentationCustom 这两种模式;
  4. UICollectionViewController 的布局转场:仅限于 UICollectionViewController 与 UINavigationController 结合的转场方式,与上面三种都有点不同,不过实现很简单,可跳转至该链接查看

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

以上前三种转场都需要转场代理和动画控制器(见下节)的帮助才能实现自定义转场动画,而触发的方式分为三种:代码里调用相关动作的方法,Segue 以及,对于上面两种容器 VC,在 UINavigationBar 和 UITabBar 上的相关 Item 的点击操作。

相关动作方法

UINavigationController 中所有修改其viewControllers栈中 VC 的方法都可以自定义转场动画:

//我们使用的最广泛的 push 和 pop 方法
func pushViewController(_ viewController: UIViewController, animated animated: Bool)
func popViewControllerAnimated(_ animated: Bool) -> UIViewController?
//不怎么常用的 pop 方法
func popToRootViewControllerAnimated(_ animated: Bool) -> [UIViewController]?
func popToRootViewControllerAnimated(_ animated: Bool) -> [UIViewController]?
//这个方法有有点特别,是对 VC 栈的整体更新,开启动画后的执行比较复杂,具体参考文档说明。不建议在这种情况下开启转场动画。
func setViewControllers(_ viewControllers: [UIViewController], animated animated: Bool)

UITabBarController 下没什么特别的:

//注意传递的参数必须是其下的子 VC
unowned(unsafe) var selectedViewController: UIViewController?
var selectedIndex: Int
//和上面类似的整体更新
func setViewControllers(_ viewControllers: [UIViewController]?, animated animated: Bool)

Modal 转场:

// Presentation 转场
func presentViewController(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion completion: (() -> Void)?)
// Dismissal 转场
func dismissViewControllerAnimated(_ flag: Bool, completion completion: (() -> Void)?)

Segue

在 storyboard 里设置 segue有两种方式:Button to VC,这种在点击 Button 的时候触发转场;VC to VC,这种需要在代码中调用performSegueWithIdentifier:sender:prepareForSegue:sender:方法是在转场发生前修改转场参数的最后机会。这点对于 Modal 转场比较重要,因为在 storyboard 里 Modal 转场的 Segue 类型不支持选择 Custom 模式,使用 segue 方式触发时必须在prepareForSegue:sender:里修改模式。

iOS 8 的变化

iOS 8 引入了适应性布局,由此添加了两种新的方式来显示一个视图控制器:

func showViewController(_ vc: UIViewController, sender sender: AnyObject?)
func showDetailViewController(_ vc: UIViewController, sender sender: AnyObject?)

这两个方法咋看上去是给 UISplitViewController 用的,在 storyboard 里 segue 的候选模式里,直接给出了Show(e.g. Push)Show Detail(e.g. Replace)这样的提示,以至于我之前一直对这两个 segue 有误解。实际上这两个方法智能判断当前的显示环境来决定如何显示,iOS 8 想统一显示视图控制器的方式,不过引入这两个方法增加了使用的复杂性,来看看这两个方法的使用规则。

这两个方法在 UISplitViewController 上的确是按名字显示的那样去工作的,而在本文关注的控制器上是这样工作的:

ViewController NavigationController TabBarController
showViewController:sender: Presentation Push Presentation(by self)
showDetailViewController:sender: Presentation Presentation(by self) Presentation(by self)

UINavigationController 重写了showViewController:sender:而执行 push 操作,上面的by self意思是用容器 VC 本身而非其下子 VC 去执行 presentation。这两个方法的行为可以通过重写来改变。

当非容器类 VC 内嵌在这两种容器 VC 里时,会通过最近的容器 VC 来执行:

VC in NavigationController VC in TabBarController
showViewController:sender: Push(by NavigationController) Presentation(by TabBarController)
showDetailViewController:sender: Presentation(by NavigationController) Presentation(by TabBarController)

Transition 解释

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

The Anatomy of Transition

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

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

1.转场代理(Transition Delegate):

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

<UINavigationControllerDelegate> //UINavigationController 的 delegate 属性遵守该协议。
<UITabBarControllerDelegate> //UITabBarController 的 delegate 属性遵守该协议。
<UIViewControllerTransitioningDelegate> //UIViewController 的 transitioningDelegate 属性遵守该协议。

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

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

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

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

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

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

4.转场环境(Transition Context):

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

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

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

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

阶段一:非交互转场

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

动画控制器协议

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

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

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

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

//返回容器视图,转场动画发生的地方。
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 之分,与转场方式无关。你可以在 fromView 和 toView 上添加任何动画,转场动画的最终效果只限制于你的想象力。**这也是动画控制器在封装后可以被第三方使用的重要原因。

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

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

动画控制器实现

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

需要举个例子了,实现哪个好呢?

毫无疑问,上面那个简单的。Are you kidding me?这种转场动画也需要你写这么长的废话来教我怎么实现?**好吧,你要知道转场动画是转场与动画的配合,下面更炫酷一点的转场动画和上面的五毛动画相比,它们在转场技术部分并没有什么区别,主要的差别在动画的部分。事实是,不管复杂与否,所有的转场动画在实现转场的部分都没有什么差别,而且从技术上来讲,实现转场并没有高深的东西,如果你动手实现过几次,你就能搞定所有的转场动画中转场的那部分。**所以,为了安安静静学习转场以及省点篇幅,我选择上面的转场动画作为例子。

在交互式转场章节里我们将在上面 Slide 动画的基础上实现文章开头提到的两种效果:NavigationController 右滑返回 和 TabBarController 滑动切换。尽管对动画控制器来说,转场方式并不重要,可以对 fromView 和 toView 进行任何动画,是的,任何动画,但上面的动画和 Modal 转场风格上有点不配,主要动画的方向不对,我在这个 Slide 动画控制器里为 Modal 转场适配了和系统的风格类似的竖直移动动画效果;另外 Modal 转场并没有比较合乎操作直觉的交互手段,而且和前面两种容器控制器的转场在机制上有些不同,所以我将为 Modal 转场示范另外一个动画。

Demo 中的 Slide 动画控制器适用于三种转场,不必修改就可以直接在工程中使用。转场中的操作是可逆的,你可以为了每一种操作实现单独的动画控制器,也可以实现通用的动画控制器。为此,Demo 中的 Slide 动画控制器针对转场的操作类型进行了适配。Swift 中 enum 的关联值可以视作有限数据类型的集合体,在这种场景下极其合适。设定转场类型:

enum SDETransitionType{
    //UINavigationControllerOperation 是枚举类型,有 None, Push, Pop 三种值。
    case NavigationTransition(UINavigationControllerOperation) 
    case TabTransition(TabOperationDirection)
    case ModalTransition(ModalOperation)
}

enum TabOperationDirection{
    case Left, Right
}

enum ModalOperation{
    case Presentation, Dismissal
}

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

let transitionType = SDETransitionType.TabTransition(.Left)

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

class SlideAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
    init(type: SDETransitionType) {...}
	
    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
        ...
         //1
        containerView.addSubview(toView)
   		
        //计算位移 transform,NavigationVC 和 TabBarVC 在水平方向进行动画,Modal 转场在竖直方向进行动画。
        var toViewTransform = ...
        var fromViewTransform = ...
        toView.transform = toViewTransform
        
        //根据协议中的方法获取动画的时间。
        let duration = self.transitionDuration(transitionContext)
        UIView.animateWithDuration(duration, animations: {
            fromView.transform = fromViewTransform
            toView.transform = CGAffineTransformIdentity
            }, completion: { _ in
                //考虑到转场中途可能取消的情况,转场结束后,恢复视图状态。
                fromView.transform = CGAffineTransformIdentity
                toView.transform = CGAffineTransformIdentity
                //2
                let isCancelled = transitionContext.transitionWasCancelled()
                transitionContext.completeTransition(!isCancelled)
        })
    }
}

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

  1. 将 toView 添加到容器视图中,使得 toView 在屏幕上显示( Modal 转场中此点稍有不同,下一节细述),也不必非得是addSubview:,某些场合你可能需要调整 fromView 和 toView 的显示顺序,总之将之加入到 containerView 里就行了;
  2. 动画结束后正确地结束转场过程。转场的结果有两种:完成或取消。非交互转场的结果只有完成一种情况,不过交互式转场需要考虑取消的情况。如何结束取决于转场的进度,通过transitionWasCancelled()方法来获取转场的结果,然后使用completeTransition:来通知系统转场过程结束,这个方法会检查动画控制器是否实现了animationEnded:方法,如果有,则调用该方法。

至此,你已经能够搞定任何动画控制器中转场的部分了,无论转场动画是简单的还是超级复杂的,是的,就这么简单,没有任何高深的东西了。转场结束后,fromView 会从视图结构中移除,UIKit 自动替我们做了这事,你也可以手动处理提前将 fromView 移除,这完全取决于你。虽然这个动画控制器实现的动画非常简单,但此刻我们已经替换掉了系统提供的默认转场动画。

以上的代码是常规的实现手法,这里还有另外一条更简单的路:UIView的类方法

transitionFromView:toView:duration:options:completion:

甚至不需要获取 containerView 以及手动添加 toView 就能实现一个指定类型的转场动画,而缺点则是只能使用指定类型的动画。

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

看到这里是否想起了点什么?UIViewController用于在子 VC 间转换的方法:

transitionFromViewController:toViewController:duration:options:animations:completion:

该方法用 toVC 的视图替换 fromVC 的视图在父视图中的位置并且执行animations闭包里的动画,但这个方法仅限于在自定义容器控制器里使用,直接使用 UINavigationController 和 UITabBarController 调用该方法在其下的子 VC 间转换会抛出异常。不过 iOS 7 中这两个容器控制器开放的自定义转场做的是同样的事情,回头再看第一章 Transition 解释,转场协议 API 将这个方法拆分成了上面的几个组件,并且加入了激动人心的交互控制,以便我们能够方便定制转场动画。

特殊的 Modal 转场

Modal 转场的差异

事先声明:尽管 Modal 转场和上面两种容器 VC 的转场在控制器结构以及视图结构都有点差别,但是在代码里实现转场时,差异非常小,仅有一处地方需要注意。所以,本节也可以直奔末尾,记住结论就好。

上一节里两种容器 VC 的转场里,fromVC 和 toVC 都是其子 VC,而在 Modal 转场里并非这样的关系,fromVC(presentingVC) present toVC(presentedVC),前者为后者提供显示的环境。两类转场的视图结构差异如下:

ContainerVC VS Modal

转场前后可以在控制台打印出它们的视图控制器结构以及视图结构观察变化情况,不熟悉相关命令的话推荐使用 chisel 工具,而使用 Xcode 的 ViewDebugging 功能可以直观地查看应用的视图结构。如果你对转场中 containerView 这个角色感兴趣,可以通过上面的方法来查看。

容器类 VC 的转场里 fromView 和 toView 是 containerView 的子层次的视图,而 Modal 转场里 presentingView 与 containerView 是同层次的视图,只有 presentedView 是 containerView 的子层次视图。

这种视图结构上的差异与 Modal 转场的另外一个不同点是相契合的:转场结束后 fromView 可能依然可见,比如 UIModalPresentationPageSheet 模式的 Modal 转场就是这样。Modal 转场有多种模式,由其modalPresentationStyle属性决定,有两种模式可以进行自定义: UIModalPresentationFullScreen 模式(以下简称 FullScreen 模式,该属性的默认值)和 UIModalPresentationCustom 模式(以下简称 Custom 模式)。容器 VC 的转场结束后 fromView 会被主动移出视图结构,这是可预见的结果,我们也可以在转场结束前手动移除;而 Modal 转场中,presentation 结束后 presentingView(fromView) 并未主动被从视图结构中移除。准确来说,在我们可自定义的两种模式里,Custom 模式下 Modal 转场结束时 fromView 并未从视图结构中移除;FullScreen 模式下 Modal 转场结束后 fromView 依然主动被从视图结构中移除了。这种差异导致在处理 dismissal 转场的时候很容易出现问题,没有意识到这个不同点的话出错时就会毫无头绪。

来看看 dismissal 转场时的场景:

  1. FullScreen 模式:presentation 结束后,presentingView 被主动移出视图结构,不过,在 dismissal 转场中希望其出现在屏幕上并且在对其添加动画怎么办呢?实际上,你按照容器类 VC 转场里动画控制器里那样做也没有问题,就是将其加入 containerView 并添加动画。不用担心,转场结束后,UIKit 会自动将其恢复到原来的位置。虽然背后的机制不一样,但这个模式下的 Modal 转场和容器类 VC 的转场的动画控制器的代码可以通用,你不必记住背后的差异。
  2. Custom 模式:presentation 结束后,presentingView(fromView) 未被主动移出视图结构,在 dismissal 中,注意不要像其他转场中那样将 presentingView(toView) 加入 containerView 中,否则 dismissal 结束后本来可见的 presentingView 将会随着 containerView 一起被移除。如果你在 Custom 模式下没有注意到这点,很容易出现黑屏之类的现象而不知道问题所在。

对于 Custom 模式,我们可以参照其他转场里的处理规则来打理:presentation 转场结束前手动将 fromView(presentingView) 移出它的视图结构,并用一个变量来维护 presentingView 的父视图,以便在 dismissal 转场中恢复;在 dismissal 转场中,presentingView 的角色由原来的 fromView 切换成了 toView,我们再将其重新恢复它原来的视图结构中。测试表明这样做是可行的。但是这样一来,在实现上,需要动画控制器用一个变量来保存 presentingView 的父视图以便在 dismissal 转场中恢复,第三方的动画控制器必须为此改造。显然,这样的代价是无法接受的。为何 FullScreen 模式的 dismissal 转场里就可以任性地将 presentingView 加入到 containerView 里呢?因为 UIKit 知道 presentingView 的视图结构,即使强行将其从原来的视图结构迁移到 containerView,事后将其恢复到正确的位置也是很容易的事情。

由于以上的区别导致实现交互化的时候在 Custom 模式下无法控制转场过程中添加到 presentingView 上面的动画。解决手段请看特殊的 Modal 转场交互化一节。

结论:不要干涉官方对 Modal 转场的处理,我们去适应它。在 Custom 模式下的 dismissal 转场中不要像其他的转场那样将 toView(presentingView) 加入 containerView,否则 presentingView 将消失不见,而应用则也很可能假死。而 FullScreen 模式下可以使用与前面的容器类 VC 转场同样的代码。因此,上一节里示范的 Slide 动画控制器不适合在 Custom 模式下使用,放心好了,Demo 里适配好了,具体的处理措施,请看下一节的处理。

iOS 8 为<UIViewControllerContextTransitioning>协议添加了viewForKey:方法以方便获取 fromView 和 toView,但是在 Modal 转场里要注意:在 Custom 模式下通过viewForKey:方法来获取 presentingView 得到的是 nil,必须通过viewControllerForKey:得到 presentingVC 后来间接获取,FullScreen 模式下没有这个问题。(原来这里没有限定是在 Custom 模式,导致 @JiongXing 浪费了些时间,抱歉)。因此在 Modal 转场中,较稳妥的方法是从 fromVC 和 toVC 中获取 fromView 和 toView。

Modal 转场实践

UIKit 已经为 Modal 转场实现了多种效果,当 UIViewController 的modalPresentationStyle属性为.Custom.FullScreen时,我们就有机会定制转场效果,此时modalTransitionStyle指定的转场动画将会被忽略。**补充说明:**自定义 Modal 转场时,modalPresentationStyle属性也可以为其他值,当你提供了转场代理和动画控制器后,系统就将转场这件事全权交给你负责了,UIKit 内部并没有对modalPresentationStyle的值进行过滤,然而该属性的值不是.Custom.FullScreen这两个官方支持的值时,会出现各种瑕疵。总之,在探索时可以各种试探,但是干活时还是老老实实听官方的话。详细讨论可以查看这个 issue

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

ModalTransition

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

class OverlayAnimationController: NSobject, UIViewControllerAnimatedTransitioning{
    ...	
    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {            
        ...
        //不像容器 VC 转场里需要额外的变量来标记操作类型,UIViewController 自身就有方法跟踪 Modal 状态。
        //处理 Presentation 转场:
        if toVC.isBeingPresented(){
            //1
            containerView.addSubview(toView)
            //在 presentedView 后面添加暗背景视图 dimmingView,注意两者在 containerView 中的位置。
            let dimmingView = UIView()
            containerView.insertSubview(dimmingView, belowSubview: toView)

            //设置 presentedView 和 暗背景视图 dimmingView 的初始位置和尺寸。
            let toViewWidth = containerView.frame.width * 2 / 3
            let toViewHeight = containerView.frame.height * 2 / 3
            toView.center = containerView.center
            toView.bounds = CGRect(x: 0, y: 0, width: 1, height: toViewHeight)
            
            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)
            })
        }
        //处理 Dismissal 转场,按照上一小节的结论,.Custom 模式下不要将 toView 添加到 containerView,省去了上面标记1处的操作;
        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)
            })
        }
    }
}

Modal 转场在 Custom 模式下必须区分 presentation 和 dismissal 转场,而在 FullScreen 模式下可以不用这么做,因为 UIKit 会在 dismissal 转场结束后自动将 presentingView 放置到原来的位置。

在 Demo 里,Slide 动画控制器里适配所有类型的转场是这样处理的:

switch transitionType{
    case .ModalTransition(let operation):
        switch operation{
            case .Presentation: containerView.addSubview(toView)
            case .Dismissal: break
        }
    default: containerView.addSubview(toView)
}

转场环境对象本身也提供了presentationStyle()方法来查询 Modal 转场的类型,在一般通用型的动画控制器里可以这样处理:

if !(transitionContext.presentationStyle() == .Custom && fromVC.isBeingDismissed()){
    containerView.addSubview(toView)
}

前面容器 VC 的转场里提到可以使用UIView的类方法transitionFromView:toView:duration:options:completion:animateTransition:方法中来执行子视图的转换,Modal 转场里,fromView 和 toView 并非同一容器视图下同层次的子视图,该方法并不适用。不过经测试,该方法在 Custom 模式下工作正常,FullScreen 模式有点不兼容。由于在 Modal 转场支持两种模式,为避免混淆建议不要使用该方法来转换视图。

至此,三种主流转场的动画控制器基本介绍完毕了,可以看到动画控制器里有关转场的部分是非常简单的,没什么难度,也没什么高级的用法,剩下的动画部分,如前面提到的那样,你可以为 fromView 和 toView 添加任何动画,而这又是另外一个话题了。

iOS 8的改进:UIPresentationController

iOS 8 针对分辨率日益分裂的 iOS 设备带来了新的适应性布局方案,以往有些专为在 iPad 上设计的控制器也能在 iPhone 上使用了,一个大变化是在视图控制器的(模态)显示过程,包括转场过程,引入了UIPresentationController类,该类接管了 UIViewController 的显示过程,为其提供转场和视图管理支持。在 iOS 8.0 以上的系统里,你可以在 presentation 转场结束后打印视图控制器的结构,会发现 presentedVC 是由一个UIPresentationController对象来显示的,查看视图结构也能看到 presentedView 是 UIView 私有子类的UITtansitionView的子视图,这就是前面 containerView 的真面目(剧透了)。

当 UIViewController 的modalPresentationStyle属性为.Custom时(不支持.FullScreen),我们有机会通过控制器的转场代理提供UIPresentationController的子类对 Modal 转场进行进一步的定制。实际上该类也可以在.FullScreen模式下使用,但是会丢失由该类负责的动画,保险起见还是遵循官方的建议,只在.Custom模式下使用该类。官方对该类参与转场的流程和使用方法有非常详细的说明:Creating Custom Presentations

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

  1. 定制 presentedView 的外观:设定 presentedView 的尺寸以及在 containerView 中添加自定义视图并为这些视图添加动画;
  2. 可以选择是否移除 presentingView;
  3. 可以在不需要动画控制器的情况下单独工作;
  4. iOS 8 中的适应性布局。

以上变化中第1点 iOS 7 中也能做到,3和4是 iOS 8 带来的新特性,只有第2点才真正解决了 iOS 7 中的痛点。在 iOS 7 中定制外观时,动画控制器需要负责管理额外添加的的视图,UIPresentationController类将该功能剥离了出来独立负责,其提供了如下的方法参与转场,对转场过程实现了更加细致的控制,从命名便可以看出与动画控制器里的animateTransition:的关系:

func presentationTransitionWillBegin()
func presentationTransitionDidEnd(_ completed: Bool)
func dismissalTransitionWillBegin()
func dismissalTransitionDidEnd(_ completed: Bool)

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

//指定初始化方法。
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。

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

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

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

class OverlayPresentationController: UIPresentationController {
    let dimmingView = UIView()
    
    //Presentation 转场开始前该方法被调用。
    override func presentationTransitionWillBegin() {
        self.containerView?.addSubview(dimmingView)
        
        let initialWidth = containerView!.frame.width*2/3, initialHeight = containerView!.frame.height*2/3
        self.dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
        self.dimmingView.center = containerView!.center
        self.dimmingView.bounds = CGRect(x: 0, y: 0, width: initialWidth , height: initialHeight)
        //使用 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里把涉及 dimmingView 的部分删除,然后在 presentedVC 的转场代理属性transitioningDelegate中提供该类实例就可以实现和上一节同样的效果。

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

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

func shouldRemovePresentersView() -> Bool

返回 true 时,presentation 结束后 presentingView 被移除,在 dimissal 结束后 UIKit 会自动将 presentingView 恢复到原来的视图结构中。此时,Custom 模式与 FullScreen 模式下无异,完全不必理会前面 dismissal 转场部分的差异了。另外,这个方法会在实现交互控制的 Modal 转场时起到关键作用,详情请看交互转场部分。

你可能会疑惑,除了解决了 iOS 7 中无法干涉 presentingView 这个痛点外,还有什么理由值得我们使用UIPresentationController类?除了能与动画控制器配合,UIPresentationController类也能脱离动画控制器独立工作,在转场代理里我们仅仅提供后者也能对 presentedView 的外观进行定制,缺点是无法控制 presentedView 的转场动画,因为这是动画控制器的职责,这种情况下,presentedView 的转场动画采用的是默认的 Slide Up 动画效果,转场协调器实现的动画则是采用默认的动画时间。

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

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

转场代理

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

UINavigationControllerDelegate

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

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

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

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

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

self.navigationController?.delegate = strongReferenceDelegate

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

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

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

self.navigationController?.pushViewController(toVC, animated: true)//or
self.navigationController?.popViewControllerAnimated(true)

Demo 地址:NavigationControllerTransition

UITabBarControllerDelegate

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

class SDETabBarControllerDelegate: NSObject, UITabBarControllerDelegate {
    //在<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 = SDETransitionType.TabTransition(tabChangeDirection)
        let slideAnimationController = SlideAnimationController(type: transitionType)
        return slideAnimationController
    }
}

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

tabBarVC.selectedIndex = ...//or
tabBarVC.selectedViewController = ...

Demo 地址:ScrollTabBarController

UIViewControllerTransitioningDelegate

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

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属性;或者全部在代码里设置,示例如下:

let presentedVC = ...
presentedVC.transitioningDelegate = strongReferenceSDEModalTransitionDelegate
//当与 UIPresentationController 配合时该属性必须为.Custom。
presentedVC.modalPresentationStyle = .Custom/.FullScreen      
presentingVC.presentViewController(presentedVC, animated: true, completion: nil)

Demo 地址:CustomModalTransition

本文第二部分:交互转场、iOS 10 新特性

本文第三部分:案例分析、自定义容器控制器转场

版权申明以及其他

版权申明:我已将本文在微信公众平台的发表权「独家代理」给 iOS 开发(iOSDevTips)微信公共帐号。扫码关注「iOS 开发」:

二维码

关于 ViewController Transition 这个主题,我其实不愿意使用转场这个翻译,不过使用字面翻译控制器转换也不方便,而且由于 objccn.io 的影响,大家都在用这个翻译,为了统一我还是使用了这个翻译,还有 context,每次遇到这个词,大家都是翻译成上下文,我个人不喜欢这个翻译。老实说,我觉得大家交流技术时对于专业词汇还是尽量用英文表达,不容易出错。另外关于这个话题的延伸阅读,我还是推荐前言里提到的官方文档:View Controller Programming Guide for iOS

如心血来潮,请随意赞赏^_^。但是要在支付宝里加我好友,这让我很困扰,支付宝好友还是算了。希望你是出于文章写得好或者帮到你的原因给我打赏,而非其他,因为打赏要加我好友还是算了。有问题,在我的简书的本文章页面下留言就好,这是最有效的交流方式。

AlipayDonate