iOS 视图控制器转场详解(下)-阿里云开发者社区

开发者社区> 开发与运维> 正文

iOS 视图控制器转场详解(下)

简介:

推荐序

这是一篇长文,详细讲解了视图控制器转场的方方面面,配有详细的示意图和代码,为了使得文章在微信公众号中易于阅读,seedante 辛苦将大量长篇代码用截图的方式呈现,满满的诚意之作。

作者 seedante 是一个低调人士,只愿意透露他的 GitHub:https://github.com/seedante 。感谢作者授权微信独家代理,本文的所有打赏归 seedante 所有。

插曲:UICollectionViewController 布局转场

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

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


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

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

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

进阶

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

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

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

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

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

案例分析

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

子元素动画

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

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

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:

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 动画


左边的动画教程:How To Make A View Controller Transition Animation Like in the Ping App;右边动画的开源地址:BubbleTransition

Mask 动画往往在视觉上令人印象深刻,这种动画通过使用一种特定形状的图形作为 mask 截取当前视图内容,使得当前视图只表现出 mask 图形部分的内容,在 PS 界俗称「遮罩」。UIView 有个属性maskView可以用来遮挡部分内容,但这里的效果并不是对maskView的利用;CALayer 有个对应的属性mask,而 CAShapeLayer 这个子类搭配 UIBezierPath 类可以实现各种不规则图形。这种动画一般就是 mask + CAShapeLayer + UIBezierPath 的组合拳搞定的,实际上实现这种圆形的形变是很简单的,只要发挥你的想象力,可以实现任何形状的形变动画。

这类转场动画在转场过程中对 toView 使用 mask 动画,不过,右边的这个动画实际上并不是上面的组合来完成的,它的真相是这样:


这个开发者实在是太天才了,这个手法本身就是对 mask 概念的应用,效果卓越,但方法却简单到难以置信。关于使用 mask + CAShapeLayer + UIBezierPath 这种方法实现 mask 动画的方法请看我的这篇文章

高性能动画框架

有些动画使用 UIView 的动画 API 难以实现,或者难以达到较好的性能,又或者两者皆有,幸好我们还有其他选择。StartWar 使用更底层的 OpenGL 框架来解决性能问题以及 Objc.io 在探讨转场这个话题时使用 GPUImage 定制动画都是这类的典范。在交互控制器章节中提到过,官方只能对 UIView 动画 API 实现的转场动画实施完美的交互控制,这也不是绝对的,接下来我们就来挑战这个难题。

自定义容器控制器转场

压轴环节我们将实现这样一个效果:


Demo 地址:CustomContainerVCTransition

分析一下思路,这个控制器和 UITabBarController 在行为上比较相似,只是 TabBar 由下面跑到了上面。我们可以使用 UITabBarController 子类,然后打造一个伪 TabBar 放在顶部,原来的 TabBar 则隐藏,行为上完全一致,使用 UITabBarController 子类的好处是可以减轻实现转场的负担,不过,有时候这样的子类不是你想要的,UIViewController 子类能够提供更多的自由度,好吧,一个完全模仿 UITabBarController 行为的 UIViewController 子类,实际上我没有想到非得这样做的原因,但我想肯定有需要定制自己的容器控制器的场景,这正是本节要探讨的。Objc.io 也讨论过这个话题,文章的末尾把实现交互控制当做作业留了下来。珠玉在前,我就站在大牛的肩上继续这个话题吧。Objc.io 的这篇文章写得较早使用了 Objective-C 语言,如果要读者先去读这篇文章再继续读本节的内容,难免割裂,所以本节还是从头讨论这个话题吧,最终效果如上面所示,在自定义的容器控制器中实现交互控制切换子视图,也可以通过填充了 UIButton 的 ButtonTabBar 来实现 TabBar 一样行为的 Tab 切换,在通过手势切换页面时 ButtonTabBar 会实现渐变色动画。ButtonTabBar 有很大扩展性,改造或是替换为其他视图还是有很多应用场景的。

实现分析

既然这个自定义容器控制器和 UITabBarController 行为类似,我便实现了一套类似的 API:viewControllers数组是容器 VC 维护的子 VC 数组,初始化时提供要显示的子 VC,更改selectedIndex的值便可跳转到对应的子视图。利用 Swift 的属性观察器实现修改selectedIndex时自动执行子控制器转场。下面是实现子 VC 转场的核心代码,转场结束后遵循系统的惯例将 fromView 移除:


