自定义 push 和 pop 实现有趣的相册翻开效果(上)

540 查看

效果预览:

苹果自家应用 Photos 里点击相册后的动画是非常精妙的,而且是可交互的。我有类似的动画需求,上面是我自己的设计效果。本指南分上下两篇,分别探讨非交互和交互动画的实现,从入门到深入,并搜集了实现过程中遇到的一些陷阱,对于想深入的人我想说这两篇文章不会浪费你的时间。

本文是将三个月前的 Demo 重构后重新写的,重构后,这个效果可以方便地在你的工程中使用,仅需添加几行代码和几个简单的设置。效果适用场景:两个UICollectionViewController类之间的 push 和 pop 操作。Demo 是个小型的相册浏览器, 这完全是基于我的需求来做的,因此在初期并没有考虑做成一个手把手教你实现这个效果的教程,不过前面说了,仅需添加几行代码就可在你的工程里使用,花上几分钟搭建一个场景照着做下来也是没问题的。另外,部分细节比较繁琐,都放进文章里就太长了,想了解的话看源代码,遇到这部分我会提示的。

Demo 地址:SDECollectionViewAlbumTransition。

动画分析

我把 iOS 里的动画分为两种:趣味动画和逻辑动画,前者比如一些加载场景的动画,用来消磨时间,怎么炫酷都可以,后者是符合场景变化的动画,符合逻辑最重要,如果还能很有趣那就更好了。我实现的效果算得上符合逻辑,离有趣或者酷还有点距离。

如上所示,我希望呈现出打开相簿后照片飞出来的效果,这个设计是行为上的拟物,最好翻开封面时还能发出金光,NO,NO,太浮夸了,简直跟中华小当家或者国产奇幻剧开宝箱似的。当然,主要是我不知道怎么做,会做的话我就会做出来给大家看的,不过,我是不会把这种效果放在正常的产品里的,在游戏界这种效果比较常见,比如炉石里新卡牌点开时就带这种圣光效果。

从技术上讲,这个动画本质上就是个 View Controller Transition 加上多个元素协作进行动画的过程。总的来说,动画分为两个部分,首先是自定义 push 和 pop,其次是各种元素的协作。现在先攻克第一个难点,下面进入科普时间。

View Controller Transition 视图控制器转换

对于这个话题,我推荐:1. WWDC13 上的 Custom Transitions Using View Controllers,2.Custom Transitions on iOS,3. Objc.io 的自定义 ViewController 容器转场。以及一个自定义 transition 效果的库:VCTransitionsLibrary,可以读读代码看看这些效果怎么实现的。

自定义 transition 类型

View Controller Transition 是什么?其实平时你就一直能看到,在切换或是添加新的视图控制器来显示视图的时候发生的过程就是 ViewController Transition,比如 push 或 pop 一个 View Controller,在 TabBarController 中切换到其他 View Controller,以模态方式显示另外一个 View Controller。只不过,在 iOS 7 之前我们无法干涉这个过程,从 iOS 7 开始支持自定义 View Controller Transition,目前仅支持以下四种自定义类型:


iOS 支持的的自定义视图转换类型 from WWDC13 #218

除了最后一个是布局转换,前三种基本囊括了 iOS 中显示切换视图的全部方式:
1.Modal 视图的显示和消失;
2.TabBar Controller 在子视图中切换;
3.Navigation Controller 推入和推出视图。

其中 presentations and dismissals 只支持 UIModalPresentationFullScreen 和 UIModalPresentationCustom 这两种 Modal 视图的显示和消失。在 iOS 8 中推出了 UIPresentationController 类对 Modal 视图的显示和消失进行了增强,增加了对自定义 Modal 视图尺寸的支持,自定义 Modal 视图尺寸这在以往是很难做到的(反正我还没有找到好的方法)。

文章开头的效果是第三种,需要实现自定义 push 和 pop。

Transition Protocol

