这篇笔记翻译自raywenderlick网站的过渡动画的一篇文章,原文用的swift,由于考虑到swift版本变动以及一些语法兼容问题,这里我还是用Objective-C进行了改写,没有逐字翻译,加了部分自己的理解。原文链接Creating Custom UIViewController Transitions。过渡动画有些地方也是翻译成转场动画,即从一个视图控制器切到另一个视图控制器,本文以过渡来译。
1 前言
iOS自身就提供了很多针对UIViewController的过渡动画,比如Cover Vertically(从下往上弹出效果)
,Cross Dissolve(淡入淡出效果)
,Partial Curl(书卷翻页效果)
等。如图1就是本文用到的示例中的iOS原生的Cover Vertically
效果的展示。
为了自己的APP更有个性,自带的效果往往不够酷炫,所以需要自定义过渡动画,通过这篇文章,我们会GET到下面几个技能:
- 过渡动画API的构建。
- 使用自定义的过渡动画来present和dismiss一个视图控制器。present过渡会在应用视图层级结构中添加一个新的视图控制器,而dismiss过渡会从层级结构中删除一个或多个视图控制器。
- 学会使用交互式过渡动画。
在我们开始的示例代码中,还没有加入自定义过渡动画,已经有的内容是一个PageViewController,里面装载的为CardViewController(内容为一个UIView+一个Label用于展示图片描述),点击CardViewController里面的卡片,会切换到RevealViewController(包含一个Label展示图片名字,一个Image View展示宠物图片,一个按钮用于返回到卡片视图)。而我们最终要达到的效果如图2所示:
2 过渡动画API探究
过渡动画API涉及到的一些角色如图3所示,下面分开介绍:
2.1 过渡动画API中的角色
本节内容对过渡动画API中的各个角色进行说明,包含的角色参照图3。
2.1.1 过渡动画代理(Transitioning Delegate)
每个View Controller都有一个transitionDelegate属性,这个代理实现了UIViewControllerTransitioningDelegate协议。
每当你要present或者dismiss一个View Controller的时候,UIKit会去过渡动画代理中查询需要使用的动画效果。实际项目中,我们可以设置代理为自定义的类来返回我们需要的自定义的动画效果。
2.1.2 动画控制器(Animation Controller)
动画控制器是实现了UIViewControllerAnimatedTransitioning协议的用于执行过渡动画的对象。
2.1.3 过渡动画上下文对象(Transitioning Context)
上下文对象实现了UIViewControllerContextTransitioning协议,在动画过程中是至关重要的,它封装了所有的参与过渡动画的View Controllers的信息。不过我们不用写代码实现它,在动画控制器里面,过渡动画执行的时候,我们的函数会接收到一个上下文对象作为参数并从中获取相关View Controller的信息。
2.2 过渡动画流程
- 你触发一个过渡动作。可以通过编码或者segue来触发。
- UIKit询问要过渡到的目的视图控制器它是否有自定义的过渡动画代理。如果没有,则UIKit将使用iOS自带的过渡动画。
- 然后,UIKit通过过渡动画代理,获取到动画控制器。比如通过
animationControllerForPresentedController(_:presentingController:sourceController:)
方法获取到动画控制器,如果返回空,则使用默认的动画控制器。
- 然后,UIKit通过过渡动画代理,获取到动画控制器。比如通过
- 一旦找到了动画控制器,UIKit构建上下文对象。
- 接着,UIKit通过动画控制器的
transitionDuration(_:)
方法获取动画执行时长。
- 接着,UIKit通过动画控制器的
- 再接着调用动画控制器的
animateTransition(_:)
完成过渡动画。
- 再接着调用动画控制器的
- 最后动画控制器调用上下文对象的
completeTransition(_:)
方法指示动画完成。图4是官方文档的一个过渡动画的API角色示意图。
- 最后动画控制器调用上下文对象的
2.3 实现Presentation过渡动画
我们总共要实现三个动画效果,一个是Presentation过渡动画,一个是dismiss过渡动画,另外还有一个交互动画。
Presentation的效果主要如下:
- 点击卡片的时候,卡片翻转显示第二个视图,且第二个视图初始大小跟卡片大小一样。
- 第二个视图放大至整个屏幕大小。
2.3.1 创建Presentation动画控制器
我们创建一个名为FlipPresentAnimationController的类来完成Presentation动画效果,这个类在我们上面说的角色中就是动画控制器。
核心代码如下,代码中有注解:
/*设置动画时长函数*/
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext {
return 2.0;
}
/*执行动画的函数*/
- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext {
//1 上下文对象transitionContext包含了参与过渡动画的视图
// 和视图控制器信息,可以通过对应的参数获取。
CardViewController *fromVC = (CardViewController *)[transitionContext viewControllerForKey:UITransitionContextFromViewKey];
UIView *containerView = [transitionContext containerView];
RevealViewController *toVC = (RevealViewController *)[transitionContext viewControllerForKey:UITransitionContextToViewKey];
//2 设置过渡目的视图的初始大小和结束大小。
// 初始大小为第一个视图的卡片的大小,结束大小为整个屏幕大小。
BOOL hasViewForKey = [transitionContext respondsToSelector:@selector(viewForKey:)];
UIView *fromView = hasViewForKey ? [transitionContext viewForKey:UITransitionContextFromViewKey] : fromVC.view;
UIView *toView = hasViewForKey ? [transitionContext viewForKey:UITransitionContextToViewKey] : toVC.view;
CGRect initialFrame = self.originFrame;
CGRect finalFrame = hasViewForKey? toView.frame : [transitionContext finalFrameForViewController:toVC];
//3 获取一个目的视图的一个快照。设置初始frame为initFrame。
UIView *snapshot = [toView snapshotViewAfterScreenUpdates:YES];
snapshot.frame = initialFrame;
snapshot.layer.cornerRadius = 25;
snapshot.layer.masksToBounds = YES;
//4 containerView加入目的视图和快照视图,并先隐藏目的视图。
// 我们的动画都在containerView来实现。
[containerView addSubview:toView];
[containerView addSubview:snapshot];
toView.hidden = YES;
//5 设置动画视角,将快照视图先沿Y轴旋转到PI/2的位置。
[AnimationHelper persipectiveTransformForContainerView:containerView];
snapshot.layer.transform = [AnimationHelper yRotation:M_PI_2];
CGFloat duration = [self transitionDuration:transitionContext];
[UIView animateKeyframesWithDuration:duration delay:0 options:UIViewKeyframeAnimationOptionCalculationModeCubic animations:^{
[UIView addKeyframeWithRelativeStartTime:0.0 relativeDuration:1.0/3 animations:^{
//6 将第一个视图旋转到-PI/2的位置,方向是顺时针
fromView.layer.transform = [AnimationHelper yRotation:-M_PI_2];
}];
[UIView addKeyframeWithRelativeStartTime:1.0/3 relativeDuration:1.0/3 animations:^{
//7 将快照视图从PI/2的位置旋转到轴线位置,也是顺时针。正好接上6的旋转效果。
snapshot.layer.transform = [AnimationHelper yRotation:0.0];
}];
[UIView addKeyframeWithRelativeStartTime:2.0/3 relativeDuration:1.0/3 animations:^{
//8 将快照视图的frame放大至整个屏幕。
snapshot.frame = finalFrame;
}];
} completion:^(BOOL finished){
toView.hidden = NO; //显示目的视图
fromView.layer.transform = [AnimationHelper yRotation:0.0]; //恢复第一个视图的位置
[snapshot removeFromSuperview]; //移除快照视图
[transitionContext completeTransition:![transitionContext transitionWasCancelled]]; //通知UIKit动画执行完成
}
];
}
额外说明几点:
- 注释2这段代码跟原文的swift的有点不一样,直接通过
transitionContext viewControllerForKey:UITransitionContextToViewKey
等函数取到的View Controller发现是nil,这样就没法取到动画过程中的视图信息。而通过transitionContext viewForKey:UITransitionContextToViewKey
取到的视图是正常的,看网上资料说可能是ios8的BUG,没有确切资料可以确认,如果是其他设置问题,麻烦大虾们告知一下。
- 注释2这段代码跟原文的swift的有点不一样,直接通过
- 关于旋转方向的问题,通过上一篇笔记我们总结了三维视图中沿Y轴旋转的正反方向,正方向为逆时针。因此注释5中我们的快照视图显示逆时针的转到了PI/2的位置,而注释6会先将第一个视图转到-PI/2的位置,动画中的旋转方向是以距离最近来旋转,因此第一个视图会顺时针旋转PI/2,然后快照视图也是顺时针旋转PI/2,最后再试快照视图放大到整个屏幕。
- 最后的
completeTransition
方法调用是必须的,如果不调用的话,动画结束后目的视图将无法接受事件响应。
- 最后的
2.3.2 连接动画控制器
在我们的CardViewController中加入动画控制器初始化代码。这里的CardViewController实现了UIViewControllerTransitioningDelegate协议,我们要设置目的控制器的transitionDelegate为CardViewController。并实现代理的方法返回我们刚刚创建的动画控制器。代码如下:
- (id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source {
self.flipPresentAnimationController.originFrame = self.cardView.frame;
return self.flipPresentAnimationController;
}
// 在CardViewController的prepareSegue方法中,设置了transitionDelegate。
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
......
revealViewController.transitioningDelegate = self;
}
2.4 实现dismiss过渡动画
dismiss的过渡动画原理类似,不过多介绍了,实现功能是:
- 第二个视图的图片先缩小到第一个视图的卡片大小。
- 两个视图先后翻转,最终回到初始位置。
代码如下:
- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext {
CardViewController *fromVC = (CardViewController *)[transitionContext viewControllerForKey:UITransitionContextFromViewKey];
UIView *containerView = [transitionContext containerView];
RevealViewController *toVC = (RevealViewController *)[transitionContext viewControllerForKey:UITransitionContextToViewKey];
BOOL hasViewForKey = [transitionContext respondsToSelector:@selector(viewForKey:)];
UIView *fromView = hasViewForKey ? [transitionContext viewForKey:UITransitionContextFromViewKey] : fromVC.view;
UIView *toView = hasViewForKey ? [transitionContext viewForKey:UITransitionContextToViewKey] : toVC.view;
CGRect initialFrame = fromView.frame;
CGRect finalFrame = self.destinationFrame;
UIView *snapshot = [fromView snapshotViewAfterScreenUpdates:YES];
snapshot.frame = initialFrame;
snapshot.layer.cornerRadius = 25;
snapshot.layer.masksToBounds = YES;
[containerView addSubview:toView];
[containerView addSubview:snapshot];
fromView.hidden = YES;
[AnimationHelper persipectiveTransformForContainerView:containerView];
toView.layer.transform = [AnimationHelper yRotation:-M_PI_2];
CGFloat duration = [self transitionDuration:transitionContext];
[UIView animateKeyframesWithDuration:duration delay:0 options:UIViewKeyframeAnimationOptionCalculationModeCubic animations:^{
[UIView addKeyframeWithRelativeStartTime:0.0 relativeDuration:1.0/3.0 animations:^{
snapshot.frame = finalFrame;
}];
[UIView addKeyframeWithRelativeStartTime:1.0/3.0 relativeDuration:1.0/3.0 animations:^{
snapshot.layer.transform = [AnimationHelper yRotation:M_PI_2];
}];
[UIView addKeyframeWithRelativeStartTime:2.0/3.0 relativeDuration:1.0/3.0 animations:^{
toView.layer.transform = [AnimationHelper yRotation:0.0];
}];
} completion:^(BOOL finished){
fromView.hidden = NO;
[snapshot removeFromSuperview];
[transitionContext completeTransition:![transitionContext transitionWasCancelled]];
}
];
}
当然,也少不了要在代理类中关联好dismiss的动画控制器。
2.5 实现交互动画
2.5.1 交互动画示例
iPhone上面的设置APP就是交互动画的一个很典型的例子,如图5所示,从左边缘开始滑动,过渡动画的进度是跟随你的手指滑动的位置来确定的(比如坐标X超过了多少则表示切换到下一个视图,否则切回上一个视图。
2.5.2 交互动画原理
交互动画通过交互控制器来控制,为了实现交互动画,过渡动画代理需要额外提供一个交互控制器。交互控制器只要实现了UIViewControllerInteractiveTransitioning协议即可,它响应触控事件,通过交互控制器,动画会随着手势拖动逐渐展开而不是像之前那样直接执行完毕。
iOS提供了一个UIPercentDrivenInteractiveTransition类,它实现了UIViewControllerInteractiveTransitioning协议,我们在例子中要用到这个类。
2.5.3 创建交互过渡动画
创建交互动画代码如下,我们需要添加拖动事件响应,在处理事件响应的函数handleGesture中,我们根据当前手势状态和所在的位置来进行处理。注意到gestureRecognizer.view
是对应的目的视图也就是RevealViewController对应的View。而它的superview则是UITransitionView这个视图。
- (void)wireToViewController:(UIViewController *)viewController {
self.viewController = viewController;
[self prepareGestureRecognizerInView:viewController.view];
}
- (void)prepareGestureRecognizerInView:(UIView *)view {
UIScreenEdgePanGestureRecognizer *gesture = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action: @selector(handleGesture:)];
gesture.edges = UIRectEdgeLeft;
[view addGestureRecognizer:gesture];
}
- (void)handleGesture:(UIScreenEdgePanGestureRecognizer *)gestureRecognizer {
//1 获取手势当前的坐标点
CGPoint translation = [gestureRecognizer translationInView:gestureRecognizer.view.superview];
CGFloat progress = (translation.x / 200);
progress = fminf(fmaxf(progress, 0.0), 1.0);
switch (gestureRecognizer.state) {
//2 开始手势,设置开始交互的标识,开始触发dismissal操作。
case UIGestureRecognizerStateBegan:
self.interactionInProgress = YES;
[self.viewController dismissViewControllerAnimated:YES completion:nil];
Break;
//3 手势拖动,判断当前的手势横轴坐标是否大于100,大于100则设置过渡动画完成。
case UIGestureRecognizerStateChanged:
self.shouldCompleteTransition = progress > 0.5;
[self updateInteractiveTransition:progress];
Break;
//4 手势取消,设置交互状态为NO,并取消交互动画。
case UIGestureRecognizerStateCancelled:
self.interactionInProgress = NO;
[self cancelInteractiveTransition];
Break;
//5 手势结束,根据进度来判断是取消还是完成交互动画。
case UIGestureRecognizerStateEnded:
self.interactionInProgress = NO;
if (!self.shouldCompleteTransition) {
[self cancelInteractiveTransition];
} else {
[self finishInteractiveTransition];
}
default:
NSLog(@"Unsupported");
break;
}
在CardViewController中需要加入对应代码才能呈现交互动画,加入代码如下:
- (id<UIViewControllerInteractiveTransitioning>)interactionControllerForDismissal:(id<UIViewControllerAnimatedTransitioning>)animator {
return self.swipeInteractionControllers.interactionInProgress ? self.swipeInteractionControllers : nil;
}
/* 在CardViewController的prepareSegue方法中,
设置了transitionDelegate,加入交互动画事件捕获。*/
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
......
revealViewController.transitioningDelegate = self;
[self.swipeInteractionControllers wireToViewController:revealViewController];
}
至此整个动画效果完成,完整代码参见