向 UINavigationController 的传统动画说”再见” — 自定义过场动画(一)

题外话
看了一眼最近写的一篇文章, 发现居然已是两个多月之前的了, 猛然间警觉到自己近期的产能下降幅度很大啊!(话外音: 咳咳, 话说, 之前也只不过是写了3篇文章而已, 装什么职业写手>_<) 于是乎, 我决定”改过自新, 重新做人”, 再次执起搁置已久的笔, 分享自己的心得!!! 好了, 吐槽到此结束, 进入正式话题!
我作为纯正的半路出家的 iOS 开发者, 特别希望能够给同样处境的朋友们献上一些自己的所知所学, 同时也作为自己对知识的一种沉淀和总结. 于是乎, 我异想天开的决定开一个超大的坑, 那就是不定期以实战的形式(所谓实战, 就是以实际开发项目的形式, 当然, 这里的所谓”项目”都是一些很简单的项目)来分享一些自己在工作和学习中了解到的知识, 可能分享的东西谈不上”高端”, 更多的是为了知识的传播.
在走上 iOS 开发的道路上后, 前前后后也读了不少相关的书籍, 看过很多大神和”所谓的”大神的博客, 其中印象最深的就是 https://www.raywenderlich.com, 这里几乎以一种手把手的形式去教授你各种 iOS 开发中能够用到的知识点. 于是, 我也决定尝试着以这种形式来写一写, 如果大家觉得读了我的文章之后有那么一丁点儿的收获, 我的付出就算值得了.
由于这类文章本质上是通过一个小 demo 去分享某个知识点, 那么我不希望大家完全从一张白纸开始. 我会为大家提供了相应的工程起始文件, 这里面会有已经写好的一些代码和相关的素材, 大家可以直接下载来使用.
文章使用的环境为 Xcode 7.3.1, 语言为 Swift 2.2. 好了, 闲话不多说了, 下面正式开始!


项目准备

想必大家对 UINavigationController 的过场动画再熟悉不过了. 没错! 就是那万年不变的 push 和 pop 动画: 在屏幕右侧以从右至左的姿态滑入, 再从右侧以从左至右的姿态消失于黑暗中… 当然, 我对此并没有任何贬义和不满, 毕竟 Apple 延续下来的东西是有必然的道理在里面的, 而且我们手机上系统自带的有导航栏的应用都延续了这种风格, 因此这也算是 Apple 血液里的一种基因了吧.
好了, 为了再次一睹这种动画的风采, 我准备好了一份项目的起始文件, 可以从 https://github.com/magiclee203/NavAnimator 下载. 这里我制作了一个非常简易的图片浏览器, 是通过 UINavigationController 的 push 和 pop 来实现浏览功能的.


嗯, 没错, 对于上面的动画, 总结一下就是: ”没有任何亮点”… 非常传统的过场动画, that’s it.
当当当当, 今天的主题终于来了!!! 少年啊, 你不会天真滴以为 UINavigationController 的过场动画仅能如此而已吧! 如果你确实这么认为, 那么很抱歉, Apple 令你失望了! (话外音: 咦? 怎么莫名滴感觉这种”失望”反而是件好事儿O)
是的, Apple 赋予了我们强大的自定义能力来重新改写过场动画. 那么一定会有小伙伴问了: 我可以自定义到什么程度呢? 我可以将过场动画制作成多么炫酷呢? 答案就是: 能制约你的过场动画炫酷程度的因素, 只有你的想象力而已!
So, 小伙伴们, 放飞你们的想象力, 让自定义来的更猛烈一些吧!!!

项目目标

由于本文的目的在于向大家介绍如何自定义过场动画, 因此并没有制作复杂和华丽的过场动画, 仅仅是将系统原生的 push 和 pop 效果进行了改动, 最终效果如下:


虽然改动之后的过场动画完全谈不上”惊艳”, 但是我们确实改变了系统原生的东西. 那么, 我们就开始喽~~

基本概念

既然要自定义过场动画, 那么首先就要清楚到底何时会出现过场动画. 能够出现过场动画的场合有如下 3 种:

  1. 本文要讲到的 navigation controller 在 push 和 pop 其内部的 view controller 时, 会有过场动画.
  2. tabbar controller 在切换其内部的 view controller 时, 会有过场动画. What!!! 意想不到吧, 当你在 tabbar 上点来点去选择 view controller 时, 其实是有过场动画的! 只不过... 额... 系统原生的效果也能叫”动画”? 还是算了吧...
  3. 当你 present 和 dismiss 一个 view controller 时, 会有过场动画. 这个就很明显了吧, 系统原生的效果是: present 时从屏幕下方跳出来一个 view controller, dismiss 时这个 view controller 再从下方退出.