iOS 提供了几套 protocol 来满足自定义 transition 的需求。


WWDC13#218-Custom Transition 的构成

对以上 protocol 的解释节选自 Objc.io 的自定义 ViewController 容器转场

iOS 7 自定义视图控制器转场的 API 基本上都是以协议的方式提供的,这也使其可以非常灵活的使用,因为你可以很简单地将它们插入到你的类中。最主要的五个组件如下:
1.动画控制器 (Animation Controllers) 遵从UIViewControllerAnimatedTransitioning协议,并且负责实际执行动画。
2.交互控制器 (Interaction Controllers) 通过遵从UIViewControllerInteractiveTransitioning协议来控制可交互式的转场。
3.转场代理 (Transitioning Delegates) 根据不同的转场类型方便的提供需要的动画控制器和交互控制器。
4.转场上下文 (Transitioning Contexts) 定义了转场时需要的元数据,比如在转场过程中所参与的视图控制器和视图的相关属性。 转场上下文对象遵从UIViewControllerContextTransitioning协议,并且这是由系统负责生成和提供的。
5.转场协调器(Transition Coordinators) 可以在运行转场动画时,并行的运行其他动画。 转场协调器遵从UIViewControllerTransitionCoordinator协议。
看晕了?没关系。这五个组件并不是全部都需要你提供,实现一个最简单的非交互的自定义 transition,只需要实现1和3即可,其实还会用到4,不过大部分情况下这个组件由系统提供给我们,我们只需要实现组件1和3就可以了。

实战

准备工作

这篇不涉及交互过程,因此我单独做了个分支:No-Interaction-Transition,是本篇内容的最终版本;或者你还是想自己动手,使用纯色块的 Cell 就好了,几分钟就能搞定,又或者不怕再麻烦一点,提取这个分支里面 Example 文件夹里的文件替换到你的工程好了。到这里还是很简单的,如果觉得不简单,那就看看好了,把本文加入待读列表过一个月后再来学习。

Demo 里有三个分支,默认分支是能够自动添加 pinch 手势支持 pop 操作,还是就是这篇文章的分支 No-Interaction-Transition,还有一种就是同时支持 push 和 pop 操作的 pinch 手势的分支 Pinch-Push-Pop-Transition

下面需要你配置这样的一个场景,在此基础上逐步改造成最终的效果:在 storyboard 里放置一个UINavigationController和两个UICollectionViewController,如果你不用 storyboard,相信你也能自己搞定设置。


使用场景

下面使用 fromVC 和 toVC 分别代表 push 和 pop 过程涉及的源和目标UICollectionViewController,animationController 代表动画控制器,它执行真正的动画。实现一个最基本的非自定义 push,在你的 fromVC 里实现以下代理方法:

现在,一个最简单的场景就搭建完成了。此时,push 和 pop 都是系统替我们完成,运行程序,动画效果是 Slide。接下来,我们就把这个动画换成我设计的。

如果你是在 storyboard 里通过拉 segue 来完成跳转,那需要你去- prepareForSegue:sender:里做一些调整了,但先别这么干,按照我的节奏来。

接手系统 transition

第一步,为UINavigationController提供遵守UINavigationControllerDelegate协议的对象(组件3)作为代理 delegate,在 push 和 pop 时系统会要求这个 delegate 来提供动画控制器和交互控制器;没有提供这个代理时,比如上面的情况里,系统将会使用默认的 Slide 动画。该协议的方法名很直白,其中前者必须实现,用于提供组件1来执行实际的动画,后者提供组件2实现交互动画,是可选的。

– navigationController:animationControllerForOperation:fromViewController:toViewController:
– navigationController:interactionControllerForAnimationController:

fromVC 也可以作为代理来提供这些方法,但这样一来不方便其他类使用该效果,这里单独提供一个对象来作为代理,俗称解耦。新建SDENavigationControllerDelegate类,声明如下:

在 storyboard 里拖一个 NSObject 下面图中这一块区域,然后将其类设置为SDENavigationControllerDelegate。你没看错,就是拖一个 NSObject,在你经常拖控件的地方输入 object 就能看到。如果你还不知道,恭喜,现在你又学到新知识了。


在 storyboard 里为 navigation controller 设置 delegate

小坑预警:如果你想在代码里设置UINavigationController的 delegate,那么viewDidLoad()并不是一个合适的地方,因为此时 ViewController 尚未被推入UINavigationController的viewControllers栈里,通过UIViewController.navigationController得到的只是 nil。哪儿合适,在viewDidAppear()后调用的方法都可以,这么说这有点……作为一个UICollectionViewController,push 时在 didSelectCell 那个方法里最合适了。
本文将只实现非交互的动画,可交互的动画在系列下篇讨论。在SDENavigationControllerDelegate类里实现以下方法提供动画控制器:

第二步,实现上面提供的动画控制器类SDEPushAndPopAnimationController,该类遵守 UIViewControllerAnimatedTransitioning协议,需要实现以下方法:

– transitionDuration: //提供 transition animation 的持续时间
– animateTransition: //执行动画的地方,最重要的方法
– animationEnded: //可选方法,动画完毕后调用,大部分时候用不上
SDEPushAndPopAnimationController类的实现:

WT…恩,暂时先这么处理吧。接下来,再次进入科普时间。

来看看 WWDC13 Session 218 中对 NavigationController push transition 的解释:


NavigationController Push Transition 图解

NavigationController 维持的 ViewController 的结构和我们想象的一样,是个栈,但其对应的 View 的结构却不是这样。在 transition 结束时,fromView 被从 containerView 中被移除,如果我们没有这么做,系统会替我们完成的。这么看来,containerView 里只保留栈顶 ViewController 的视图,也就是屏幕上我们看到的那个视图。

图中的两个状态之间的变化就发生在- animateTransition:里,不过动画的执行不限于这里,viewWillXXX, viewDidXXX等这些方法里都可以执行你想要的动画,但是,出于解耦的目的,将所有的动画都放在- animateTransition:里执行,这样就能够也适用于其他UICollectionViewController类了,而如果你需要保证动画执行的顺序,那么这些方法并不是一个好的选择,在 WWDC13 Session 218 里苹果的工程师提到了不能保证viewDidXXX一定在对应的viewWillXXX后面执行,虽然我在三个月前的实现里是依赖这些方法而且没有发现这个问题,那么,可以继续这样做吗?答案是否,使用- animateTransition:可以从根源上杜绝此类问题;不过,所有动画放在这里执行还有一个最最最最最重要的目的,先放结论:你想纳入交互化控制过程的动画必须在- animateTransition:里执行,而且,必须使用 UIView Animation 来实现,不要使用 Core Animation,在系列下篇里实现交互动画时会详细讨论有关细节。科普结束,返回实现过程。

– animateTransition:
该方法原型为:
func animateTransition(_ transitionContext: UIViewControllerContextTransitioning)
该函数的参数由系统提供给我们,同时该参数就是组件4,它提供了 transition 过程中我们需要的绝大部分信息,包括参与 transition 过程的控制器以及 transition 过程的状态,最后还要将 transition 的执行结果通知给系统。

在很多文章里,会给你演示一些简单的动画,不过,在这里我需要你明白,此时的环境是怎样的以及你能够做什么。整理下现在的局面,现在屏幕的内容由当前视图提供,无论你以何种方式 push 或是 pop,最终会切换到下一屏的画面,系统会询问当前 NavigationController 的 delegate 要求提供动画控制器和交互控制器,如果我们没有提供动画控制器,那么系统就用 Slide 动画来展示当前画面和下一屏的画面的切换。不过,现在我们提供了动画控制器,系统问我们的动画控制器怎么处理这个切换过程。这时候,我们有当前视图 fromView,当前视图的容器视图 containerView,还有下一个屏幕的内容视图 toView,需要我们做的是将 toView 添加到 containerView 里用于显示下一屏的内容,而在 push 或 pop 结束时,fromView 会被从 containerView 里移除,如果我们没有这么做,系统会自动替我们在结束时移除,如果你想干预这个过程也是可以的,在 push 或 pop 结束之前,我们可以对当前视图 fromView 和下一屏视图 toView 做任何你想做的事,这就是我们即将要实现的动画。

