自定义转场动画

作者: 豆大大 | 来源:发表于2017-07-21 17:54 被阅读96次
    写在前面

    本文中提到的 presented 和 presenting ,分别指被展示者和展示者,譬如 [viewControllerA presentViewController:viewControllerB animated:NO] 中,viewControllerA 是 presenting,viewControllerB 是 presented。

    从熟悉的地方开始

    苹果提供的几种转场动画,可以用 UIModalPresentationStyleUIModalTransitionStyle 来设置,从 presentation 和 transition 两个关键词的语意来理解转场动画,presentation 重静态的展示,可通过 UIModalPresentationStyle 来定义 presented view 的展示形式(全屏、formsheetpopover 等);transition 重动态的转场,可通过 UIModalTransitionStyle 来定义从 presenting view 上呈现 presented view 的动画。

    UIModalPresentationStyle Discussion
    UIModalPresentationFullScreen 全屏展示presented view,presenting view 在转场动画完成后被移除
    UIModalPresentationPageSheet horizontally compact 场景下同 UIModalPresentationFullScreen;horizontally regular 场景下 presented view 的 width 和 height 等于 presenting view 在 portrait 模式下的 width 与 height;未遮挡部分虚化禁止用户交互
    UIModalPresentationFormSheet horizontally compact 场景下同 UIModalPresentationFullScreen; horizontally regular 场景下,presented view 的长宽小于 screen 、居中显示,landscape mode 下presented view 会随着键盘的弹出而上移;未遮挡部分虚华禁止用户交互
    UIModalPresentationCurrentContext 转场动画开始前,UIKit 开始从 presenting view controller 向上寻找,presented view controller 的内容将覆盖第一个找到的 definesPresentationContext = YES 的 view controller 的内容;转场动画结束时,被覆盖的内容将被移除
    UIModalPresentationCustom 将转场动画交由 view controller 的 transitioningDelegate 对象来管理
    UIModalPresentationOverFullScreen presented view 覆盖的 content 不会从 view hierarchy 中移除,未被遮挡的部分对用户可见
    UIModalPresentationOverCurrentContext CurrentContext 和 OverFullScreen 的结合
    UIModalPresentationPopover horizontally regular 场景下, popover 展示;horizontally compact 场景下,同 FullScreen
    UIModalTransitionStyle Discussion
    UIModalTransitionStyleCoverVertical 默认值,从屏幕底部上推
    UIModalTransitionStyleFlipHorizontal 旋转门 自己体会
    UIModalTransitionStyleCrossDissolve 渐入渐出
    UIModalTransitionStylePartialCurl 浮夸的翻页

    (transition stylepresentation stylepresented view controller 来设置,这也符合 presented one 不应该依赖 presenting one 的思想。)

    如果上述预定义转场动画不能满足你,那么需要实现自定义动画;自定义动画主要通过实现或集成了 UIViewControllerAnimatedTransitioning 协议和 UIPresentationController 类的动画控制器 animator 来实现,稍作了解后,你会发现 UIViewControllerAnimatedTransitioningUIModalTransitionStyle 相似, UIPresentationControllerUIModalPresentationStyle 相似。

    预定义转场动画通过给 UIViewControllermodalPresentationStylemodalTransitionStyle 属性赋值来实现,而自定义转场动画则须是令 modalPresentationStyle = UIModalPresentationStyleCustom ,并且给 transitioningDelegate 赋值一个继承了 UIViewControllerTransitioningDelegate 的对象 (初次使用时容易把 UIViewControllerAnimatedTransitioningUIViewControllerTransitioningDelegate 弄混,其实二者是截然不同的概念),继而在此对象的协议方法中分配动画控制器。通常我们使用当前的 view controller 来实现 UIViewControllerTransitioningDelegate 协议,具体代码如下:

    @interface ViewController () <UIViewControllerTransitioningDelegate>
    @end
    
    @implementation ViewController 
    
    - (instancetype)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil
    {
      if ((self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]) != nil) {
        [self setModalPresentationStyle:UIModalPresentationCustom];
        [self setTransitioningDelegate:self];
      }
    
      return self;
    }
    
    - (id <UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source
    {
      return nil; // return a transition animator  
    }
    
    - (id <UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed
    {
      return nil; // return a transition animator
    }
    
    - (UIPresentationController *)presentationControllerForPresentedViewController:(UIViewController *)presented presentingViewController:(UIViewController *)presenting sourceViewController:(UIViewController *)source
    {
      return nil; // return a presentation animator
    }
    

    上述代码中,animationControllerForPresentedController 要求 programer 返回一个 present 当前 ViewController 的动画控制器,animationControllerForDismissController 要求 coder 返回一个 dismiss 当前 view controller 的动画控制器,如何编写动画控制器就是我们要发挥想象力的地方了。

    好在苹果给了一个很好用的协议,就是上文中提到的 UIViewControllerAnimatedTransitioning ,它有以下方法:

    - (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext;

    • calls when presenting or dismissing a view controller
    • configure your custom transition in it
    • use view-based animation or core animation as you like
    • all animations must take place in view specified by the containerView property of transitionContext

    animateTransition: 前三段解释都好理解,第四条提出了一个 containerView 的属性, containerView 是转场动画中涉及的所有 view 的 superview,且 containerView 默认添加了 presenting view controller 的 view ,你要做的事情是将 presented view controller 的 view 也添加到 containerView 上。
    (不得不说 containerView 是 UIKit 作的很大的一个改变,在 iOS5 之前,view controller 被苹果封装得“紧密严合”,虽然知道不同 view controller 之前的转场不过是 view 的叠加,但我们不能真的用[presentingViewController.view addSubview:presentedViewController.view]这样的代码来替代 presentViewController:方法,而在 animateTransition: 方法中,你可以真切地感受到 view 的 present 与 dismiss 是通过 addSubview:removeFromSuperView 来完成的,不同之处是 containerView 必须是所有 view 的 "container")

    - (void)animationEnded:(BOOL)transitionCompleted;

    • transitionCompleted = YES if the transition completed successfully and the new view controller is displayed
    • transitionCompleted = NO if the transition is canceled and the original view is still visible
    • use this method to perform any final cleanup operations required by your transition animator

    animationEnded: 类似 completion handlerfailure handler

    - (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext;

    • return the duration in seconds of your custom transition
    • the value you provide should be the same value that you use when configuring the animations in your animateTransition: method.
    • UIKit uses the value to synchronize the actions of other objects that might be involved in the transition

    了解了必要的知识以后,开始做真正有意思的事情吧~
    (如果你想了解实现思路,可以浏览后文中的 GIF 图和伪代码;如果想要看到具体的实现,可以使用【真·代码阅读术】前往 JYCustomTransition 哦)

    仿iPhone相册-点击图片放大的转场动画
    photo-compressed.gif

    深情凝视 iOS 系统自带相册,会发现在照片流里 "点击一张照片放大查看" 的过程,看似是 UIImageViewframe 发生了变化,实际上是 UICollectionViewController 切换到了 UIPageViewController 。当然这只是没找到源代码之后基于观察的猜测,两个不同 view controller 切换更符合 Cocoa Frameworks 中一贯的各司其职的原则。
    那么,仿制一个苹果系统相册,coder 须要做的是将 presentViewController 的具体过程委托给自定义的第三方(也就是上文提到的动画控制器)来实现,以达到欺骗用户眼球的效果。重点在于我们看到的 “整齐排列的照片中一张照片被放大” 的效果,笔者的做法是创建一个独立于 viewControlleranimateImageView ,设置 animateImageView 的隐式动画来欺骗眼球,动画完成后即 remove,伪代码如下:

    - (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext
    {
         UIImage *image = getImageFromPhotoGraphy();
         UIImageView *animateImageView = [[UIImageView alloc] initWithImage:image];
         [animateImageView setFrame:getInitialFrameFromPhotoGraphy()];
         [containerView addSubview:animateImageView ];
    
         [UIView animateWithDuration:0.5 animations:^{
             [animateImageView setFrame:getFinalFrameFromPhotoGraphy()];
         } completion: ^(BOOL finished) {
             [animateImageView removeFromSuperView]
         }]
    }
    

    在具体实现的过程中,笔者还考虑了不同图片尺寸的修正来达到视觉上的流畅效果,感兴趣的小伙伴可以前往 JYCustomTransition 查看~

    为了欺骗而欺骗-开门/关门的转场动画

    首先直接上GIF图吧

    door_effect-iloveimg-compressed.gif
    依然是 “看起来” 是一张图片被切割、被旋转,实际上是 view controller 之间的转场。
    (笔者在敲代码的时候想了想,非游戏类的 App 应该不会特意做这么花哨并且毫无意义的转场动画,不过最后还是决定做出来,一个是为了向小伙伴们展示 coder 们为了欺骗用户眼球可以做到什么地步,二是为了介绍转场过程中用到的干货 - CATransform 3D,有很多漂亮的转场效果都有小小的用到 3D Animation 哦)
    具体的实现过程是用 UIKit 提供的 UIGraphicsGetImageFromCurrentImageContext()presenting view controller(雪山背景那个)截屏生成 snapshotImage,通过对snapshotImage切割生成的 UIImageView 设置 transform 属性来实现旋转开门/关门效果,伪代码如下:
    - (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext
    {
        UIImage *snapshotImage = getSnapshotImageFromPresentingViewController();
    
        UIImageView *leftImageView = cropImageViewFromLeftSnapshotImage();
        [[leftImageView layer] setAnchorPoint:CGPointMake(0.0, 0.5)];
        [leftImageView setFrame:leftRect];
        [containerView insertSubview:leftImageView aboveSubview:toView];
    
        UIImageView *rightImageView = cropImageViewFromRightSnapshotImage();
        [[rightImageView layer] setAnchorPoint:CGPointMake(1.0, 0.5)];
        [rightImageView setFrame:rightRect];
        [containerView insertSubview:rightImageView aboveSubview:toView];
    
        CATransform3D leftRotateTransform = CATransform3DIdentity;
        leftRotateTransform.m34 = 4.5 / -2000;
        leftRotateTransform = CATransform3DRotate(leftRotateTransform, 90.0 * M_PI / 180.0f, 0, 1.0, 0);
    
        CATransform3D rightRotateTransform = CATransform3DIdentity;
        rightRotateTransform.m34 = 4.5 / -2000;
        rightRotateTransform = CATransform3DRotate(rightRotateTransform, -90.0 * M_PI / 180.0f, 0, 1.0, 0);
    
        [UIView animateWithDuration:0.5 delay:0.0 options:UIViewAnimationOptionCurveEaseOut animations:^{
          [[leftImageView layer] setTransform:leftRotateTransform];
          [[rightImageView layer] setTransform:rightRotateTransform];
        } completion:^(BOOL finished) {
           [leftImageView removeFromSuperview];
          [rightImageView removeFromSuperview];
        }];
    
    }
    

    笔者在具体实现过程遇到了多个 layer 相互遮挡的问题,后来发现是由于 [leftImageView layer][rightImageView layer]z 坐标小于 presented view controllerlayerz 坐标的缘故,举一反三的小伙伴们可以停下来想想怎么解决这个问题,也可以空降到 JYCustomTransition 去看看具体的解决方法哦

    终于用到了UIPresentationController-弹出便笺的转场动画
    memo-compressed.gif
    最后这个看起来最朴素的动画包含了 UIViewControllerAnimatedTransitioningUIPresentationController 两大主力, UIViewControllerAnimatedTransitioning 实现转场中的 frame 变化,伪代码如下:
    - (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext
    {
      UIViewController *fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
      UIViewController *toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
      UIView *fromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
      UIView *toView = [transitionContext viewForKey:UITransitionContextToViewKey];
      UIView *containerView = [transitionContext containerView];
    
      const CGRect endFrame = [transitionContext finalFrameForViewController:toViewController];
      const CGRect startFrame = CGRectOffset(endFrame, 0.0, CGRectGetHeight(endFrame));
    
      [toView setFrame:startFrame];
      [toView setAlpha:0.0f];
      [containerView addSubview:toView];
    
      [UIView animateWithDuration:0.2 delay:0.0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn animations:^{
        [toView setFrame:endFrame];
        [toView setAlpha:1.0];
      } completion:^(BOOL finished) {
        if ([transitionContext transitionWasCancelled]) {
          [toView removeFromSuperview];
        }
        [transitionContext completeTransition:YES];
      }];
    }
    

    而展示的效果则是在 UIPresentationController 的子类实现,设置了 presented view controller 中的 viewframeportrait 模式下固定高度 500,landscape 模式下占据全屏),并为 view 加了一个半透明的背景 backgroundView ,这样在 presented view controller 的初始化方法中就可以放心大胆的使用 [[self view] addSubview:subview] 而不用担心 subview 相对上边界的偏移,伪代码如下:

    @implementation _JYMemoViewController_PresentationController
    
    - (instancetype)initWithPresentedViewController:(UIViewController *)presentedViewController presentingViewController:(nullable UIViewController *)presentingViewController
    {
      if ((self = [super initWithPresentedViewController:presentedViewController presentingViewController:presentingViewController]) != nil) {
        _backgroundView = [[UIView alloc] initWithFrame:CGRectZero];
        [_backgroundView setUserInteractionEnabled:NO];
        [_backgroundView setTranslatesAutoresizingMaskIntoConstraints:NO];
    
        _tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_handleTapGestureRecognizer:)];
        [_tapGestureRecognizer setDelegate:self];
      }
    
      return self;
    }
    
    - (CGRect)frameOfPresentedViewInContainerView
    {
      CGFloat height = fmin(CGRectGetHeight([[self containerView] frame]), 500.0);
      return CGRectMake(0.0, CGRectGetMaxY([[self containerView] frame]) - height, CGRectGetWidth([[self containerView] frame]), height);
    }
    
    - (void)presentationTransitionWillBegin
    {
      [super presentationTransitionWillBegin];
    
      [_backgroundView setBackgroundColor:[UIColor colorWithWhite:0.0 alpha:0.5]];
      [_backgroundView setAlpha:0.0];
      [[self containerView] addSubview:_backgroundView];
    
      [[[_backgroundView topAnchor] constraintEqualToAnchor:[[self containerView] topAnchor]] setActive:YES];
      [[[_backgroundView bottomAnchor] constraintEqualToAnchor:[[self containerView] bottomAnchor]] setActive:YES];
      [[[_backgroundView leadingAnchor] constraintEqualToAnchor:[[self containerView] leadingAnchor]] setActive:YES];
      [[[_backgroundView trailingAnchor] constraintEqualToAnchor:[[self containerView] trailingAnchor]] setActive:YES];
    
      [[[self presentingViewController] transitionCoordinator] animateAlongsideTransition:^(id <UIViewControllerTransitionCoordinatorContext>  context) {
        [_backgroundView setAlpha:1.0];
      } completion:^(id <UIViewControllerTransitionCoordinatorContext> context) {
        if ([context isCancelled]) {
          [[[self presentedView] layer] setShadowOpacity:0.0f];
          [_backgroundView setAlpha:0.0];
        }
      }];
    }
    
    - (void)presentationTransitionDidEnd:(BOOL)completed
    {
      if (completed) {
        [[self containerView] addGestureRecognizer:_tapGestureRecognizer];
      }
    
      [super presentationTransitionDidEnd:completed];
    }
    
    - (void)dismissalTransitionWillBegin
    {
      [super dismissalTransitionWillBegin];
    
      [[self containerView] removeGestureRecognizer:_tapGestureRecognizer];
    
      [[[self presentingViewController] transitionCoordinator] animateAlongsideTransition:^(id <UIViewControllerTransitionCoordinatorContext> context) {
        [[[self presentedView] layer] setShadowOpacity:0.0f];
        [_backgroundView setAlpha:0.0];
      } completion:^(id <UIViewControllerTransitionCoordinatorContext> context) {
        if ([context isCancelled]) {
          [[[self presentedView] layer] setShadowOpacity:1.0f];
          [_backgroundView setAlpha:1.0];
        }
        else {
          [_backgroundView removeFromSuperview];
        }
      }];
    }
    
    - (void)dismissalTransitionDidEnd:(BOOL)completed
    {
      if (!completed) {
        [[self containerView] addGestureRecognizer:_tapGestureRecognizer];
      }
    
      [super dismissalTransitionDidEnd:completed];
    }
    
    - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id <UIViewControllerTransitionCoordinator>)coordinator
    {
      [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
    
      [coordinator animateAlongsideTransition:^(id <UIViewControllerTransitionCoordinatorContext> context) {
        [[self presentedView] setFrame:CGRectMake(CGRectGetMinX([[self containerView] frame]),  CGRectGetMinY([[self containerView] frame]) + fmax(size.height - 500.0, 0.0), CGRectGetWidth([[self containerView] frame]), CGRectGetHeight([[self containerView] frame]) - fmax(size.height - 500.0, 0.0))];
      } completion:nil];
    }
    
    - (void)_handleTapGestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
    {
      [[self presentingViewController] dismissViewControllerAnimated:YES completion:nil];
    }
    
    #pragma mark - UIGestureRecognizerDelegate
    
    - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
    {
      if (gestureRecognizer == _tapGestureRecognizer) {
        if ([touch view] != nil) {
          return [touch view] != [self presentedView] && ![[touch view] isDescendantOfView:[self presentedView]];
        }
      }
      
      return YES;
    }
    
    @end
    

    依然可以前往 JYCustomTransition 看到全部代码哦

    相关文章

      网友评论

        本文标题:自定义转场动画

        本文链接:https://www.haomeiwen.com/subject/fzsjnttx.html