以上 3 种情况下出现的过场动画都是可以自定义的.
那么过场动画又有几种类型呢? 有 2 种 (注意, 这里所谓的动画”类型”与实现出来的动画”效果”没有任何联系!!)

  1. 无交互效果的过场动画. 顾名思义, 这种过场动画就是你无法控制的. 回想一下系统 navigation controller 在 push 时, 你什么都不能做, 只能等待这个过场动画结束, 然后才能操作 push 出的页面.
  2. 有交互效果的过场动画. 再次回想系统原生的 navigation controller. 我想大家都应该知道 pop 一个页面的方法不只有点击导航栏左上角的返回按钮吧, 当你按住屏幕的左侧, 然后向右滑动时, 这个页面依然会被 pop, 而且整个过程完全在你的掌控之下, 想滑到哪里就可以滑到哪里. (什么? 莫非有人还不知道这件事儿? 那赶紧打开设备去试一下吧!) 这就是有交互效果的过场动画.

有交互效果的过场动画在某种程度上是依赖于无交互效果的过场动画的(这种说法可能不太严谨, 目前可以这么认为), 因此本文先从无交互效果的过场动画说起, 我个人觉得也更好理解一些.

无交互效果过场动画的具体实现

要实现无交互效果的过场动画, 只需要做 3 件事儿!

  1. 你需要让 view controller 知道接下来要进行过场动画了. 以本文为例, 你需要让 navigation controller 知道 push 或 pop 即将发生. 因此, 你首先需要为对应的 view controller 设置代理来感知这件事儿.
  2. 当代理知道过场动画即将开始时, 它会去寻找一个动画控制器. 这个动画控制器就是一个遵守了 UIViewControllerAnimatedTransitioning 协议的东西, 因此, 你可以令任何东西担负起变为动画控制器的职责, 只要其遵循 UIViewControllerAnimatedTransitioning 协议即可. 一旦代理找到了动画控制器, 那么就执行动画控制器定义的过场动画, 反之如果没有找到动画控制器, 那么系统默认的过场动画就会被执行.
  3. 去动画控制器中实现具体的动画. 由于动画控制器遵守了 UIViewControllerAnimatedTransitioning 协议, 那么就需要实现该协议中的两个 required 方法, 分别为:
    (1) transitionDuration: 方法. 这个方法返回了自定义动画的执行时间.
    (2) animateTransition: 方法. 整个自定义动画的核心, 到底要执行什么样的动画均在该方法中定义.

好了, 了解了自定义过场动画的整体步骤后, 我们就直接撸代码吧!


先大致说明一下工程起始文件的结构.(我是个偏执的代码党, 所以几乎不使用 storyboard 和 xib, 望大家谅解>_<)
> 由于要自定义 navigation controller 的过场动画, 后续对其会进行一些操作, 所以没有直接使用 UINavigationController, 而是自定义了一个 DTNavController, 继承自UINavigationController.
> DTViewController 就是实际展示图片的 view controller.


Step 1

首先要让 DTNavController 知道要执行过场动画了, 因此, 我们需要为其设置代理. 让 DTNavController 自己通知自己最好不过了, 所以我们将其自身设置为代理. 别忘了在设置代理前, 要先遵守 UINavigationControllerDelegate 协议.
我们直接在 DTNavController 的 viewDidLoad 方法中来操作.

class DTNavController: UINavigationController, UINavigationControllerDelegate {
      override func viewDidLoad() {
          super.viewDidLoad()
          self.delegate = self
      }
}

Step 2

设置了代理后, DTNavController 的代理会在即将进行过场动画时去寻找动画控制器, 因此我们要提供一个动画控制器.
一旦 DTNavController 要执行过场动画, 它的代理(目前就是其自身)就会收到如下消息:
navigationController:animationControllerForOperation:fromViewController:toViewController:.
可以看到, 这个消息有返回值, 并且返回值是一个遵守了 UIViewControllerAnimatedTransitioning协议 的东西, 这正是我们需要的动画控制器.
因此在 DTNavController 类中实现 UINavigationControllerDelegate 协议 里的方法, 并返回一个动画控制器.

func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
     // 这里要 return 一个遵守了 UIViewControllerAnimatedTransitioning 协议的东西
}

好了, 我们接下来要做的就是去制造一个动画控制器.

Step 3

动画控制器是任何遵循了 UIViewControllerAnimatedTransitioning协议 的东西, 所以我们完全可以让 DTNavController 自己来做这件事儿. 但考虑到代码结构的合理性, 单独创建一个动画控制器类来做这件事儿是更合理的.
于是乎, 我们的动画控制器 DTAnimationController 就这样诞生了, 而且不要忘了实现 2 个重要的方法

class DTAnimationController: NSObject, UIViewControllerAnimatedTransitioning {     
      func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
       // 1
         return 0.4
      }
 
      func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
       // 2
      }
 } 
  1. 返回过场动画的持续时间
  2. 具体执行的过场动画