实现转场就是这么十几行代码而已,其他容器 VC 转场过程做了类似的事情。回忆下我们在动画控制器里做的事情,实际上只是上面代码中的一部分。转场协议这套 API 将这个过程分割为五个组件,这套复杂的结构带来了可高度自定义的动画效果和交互控制。我们温习下转场协议,来看看如何在既有的转场协议框架下实现自定义容器控制器的转场动画以及交互控制:

  1. 转场代理:既有的转场代理协议并没有直接支持我们这种转场方式,没关系,我们自定义一套代理协议来提供动画控制器和交互控制器;

  2. 动画控制器:动画控制器是可复用的,这里采用动画控制器章节封装的 Slide 动画控制器,可以拿来直接使用而不用修改;

  3. 交互控制器:官方封装了一个现成的交互控制器类,但这个类是与 UIKit 提供的转场环境对象配合使用的,而这里的转场显然需要我们来提供转场环境对象,因此UIPercentDrivenInteractiveTransition无法在这里使用,需要我们来实现这个协议;

  4. 转场环境:在官方支持的转场方式中,转场环境是由 UIKit 主动提供给我们的,既然现在的转场方式不是官方支持的,显然需要我们自己提供这个对象以供动画控制器和交互控制器使用;

  5. 转场协调器:在前面的章节中我提到过,转场协调器(Transition Coordinator)的使用场景有限而关键,也是由系统提供,我们也可以重写相关方法来提供。这个部分我留给读者当作是本文的一道作业吧。

下面我们来将上面的十几行代码(不包括实际的动画代码)使用协议封装成本文前半部分里熟悉的样子。

协议补完

模仿 UITabBarControllerDelegate 协议的 ContainerViewControllerDelegate 协议:


在容器控制器SDEContainerViewController类中,添加转场代理属性:

weak var containerTransitionDelegate: ContainerViewControllerDelegate?

代理的定位就是提供动画控制器和交互控制器,系统打包的UIPercentDrivenInteractiveTransition类只是调用了转场环境对象的对应方法而已,执行navigationController.pushViewController(toVC, animated: true)这类语句触发转场后 UIKit 就接管了剩下的事情,再综合文档的描述,可知转场环境便是实现这一切的核心。

在文章前面的部分里转场环境对象的作用只是提供涉及转场过程的信息和状态,现在需要我们实现该协议,并且实现隐藏的那部分职责。
<UIViewControllerContextTransitioning>协议里的绝大部分方法都是必须实现的,不过现在我们先实现非交互转场的部分,实现这个是很简单的,主要是调用动画控制器执行转场动画。在「实现分析」一节里我们看到实现转场的代码只有十几行而已,动画控制器需要做的只是处理视图和动画的部分,转场环境对象则要负责管理子 VC,通过SDEContainerViewController提供 containerView 以及 fromVC 和 toVC,实现并不是难事。显然由我们实现的自定义容器 VC 来提供转场环境对象是最合适的,并且转场环境对象应该是私有的,其初始化方法极其启动转场的方法如下:


SDEContainerViewController类中,添加转场环境属性:

private var containerTransitionContext: ContainerTransitionContext?

并修改transitionViewControllerFromIndex:toIndex方法实现自定义容器 VC 转场动画:


这样我们就利用协议实现了自定义容器控制器的转场动画,可以使用第三方的动画控制器来实现不同的效果。

不过要注意这几个对象之间错综复杂的引用关系避免引用循环,关系图如下:


交互控制

交互控制器的协议<UIViewControllerInteractiveTransitioning>仅仅要求实现一个必须的方法:

func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning)

根据文档的描述,该方法用于配置以及启动交互转场。我们前面使用的UIPercentDrivenInteractiveTransition类提供的更新进度的方法只是调用了转场环境对象的相关方法。所以,是转场环境对象替交互控制器把脏活累活干了,我们的实现还是维持这种关系好了。正如前面说的,「交互手段只是表现形式,本质是驱动转场进程」,让我们回到转场环境对象里实现对动画进度的控制吧。

