View Controller 转场

自定义转场动画

iOS 7 中最让我激动的特性之一就是提供了新的 API 来支持自定义 view contrioller 之间的转场动画。iOS 7 发布之前,我自己写过一些 view controller 之间的转场动画,这是一个比较头疼的过程,而且这种做法并不被苹果完全地支持,尤其是如果你想让这个转场动画有交互式的效果就更难了。

在继续阅读之前,我需要先声明一下:这个 API 是新近才发布的,目前还没有所谓的最佳实践。通常来说,开发者需要探索几个月才能得出关于新 API 的最佳实践。因此请将本文看做对一个新 API 的探索,而非关于这个新 API 的最佳实践介绍。如果您有更好的关于这个 API 的实践,请不吝赐教,我们会把您的实践更新到这篇文章中。

在开始研究新的 API 之前,我们先来看看在 iOS 7 中 navigation controller 之间的默认的行为发生了那些改变:在 navigation controller 中,切换两个 view controller 的动画变得更有交互性。比方说你想要 pop 一个 view controller 出去,你可以用手指从屏幕的左边缘开始拖动,慢慢地把当前的 view controller 向右拖出屏幕去。

接下来,我们来看看这个新 API。很有趣的一个现象是,这部分 API 大量的使用了协议而不是具体的对象。这初看起来有点奇怪,但我个人更喜欢这样的 API 设计,因为这种设计给了我们这些开发者更大的灵活性。下面,让我们来做件简单的事情:在 Navigation Controller 中,实现一个自定义的 push 动画效果(本文中的示例代码托管在 Github)。为了完成这个任务,需要实现 UINavigationControllerDelegate 中的新方法:

编者注 原文的作者在 Github 上面的示例代码和文章中的代码有一些出入(比如下面这里是 Push,但是在示例代码中是 Pop)。如果需要,您也可以参考这个修正版示例代码,和文章的代码差异要小一点。

- (id<UIViewControllerAnimatedTransitioning>)
                   navigationController:(UINavigationController *)navigationController
        animationControllerForOperation:(UINavigationControllerOperation)operation
                     fromViewController:(UIViewController*)fromVC
                       toViewController:(UIViewController*)toVC
{
    if (operation == UINavigationControllerOperationPush) {
        return self.animator;
    }
    return nil;
}

从上面的代码可以看出,我们可以根据不同的 operation(Push 或 Pop)返回不同的 animator。我们可以把 animator 存到一个属性中,从而在多个 operation 之间实现共享,或者我们也可以为每个 operation 都创建一个新的 animator 对象,这里的灵活性很大。

为了让动画运行起来,我们创建一个自定义类,并且实现 UIViewControllerAnimatedTransitioning 这个协议:

@interface Animator : NSObject <UIViewControllerAnimatedTransitioning>

@end

这个协议要求我们实现两个方法,其中一个定义了动画的持续时间:

- (NSTimeInterval)transitionDuration:(id <UIViewControllerContextTransitioning>)transitionContext
{
    return 0.25;
}

另一个方法描述整个动画的执行效果:

- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext
{
    UIViewController* toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIViewController* fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    [[transitionContext containerView] addSubview:toViewController.view];
    toViewController.view.alpha = 0;

    [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
        fromViewController.view.transform = CGAffineTransformMakeScale(0.1, 0.1);
        toViewController.view.alpha = 1;
    } completion:^(BOOL finished) {
        fromViewController.view.transform = CGAffineTransformIdentity;
        [transitionContext completeTransition:![transitionContext transitionWasCancelled]];

    }];

}

从上面的例子中,你可以看到如何运用协议的:这个方法中通过接受一个类型为 id<UIViewControllerContextTransitioning> 的参数,来获取 transition context。值得注意的是,执行完动画之后,我们需要调用 transitionContext 的 completeTransition: 这个方法来更新 view controller 的状态。剩下的代码和 iOS 7 之前的一样了,我们从 transition context 中得到了需要做转场的两个 view controller,然后使用最简单的 UIView animation 来实现了转场动画。这就是全部代码了,我们已经实现了一个缩放效果的转场动画。

注意,这里只是为 Push 操作实现了自定义效果的转场动画,对于 Pop 操作,还是会使用默认的滑动效果,另外,上面我们实现的转场动画无法交互,下面我们就来看看解决这个问题。

交互式的转场动画

想要动画变地可以交互非常简单,我们只需要覆盖另一个 UINavigationControllerDelegate 的方法:

- (id <UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController*)navigationController
                          interactionControllerForAnimationController:(id <UIViewControllerAnimatedTransitioning>)animationController
{
    return self.interactionController;
}

注意,在非交互式动画效果中,该方法返回 nil。

这里返回的 interaction controller 是 UIPercentDrivenInteractionTransition 类的一个实例,开发者不需要任何配置就可工作。我们创建了一个拖动手势(Pan Recognizer),下面是处理该手势的代码:

if (panGestureRecognizer.state == UIGestureRecognizerStateBegan) {
    if (location.x >  CGRectGetMidX(view.bounds)) {
        navigationControllerDelegate.interactionController = [[UIPercentDrivenInteractiveTransition alloc] init];
        [self performSegueWithIdentifier:PushSegueIdentifier sender:self];
    }
} 

编者注 这里的代码有一点示意的意思,和实际代码有些出入,为了尊重原作者,我们没有进行修改,您可以参考原文在 Github 上的示例代码进行对比,也可以参考这个修正版示例代码