最终 BOSS 战 — 过场动画的具体实现

首先要介绍一个非常重要的小伙伴. 大家可能已经看到了, transitionDuration:animateTransition: 这两个方法都有一个参数 transitionContext, 这是个遵守了 UIViewControllerContextTransitioning 协议 的东西(如果一定要翻译过来的话应该是叫”过场上下文”, 总感觉好拗口, 下文中就直接用英文名称 transitionContext 了).
transitionContext 这个东西非常给力, 它能提供给你本次过场动画涉及到的方方面面的东西, 包括:

  1. 一个容器 view(containerView). 你可以把这个容器 view 想象成一张大大的画板, 你将要执行的过场动画就是在这张画板上展现出来的.
  2. 要消失的 view controller 和要显现的 view controller.
  3. 要消失的 view 和要显现的 view. 通常情况下, 这两个 view 就是对应的 view controller 的 view, 但以防万一, transitionContext 直接将这两个 view 提供给了我们, 多么贴心啊!
  4. 要消失的 view 的起始 frame 和要显现的 view 的终止 frame.
  5. 要消失的 view 已经被添加到容器 view 上了.

怎么样? 是不是有点儿蒙圈了, 这都什么跟什么啊… >_<
来, 希望通过下面的一系列图示为你理清上述内容的关系. 就以实现这个过场动画的效果为例:



对这个过场动画而言, 要消失的 view 和 view controller 是路飞(本质上来说他们并没有消失, 还是在原地, 只不过被覆盖住了, 因此所谓的”要消失”是指视觉上的看不到了), 要显现的 view 和 view controller 是索隆.



所以, 所谓的自定义过场动画, 就是由我们来填补这两个状态之间的空白, 仅此而已!
如何填补呢? 做下面两件事儿就足够了:
  1. 将要显现的 view 添加到容器 view 的正确起始位置上
  2. 对要显现的 view 做动画. 当然了, 如果你想对要消失的 view 做动画也是完全可以的.

对于我们的这个效果而言, 只要对要显现的 view (索隆)做动画就行了, 要消失的 view 可以不动, 见下图:



没错, 就是这样, 看似很厉害的自定义过场动画就这么搞定了!! 其实并没有你想象的那么难! 来, 让我们欢快滴撸一会儿代码吧!

func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
    // 1
    let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)!
    let toView = transitionContext.viewForKey(UITransitionContextToViewKey)!

    // 2     
    let toViewEndFrame = transitionContext.finalFrameForViewController(toViewController)
    var toViewStartFrame = toViewEndFrame
    toViewStartFrame.origin.y -= toViewEndFrame.size.height
     
    let containerView = transitionContext.containerView()!
    containerView.addSubview(toView)
    toView.frame = toViewStartFrame
   
    // 3  
    UIView.animateWithDuration(self.transitionDuration(transitionContext), animations: {
        toView.frame = toViewEndFrame
    }, completion: { _ in
       // 4
        transitionContext.completeTransition(true)
    })
}
  1. 首先通过 transitionContext 拿到了要显现的 view controller 和 view.
  2. 通过要显现的 view controller 拿到要显现的 view 的终止位置, 进而计算出要显现的 view 的起始位置, 并将要显现的 view 加到了容器 view 上.
  3. 执行动画, 动画的效果就是使要显现的 view 出现在其终止位置上.

咦? 第 4 步是怎么回事儿? 细心的你应该发现了, 在动画结束的时候还做了一步事情. 千万注意!!! 这一步非常非常非常的重要!!! 即是没有说三遍, 这件事儿也是相当重要的!!!
当自定义的过场动画结束后, 你一定不要忘记通知 transitionContext, 告诉它过场动画已经执行完了, 向其发送 completeTransition: 消息即可.
最后一步, 我们只要回到 DTNavController 中, 将上述我们制造的动画控制器作为其代理方法的返回值即可.

func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
     let animationController = DTAnimationController()
     return animationController
}

至此, 终极 BOSS 已经被我们征服. 恭喜你勇士, 你又 get 了一个新技能 O 赶快来看看我们自定义的过场动画吧.


程序的完善

你应该发现了, 尽管上述程序已经可以实现自定义的过场动画, 但还是有些缺陷. 当我们点击导航栏上左上角的返回按钮时, pop 的过场动画居然也是”从天而降”的, 显然不太合理. 既然 push 的方式是从天而降, 那么 pop 应该是”一飞冲天”的方式才对!
为此, 我们需要修改一部分代码.

1. navigation controller 的代理方法

