iOS视图控制器转场动画
屏幕左边缘右滑返回,TabBar 滑动切换,你是否喜欢并十分依赖这两个操作,甚至觉得 App 不支持这类操作的话简直反人类?这两个操作在大屏时代极大提升了操作效率,其背后的技术便是今天的主题:视图控制器转换(View Controller Transition)。
前言
通过学习seedante的iOS 视图控制器转场详解:从入门到精通的这篇文章,对视图转场有了新的认识,写这篇文章的目的,主要是记录一下自己对视图转场动画的理解并做一个总结方便以后查阅。
目前为止,官方支持以下几种方式的自定义转场:
- 在
UINavigationController
中 push 和 pop - 在
UITabBarController
中切换 Tab - Modal 转场:presentation 和 dismissal,俗称视图控制器的模态显示和消失,仅限于modalPresentationStyle属性为 UIModalPresentationFullScreen 或
UIModalPresentationCustom
这两种模式 -
UICollectionViewController
的布局转场:仅限于 UICollectionViewController 与 UINavigationController 结合的转场方式
转场协议
转场动画的本质: 下一场景(子 VC)的视图替换当前场景(子 VC)的视图以及相应的控制器(子 VC)的替换,表现为当前视图消失和下一视图出现。
iOS 7 以协议的方式开放了自定义转场的 API,协议的好处是不再拘泥于具体的某个类,只要是遵守该协议的对象都能参与转场,非常灵活。主要有一下几个协议:
- 转场代理(Transition Delegate)
- 动画控制器(Animation Controller)
- 交互控制器(Interaction Controller)
- 转场环境(Transition Context)
- 转场协调器(Transition Coordinator)
对于非交互式动画我们只需要实现转场代理
和动画控制器
协议即可,对于交互式动画我们还需要实现交互控制器
协议。👇下面对每个协议进行详细介绍。
1. 转场代理
UINavigationControllerDelegate
/*返回已经实现的`动画控制器`,如果返回nil则使用系统默认的动画效果*/
- (nullable id <UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController animationControllerForOperation:(UINavigationControllerOperation)operation fromViewController:(UIViewController *)fromVC toViewController:(UIViewController *)toVC NS_AVAILABLE_IOS(7_0);
/*返回已经实现的`交互控制器`,如果返回nil则不支持手势交互*/
- (nullable id <UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController:(id <UIViewControllerAnimatedTransitioning>) animationController NS_AVAILABLE_IOS(7_0);
UITabBarControllerDelegate
同样作为容器控制器,UITabBarController 的转场代理和 UINavigationController 类似,通过类似的方法提供动画控制器,不过UINavigationControllerDelegate
的代理方法里提供了操作类型,但UITabBarControllerDelegate
的代理方法没有提供滑动的方向信息,需要我们来获取滑动的方向。
/*同理返回已经实现的`动画控制器`,返回nil是默认效果*/
- (nullable id <UIViewControllerAnimatedTransitioning>)tabBarController:(UITabBarController *)tabBarController animationControllerForTransitionFromViewController:(UIViewController *)fromVC toViewController:(UIViewController *)toVC;
/*返回已经实现的`交互控制器`,返回nil则不支持用户交互*/
- (nullable id <UIViewControllerInteractiveTransitioning>)tabBarController:(UITabBarController *)tabBarController interactionControllerForAnimationController: (id <UIViewControllerAnimatedTransitioning>)animationController NS_AVAILABLE_IOS(7_0);
UIViewControllerTransitioningDelegate
Modal 转场的代理协议UIViewControllerTransitioningDelegate
是 iOS 7 新增的,其为 presentation 和 dismissal 转场分别提供了动画控制器。
UIPresentationController
只在 iOS 8中可用,通过available关键字可以解决 API 的版本差异。
/*present时调用,返回已经实现的`动画控制器`*/
- (nullable id <UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source;
/*dissmis时调用,返回已经实现的`动画控制器`*/
- (nullable id <UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed;
/*交互动画present时调用,返回已经实现的`交互控制器`*/
- (nullable id <UIViewControllerInteractiveTransitioning>)interactionControllerForPresentation:(id <UIViewControllerAnimatedTransitioning>)animator;
/*交互动画dissmiss时调用,返回已经实现的`交互控制器`*/
- (nullable id <UIViewControllerInteractiveTransitioning>)interactionControllerForDismissal:(id <UIViewControllerAnimatedTransitioning>)animator;
/*ios8新增的协议*/
- (nullable UIPresentationController *)presentationControllerForPresentedViewController:(UIViewController *)presented presentingViewController:(nullable UIViewController *)presenting sourceViewController:(UIViewController *)source NS_AVAILABLE_IOS(8_0);
Modal 转场的代理由 presentedVC 的transitioningDelegate
属性来提供,这与前两种容器控制器的转场不一样,另外,需要将 presentedVC 的modalPresentationStyle
属性设置为.Custom或.FullScreen,只有这两种模式下才支持自定义转场,该属性默认值为.FullScreen。当与 UIPresentationController
配合时该属性必须为.Custom。
2. 动画控制器
动画控制器负责添加视图以及执行动画,遵守UIViewControllerAnimatedTransitioning
协议,该协议要求实现以下方法:
/*返回动画执行时间,一般0.5s就足够了*/
- (NSTimeInterval)transitionDuration:(nullable id <UIViewControllerContextTransitioning>)transitionContext;
/*核心方法,做一些动画相关的操作*/
- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext;
UIKit 在转场开始前生成遵守转场环境协议<UIViewControllerContextTransitioning>
的对象 transitionContext,它有以下几个方法来提供动画控制器需要的信息:
/*获取容器视图,转场发生的地方*/
UIView *containerView = [transitionContext containerView];
/*获取参与转场的视图控制器*/
UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
/*获取参与参与转场的视图View*/
UIView *fromView;
UIView *toView;
if ([transitionContext respondsToSelector:@selector(viewForKey:)]) {
//iOS8新增的方法
fromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
toView = [transitionContext viewForKey:UITransitionContextToViewKey];
}else{
//iOS8之前的方法
fromView = fromVC.view;
toView = toVC.view;
}
通过viewForKey
:获取的视图是viewControllerForKey
:返回的控制器的根视图,或者 nil。viewForKey
:方法返回 nil 只有一种情况: UIModalPresentationCustom
模式下的 Modal 转场 ,通过此方法获取 presentingView
时得到的将是 nil,因此在 Modal 转场中,较稳妥的方法是从 fromVC 和 toVC 中获取 fromView
和 toView
。
需要注意的地方:
- 将 toView 添加到容器视图中,使得 toView 在屏幕上显示(Modal 转场中此点稍有不同)不必非得是
addSubview:
,某些场合你可能需要调整 fromView 和 toView 的显示顺序,总之将之加入到containerView
里就行了。 - 动画结束后正确地结束转场过程。转场的结果有两种:完成或取消。非交互转场的结果只有完成一种情况,不过交互式转场需要考虑取消的情况。如何结束取决于转场的进度,通过
transitionWasCancelled()
方法来获取转场的结果,然后使用completeTransition:
来通知系统转场过程结束。 - 转场结束后,
fromView
会从视图结构中移除,UIKit 自动替我们做了这事,你也可以手动处理提前将 fromView 移除,这完全取决于你。 - Model中,在 Custom 模式下的
dismissal
转场中不要像其他的转场那样将 toView(presentingView) 加入containerView
,否则presentingView
将消失不见,而应用则也很可能假死。而 FullScreen 模式下可以使用与前面的容器类 VC 转场同样的代码,(Modal 转场在 Custom 模式下必须区分 presentation 和 dismissal 转场,而在 FullScreen 模式下可以不用这么做)。
特殊的Model转场
iOS8引入了UIPresentationController
类,该类接管了 UIViewController 的显示过程,为其提供转场和视图管理支持,model模式必须是Custom
。
UIPresentationController
类主要给 Modal 转场带来了以下几点变化:
- 定制 presentedView 的外观:设定 presentedView 的尺寸以及在 containerView 中添加自定义视图并为这些视图添加动画。
- 可以选择是否移除 presentingView。
- 可以在不需要动画控制器的情况下单独工作。
- iOS 8 中的适应性布局
👇介绍相关的方法:
/**在呈现过渡即将开始的时候被调用的*/
- (void)presentationTransitionWillBegin;
/**在呈现过渡结束时被调用的*/
- (void)presentationTransitionDidEnd:(BOOL)completed;
/**在退出过渡即将开始的时候被调用的*/
- (void)dismissalTransitionWillBegin;
/**在退出的过渡结束时被调用的*/
- (void)dismissalTransitionDidEnd:(BOOL)completed;
/*提供给动画控制器使用的视图,默认返回 presentedVC.view,通过重写该方法返回其他视图,但一定要是 presentedVC.view 的上层视图。对 presentedView 的外观进行定制。*/
- (UIView *)presentedView;
/*返回动画结束后的`presented view`的frame*/
- (CGRect)frameOfPresentedViewInContainerView;
有个问题,无法直接访问动画控制器,不知道转场的持续时间,怎么与转场过程同步?这时候前面提到的用处甚少的转场协调器(Transition Coordinator)将在这里派上用场。该对象可通过UIViewController
的transitionCoordinator()
方法获取,这是 iOS 7 为自定义转场新增的 API,该方法只在控制器处于转场过程中才返回一个与当前转场有关的有效对象,其他时候返回 nil。
转场协调器遵守<UIViewControllerTransitionCoordinator>
协议,它含有以下几个方法:
/*与动画控制器中的转场动画同步,执行其他动画*/
- (BOOL)animateAlongsideTransition:(void (^ __nullable)(id <UIViewControllerTransitionCoordinatorContext>context))animation completion:(void (^ __nullable)(id <UIViewControllerTransitionCoordinatorContext>context))completion;
/*与动画控制器中的转场动画同步,在指定的视图内执行动画*/
- (BOOL)animateAlongsideTransitionInView:(nullable UIView *)view animation:(void (^ __nullable)(id <UIViewControllerTransitionCoordinatorContext>context))animation completion:(void (^ __nullable)(id <UIViewControllerTransitionCoordinatorContext>context))completion;
在 iOS 7 中,Custom 模式的 Modal 转场里,presentingView
不会被移除,如果我们要移除它并妥善恢复会破坏动画控制器的独立性使得第三方动画控制器无法直接使用;在 iOS 8 中,UIPresentationController
解决了这点,给予了我们选择的权力,通过重写下面的方法来决定 presentingView
是否在 presentation 转场结束后被移除:
- (BOOL)shouldRemovePresentersView
返回 true 时,presentation 结束后presentingView
被移除,在 dimissal 结束后 UIKit 会自动将 presentingView 恢复到原来的视图结构中。此时,Custom 模式与 FullScreen
模式下无异,完全不必理会前面 dismissal 转场部分的差异了。
3. 交互控制器
实现交互效果需要在非交互转场的基础上实现下面两个方法:
- 由转场代理提供交互控制器,这是一个遵守
<UIViewControllerInteractiveTransitioning>
协议的对象,不过系统已经打包好了现成的类UIPercentDrivenInteractiveTransition
供我们使用。我们不需要做任何配置,仅仅在转场代理的相应方法中提供一个该类实例便能工作。另外交互控制器必须有动画控制器才能工作。 - 交互控制器还需要交互手段的配合,最常见的是使用手势,或是其他事件,来驱动整个转场进程。
/*更新转场进度,进度数值范围为0.0~1.0。*/
- (void)updateInteractiveTransition:(CGFloat)percentComplete;
/*取消转场,转场动画从当前状态返回至转场发生前的状态。*/
- (void)cancelInteractiveTransition;
/*完成转场,转场动画从当前状态继续直至结束。*/
- (void)finishInteractiveTransition;
交互控制协议<UIViewControllerInteractiveTransitioning>
只有一个必须实现的方法:
/*交互转场,获取转场上下文*/
- (void)startInteractiveTransition:(id <UIViewControllerContextTransitioning>)transitionContext;
需要注意的地方:
如果在转场代理中提供了交互控制器,而转场发生时并没有方法来驱动转场进程(比如手势),转场过程将一直处于开始阶段无法结束,应用界面也会失去响应:在 NavigationController 中点击 NavigationBar 也能实现 pop 返回操作,但此时没有了交互手段的支持,转场过程卡壳;在 TabBarController 的代理里提供交互控制器存在同样的问题,点击 TabBar 切换页面时也没有实现交互控制。因此仅在确实处于交互状态时才提供交互控制器,可以使用一个变量来标记
交互状态,该变量由交互手势来更新状态。
- (void)leftPan:(UIScreenEdgePanGestureRecognizer *)recognizer{
CGPoint currentPoint = [recognizer translationInView:recognizer.view];
CGFloat progress = currentPoint.x/CGRectGetWidth(recognizer.view.frame);
progress = MIN(1, MAX(0, progress));
if (recognizer.state == UIGestureRecognizerStateBegan){
//使用变量来标记交互状态
_isStart = YES;
[self.controller.navigationController popViewControllerAnimated:YES];
}else if (recognizer.state == UIGestureRecognizerStateChanged){
[self updateInteractiveTransition:progress];
}else if (recognizer.state == UIGestureRecognizerStateEnded || recognizer.state == UIGestureRecognizerStateCancelled){
_isStart = NO;
if (progress > 0.4) {
[self finishInteractiveTransition];
}else{
[self cancelInteractiveTransition];
}
}
}
- (nullable id <UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController
interactionControllerForAnimationController:(id <UIViewControllerAnimatedTransitioning>) animationController{
return _percentModel.isStart ? _percentModel : nil;
}
动画实例
1. Keynote中的神奇移动效果
KeyNoteTransition.gif实现思路:
获取UICollectionView
当前选中的Cell上的ImageView
,并且对ImageView进行截图,将ToView和截图ImageView添加到ContainerView
,以动画的方式将截图imageView的frame转换为toView的ImageView的Frame。下面请看Push详细代码,Pop代码同理:
- (void)PushAnimation:(id <UIViewControllerContextTransitioning>)transitionContext{
/*切出和切入的VC*/
FistViewController *fromVC = (FistViewController*)[transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
DetailController *toVC = (DetailController*)[transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
/*VC切换所发生的view容器,开发者应该将切出的view移除,将切入的view加入到该view容器中。*/
UIView *containerView = [transitionContext containerView];
/*对选中cell的imageView截图*/
NSIndexPath *indexPath = [[fromVC.myCollection indexPathsForSelectedItems] firstObject];
fromVC.selectIndexPath = indexPath;
FirstCollectionViewCell *selectCell = (FirstCollectionViewCell*)[fromVC.myCollection cellForItemAtIndexPath:indexPath];
UIView *snapShotView = [selectCell.avatarimageView snapshotViewAfterScreenUpdates:NO];
// 将rect从view中转换到当前视图中,返回在当前视图中的rect
snapShotView.frame = fromVC.finalCellRect = [containerView convertRect:selectCell.avatarimageView.frame fromView:selectCell.avatarimageView.superview];
selectCell.avatarimageView.hidden = YES;
/*设置第二个控制器的位置,透明度*/
toVC.view.frame = [transitionContext finalFrameForViewController:toVC];
toVC.view.alpha = 0;
toVC.avatarImageView.hidden = YES;
CGPoint currentCenter = toVC.textView.center;
toVC.textView.center = CGPointMake(currentCenter.x + 30, currentCenter.y);
/*将动画前后的两个View添加到containerView,注意添加顺序,snapShotView在上面*/
[containerView addSubview:toVC.view];
[containerView addSubview:snapShotView];
/*开始动画*/
[UIView animateWithDuration:[self transitionDuration:transitionContext] delay:0.0 usingSpringWithDamping:0.6 initialSpringVelocity:1.0 options:UIViewAnimationOptionCurveLinear animations:^{
//textView中心点
toVC.textView.center = currentCenter;
//透明度,frame变换
toVC.view.alpha = 1.0;
snapShotView.frame = [containerView convertRect:toVC.avatarImageView.frame toView:toVC.avatarImageView.superview];
} completion:^(BOOL finished) {
toVC.avatarImageView.hidden = NO;
selectCell.avatarimageView.hidden = NO;
[snapShotView removeFromSuperview];
/*告诉系统动画结束*/
[transitionContext completeTransition:!transitionContext.transitionWasCancelled];
}];
}
2. Mask圆形转场
MaskTransition.gif实现思路:
使用View的layer的遮罩效果,Layer遮罩是一个圆形,push变换时圆形的半径从button的半径增加到button圆心距屏幕边缘的最大值,pop时相反,push动画代码如下,pop动画同理:
- (void)pushAnimation:(id <UIViewControllerContextTransitioning>)transitionContext{
//获取fromVC和toVC,以及containerView
FirstViewController *fromVC = (FirstViewController*)[transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
SecondViewController *toVC = (SecondViewController*)[transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
UIView *containerView = [transitionContext containerView];
//设置遮罩
CGPoint buttonCenter = fromVC.targetButton.center;
CGRect buttonFrame = fromVC.targetButton.frame;
CGFloat paddingX = MAX(buttonCenter.x, CGRectGetWidth(fromVC.view.frame) - buttonCenter.x);
CGFloat paddingY = MAX(buttonCenter.y, CGRectGetHeight(fromVC.view.frame) - buttonCenter.y);
CGFloat distance = sqrtf((paddingX * paddingX) + (paddingY * paddingY));
UIBezierPath *startPath = [UIBezierPath bezierPathWithOvalInRect:buttonFrame];
UIBezierPath *endPath = [UIBezierPath bezierPathWithOvalInRect:CGRectInset(buttonFrame, -(distance - CGRectGetWidth(buttonFrame)/2.0), -(distance - CGRectGetHeight(buttonFrame)/2.0))];
CAShapeLayer *maskLayer = [CAShapeLayer layer];
//将参与变换的视图添加到contaier上
[containerView addSubview:toVC.view];
toVC.view.layer.mask = maskLayer;
//防止最后闪屏一下
maskLayer.path = endPath.CGPath;
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"path"];
animation.duration = 0.6;
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
animation.delegate = self;
animation.fromValue = (__bridge id)startPath.CGPath;
animation.toValue = (__bridge id)endPath.CGPath;
[animation setValue:@"maskAnimation" forKey:AnimationKey];
[animation setValue:transitionContext forKey:TransitionContextKey];
[maskLayer addAnimation:animation forKey:nil];
}
动画结束后要将toView或者FromView的遮罩设置为nil。
- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag{
if ([[anim valueForKey:AnimationKey] isEqualToString:@"maskAnimation"]){
id <UIViewControllerContextTransitioning> transitionContext = [anim valueForKey:TransitionContextKey];
SecondViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
toVC.view.layer.mask = nil;
//完成动画
[transitionContext completeTransition:!transitionContext.transitionWasCancelled];
}else if ([[anim valueForKey:AnimationKey]isEqualToString:@"maskAnimationPop"]){
id <UIViewControllerContextTransitioning> transitionContext = [anim valueForKey:TransitionContextKey];
SecondViewController *FromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
FromVC.view.layer.mask = nil;
//完成动画
[transitionContext completeTransition:!transitionContext.transitionWasCancelled];
}
}
3. Presentation转场动画
presentation.gif这个动画使用iOS8引入了
UIPresentationController
类。原理上面已经解释的很清楚了,直接上代码:
- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext{
/*获取controller,ContainerView*/
UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
UIView *containerView = [transitionContext containerView];
UIView *fromView;
UIView *toView;
if ([transitionContext respondsToSelector:@selector(viewForKey:)]) {
fromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
toView = [transitionContext viewForKey:UITransitionContextToViewKey];
}else{
fromView = fromVC.view;
toView = toVC.view;
}
CGRect fromViewFrame = [transitionContext initialFrameForViewController:fromVC];
CGRect toViewFrame = [transitionContext finalFrameForViewController:toVC];
/*进行动画*/
if (_type == AnimationTypePresent) {
CGRect orginalFrame = CGRectZero;
orginalFrame.origin = CGPointMake(CGRectGetMinX(containerView.bounds), CGRectGetMaxY(containerView.bounds));
orginalFrame.size = toViewFrame.size;
toView.frame = orginalFrame;
[containerView addSubview:toView];
}else if (_type == AnimationTypeDissmiss){
/**
处理 Dismissal 转场,按照上一小节的结论,.Custom模式下不要将 toView添加到 containerView
*/
fromViewFrame = CGRectOffset(fromViewFrame, 0, CGRectGetHeight(containerView.bounds));
}
[UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
if (_type == AnimationTypePresent) {
toView.frame = toViewFrame;
}else if (_type == AnimationTypeDissmiss){
fromView.frame = fromViewFrame;
}
} completion:^(BOOL finished) {
[transitionContext completeTransition:![transitionContext transitionWasCancelled]];
}];
}
动画控制协调器中执行背景透明度变化,与动画控制器中的转场动画同步。
self.dimmingView.alpha = 0.0;
[self.presentingViewController.transitionCoordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
self.dimmingView.alpha = 0.7;
self.presentingViewController.view.transform = CGAffineTransformScale(CGAffineTransformIdentity, 0.92, 0.92);
} completion:nil];
资料
- 文中Demo下载
- 开源视图控制器的转场库VCTransitionsLibrary
- 一些好看的转场动画效果GitHub