原文:How To Make A UIViewController Transition Animation Like in the Ping App
作者:Luke Parham
译者:kmyhy更新说明:本教程由 Luke Parhm 更新至 Xcode 9/Swift 4。原文作者是 Rounak Jain。
不久前,匿名社交网络 app Secret 的作者发布了一个 app 叫做 Ping,它允许用户接收他们感兴趣的话题的通知。
除了它出人意料的推荐之外,Ping 有一个地方做的非常好,那就是它的主界面和菜单之间的圆形转换动画,也就是你看到的这个动画。
当你看到一个好玩的东西,你很自然地总是想研究下它是怎么实现的。哪怕你是那种麻木不仁的家伙,根本不会对自己看到的每个动画进行思考,你也可以在研究这个动画的同时学到大量的 view controller 转换动画的知识。
在本教程中,你将学习如何在 Swift 中用 UIViewController 转换动画实现这个酷炫的动画。在此过程中,你将学到形状图层、遮罩、UIViewControllerAnimatedTransitioning 协议,UIPercentDrivenInteractiveTransition 类等等。
对于本教程来说,拥有 view controller 转换动画是有帮助的,但不是必须的。如果你需要一个入门教程,请阅读《自定义 UIViewController 转换:开始》。
在 Ping 中,UIViewController 转换动画会在你从一个 view controller 导航到另一个 view controller 时发生。
在 iOS 中,你可以为放在一个 UINavigationController 中的两个 view controller 编写自定义的转换,同时实现 iOS 的 UIViewControllerAnimattedTransitioning 协议去驱动这个转换。
注意在开始之前,你可以用任意方式来实现这些动画,pop、UIView、UIKit Dynamics 或者底层的 Core Animation API。
在本教程中,你将主要使用标准的 UIView 和 Core Animation API。
现在,你已经晓得将代码写在哪里了,接下来让我们想一下如何实现这个圆形动画。
从表面上看,可以大胆猜测这个动画的实现是这个样子的:
大致搞清楚你要做的事情后,让我们开始动手。
首先下载开始 app。当你 build & run,你会看到这个 app 在纯色背景上写了一些简单的文字。先点几次圆形按钮看看。
如你所见,你会看到一个经典的默认的 push/pop 动画。我妈总是跟我说“世上无难事,只怕有心人”,因此我想对你说,你可以让这个动画变得更好看!
UINavigationController 对象有一个 delegate 属性,它可以是任意实现了 UINavigationControllerDelegate 协议的对象。
这个协议中有 4 个方法用于处理 view controller 的显示以及指定所支持的屏幕方向,有 2 个方法允许你指定由哪个对象来负责实现自定义转换动画。
在动手之前,你需要创建一个新类,用它来充当这个 delegate 对象。
打开开始项目,选择 Pong 文件夹,按 command+N 添加新文件。选择 Cocoa Touch Class 然后点击 Next。文件取名为 TransitionCoordinator,确认继承自 NSObject 且语言为 Swift,点击 Next、Create。
这个类必须实现 UINavigationControllerDelegate 协议。将类的定义修改为:
class TransitionCoordinator: NSObject, UINavigationControllerDelegate {
就目前来说还不错,接下来你需要实现一个委托方法。在 TransitionCoordinator 添加:
func navigationController(_ navigationController: UINavigationController,
animationControllerFor operation: UINavigationControllerOperation,
from fromVC: UIViewController,
to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return nil
}
这个方法只需要注意哪个 view controller 正在离场,而哪个 view controller 正在入场,然后根据判断返回合适的 animation 对象。
现在,你返回的是 nil,这是默认的情况。当一个导航控制器需要你提供一个 animation 而你返回一个 nil 时,它会用默认的 push/pop 转换动画,就像你刚刚看到的。
等会我们会回到这个类,返回一个真正的 animation
在 AppDelegate.swift 的 window 属性之后添加:
let transitionCoordinator = TransitionCoordinator()
这里初始化了一个 TransitionCoordinator 并持有了它。
现在找到隐藏 navigation bar 的这一句:
nav.isNavigationBarHidden = true
在这一句之后,将 transitionCoordinator 对象赋给导航控制器:
nav.delegate = transitionCoordinator
Build & run。你什么也不会看到,因为你让委托对象返回了一个空的 animation。
好的,这个说过了,就不再叽叽歪歪了!
TransitionCoordinator 应该返回的 “animation 对象” 是必须实现 UIViewControllerAnimatedTransitioning。
让对象实现这个协议其实并不难。它们只需要实现两个方法。第一个返回一个动画时长,单位秒。第二个会传入一个 context 对象,动画中会用到的所有信息都放在了这个 context 中。
通常如果 push 和 pop 动画不一样,我们会创建一个 animation 对象,将它赋给 UINavigationControllerOperation 参数。
在我们的例子里,你不需要它,因为我们的 push 和 pop 是相同的,因此如果写的时候不需要管动画方向。
说完这些,你可以创建新类了。按 command+N,选择 Cocoa Touch Class,文件取名为 CircularTransition。
首先需要声明这个类遵循 UIViewControllerAnimatedTransitioning 协议。在声明继承 NSObject 之后添加:
class CircularTransition: NSObject, UIViewControllerAnimatedTransitioning {
通常,Xcode 会立即提示你没有实现该协议。Xcode 真的很厉害,这正是你接下来需要做的事情。
首先,添加这个方法,告诉动画需要执行的时间。
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?)
-> TimeInterval {
return 0.5
}
这是 UIKit 调用的第一个方法,如果你指定了导航控制器的 delegate 的话。这里你指定转换动画的时间为 0.5 秒。
然后,添加一个空方法,真正的动画动作将在后面编写。
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
//make some magic happen
}
这里你会获得一个转换上下文对象,当你编写动画代码时会用上的所有信息都在它里面包含。
激动了吧?
回到 TransitionCoordinator.swift,将返回 nil 一句替换成:
return CircularTransition()
这里,你告诉了导航控制器,你不想使用 push/pop 转换,而是用更好的动画代替。从现在起,UIKit 会用这个 UIViewControllerAnimatedTransitionging 对象驱动导航控制器的所有转换动画。
这很好,但记住,权利越大,责任越大。因此请回到 CircularTransition.swift 开始真正的工作!
如果你尝试写过转换动画,你可能会说编写深入到内部 view controller 的代码虽然不难,但同时写起来很“不舒服”。在这里,我们将 view controller 需要提供的东西定义在前面,允许所有想使用这种转换动画的 view controller 来访问这些 view。
在 CircularTransition.swift,在类定义之前添加:
protocol CircleTransitionable {
var triggerButton: UIButton { get }
var contentTextView: UITextView { get }
var mainView: UIView { get }
}
这个协议定义了为了执行这个动画需要从每个 view controller 获得的信息。
然后,打开 ColoredViewController.swift,让它声明遵循新协议,将类声明修改为:
class ColoredViewController: UIViewController, CircleTransitionable {
刚好,这个 view controller 已经定义了 triggerButton 和 contentTextView。最后一件事情是需要添加一个计算属性 mainView。添加下列属性:
var mainView: UIView {
return view
}
这里,你只需要返回 view controller 默认的 view 属性就可以了。
这个项目包含了一个 BlackViewController 和一个 WhiteViewController,用于表示 app 中的两个页面。两个 view controller 都是 ColoredViewController 的子类,因此你实际上已经让这两个类都支持转换动画了!
终于,可以开始实现动画了。
回到 CircularTransition.swift,在 animateTransition(transitionContext:) 方法中添加下列 guard 语句。
guard let fromVC = transitionContext.viewController(forKey: .from) as? CircleTransitionable,
let toVC = transitionContext.viewController(forKey: .to) as? CircleTransitionable,
let snapshot = fromVC.mainView.snapshotView(afterScreenUpdates: false) else {
transitionContext.completeTransition(false)
return
}
这里,你先确认所有必要的素材都已经准备好。transitionContext 允许你访问转换过程中用到的 view controller 引用。将它们转换为 CircleTransitionable,以便访问它们的 main view 和 text view。
snapshotView(afterScreenUpdates:) 会返回一个 fromVC 的截图。
在动画过程中,对视图进行截图通常是一个非常有用的获得视图的一次性拷贝的好方法。你不需要分别对视图的 subview 进行动画,如果你只需要对整个视图树进行动画而不需要将东西放回去的话,使用截屏是一种理想的方式。
在 guard 语句的 else 子句中,你调用了 transitionContext 的 completeTransition() 方法。参数 false 告诉 UIKit,你无法完成转换,UIKit 将无法移动到下一个 view controller。
在 guard 语句之后,从上下文中获得 container view。
let containerView = transitionContext.containerView
这个视图相当于一个缓存,你可以向动画的最终目标视图中添加和删除视图。
在动画过程中,你需要在 containerView 上做这几件事情:
在 animateTransition(transitionContext:) 继续添加:
containerView.addSubview(snapshot)
为了在“旧文字”离场的同时不改动真正的 text view 的 frame,我们将用一个截图来替代。
然后,将正真的 view 移除,我们用不着它了。
fromVC.mainView.removeFromSuperview()
最后,添加下列动画方法:
func animateOldTextOffscreen(fromView: UIView) {
// 1
UIView.animate(withDuration: 0.25,
delay: 0.0,
options: [.curveEaseIn],
animations: {
// 2
fromView.center = CGPoint(x: fromView.center.x - 1300,
y: fromView.center.y + 1500)
// 3
fromView.transform = CGAffineTransform(scaleX: 5.0, y: 5.0)
}, completion: nil)
}
这个方法十分简单:
这会让文字变大并离场。这些数字从哪来的?它们只是通过实际运行后得来的经验值。你可以随便修改它们,只要你自己觉得合适。
在 animateTransition(transitionContext:) 中继续敲入:
animateOldTextOffscreen(fromView: snapshot)
将截屏图传给新方法,以便让它在动画中离场。
Build & run 看看你的杰作。
OK,看起来不是很完美,但这是一个复杂动画,一次只能完成其中的一小块。
注意:在 CircularTransition.swift 中仍然有一个警告,别急,我们一会再来搞定它。
有一个比较讨厌的问题就是,因为是整个视图离场,你会看到后面有一个黑色的背景。
黑色背景是 containerView,你其实是想让文字离场,而不是整个背景离场。要解决这个问题,必须要在后面加一个不会动的背景 view。
在 CircularTransition.swift,找到 animateTransition(using:) 方法。在你获得一个 containerView 的引用之后、将 snapshotView 加进去之前,添加几句代码:
let backgroundView = UIView()
backgroundView.frame = toVC.mainView.frame
backgroundView.backgroundColor = fromVC.mainView.backgroundColor
这里,你创建了一个 backgroundView,将它的 frame 设置为全屏,背景色设置为 fromVC 的背景色。
然后,将它添加到 containerView。
containerView.addSubview(backgroundView)
Build & run。
好多了。
完成第一部分之后,接下来就是圆形动画,新的 view controller 从按钮位置入场。
在 CircularTransition 中新增方法:
func animate(toView: UIView, fromTriggerButton triggerButton: UIButton) {
}
这个方法将实现圆形动画——我们将马上实现它!
在 animateTransition(using:) 方法的 animateOldTextOffscreen(fromView:snapshot) 之后添加:
containerView.addSubview(toVC.mainView)
animate(toView: toVC.mainView, fromTriggerButton: fromVC.triggerButton)
这里将最后的 view 也添加进了 containerView,然后对它进行动画——在你实现了这个 animate 方法之后!
现在,你的圆形动画已经初具雏形。但是,这个动画中真正重要的是理解 CAShapeLayer 中图层遮罩的概念。
CAShapeLayers 是 CALayer 的一个特殊子类,只不过不一样的是,它不是只有矩形一种形状,它可以有其它形状,首先定义一个 bezier 路径,然后将路径赋给图层的 path 属性。
在这个例子中,你需要定义两个 bezier 路径,然后在它们之间进行动画。
在 animate(toView:, fromTriggerButton:) 方法中添加下列代码:
// 1
let rect = CGRect(x: triggerButton.frame.origin.x,
y: triggerButton.frame.origin.y,
width: triggerButton.frame.width,
height: triggerButton.frame.width)
// 2
let circleMaskPathInitial = UIBezierPath(ovalIn: rect)
这里以 triggerButton 的 frame 为框创建了一个圆形的 bezier 路径。
你创建了一个:
然后,创建一个圆表示动画结束的状态。因为你只能看见圆内的内容,你不想在动画结束之后仍然能看到圆的边。在刚才的代码下面添加:
// 1
let fullHeight = toView.bounds.height
let extremePoint = CGPoint(x: triggerButton.center.x,y: triggerButton.center.y - fullHeight)
// 2
let radius = sqrt((extremePoint.x*extremePoint.x) +(extremePoint.y*extremePoint.y))
// 3
let circleMaskPathFinal = UIBezierPath(ovalIn: triggerButton.frame.insetBy(dx: -radius,dy: -radius))
这段代码解释如下:
创建好 bezier 路径之后,接下来就是使用它们。还是在 animate(toView:triggerButton:) 中添加代码:
let maskLayer = CAShapeLayer()
maskLayer.path = circleMaskPathFinal.cgPath
toView.layer.mask = maskLayer
这里创建了一个 CAShapeLayer,设置它的 path 属性为圆形 bezier 的 path。maskLayer 会作为目标视图的遮罩。
等等,遮罩是如何工作的?
通常,一个遮罩中, alpha 值为 1 的地方会显示下层图层的内容,alpha 值为 0 的地方则不显示下层图层内容。介于二者之间的值则部分显示图层内容。用下图加以说明:
基本上,你可以认为,是将你所看到的形状挖去,这样你就可以透过它看到下面的内容。除此之外的都是被隐藏掉的。在两个 bezier 路径中,圆圈里面的像素的 alpha 值为 1,而圆之外的像素是透明的,因此你在 masked view 中无法看到这些点。
经过这些准备,接下来要做的事情就是在两个圆形遮罩之间进行动画了。有一个问题是,迄今为止你只用过 UIView 动画,还没有用过 CALayer 的动画呢。
此时,你曾经用过的 UIView 动画只是不再有用了,你必须用到更底层的层级。
这是肯定的事情,但不用担心,这个 API 超级简单。它非常容易理解,因为 UIView 动画其实底层也是 CATransaction。
和基于闭包的 UIView 动画 API 不同,Core Animation 动画使用基于对象的方式。这也是一种对 CATransaction 的抽象,对于许多你想进行的和视图有关的事情来说,都是一样的。
还是在 animate(toView:triggerButton:) 方法,创建一个 CABasicAnimation 对象来进行动画。
let maskLayerAnimation = CABasicAnimation(keyPath: "path")
这里,创建了一个 animation 对象,指定它需要动画的属性是 path。也就是说你将对渲染出的形状进行动画。
然后,设置这个 animation 的 toValue 值。
maskLayerAnimation.fromValue = circleMaskPathInitial.cgPath
maskLayerAnimation.toValue = circleMaskPathFinal.cgPath
用前面创建的两个 bezier 路径定义动画之间的两个状态。
最后一个事情是配置 animation,告诉它如何运行。也就是这一句:
maskLayerAnimation.duration = 0.15
这表示动画的执行时间是 0.15 秒。
和使用 UIView 动画的完成块不同,CAAnimation 使用委托回调来表示完成。虽然对于这个动画来说并不需要委托,但实现委托能让我们更好地理解它。
加上这句:
maskLayerAnimation.delegate = self
这个类现在变成了 animation 对象的委托。
回到文件底部,添加一个扩展实现 CAAnimationDelegate 协议。
extension CircularTransition: CAAnimationDelegate {
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
}
}
当这个动画完成,你可以认为整个动画执行成功。在这个方法中,你可能想在第一个动画中的上下文对象上调用 completeTransition() 方法。
不幸的是,在使用这个委托回调时,有个麻烦问题。为了访问这个上下文对象,你必须在开始的主动画中保存一个对它的引用。
首先,回到 CircularTransition 头部添加一句:
weak var context: UIViewControllerContextTransitioning?
然后,在 animateTransition(transitionContext:) 方法的 guard 语句之后保存上下文对象。
context = transitionContext
最后,回到扩展中的 animationDidStop(anim:finished:) 方法添加:
context?.completeTransition(true)
当动画成功完成,通知系统。
你的动画对象创建好了,将它加到 maskLayer 上吧。在 animate(toView:triggerButton:) 中加入:
maskLayer.add(maskLayerAnimation, forKey: "path")
你需要再次标明,你想对 maskLayer 的 path 进行动画。一旦为一个 layer 添加一个动画,它就会自动开始执行。
Build & run 看看近乎成品的动画吧!
为了完整,你还需要再增加一个动画。除了圆圈变大,显示目标视图控制器之外,你还要将目标视图中的文字从右边渐入。
和最后的那个动画相比,这几乎不值一提。在 CircularTransition 类最后添加一个方法:
func animateToTextView(toTextView: UIView, fromTriggerButton: UIButton) {
}
在这个方法中,加入:
let originalCenter = toTextView.center
toTextView.alpha = 0.0
toTextView.center = fromTriggerButton.center
toTextView.transform = CGAffineTransform(scaleX: 0.1, y: 0.1)
这里,你设置了 toTextView 的开始状态。将它的 alpha 设置为 0,将它和 triggerButton 居中对齐,然后将它的大小缩小到 1/10。
然后,添加一个 UIView 动画:
UIView.animate(withDuration: 0.25, delay: 0.1, options: [.curveEaseOut], animations: {
toTextView.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
toTextView.center = originalCenter
toTextView.alpha = 1.0
}, completion: nil)
除了将 text view 以渐入动画的方式移动到屏幕中央并恢复它的原大小外什么都没有做。
最后,在 animateTransition(transitionContext:) 方法最后加上:
animateToTextView(toTextView: toVC.contentTextView, fromTriggerButton: fromVC.triggerButton)
在参数中我们传入了 toVC 的 text view 和 fromVC 的按钮。现在,text view 动画会随着其他动画一起执行。
最后一次 build & run,呈现了一个完全模拟了 Ping app 的转换动画!
在这里下载完整的 Pong app。
如果想看苹果官方 UIViewController 转换动画文档,请参考 iOS View Controller 编程指南中的自定义转换动画。
希望你喜欢本教程,希望你在克隆其它现实生活中看到动画时更加的信心满满。
如果想学习更多内容,可以看看我们的初级、中级动画视频课程,以及我们的这本书《iOS 动画教程》。
有任何问题和评论,或者想分享一下你的精彩动画,请在下面留言。