navigationController:animationControllerForOperation:fromViewController:toViewController: 有一个参数 operation, 这是个枚举值, 它可以告诉你当前要执行的是 push 操作还是 pop 操作. 因此, 我们可以通过这个值来执行不同的过场动画.
你可能想到再写一个 pop 操作的动画控制器, 将这个动画控制器和之前我们完成的 DTAnimationController 区分开. 这完全可以, 但只要稍加处理, 我们还是可以靠一个 DTAnimationController 来同时完成 push 和 pop 的动画的.

2. 为 DTAnimationController 添加属性 operation

这个属性对应着 navigation controller 的 push 和 pop 操作.
var operation: UINavigationControllerOperation = .None
回到 DTNavController 中, 修改代理方法如下:

func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
     let animationController = DTAnimationController()
     animationController.operation = operation
     return animationController
}

这样, 我们的动画控制器就知道该执行何种动画效果了.

3. 修改 DTAnimationController 的动画效果

有了写 push 动画的经验, 再写一个 pop 动画应该不在话下了吧.
借此回顾一下: 只要我们将要显现的 view 加到容器 view 的正确起始位置上, 然后再根据实际的需求对要显现的 view 和/或 要消失的 view 执行动画就可以了.
根据我们的需求, 写 pop 动画时, 要显现的 view 和 要消失的 view 要执行的事情如下:

  1. 要显现的 view 摆放在终止位置不动, 对要消失的 view 做”一飞冲天”的动画即可.
  2. 由于要消失的 view 已经在容器 view 上了, 那么在容器 view 上添加了要显现的 view 之后, 要显现的 view 就会覆盖在要消失的 view 上面, 这种情况下, 你是看不到要消失的 view 在执行 pop 动画. 为了解决这个问题, 在执行动画之前, 需要调整要显现的 view 和 要消失的 view 在容器 view 中的层级关系, 即应该将要显现的 view 放到要消失的 view 的下面.

不多说了, 撸段代码瞧瞧就知道了.

func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
     // 1
     let fromViewController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)!
     let fromView = transitionContext.viewForKey(UITransitionContextFromViewKey)!
     let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)!
     let toView = transitionContext.viewForKey(UITransitionContextToViewKey)!
     
     let containerView = transitionContext.containerView()!
     containerView.addSubview(toView)
     
     let fromViewStartFrame = transitionContext.initialFrameForViewController(fromViewController)
     let toViewEndFrame = transitionContext.finalFrameForViewController(toViewController)
     var fromViewEndFrame = fromViewStartFrame
     var toViewStartFrame = toViewEndFrame
 
     // 2   
     if operation == .Push {
         toViewStartFrame.origin.y -= toViewEndFrame.size.height
     } else if operation == .Pop {
         fromViewEndFrame.origin.y -= fromViewStartFrame.size.height
         containerView.sendSubviewToBack(toView)
     }
     
     fromView.frame = fromViewStartFrame
     toView.frame = toViewStartFrame
    
     // 3 
     UIView.animateWithDuration(self.transitionDuration(transitionContext), animations: {
         fromView.frame = fromViewEndFrame
         toView.frame = toViewEndFrame
     }, completion: { _ in
        // 4
         transitionContext.completeTransition(true)
     })
}
  1. 还是老套路, 将需要的参数先提取出来. 不过这一次因为要应对 push 和 pop 的两种情况, 因此要将要显现的 view 和要消失的 view 的信息全部取出.
  2. 根据 push 和 pop 来配置相应 view 的 frame. 要注意, 在执行 pop 动画操作前, 不要忘记将要显现的 view 放到底层, 否则就会覆盖在要消失的 view 上面.
  3. 执行动画.
  4. 千万别忘了通知 transitionContext, 过场动画已经执行完毕!!

好了, 至此, 彻底的大功告成!!!
如果对代码部分有疑惑, 可以去 https://github.com/magiclee203/NavAnimator 下载工程结束时的代码.

小作业

如果你有兴趣, 你可以尝试如何为 tabbar controller 添加能明显看到的过场动画. 当你在不同的 view controller 之间切换时, 展现一些酷炫的视觉效果吧!

下期预告

不要大意, 这里只是一小步! 我们实现了无交互效果的自定义过场动画, 动画执行的整个过程我们都无法参与. 如果你想依靠手势来实现可交互的过场动画, 就像系统原生的 navigation controller 可以依靠拖拽来实现 pop 效果那样, 那么敬请期待下一期吧!!!

(话外音: 喂喂! 下一期到底什么时候来啊?)
额... 尽量不太晚吧…
(话外音: 真是个靠不住的作者啊…)
(话外音2: 有收获吗? 有收获就打个赏吧, 哈哈哈哈!!!)

开玩笑啦, 只要你们有收获, 就是对我最大的支持. 不过由于作者还有班要上, 所以更新时间的问题嘛, 大家就不要太苛刻了, 吼吼吼吼!!!!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容