怎么控制动画的进度?这个问题的本质是怎么实现对 UIView 的 animateWithDuration:animations:completion:这类方法生成的动画的控制。能够控制吗?能。

动画控制和 CAMediaTiming 协议

这个协议定义了一套时间系统,是控制动画进度的关键。UIView Animation 是使用 Core Animation 框架实现的,也就是使用 UIView 的 CALayer 对象实现的动画,而 CALayer 对象遵守该协议。

在交互控制器的小节里我打了一个比方,交互控制器就像一个视频播放器一样控制着转场动画这个视频的进度。依靠 CAMediaTiming 这套协议,我们可以在 CALayer 对象上对添加的动画实现控制。官方的实现很有可能也是采用了同样的手法。CAMediaTiming 协议中有以下几个属性:


Core Animation 的文档中提供了如何暂停和恢复动画的示例:How to pause the animation of a layer tree。我们将之利用实现对进度的控制,这种方法对其中的子视图上添加的动画也能够实现控制,这正是我们需要的。假设在 containerView 中的 toView 上执行一个简单的沿着 X 轴方向移动 100 单位的位移动画,由executeAnimation()方法执行。下面是使用手势控制该动画进度的核心代码:


取消转场

交互控制动画时有可能被取消,这往往带来两个问题:恢复数据和逆转动画。

这里需要恢复的数据是selectedIndex,我们在交互转场开始前备份当前的selectedIndex,如果转场取消了就使用这个备份数据恢复。逆转动画反而看起来比较难以解决。

在上面的 pan 手势处理方法中,我们如何逆转动画的运行呢?既然speed为0时动画静止不动,调整为负数是否可以实现逆播放呢?不能,效果是视图消失不见。不过我们还可以调整timeOffset属性,从当前值一直恢复到0。问题是如何产生动画的效果?动画的本质是视图属性在某段时间内的连续变化,当然这个连续变化并不是绝对的连续,只要时间间隔够短,变化的效果就会流畅得看上去是连续变化,在这里让这个变化频率和屏幕的刷新同步即可,CADisplayLink可以帮助我们实现这点,它可以在屏幕刷新时的每一帧执行绑定的方法:


最后一句代码会令人疑惑,为何让视图恢复为最终状态,与我们的初衷相悖。speed必须恢复为1,不然后续发起的转场动画无法顺利执行,视图也无法响应触摸事件,直接原因未知。但speed恢复为1后会出现一个问题:由于在原来的动画里 fromView 最终会被移出屏幕,尽管 Slide 动画控制器 UIView 动画里的 completion handle 里会恢复 fromView 和 toView 的状态,这种状态的突变会造成闪屏现象。怎么解决?添加一个假的 fromView 到 containerView替代已经被移出屏幕外的真正的 fromView,然后在很短的时间间隔后将之移除,因为此时 fromView 已经归位。在恢复speed后添加以下代码:


经过试验,上面用来控制和取消 UIView 动画的方法也适用于用 Core Animation 实现的动画,毕竟 UIView 动画是用 Core Animation 实现的。不过,我们在前面提到过,官方对 Core Animation 实现的交互转场动画的支持有缺陷,估计官方鼓励使用更高级的接口吧,因为转场动画结束后需要调用transitionContext.completeTransition(!isCancelled),而使用 Core Animation 完成这一步需要进行恰当的配置,实现的途径有两种且实现并不简单,相比之下 UIView 动画使用 completion block 对此进行了封装,使用非常方便。转场协议的结构已经比较复杂了,选择 UIView 动画能够显著降低实现成本。

上面的实现忽略了一个细节:时间曲线。逆转动画时每一帧都回退相同的时间,也就是说,逆转动画的时间曲线是线性的。交互控制器的协议<UIViewControllerInteractiveTransitioning>还有两个可选方法:

optional func completionCurve() -> UIViewAnimationCurve
optional func completionSpeed() -> CGFloat

这两个方法记录了动画采用的动画曲线和速度,在逆转动画时如果能够根据这两者计算出当前帧应该回退的时间,那么就能实现完美的逆转,显然这是一个数学问题。恩,我们跳过这个细节吧,因为我数学不好,讨论这个问题很吃力。推荐阅读 Objc.io 的交互式动画一文,该文探讨了如何打造自然真实的交互式动画。

最后的封装

接下来要做的事情就是将上述代码封装在转场环境协议要求实现的三个方法里:

func updateInteractiveTransition(percentComplete: CGFloat)
func finishInteractiveTransition()
func cancelInteractiveTransition()

正如系统打包的UIPercentDrivenInteractiveTransition类只是调用了 UIKit 提供的转场环境对象里的同名方法,我实现的SDEPercentDrivenInteractiveTransition类也采用了同样的方式调用我们实现的ContainerTransitionContext类的同名方法。

引入交互控制器后的转场引用关系图:


回到SDEContainerViewController类里修改转场过程的入口处:


实现手势控制的部分就如前面的交互控制器章节里的那样,完整的代码请看 Demo。

顺便说下 ButtonTabButton 在交互切换页面时的渐变色动画,这里我只是随着转场的进度更改了 Button 的字体颜色而已。那么当交互结束时如何继续剩下的动画或者取消渐变色动画呢,就像交互转场动画的那样。答案是CADidplayLink,前面我使用它在交互取消时逆转动画,这里使用了同样的手法。

关于转场协调器,文档表明在转场发生时transitionCoordinator()返回一个有效对象,但系统并不支持当前的转场方式,测试表明在当前的转场过程中这个方法返回的是 nil,需要重写该方法来提供。该对象只需要实现前面提到三个方法,其中在交互中止时执行绑定的闭包的方法可以通过通知机制来实现,有点困难的是两个与动画控制器同步执行动画的方法,其需要精准地与动画控制器中的动画保持同步,这两个方法都要接受一个遵守<UIViewControllerTransitionCoordinatorContext>协议的参数,该协议与转场环境协议非常相似,这个对象可以由我们实现的转场环境对象来提供。不过既然现在由我们实现了转场环境对象,也就知道了执行动画的时机,提交并行的动画似乎并不是难事。这部分就留给读者来挑战了。

尾声:转场动画的设计

虽然我不是设计师,但还是想在结束之前聊一聊我对转场动画设计的看法。动画的使用无疑能够提升应用的体验,但仅限于使用了合适的动画。

除了一些加载动画可以炫酷华丽极尽炫技之能事,绝大部分的日常操作并不适合使用过于炫酷或复杂的动画,比如 VCTransitionsLibrary 这个库里的大部分效果。该库提供了多达10种转场效果,从技术上讲,大部分效果都是针对 transform 进行动画,如果你对这些感兴趣或是恰好有这方面的使用需求,可以学习这些效果的实现,从代码角度看,封装技巧也很值得学习,这个库是学习转场动画的极佳范例;不过从使用效果上看,这个库提供的效果像 PPT 里提供的动画效果一样,绝大部分都应该避免在日常操作中使用。不过作为开发者,我们应该知道技术实现的手段,即使这些效果并不适合在绝大部分场景中使用。

场景转换的目的是过渡到下一个场景,在操作频繁的日常场景中使用复杂的过场动画容易造成视觉疲劳,这种情景下使用简单的动画即可,实现起来非常简单,更多的工作往往是怎么把它们与其他特性更好地结合起来,正如 FDFullscreenPopGesture 做的那样。除了日常操作,也会遇到一些特殊的场景需要定制复杂的转场动画,这种复杂除了动画效果本身的复杂,这需要掌握相应的动画手段,也可能涉及转场过程的配合,这需要对转场机制比较熟悉。比如 StarWars,这个转场动画在视觉上极其惊艳,一出场便获得上千星星的青睐,它有贴合星战内涵的创意设计和惊艳的视觉表现,以及优秀的性能优化,如果要评选年度转场动画甚至是史上最佳,我会投票给它;而我在本文里实现的范例,从动画效果来讲,都是很简单的,可以预见本文无法吸引大众的转发,压轴环节里的自定义容器控制器转场也是如此,但是后者需要熟知转场机制才能实现。从这点来看,转场动画在实际使用中走向两个极端:日常场景中的转场动画十分简单,实现难度很低;特定场景的转场动画可能非常复杂,不过实现难度并不能一概而论,正如我在案例分析一节里指出的几个案例那样。

希望本文能帮助你。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:
开发与运维
使用钉钉扫一扫加入圈子
+ 订阅

集结各类场景实战经验,助你开发运维畅行无忧

其他文章