本教程使用: xcode 7和swift 2
翻译自: http://www.raywenderlich.com/110536/custom-uiviewcontroller-transitions
Push、pop、cover vertically... 我们可以从iOS内置的转场动画中得到这些很棒的效果,但是自己尝试自己的动画也是个很棒的体验!自定义转场动画可以极大地增加用户体验,并且让你的app和其他的看起来不一样。如果你之前都没有自定义过转场动画,通过阅读本教程你会发现,这比你想象的要简单很多!
在本教程中,我们会在一个demo中增加自定义转场动画,当我们完成此教程时,你应该学会了以下技能:
- 了解转场API的结构
- 如何利用转场动画来自定义present和dismiss动画
- 开发出互动的转场动画
开始
下载起始项目。编译运行程序,我们会看到:
这个demo在page view controller中展示了几张卡片,每张卡片都是每种宠物的描述,点击卡片就会弹出该宠物的照片。
大部分的跳转逻辑都已经写好了,但是这个demo看起来还是有点平淡无奇,我们这就来用自定义的转场动画来增加效果。
浏览转场API
相比于具体的对象,转场API运用了大量的协议,在这个模块的最后,你会了解到每个协议的作用以及它们之间的联系。这个图表显示了API中主要的部分:
组成部分
尽管上面图表看起来比较复杂,但当你了解到各部分是如何工作之后,它看起来就直观很多了。
Transitioning Delegate
每个view controller都可以有一个transitioningDelegate代理,它实现了UIViewControllerTransitioningDelegate的协议。
不管你是present还是dismiss一个view controller,UIKit总会向transitioning delegate询问是否有代理可用。设置view controller的transitioningDelegate为我们自定义的类来作出自定义的转场动画。
Animation Controller
自定义的类实现UIViewControllerAnimatedTransitioning协议,来完成具体的转场动画。
Transitioning Context
这个context对象实现了UIViewControllerContextTransitioning协议,在转场过程中扮演重要的角色,它包含了view controllers的重要信息。
我们实际上不用去在我们自己的代码来实现它,当转场发生时,UIKit会提供给我们这样一个context的对象的。
转场过程
下面是一个present动画的步骤:
1.不管是用代码还是通过segue,我们需要触发转场。
2.UIKit向"to" view controller(将被显示的view controller)的transitioning delegate询问,如果没有代理,就会用内置的默认转场动画。
3.UIKit之后向transigioning delegate通过代理方法animationControllerForPresentedController(_:presentingController:sourceController:)询问一个animation controller,如果返回nil,仍然会用默认的动画。
4.一旦有一个有效的animation controller后,UIKit就会构建transitioning context。
5.UIKit之后向animation controller通过transitionDuration(_:)询问动画持续时长。
6.UIKit调用animateTransition(_:)来实现animation controller的转场动画。
7.最后,animation controller调用context的completeTransition(_:)方法来表示这次动画已经结束。
创建一个自定义的presentation动画
是时候来实践了!
你的目的是实现下面的动画:
- 当用户点击卡片,它会翻转到第二个view,这个view的尺寸和卡片尺寸一样。
- 随着翻转,第二个view放大到全屏。
创建Animator
我们由创建animation controller开始。
首先创建一个继承自NSObject
的子类,并且语言为Swift,命名为FlipPresentAnimationController.swift,接着更新其声明:
import UIKit
class FlipPresentAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
}
注意我们可能因为没有实现代理方法而受到编译错误的提示,我们现在来fix。
在当用户点击图片时,我们会用到动画起始时的frame,在类的body中加入如下变量:
var originFrame = CGRect.zero
为了满足UIViewControllerAnimatedTransitioning的要求,我们还需要加入两个method:
func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimerInterval {
return 2.0
}
根据名字也知道,这个方法指定了转场的持续时间,设置成2秒能让我们在开发过程中更容易观察动画。
现在加入另一个方法:
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
}
这个协议方法就是用来实现转场动画本身的,从这开始我们加入下面的代码:
// 1
guard let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey),
let containerView = transitionContext.containerView(),
let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) else {
return
}
// 2
let initialFrame = originFrame
let finalFrame = transitionContext.finalFrameForViewController(toVC)
// 3
let snapshot = toVC.view.snapshotViewAfterScreenUpdates(true)
snapshot.frame = initailFrame
snapshot.layer.cornerRadius = 25
snapshot.layer.masksToBounds = true
上面代码做了这些:
- transitioning context提供了参与转场动画的view controllers以及views,我们可以通过合适的key来获取它们。
- 接着我们指定"to" view的起始和最终的frame,在这个例子中,转场从卡片的frame开始,然后放大到全屏。
- UIView的snapshotting捕获了"to" view的截屏,这样我们利用截屏来做动画,截屏也是从卡片的frame开始,圆角也设成和卡片一样的。
继续加入如下代码:
containerView.addSubview(toVC.view)
containerView.addSubview(snapshot)
toVC.view.hidden = true
AnimationHelper.perspectiveTransformForContainerView(containerView)
snapshot.layer.transform = AnimationHelper.yRotation(M_PI_2)
一个新的成员出现了:container view。把它想象成一个舞台而转场在上面跳舞。container view已经包含了"from" view,但是我们需要负责向其中加入"to" view。
我们并且加入了snapshot view并且隐藏了真的"to" view,完整的动画会旋转snapshot然后把它隐藏。
注意:不用太在意
AnimationHelper
,这只是个小的工具类,负责给view增加perspective和rotation的transforms的。
是时候来具体实现我们的动画了,继续增加这些代码:
// 1
let duration = transitionDuration(transitionContext)
UIView.animateKeyframesWithDuration(
duration,
delay: 0,
options: .CalculationModeCubic,
animations: {
// 2
UIView.addKeyframeWithRelativeStartTime(0.0, relativeDuration: 1/3, animations: {
fromVC.view.layer.transform = AnimationHelper.yRotation(-M_PI_2)
})
// 3
UIView.addKeyframeWithRelativeStartTime(1/3, relativeDuration: 1/3, animations: {
snapshot.layer.transform = AnimationHelper.yRotation(0.0)
})
// 4
UIView.addKeyframeWithRelativeStartTime(2/3, relativeDuration: 1/3, animations: {
snapshot.frame = finalFrame })
},
completion: { _ in
// 5
toVC.view.hidden = false
fromVC.view.layer.transform = AnimationHelper.yRotation(0.0)
snapshot.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled())
})
我们用注释来把代码分成各区域:
- 首先我们指定了动画的持续时间,注意使用
transitionDuration(_:)
方法,我们需要让我们的动画和这个持续时间保持一致,这样UIKit才能同步。 - 我们根据y轴旋转"from" view 90度来隐藏from view。
- 接着我们用同样的方法来恢复snapshot的transform。
- 设置shapshot的frame来充满屏幕。
- 最后,我们remove掉snapshot,显示"to" view,还原"from" view,否则,它会一直隐藏着。 调用
completeTransition
来通知transition context动画已经结束,UIKit会保证最后remove掉"from" view。
现在我们可以使用我们写的animtion controller了!
使用animator
打开CardViewController.swift并且加入如下声明:
private let filpPresentAnimationController = FlipPresentAnimationController()
UIKit希望用代理来返回animation controller,所以我们必须提供一个实现了UIViewControllerTransitioningDelegate
的对象。
在这个demo中,CardViewController
会扮演这个transitioning delegate,加入如下的extension来让该类实现UIViewControllerTransitioningDelegate
:
extension CardViewController: UIViewControllerTransitioningDelegate {
}
接下来,在上述extension中加入:
func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
flipPresentAnimationController.originFrame = cardView.frame
return flipPresentAnimationController
}
这里我们返回了自定义的animation controller,并且这里我们设置了起始frame来保证动画位置的正确。
最后一步就是把CardViewController
当作transitioning delegate,每个view controller都有transitioningDelegate
属性,UIKit会根据这个属性来判断是否执行自定义的转场。
在prepareForSeque(_:sender:)
中,在card assignment后面加入:
destinationViewController.transitioningDelegate = self;
值得注意的是,是被presented的view controller设置代理,而不是主动去present的view controller来设置代理!
编译运行:
我们已经有了我们第一个自定义的转场动画,但是presenting只是一半,我们还需要dismiss的动画。
创建 Dismissing 动画
同样的,新建一个FlipDismissAnimationController
,替换类的内容如下:
import UIKit
class FlipDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
var destinationFrame = CGRectZero
func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimeInterval {
return 0.6
}
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
}
}
这个类的目的是反向实现presenting的动画:
- 缩小显示的view到卡片的大小,
destinationFrame
就是卡片的frame。 - 翻转view,并且恢复卡片内容
加入如下代码到animateTransition(_:)
:
guard let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey),
let containerView = transitionContext.containerView(),
let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) else {
return
}
// 1
let initialFrame = transitionContext.initialFrameForViewController(fromVC)
let finalFrame = destinationFrame
// 2
let snapshot = fromVC.view.snapshotViewAfterScreenUpdates(false)
snapshot.layer.cornerRadius = 25
snapshot.layer.masksToBounds = true
// 3
containerView.addSubview(toVC.view)
containerView.addSubview(snapshot)
fromVC.view.hidden = true
AnimationHelper.perspectiveTransformForContainerView(containerView)
//4
toVC.view.layer.transform = AnimationHelper.yRotation(-M_PI_2)
来解释一下:
- 因为动画会缩小view,我们需要设置好起始和最终的frame
- 这次我们截屏"from" view。
- 和之前一样,加入"to" view和snapshot到container view中,隐藏"from" view,这样不会和snapshot冲突。
- 最后通过旋转技巧隐藏"to" view
剩下的就是加入动画本身了。
直接在animateTransition(_:)
直接加入如下代码:
let duration = transitionDuration(transitionContext)
UIView.animateKeyframesWithDuration(
duration,
delay: 0,
options: .CalculationModeCubic,
animations: {
// 1
UIView.addKeyframeWithRelativeStartTime(0.0, relativeDuration: 1/3, animations: {
snapshot.frame = finalFrame
})
UIView.addKeyframeWithRelativeStartTime(1/3, relativeDuration: 1/3, animations: {
snapshot.layer.transform = AnimationHelper.yRotation(M_PI_2)
})
UIView.addKeyframeWithRelativeStartTime(2/3, relativeDuration: 1/3, animations: {
toVC.view.layer.transform = AnimationHelper.yRotation(0.0)
})
}, completion: { _ in
// 2
fromVC.view.hidden = false
snapshot.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled())
})
只就是在反向执行present的动画而已:
- 首先缩小view,用旋转90度来隐藏snapshot,接着也用旋转�恢复"to" view。
- 最后,remove掉snapshot和通知context转场已经完成,这会让UIKit更新view controller状态。
打开CardViewController.swift,声明如下属性:
private let flipDismissAnimationController = FlipDismissAnimationController()
接着加入如下extension:
func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
flipDismissAnimationController.destinationFrame = cardView.frame
return flipDismissAnimationController
}
这样传递了正确的frame给dismiss animation controller。
最后一步,修改FlipPresentAnimationController
的transitionDuration
,让动画更快一点:
func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimeInterval {
return 0.6
}
这时候编译运行:
我们的自定义转场动画看起来很酷,但是我们还能通过加入交互特性来优化它。
让转场具有交互性
iOS内置的设置应用,很好展示了交互式的转场动画:
这次我们的任务是通过慢慢在屏幕最左边滑动来返回界面,并且动画过程跟随着我们手指的滑动过程。
交互式转场如何工作的
一个交互式的controller能响应包括了加速、减速或者反转转场过程。为了能开启交互式转场功能,transitioning delegate必须能提供一个interaction controller。这个controller可以是任何对象,只要它实现了UIViewControllerInteractiveTransitioning
的协议。我们已经实现了转场的动画,而interaction controller就负责把这个动画和我们的手势连在一起,而不是简单地像放电影一样的闪过。
Apple已经提供了一个UIPercentDrivenInteractiveTransition
的类,它是一个具体的interaction controller的实现,我们会用这个类来实现我们的交互式转场。
创建一个交互式转场
我们第一个工作是要创建一个interaction controller,创建一个SwipeInteractionController,继承自UIPercentDrivenInteractiveTransition
,选中语言为Swift,打开SwipeInteractionController.swift,加入如下属性定义:
var interactionInProgress = falseprivate
var shouldCompleteTransition = false
private weak var viewController: UIViewController!
上述代码很直观:
- ```interactionInProgress``表示一个交互转场是否正在进行中。
- 我们用
shouldCompleteTransition
来控制转场,后面会看到。 - 这个interaction controller会直接present和dismiss view controllers,所以我们需要持有viewController。
然后加入到class的body中:
func wireToViewController(viewController: UIViewController!) {
self.viewController = viewController
prepareGestureRecognizerInView(viewController.view)
}
实现prepareGestureRecognizerInView(_:)
:
private func prepareGestureRecognizerInView(view: UIView) {
let gesture = UIScreenEdgePanGestureRecognizer(target: self, action: "handleGesture:")
gesture.edges = UIRectEdge.Left
view.addGestureRecognizer(gesture)
}
这里我们声明了一个gesture recognizer,它会触发屏幕左边缘的手势。
最后是加入handleGesture(_:)
:
func handleGesture(gestureRecognizer: UIScreenEdgePanGestureRecognizer) {
// 1
let translation = gestureRecognizer.translationInView(gestureRecognizer.view!.superview!)
var progress = (translation.x / 200) progress = CGFloat(fminf(fmaxf(Float(progress), 0.0), 1.0))
switch gestureRecognizer.state {
case .Began:
// 2
interactionInProgress = true
viewController.dismissViewControllerAnimated(true, completion: nil)
case .Changed:
// 3
shouldCompleteTransition = progress > 0.5
updateInteractiveTransition(progress)
case .Cancelled:
// 4
interactionInProgress = false
cancelInteractiveTransition()
case .Ended:
// 5
interactionInProgress = false
if !shouldCompleteTransition {
cancelInteractiveTransition()
} else {
finishInteractiveTransition()
}
default:
println("Unsupported") }
}
我们来解释下:
- 定义了一些局部的变量来控制整个过程,我们记录view的位移来计算progress,200的位移再松手就视为继续完成转场。
- 手势开始时,我们把
interactionInProgress
设为true
,并且开始执行dismiss。 - 当手势进行中,我们不断地用
progress
更新updateInteractiveTransition
。 - 当手势被中断时,我们把
interactionInProgress
设回false
,并且中断转场。 - 当手势结束时,根据之前逻辑判断的
shouldCompleteTransition
来完成或者中断转场。
打开CardViewController.swift,声明如下属性:
private let swipeInteractionController = SwipeInteractionController()
加入如下的extension:
func interactionControllerForDismissal(animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return swipeInteractionController.interactionInProgress ? swipeInteractionController : nil
}
这个实现先检查是否有手势,如果有的话则返回interaction controller,否则返回nil。
现在在prepareForSegue(_:sender:)
,在transitioningDelegate
赋值代码后加入:
swipeInteractionController.wireToViewController(destinationViewController)
这样就绑定了interaction controller和view controller。
编译运行:
译者注:完整的demo可以从https://github.com/Mercy-Li/GuessThePet 获取。