iOS 自定义转场动画 初窥

简书上的所有内容都可以在我的个人博客上找到

这两天学习了一下自定义转场动画的内容,刚开始看的时候被这几个又长又很相似的协议弄的晕头转向,所以希望能写一篇浅显易懂的入门文章。本文的内容会比较基础,不会涉及的很深的,就像题目说的初窥。通过本文可以用最简单的方式实现最简单的自定义转场动画。如果你需要更加深入的知识,可以参考官方文档。

Customizing the Transition Animations

本文中的 Demo 可以从 这里 下载


在讲主要的内容之前我们需要分清几个概念:

  • presentingViewController
  • presentedViewController
  • fromViewController
  • toViewController

presentingVCpresentedVC的概念比较容易理解,前者就是 执行present 动作的那个控制器,而后者就是 被present 的那个控制器,这两个控制器的身份是始终不会改变的。

fromVCtoVC是个相对的概念,在执行 present 的时候presentingVC就是fromVC,而presentedVC就是toVC。在 dismiss 的时候就要反一反了presentedVCfromVC,而presentingVCtoVC。官方有一张图是这样的:

普通 view controller 自定义转场


我们先以普通的 view controller 为例子,讲 present 和 dismiss 的转场。往简单了说,我们只需要知道 3 个协议就可以实现自定义转场。

@protocol UIViewControllerContextTransitioning <NSObject>
@protocol UIViewControllerAnimatedTransitioning <NSObject>
@protocol UIViewControllerTransitioningDelegate <NSObject>

第一个协议UIViewControllerContextTransitioning 实现了这个协议的对象,我它为转场上下文,一般来说,转场上下文不用我们自己实现,由系统提供给我们。通过它,我们可以获取到很多转场相关的信息。这里我只列举几个重要的属性或方法,其他的看名字和注释也能很容易知道它们的用途:

// 所有要执行动画的 view 都要加入到 containerView
- (UIView *)containerView;  
// 通过 key 来返回转场前,转场后的 view controller
// UITransitionContextFromViewControllerKey
// UITransitionContextToViewControllerKey
- (UIViewController *)viewControllerForKey:(NSString *)key;
// 可以得到参与转场的 view controller 起始和结束时的 frame,一般来说通过 fromVC 的到起始的,通过 toVC 的到结束时的
- (CGRect)initialFrameForViewController:(UIViewController *)vc;
- (CGRect)finalFrameForViewController:(UIViewController *)vc;
// 在转场动画结束,或者取消时要通知系统是否完成
- (void)completeTransition:(BOOL)didComplete;

第二个协议UIViewControllerAnimatedTransitioning 负责的是转场动画的内容,我把实现了这个协议的对象称作动画控制器。它有两个必须实现的方法:- (NSTimeInterval)transitionDuration:- (void)animateTransition:。第一个方法用来返回动画的时长,而第二个方法就是实现动画的过程,它有一个参数,这个参数就是一个转场上下文,通过这个参数我们可以取到很多转场的信息,然后进行动画。

第三个协议UIViewControllerTransitioningDelegate 我们需设置presentedVCtransitioningDelegate属性为一个实现了这个协议的对象。我们现在只要先关注这个协议的两个方法:

// 返回 present 动画控制器
- (id <UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source;
// 返回 dismiss 动画控制器
- (id <UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed;

我们可以把 present 和 dismiss 动画控制器写成一个对象,在内部通过一些逻辑判断来执行对应的动画,当然也可以分成两个对象。

在知道了上面这些内容后,我们就可以一起来写一个 demo 来学习自定义转场动画了。

Demo


先写两个 view controller,分别是 presentingVC 和 presentedVC,内容基本上是一样的,我就不贴两份了。只有buttonClicked方法不一样,一个是 present, 一个是 dismiss。

@implementation PresentingViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
    button.bounds = CGRectMake(0, 0, 200, 30);
    button.center = self.view.center;
    [button setTitle:@"present view controller" forState:UIControlStateNormal];
    [button addTarget:self action:@selector(buttonClicked) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:button];

}
- (void)buttonClicked {
    PresentedViewController *presentedVC = [PresentedViewController new];
    [self presentViewController:presentedVC animated:YES completion:nil];
}
@end

// ----- in PresentedViewController.m -----
- (void)buttonClicked {
    [self dismissViewControllerAnimated:YES completion:nil];
}

完成这些后我们的程序就能实现转场了,只不过是系统默认的转场方式。接下来,我们要写一个自己的动画控制器,为了区分和系统的动画,我们的动画选择从上往下划出的转场方式。我们新建一个类,让它遵循UIViewControllerAnimatedTransitioning协议。

// AnimationController.h
@interface AnimationController : NSObject<UIViewControllerAnimatedTransitioning>
@end

// AnimationController.m
@implementation AnimationController
// 转场的时间
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext {
    return 0.8;
}
// 转场动画实现
- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext {
    // 通过 key 取到 fromVC 和 toVC
    UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    // 把 toVC 加入到 containerView
    UIView *containerView = [transitionContext containerView];
    [containerView addSubview:toVC.view];
    // 一些动画要用的的数据
    CGRect finalFrame = [transitionContext finalFrameForViewController:toVC];
    NSTimeInterval duration = [self transitionDuration:transitionContext];
    // 动画过程
    if (toVC.isBeingPresented) {
        toVC.view.frame = CGRectOffset(finalFrame, 0, -finalFrame.size.height);
        [UIView animateWithDuration:duration
                         animations:^{
                             toVC.view.frame = finalFrame;
                         }
                         completion:^(BOOL finished) {
                             // 结束后要通知系统
                             [transitionContext completeTransition:YES];
                         }];
    }
    if (fromVC.isBeingDismissed) {
        [containerView sendSubviewToBack:toVC.view];
        [UIView animateWithDuration:duration
                         animations:^{
                             fromVC.view.frame = CGRectOffset(finalFrame, 0, -finalFrame.size.height);
                         }
                         completion:^(BOOL finished) {
                             // dismiss 动画添加了手势后可能出现转场取消的状态,所以要根据状态来判定是否完成转场
                             BOOL isComplete = ![transitionContext transitionWasCancelled];
                             [transitionContext completeTransition:isComplete];
                         }];
    }
}
@end
  • 第一个方法,我们返回了动画的时间 0.8 秒
  • 第二个方法
    • 先通过 key 取到 fromVC 和 toVC。
    • 然后把 toVC 的 view 加入到 containerView 中 ,fromVC 的 view 是本来就在 containerView 中的
    • 动画的过程分为两块,分别是 present 动画 和 dismiss 动画,我们可以通过 UIViewController 自带的 isBeingPresentedisBeingDismissed 属性来判断当前是那种类型的转场。动画的过程就可以自己发挥想象力了。在动画结束后要通知系统完成转场,这里要注意的是,因为稍后我们要给 dismiss 添加手势驱动,所以转场存在取消的可能,所以我们通过[transitionContext transitionWasCancelled]来得到转场的状态,再判断是否通知系统转场完成。

最后一步,我们要通过UIViewControllerTransitioningDelegate动画控制器视图控制器联系起来。我们要给PresentingViewController添加一些内容。

先让 presentingVC 遵循 UIViewControllerTransitioningDelegate 协议,并且添加一个属性。

@interface PresentingViewController ()<UIViewControllerTransitioningDelegate>
// 动画控制器
@property (nonatomic, strong)id<UIViewControllerAnimatedTransitioning> animationController;
@end

然后在- (void)viewDidLoad中初始化动画控制器 _animationController = [AnimationController new];

- (void)viewDidLoad {
    [super viewDidLoad];
    ...
    // 初始化动画控制器
    _animationController = [AnimationController new];
}

接着在 - (void)buttonClicked方法中设置presentedVC的代理

- (void)buttonClicked {
    PresentedViewController *presentedVC = [PresentedViewController new];
    // 设置 presented view controller 的转场代理
    presentedVC.transitioningDelegate = self;
    [self presentViewController:presentedVC animated:YES completion:nil];
}

最后添加两个代理方法

// 返回 present 动画控制器
- (id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source {
    return _animationController;
}

// 返回 dismiss 动画控制器
- (id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed {
    return _animationController;
}

至此,我们已经完成了一个自定义的转场动画了😀

注意点

presentedVC 的 modalPresentationStyle 默认为UIModalPresentationFullScreen,这种情况下在转场完成后系统会自动隐藏 presentingVC 的 view 。如果我们设置了 UIModalPresentationCustom那么转场完成后,presentingVC 的 view 不会隐藏。一般来说在动画的时候我们都会把 toVC 的 view 加入到 containerView 中,在这种模式下执行 dismiss 的时候我们不能把 toVC.view(presentingVC.view) 加入到 containerView 中,因为这个 view 并由系统额外管理,如果我们改变了它,那就有可能把从原来的视图层次中移除而导致它消失不见。

交互式转场


实现交互式的转场需要在UIViewControllerTransitioningDelegate的协议方法中返回一个实现了UIViewControllerInteractiveTransitioning的对象。官方已经给我们封装好了一个UIPercentDrivenInteractiveTransition类,我们只要继承这个类在加入我们自己的一些内容就可以实现交互式转场。有几个方法我们需要先知道:

// 更新转场状态
- (void)updateInteractiveTransition:(CGFloat)percentComplete;
// 取消转场
- (void)cancelInteractiveTransition;
// 完成转场
- (void)finishInteractiveTransition;

需要注意的是,在转场发生时,如果返回了交互控制器,但是却没有通过交互的方式来执行转场,那么整个过程就卡住。所以我们需要给交互控制器添加一个属性,用来监听当前是否是通过手势驱动,如果不是我们就返回一个 nil,这样就不会执行交互式转场,而只会执行普通的动画转场。接下来我们来看一下代码:

// InteractionController.h
@interface InteractionController : UIPercentDrivenInteractiveTransition
// 标记是否是交互转场
@property (nonatomic, readonly, getter=isInteracting)BOOL interacting;
// 一些初始化工作
- (void)prepareForViewController:(UIViewController *)viewController;
@end

// InteractionController.m
@interface InteractionController ()
@property (nonatomic, weak)UIViewController *presentedVC;   // 注意是弱引用
@property (nonatomic, assign)BOOL shouldComplete;
@end

@implementation InteractionController
// 给 viewController 的 view 添加手势
- (void)prepareForViewController:(UIViewController *)viewController {
    _presentedVC = viewController;
    UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panGestureAction:)];
    [viewController.view addGestureRecognizer:panGesture];
}
- (void)panGestureAction:(UIPanGestureRecognizer *)gestureRecognizer {
    CGPoint translation = [gestureRecognizer translationInView:gestureRecognizer.view.superview];
    // 动画的百分比
    CGFloat percent = 0.0;
    switch (gestureRecognizer.state) {
        case UIGestureRecognizerStateBegan:
            // 设置交互状态为 YES
            _interacting = YES;
            // 手势开始时要调用 dismiss
            [_presentedVC dismissViewControllerAnimated:YES completion:nil];
            break;
        case UIGestureRecognizerStateChanged:
            // 计算百分比
            percent = -translation.y/_presentedVC.view.bounds.size.height;
            // 更新转场的进度 传入的参数值要在 0.0~1.0 之间
            [self updateInteractiveTransition:percent];
            // 如果滑动超过 30% 就视为转场完成
            _shouldComplete = (percent > 0.3);
            break;
        case UIGestureRecognizerStateCancelled:
            _interacting = NO;
            [self cancelInteractiveTransition];
            break;
        case UIGestureRecognizerStateEnded:
            _interacting = NO;
            if (_shouldComplete) {
                [self finishInteractiveTransition];
            } else {
                [self cancelInteractiveTransition];
            }
            break;
        default:
            break;
    }
}
@end

注释已经写得很详细了,需要注意的是在完成或者取消的时候一定要调用对应的方法来通知系统。完成了这个类后我们需要再次修改 PresentingViewController的内容。

再添加一个属性

@property (nonatomic, strong)InteractionController *interactiveTransition;

并且在- (void)viewDidLoad中初始化它

- (void)viewDidLoad {
    [super viewDidLoad];
    ...
    // 初始化交互控制器
    _interactiveTransition = [InteractionController new];
}

- (void)buttonClicked中执行- (void)prepareForViewController:(UIViewController *)viewController方法

- (void)buttonClicked {
    ...
    // 添加交互
    [_interactiveTransition prepareForViewController:presentedVC];
    [self presentViewController:presentedVC animated:YES completion:nil];
}

在最后添加协议方法,在方法中要通过 isInteracting 属性来判断是否是执行交互式转场,如果不是则返回 nil

// 返回 dismiss 的交互控制器
- (id<UIViewControllerInteractiveTransitioning>)interactionControllerForDismissal:(id<UIViewControllerAnimatedTransitioning>)animator {
    return _interactiveTransition.isInteracting ? _interactiveTransition : nil;
}

关于普通的 view controller 的自定义转场就到此结束了。如果你按照文章的步骤一步一步写下来,相信你已经完成了一个最简单的自定义转场。

容器 view controller 的自定义转场


UINavigationControllerUITabBarController 都属于 容器VC。与普通的 VC 不同的是,它们通过 UINavigationControllerDelegate或者UITabBarControllerDelegate的代理方法来返回动画控制器,而动画控制器的具体实现,几乎是一模一样的。我放在 github 上的 Demo 中,有这三种自定转场的代码,用的基本上是同一个 animationController ,如果你掌握了前面的内容,那么这里也就不是问题了。具体的代码我就不展开了。

推荐阅读更多精彩内容