环境集成iOS Developer牛叉的demo

自定义转场动画

2017-07-21  本文已影响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;

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;

animationEnded: 类似 completion handlerfailure handler

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

了解了必要的知识以后,开始做真正有意思的事情吧~
(如果你想了解实现思路,可以浏览后文中的 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 看到全部代码哦

上一篇下一篇

猜你喜欢

热点阅读