只有当用户从屏幕右半部分开始触摸的时候,我们才把下一次动画效果设置为交互式的(通过设置 interactionController 这个属性来实现),然后执行方法 performSegueWithIdentifier:(如果你不是使用的 storyboards,那么就直接调用 pushViewController... 这类方法)。为了让转场动画持续进行,我们需要调用 interaction controller 的一个方法:

else if (panGestureRecognizer.state == UIGestureRecognizerStateChanged) {
    CGFloat d = (translation.x / CGRectGetWidth(view.bounds)) * -1;
    [interactionController updateInteractiveTransition:d];
} 

该方法会根据用户手指拖动的距离计算一个百分比,切换的动画效果也随着这个百分比来走。最酷的是,interaction controller 会和 animation controller 一起协作,我们只使用了简单的 UIView animation 的动画效果,但是interaction controller 却控制了动画的执行进度,我们并不需要把 interaction controller 和 animation controller 关联起来,因为所有这些系统都以一种解耦的方式自动地替我们完成了。

最后,我们需要根据用户手势的停止状态来判断该操作是结束还是取消,并调用 interaction controller 中对应的方法:

else if (panGestureRecognizer.state == UIGestureRecognizerStateEnded) {
    if ([panGestureRecognizer velocityInView:view].x < 0) {
        [interactionController finishInteractiveTransition];
    } else {
        [interactionController cancelInteractiveTransition];
    }
    navigationControllerDelegate.interactionController = nil;
}

注意,当切换完成或者取消的时候,记得把 interaction controller 设置为 nil。因为如果下一次的转场是非交互的, 我们不应该返回这个旧的 interaction controller。

现在我们已经实现了一个完全自定义的可交互的转场动画了。通过简单的手势识别和 UIKit 提供的一个类,用几行代码就达到完成了。对于大部分的应用场景,你读到这儿就够用了,使用上面提到的方法就可以达到你想要的动画效果了。但如果你想更对转场动画或者交互效果进行深度定制,请继续阅读下面一节。

使用 GPUImage 定制动画

下面我们就来看看如何真正的,彻底的定制动画效果。这一次我们不使用 UIView animation,甚至连 Core Animation 也不用,完全自己来实现所有的动画效果。在 Letterpress-style 这个项目中,刚开始我尝试使用 Core Image 来做动画效果,但是在我的 iPhone 4 上,动画的渲染最高只能达到 9 帧/秒,离我想要的 60 帧/秒差得很远。

但是当我使用了 GPUImage 之后,实现一个非常漂亮的动画变的异常简单。这里我们要实现的转场效果是:两个 view controller 像素化,然后相互消融在一起。实现方法是先对两个 view controller 进行截屏,然后再用 GPUImage 的图片滤镜(filter)处理这两张截图。

首先,我们先创建一个自定义类,这个类实现了 UIViewControllerAnimatedTransitioningUIViewControllerInteractiveTransitioning 这两个协议:

@interface GPUImageAnimator : NSObject
  <UIViewControllerAnimatedTransitioning,
   UIViewControllerInteractiveTransitioning>

@property (nonatomic) BOOL interactive;
@property (nonatomic) CGFloat progress;

- (void)finishInteractiveTransition;
- (void)cancelInteractiveTransition;

@end

为了加速动画的运行,我们可以把图片一次加载到 GPU 中,然后所有的处理和绘图都直接在 GPU 上执行,不需要再传送到 CPU 处理(这种数据传输非常慢)。通过使用 GPUImageView,我们就可以直接使用 OpenGL 画图(我们不需要手写 OpenGL 这种底层的代码,只要继续使用 GPUImage 封装好的接口就可以)。

创建滤镜链(filter chain)也非常的直观,我们可以直接在样例代码的 setup 方法中看到如何构造它。比较有挑战的是如何让滤镜也“动”起来。GPUImage 没有直接提供给我们动画效果,因此我们需要每渲染一帧就更新一下滤镜来实现动态的滤镜效果。使用 CADisplayLink 可以完成这个工作:

编者注 原文中的示例代码中缺少了这一章的内容,我在原作者的 Github Gist 上找到了相关的源码,整理之后放到了 Github 上,您可以在这里找到它。

self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(frame:)];
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

frame 方法中,我们可以根据时间来更新动画进度,并相应地更新滤镜:

- (void)frame:(CADisplayLink*)link
{
    self.progress = MAX(0, MIN((link.timestamp - self.startTime) / duration, 1));
    self.blend.mix = self.progress;
    self.sourcePixellateFilter.fractionalWidthOfAPixel = self.progress *0.1;
    self.targetPixellateFilter.fractionalWidthOfAPixel = (1- self.progress)*0.1;
    [self triggerRenderOfNextFrame];
}

好了,基本上这样就完成了。如果你想要实现交互式的转场效果,那么在这里,就不能使用时间,而是要根据手势来更新动画进度,其他的代码基本差不多。

这个功能非常强大,你可以使用 GPUImage 中任何已有的滤镜,或者写一个自己的 OpenGL 着色器(shader)来达到你想要的效果。

结论

本文只探讨了在 navigation controller 中的两个 view controller 之间的转场动画,但是这些做法在 tab bar controller 或者任何你自己定义的 view controller 容器中也是通用的。另外,在 iOS 7 中,UICollectionViewController 也进行了扩展,现在你可以在布局之间进行自动以及交互的动画切换,背后使用的也是同样的机制。这真是太强大了。

在和 Orta 讨论这个 API 的时候,他提到他已经在大量地使用这些机制以创建更轻量的 view controller。与其在一个 view controller 中维护各种状态,不如再创建一个新的 view controller,使用自定义的转场动画,然后在这个转场动画中来移动你的各种 view。

扩展阅读


原文 View Controller Transitions