VCTransitionsLibrary 这个库囊括了大部分对 view 整体之间进行切换的效果,而当 transition 涉及 view 上的元素的话,就需要你针对元素进行定制了,这个库就不适用这种情况了。比如神奇移动,就是将 fromView 上的元素移动到 toView 上,实现思路有两种:一是,toView 出现时,将目标元素移动到源元素的位置进行遮挡,然后移动到预定位置,比较简单;二是将 fromView 和 toView 中相同元素都隐藏,对源元素截图并加入 toView 中作为伪装,然后将伪装的源元素移动到 toView 上的指定位置,最后移除伪装的元素然后将目标元素恢复显示。这两个方法中很重要的一点就是无论是伪装的元素还是目标元素在开始和结束移动时的位置和大小都要吻合,不然就露馅了。

说教完毕,那么来实现开头的效果吧。

动画技术点

认真看下开头的效果,以 push 为例:图片像一本相册的封面一样翻开,这是一个可用 transform 实现的 flip 动画;下一层级的视图里的元素也就是相册里的照片在封面后出现,这个效果需要缩小照片并按一定规则排列好;封面继续往左翻动,而照片则移动到预定位置并在这个过程中恢复到原大小。

上面提到,实现交互动画,一定要使用 UIView Animation 而不是 Core Animation。而且这里的动画还涉及多个元素的配合,不同元素的动画的开始时间与持续时间都不一样,使用 UIView Animation 是没法满足这个要求的,因为常规的延迟执行手段在交互动画里没有作用,只有一个解决办法:UIView key frame animation,这里 push 和 pop 过程中的动画都是采用这种方式实现的。

在回到- animateTransition:执行动画之前,还有一个问题,从技术角度讲,pop 结束后要恢复被隐藏的封面,但这和 push 有什么关系呢?有关系,大有关系,事前做好准备才不怕事后找麻烦嘛。我们需要在 push 前保留这个被点击的封面的 indexpath 以便在 pop 结束时能够将之恢复。但又不想在UICollectionViewController添加属性,因为你让别人在自己的工程中为这个类添加这个属性还是挺麻烦的,有办法:extens tion + associated object,这个技巧是从这个库学来的。为UICollectionViewController添加一个 extension,新建UICollectionViewControllerExtension.swift文件,为所有的UICollectionViewController类添加下面两个属性:

 

然后需要在之前的代理方法里做添加一行代码:

 

准备工作完成了, 动画过程中包括这么几个步骤,同时也是问题:

问题1:封面旋转。封面的动画过程本质上和神奇移动有点像,只不过神奇移动里元素在移动,而这里元素位置在原来的位置不动,并且绕左侧旋转。不过,神奇移动之所以为神奇移动在于前后的内容里有相同的元素,但这里并不是,但依然可以采用神奇移动的思路来实现这个效果。由于 toView 里并没有封面这个元素,需要使用伪装的封面,push 时隐藏原封面的同时在 toView 上添加和原封面内容一样的视图来欺骗我们的眼睛,pop 时则将这个伪装封面翻回去,然后恢复源封面的显示。封面的第二个问题,如何保证封面在 toView 上依然保持在视觉正确的位置。这个也好解决,无论当前 collectionView 怎么移动,封面相对于 fromView.superView 和封面相对于 toView.superView 的位置是一样的,因为这两个位置都是相对于当前屏幕的位置。UIView 有一套”convertXXX”的方法用于属于同一个 UIWindow 的视图之间进行坐标的转换: