原文:Custom UIViewController Transitions: Getting Started
作者:Richard Critz
译者:kmyhy更新说明: 本教程由 Richard Critz 更新至 iOS11 和 Swift 4。原文作者是 József Vesza。
iOS 内置了一些好看的 View Controller 转换动画——push、pop、cover vertically——这些都是现成的,但创建自己的动画岂不更有趣呢?自定义 UIViewController 转换能大大地提高用户体验,并让你的 app 明显超出其它 app 一大截。如果你曾经因为这个过程太难而不愿意自定义转换动画,你会发现其实它并没有你想象中的那么难。
在本教程中,我们将为一个简单的猜谜游戏添加自定义 UIViewController 转换动画。在最后,你将学习到:
注意:本教程中演示的转换动画使用的是 UIView 动画,你需要对此有所了解。如果你需要帮助,请参考我们的 iOS Animation 以便快速进入我们的主题。
下载开始项目。Build & run,你会看到:
这个 app 用一个 page view controller 来展现几个不同的卡片。每张卡片显示一段关于宠物的描述,当你点击一张卡片显示它所描述的是什么样的宠物。
你的任务是猜猜这是什么宠物?猫、狗、还是鱼?试完一下 app,看看你猜得准不准?
导航逻辑是写好的,但 app 给人的感觉平淡无奇。我们将通过自定义转换动画来为它增添一些色彩。
Transitioning API 是一个协议集。它允许你为你的 app 选择最合适的一种实现方式:使用负责管理转换动画的现成对象或者创建专门的对象。这一节结束,你将了解每个协议的作用及其相互间的关联。下图显示了 API 的组成部分:
尽管图很复杂,但一旦你理解如何将各部分组装起来之后就会变得很简单了。
每个 view controller 都有一个 transitioningDelegate 属性,这个对象实现了 UIViewControllerTransitioningDelegate 协议。
当你呈现或解散一个 view controller 时,UIKit 会询问 transitioning delegate 对象要使用哪一个 animation controller。要将默认的动画替换成你自己的动画,你必须实现一个 transitioning delegate 并通过它返回一个特定的动画控制器。
transitioning delegate 对象所返回的 animation controller 对象则实现了 UIViewControllerAnimatedTransitioning 协议。它负责实现转换动画的“重体力活”。
Transitioning contenxt 对象实现了 UIViewControllerContextTransitioning 协议并负责转换过程中的一个重要角色:它封装了和动画相关的视图和视图控制器的信息。
如你在上图中所见,你不需要自己实现这个协议。UIKit 会为你创建和配置 transitioning context 并在动画发生时传递给你的 animation controller。
在呈现动画中包括:
解散过程与此类似。只不过,UIKit 是向 “from” view controller(即将被解散的控制器)索要 transitioning delegate 对象。而 transitioning delegate 对象是通过 animationController(forDismissed:) 方法返回 animation controller。
到了真枪实干的时候了!我们的目的是实现这个动画:
开始来创建 animation controller。
File\New\File…,选择 iOS\Source\Cocoa Touch Class 然后点 Next。文件命名为 FlipPresentAnimationController,继承 NSObject ,余元选 Swift。点 Next,勾上 Group to Animation Controllers。点击 Create。
Animation controllers 必须实现 UIViewControllerAnimatedTransitioning 协议。打开 FlipPresentAnimationController.swift 并适当修改类声明。
class FlipPresentAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
}
Xcode 会报错,说 FlipPresentAnimationController 未实现 UIViewControllerAnimatedTransitioning 协议,点击 Fix to 添加对应的空方法。
我们在动画一开始会用到所点击的卡片的 frame。在类的实现中,添加一个属性来保存这个信息。
private let originFrame: CGRect
init(originFrame: CGRect) {
self.originFrame = originFrame
}
然后,你需要在刚才新增的两个空方法中编写代码。将 transitionDuration(using:) 修改为:
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 2.0
}
正如方法名所暗示的,这个方法用于返回动画时长。将其设置为 2 秒足以让你有足够的时间看到这个动画。
在 animateTransition(using:) 方法中添加:
// 1
guard let fromVC = transitionContext.viewController(forKey: .from),
let toVC = transitionContext.viewController(forKey: .to),
let snapshot = toVC.view.snapshotView(afterScreenUpdates: true)
else {
return
}
// 2
let containerView = transitionContext.containerView
let finalFrame = transitionContext.finalFrame(for: toVC)
// 3
snapshot.frame = originFrame
snapshot.layer.cornerRadius = CardViewController.cardCornerRadius
snapshot.layer.masksToBounds = true
这段代码中做了这些事情:
继续在 animateTransition(using:) 方法中添加:
// 1
containerView.addSubview(toVC.view)
containerView.addSubview(snapshot)
toVC.view.isHidden = true
// 2
AnimationHelper.perspectiveTransform(for: containerView)
snapshot.layer.transform = AnimationHelper.yRotation(.pi / 2)
// 3
let duration = transitionDuration(using: transitionContext)
container view 在刚刚被 UIKit 创建时,它只包含了 from 视图。你必须将动画中涉及到的其它视图添加进去。记住 addSubview(_:) 方法会将新的视图添加到视图树的最上面,因此视图树的顺序就是你添加它们的顺序。
注意:AnimatorHelper 是一个工具类,用于给视图添加透视和旋转变形。你可以看看它的实现。如果你想了解 perspectiveTransform 方法的原理,请在完成教程后为这个方法添加注释。
现在前期工作完成了,来执行动画吧!添加这个方法最后的代码:
// 1
UIView.animateKeyframes(
withDuration: duration,
delay: 0,
options: .calculationModeCubic,
animations: {
// 2
UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 1/3) {
fromVC.view.layer.transform = AnimationHelper.yRotation(-.pi / 2)
}
// 3
UIView.addKeyframe(withRelativeStartTime: 1/3, relativeDuration: 1/3) {
snapshot.layer.transform = AnimationHelper.yRotation(0.0)
}
// 4
UIView.addKeyframe(withRelativeStartTime: 2/3, relativeDuration: 1/3) {
snapshot.frame = finalFrame
snapshot.layer.cornerRadius = 0
}
},
// 5
completion: { _ in
toVC.view.isHidden = false
snapshot.removeFromSuperview()
fromVC.view.layer.transform = CATransform3DIdentity
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
这是详细解释:
你的 animation controller 已经准备好了!
UIKit 需要一个 transitioning delegate 对象为它提供 animation controller。因此,你必须用某个对象来实现 UIViewControllerTransitioningDelegate 协议。在本例中,我们用 CardViewController 来充当这个 transitioning delegate>
打开 CardViewController.swift 在文件最后声明一个扩展。
extension CardViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController)
-> UIViewControllerAnimatedTransitioning? {
return FlipPresentAnimationController(originFrame: cardView.frame)
}
}
我们在这里返回一个自己定义的 animation controller 对象,用当前卡片的 frame 进行初始化。
最后是将 CardViewController 设置为 transitioning delegate。View Controller 有一个 transitioningDelegate 属性,UIKit 会通过它来判断是否要使用自定义的转换动画。
在 prepare(for:sender:) 方法中的对 card 赋值之后添加:
destinationViewController.transitioningDelegate = self
注意,是对被呈现的(presented) view controller 索要 transitioning delegate,而不是对触发呈现动作的(presenting) view controller 进行索要。
Build & run。点击一张卡片,你会看到:
这就是你的第一个自定义转换动画!
好棒!
你完成了一个漂亮的呈现动画,但这只完成了一半的工作。你的解散过程仍然是默认的。让我们来搞定它!
打开 File\New\File…,选择 iOS\Source\Cocoa Touch Class,然后点击 Next。文件名为 FlipDismissAnimationController,让它继承 NSObject 并指定语言为 Swift。点击 Next 并将文件夹指定到 Animation Controllers。点击 Create。
将类定义修改成:
class FlipDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
private let destinationFrame: CGRect
init(destinationFrame: CGRect) {
self.destinationFrame = destinationFrame
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.6
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
}
}
这个 animation controller 的工作是对呈现动画进行逆向操作,这样 UI 上给人的感觉是对称的。要做到这一点,你需要:
在 animateTransition(using:) 方法中添加代码。
// 1
guard let fromVC = transitionContext.viewController(forKey: .from),
let toVC = transitionContext.viewController(forKey: .to),
let snapshot = fromVC.view.snapshotView(afterScreenUpdates: false)
else {
return
}
snapshot.layer.cornerRadius = CardViewController.cardCornerRadius
snapshot.layer.masksToBounds = true
// 2
let containerView = transitionContext.containerView
containerView.insertSubview(toVC.view, at: 0)
containerView.addSubview(snapshot)
fromVC.view.isHidden = true
// 3
AnimationHelper.perspectiveTransform(for: containerView)
toVC.view.layer.transform = AnimationHelper.yRotation(-.pi / 2)
let duration = transitionDuration(using: transitionContext)
看起来眼熟啊。不同之处在于:
然后开始真正的动画。在 animateTransition(using:) 继续编写代码。
UIView.animateKeyframes(
withDuration: duration,
delay: 0,
options: .calculationModeCubic,
animations: {
// 1
UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 1/3) {
snapshot.frame = self.destinationFrame
}
UIView.addKeyframe(withRelativeStartTime: 1/3, relativeDuration: 1/3) {
snapshot.layer.transform = AnimationHelper.yRotation(.pi / 2)
}
UIView.addKeyframe(withRelativeStartTime: 2/3, relativeDuration: 1/3) {
toVC.view.layer.transform = AnimationHelper.yRotation(0.0)
}
},
// 2
completion: { _ in
fromVC.view.isHidden = false
snapshot.removeFromSuperview()
if transitionContext.transitionWasCancelled {
toVC.view.removeFromSuperview()
}
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
这实际上是呈现动画的逆过程。
最后,还要在宠物图片解散时让 transitioning delegate 返回这个 animation controller。
打开 CardViewController.swift 在 UIViewControllerTransitioningDelegate 扩展中添加下列方法。
func animationController(forDismissed dismissed: UIViewController)
-> UIViewControllerAnimatedTransitioning? {
guard let _ = dismissed as? RevealViewController else {
return nil
}
return FlipDismissAnimationController(destinationFrame: cardView.frame)
}
确保被解散的 View controller 我们所期望的类型,然后创建 animation controller,提供一个正确的卡片显示时的 frame。
现在不需要将呈现动画的时长设置得那么慢了。打开 FlipPresentAnimationController.swift 将 duration 从 2.0 修改成 0.6,这样它就和你的解散动画相一致了。
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.6
}
Build & run。测试一下 app,欣赏一下新的转换动画。
你的自定义动画看起来不错。但是,你还可以更进一步,在解散动画中添加与用户交互的能力。iOS 的设置 app 是一个很好的交互式转换动画的例子:
在这一节中,我们的任务是通过从左轻扫的手势返回到卡片背面朝上的状态。转换动画的进度将跟随用户的手指而定。
一个交互式控制器能够响应触摸事件或者程序输入,它能够加快、减慢甚至反向动画过程。为了使用交互式转换动画,transitioning delegate 必须提供一个交互式控制器。这是另外一种实现了 UIViewControllerInteractiveTransitioning 协议的对象。
你已经创建了一个转换动画。交互式控制器会根据手势的响应来管理动画,而不仅仅是播放一个视频。苹果提供了一个预置的 UIPercentDrivenInteractiveTransition 类,它就是一个交互式控制器的具体实现。你可以用这个类来创建自己的交互式转换动画。
点击 File\New\File…,选择 iOS\Source\Cocoa Touch Class,然后点击 Next。命名文件为 SwipeInteractionController,让它继承 UIPercentDrivenInteractiveTransition ,语言选择 Swift。点击 Next,将文件夹指定为 Interaction Controllers。点击 Create。
在类中编写代码:
var interactionInProgress = false
private var shouldCompleteTransition = false
private weak var viewController: UIViewController!
init(viewController: UIViewController) {
super.init()
self.viewController = viewController
prepareGestureRecognizer(in: viewController.view)
}
这些定义非常易懂。
然后是创建手势识别器。
private func prepareGestureRecognizer(in view: UIView) {
let gesture = UIScreenEdgePanGestureRecognizer(target: self,
action: #selector(handleGesture(_:)))
gesture.edges = .left
view.addGestureRecognizer(gesture)
}
这个手势识别器在用户从屏幕左边沿轻扫时触发,将它添加到视图中。
最后是 handleGesture(_:) 方法。在类中添加:
@objc func handleGesture(_ gestureRecognizer: UIScreenEdgePanGestureRecognizer) {
// 1
let translation = gestureRecognizer.translation(in: gestureRecognizer.view!.superview!)
var progress = (translation.x / 200)
progress = CGFloat(fminf(fmaxf(Float(progress), 0.0), 1.0))
switch gestureRecognizer.state {
// 2
case .began:
interactionInProgress = true
viewController.dismiss(animated: true, completion: nil)
// 3
case .changed:
shouldCompleteTransition = progress > 0.5
update(progress)
// 4
case .cancelled:
interactionInProgress = false
cancel()
// 5
case .ended:
interactionInProgress = false
if shouldCompleteTransition {
finish()
} else {
cancel()
}
default:
break
}
}
这是具体解释:
现在,你必须来真正创建你的 SwipeInteractionController。打开 RevealViewController.swift 添加下列属性。
var swipeInteractionController: SwipeInteractionController?
然后,在 viewDidLoad() 方法最后添加:
swipeInteractionController = SwipeInteractionController(viewController: self)
当宠物卡片的照片显示时,会创建一个 interaction controller 并赋给这个属性。
打开 FlipDismissAnimationController.swift 在 destinationFrame 后添加属性。
let interactionController: SwipeInteractionController?
将 init(destinationFrame:) 修改成:
init(destinationFrame: CGRect, interactionController: SwipeInteractionController?) {
self.destinationFrame = destinationFrame
self.interactionController = interactionController
}
这个 animation controller 必须获得一个 interaction controller 的引用,这样它们两才能成为一对好基友。
打开 CardViewController.swift 将animationController(forDismissed:) 修改为:
func animationController(forDismissed dismissed: UIViewController)
-> UIViewControllerAnimatedTransitioning? {
guard let revealVC = dismissed as? RevealViewController else {
return nil
}
return FlipDismissAnimationController(destinationFrame: cardView.frame,
interactionController: revealVC.swipeInteractionController)
}
这里将 FlipDismissAnimationController 的创建改成和新的初始化方法相一致。
最后,UIKit 是通过调用 transitioning delegate 对象的interactionControllerForDismissal(using:) 方法来索要 interaction controller 的。在 UIViewConrollerTransitioningDelegate 扩展的最后添加zhege 方法:
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning)
-> UIViewControllerInteractiveTransitioning? {
guard let animator = animator as? FlipDismissAnimationController,
let interactionController = animator.interactionController,
interactionController.interactionInProgress
else {
return nil
}
return interactionController
}
这首先会检查 animation controller 是否是一个 FlipDismissAnimationController。如果是,获得一个对 interaction controller 的引用,并检查是否处于和用户交互的过程中。如果这些条件任何一个不满足,返回 nil,这样动画将以非交互的方式进行。否则,将 interaction controller 返回给 UIKit,以便它能够执行这种转换。
Build & run。点击一张卡片,然后从屏幕左边沿开始滑动,看看最终效果。
恭喜你!你创建了一个有趣和迷人的交互式转换动画!
你可以从这里下载已经完成的项目。
要学习更多动画,请阅读《iOS Animations by Tutorials》第17张“呈现控制器和方向动画” 。
本教程主要介绍了模式呈现和解散动画。有一点需要注意,自定义 UIViewController 转换动画也能用在 container view controller 上:
希望你喜欢本教程。如果有任何问题和建议,请在论坛中留言。