iOS视图控制器转场动画

2,053 阅读12分钟

屏幕左边缘右滑返回,TabBar 滑动切换,你是否喜欢并十分依赖这两个操作,甚至觉得 App 不支持这类操作的话简直反人类?这两个操作在大屏时代极大提升了操作效率,其背后的技术便是今天的主题:视图控制器转换(View Controller Transition)。

前言

通过学习seedanteiOS 视图控制器转场详解:从入门到精通的这篇文章,对视图转场有了新的认识,写这篇文章的目的,主要是记录一下自己对视图转场动画的理解并做一个总结方便以后查阅。

目前为止,官方支持以下几种方式的自定义转场:

  1. UINavigationController 中 push 和 pop
  2. UITabBarController 中切换 Tab
  3. Modal 转场:presentation 和 dismissal,俗称视图控制器的模态显示和消失,仅限于modalPresentationStyle属性为 UIModalPresentationFullScreen 或 UIModalPresentationCustom这两种模式
  4. UICollectionViewController 的布局转场:仅限于 UICollectionViewController 与 UINavigationController 结合的转场方式

转场协议

转场动画的本质: 下一场景(子 VC)的视图替换当前场景(子 VC)的视图以及相应的控制器(子 VC)的替换,表现为当前视图消失和下一视图出现。 iOS 7 以协议的方式开放了自定义转场的 API,协议的好处是不再拘泥于具体的某个类,只要是遵守该协议的对象都能参与转场,非常灵活。主要有一下几个协议:

  1. 转场代理(Transition Delegate)
  2. 动画控制器(Animation Controller)
  3. 交互控制器(Interaction Controller)
  4. 转场环境(Transition Context)
  5. 转场协调器(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 中获取 fromViewtoView

需要注意的地方:

  1. 将 toView 添加到容器视图中,使得 toView 在屏幕上显示(Modal 转场中此点稍有不同)不必非得是addSubview:,某些场合你可能需要调整 fromView 和 toView 的显示顺序,总之将之加入到containerView 里就行了。
  2. 动画结束后正确地结束转场过程。转场的结果有两种:完成或取消。非交互转场的结果只有完成一种情况,不过交互式转场需要考虑取消的情况。如何结束取决于转场的进度,通过transitionWasCancelled()方法来获取转场的结果,然后使用completeTransition:来通知系统转场过程结束。
  3. 转场结束后,fromView 会从视图结构中移除,UIKit 自动替我们做了这事,你也可以手动处理提前将 fromView 移除,这完全取决于你。
  4. Model中,在 Custom 模式下的 dismissal转场中不要像其他的转场那样将 toView(presentingView) 加入 containerView,否则presentingView将消失不见,而应用则也很可能假死。而 FullScreen 模式下可以使用与前面的容器类 VC 转场同样的代码,(Modal 转场在 Custom 模式下必须区分 presentation 和 dismissal 转场,而在 FullScreen 模式下可以不用这么做)。

特殊的Model转场

iOS8引入了UIPresentationController类,该类接管了 UIViewController 的显示过程,为其提供转场和视图管理支持,model模式必须是CustomUIPresentationController类主要给 Modal 转场带来了以下几点变化:

  1. 定制 presentedView 的外观:设定 presentedView 的尺寸以及在 containerView 中添加自定义视图并为这些视图添加动画。
  2. 可以选择是否移除 presentingView。
  3. 可以在不需要动画控制器的情况下单独工作。
  4. 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)将在这里派上用场。该对象可通过UIViewControllertransitionCoordinator()方法获取,这是 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. 交互控制器

实现交互效果需要在非交互转场的基础上实现下面两个方法:

  1. 由转场代理提供交互控制器,这是一个遵守<UIViewControllerInteractiveTransitioning>协议的对象,不过系统已经打包好了现成的类UIPercentDrivenInteractiveTransition供我们使用。我们不需要做任何配置,仅仅在转场代理的相应方法中提供一个该类实例便能工作。另外交互控制器必须有动画控制器才能工作。
  2. 交互控制器还需要交互手段的配合,最常见的是使用手势,或是其他事件,来驱动整个转场进程。
/*更新转场进度,进度数值范围为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];

资料

  1. 文中Demo下载
  2. 开源视图控制器的转场库**VCTransitionsLibrary**
  3. 一些好看的转场动画效果GitHub