Swift每日一练:自定义转场在iOS8中的那些坑

之前因为面试的缘故发现了自己在自定义转场这块有点欠缺,今天拿Swift练下手,实现一个自定义转场的效果。没耐心的话请直接翻到最后吧,我前面都是铺垫呢。

首先让我们先来看看最后实现的效果:

下面就让我们一步步来看看是如何实现这个效果的。

自定义转场

要实现自定义转场动画,比较重要的就是三个部分。

  • UIViewControllerContextTransition

    这个接口主要用来提供切换上下文给开发者使用,包含了从哪个VC到哪个VC等各类信息,一般不需要开发者自己实现。

    本文关注的包含了如下一些内容:

    1. - (UIView *)containerView; 
    // VC切换所发生的view容器    
    
    2. - (UIViewController *)viewControllerForKey:(NSString *)key;
    // 根据UITransitionContextFromViewControllerKey和UITransitionContextToViewControllerKey两种,分别返回将要切出和切入的ViewController。
    
    3. - (void)completeTransition:(BOOL)didComplete; 
    // 报告切换已经完成。
    
  • UIViewControllerAnimatedTransition

    这个接口主要用来定义如何完成转场动画,同时定义转场动画的持续时间。(在本文中我们不考虑交互式的转场)

    1. - (NSTimeInterval)transitionDuration:(id < UIViewControllerContextTransitioning >)transitionContext; 
    // 返回转场动画持续的时间
    
    2. - (void)animateTransition:(id < UIViewControllerContextTransitioning >)transitionContext; 
    // 我们自定义的转场要在这里完成
    
  • UIViewControllerTransitionDelegate

    这个接口主要用于指定,我们希望采用哪种转场效果(比如你可以根据不同的状态,切换不同的自定义专场效果)

    1. - (id< UIViewControllerAnimatedTransitioning >)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source;
    // 当弹出模态窗口的时候,使用什么转场效果
    
    2. - (id< UIViewControllerAnimatedTransitioning >)animationControllerForDismissedController:(UIViewController *)dismissed;
    // 当关闭模态窗口的时候,使用什么转场效果
    

实现

知道了转场动画需要的必要条件,我们可以很轻松分别实现三个部分。

第一部分UIViewControllerContextTransition在本文中并没有特殊定制化的地方,直接完成。

第二部分关于UIViewControllerAnimatedTransition的代码如下:

class SwipeAnimator: NSObject, UIViewControllerAnimatedTransitioning {
    enum SwipeTo {
        case Main
        case Modal
    };

    var transitonTo:SwipeTo = .Main

    // 0. 返回动画时间
    func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimeInterval {
        return 1.0
    }

    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
        // 1. 获取相关资源
        let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)!

        let toView = transitionContext.viewForKey(UITransitionContextToViewKey)

        // 2. 弹出模态
        if (self.transitonTo == .Modal) {
            if toView != nil {
                transitionContext.containerView().addSubview(toView!)

                toView!.alpha = 0.0

                // 2.1 以左上角为锚点旋转
                fromVC.view.layer.anchorPoint = CGPoint(x: 0, y: 0)
                fromVC.view.layer.position = CGPointMake(0, 0)

                UIView.animateWithDuration(1.0, animations: { () -> Void in
                    fromVC.view.transform = CGAffineTransformMakeRotation(CGFloat(-M_PI/2))
                    toView!.alpha = 1.0
                }, completion: { (completion:Bool) -> Void in
                    // 2.2 报告转场动画完成
                    transitionContext.completeTransition(!transitionContext.transitionWasCancelled())
                })
            }
        } else {
            // 3. 关闭模态
            if fromView != nil {
                fromView!.alpha = 1.0
                UIView.animateWithDuration(1.0, animations: { () -> Void in
                    toVC.view.transform = CGAffineTransformMakeRotation(0)
                    fromView!.alpha = 0.0
                }, completion: { (completion:Bool) -> Void in
                    transitionContext.completeTransition(!transitionContext.transitionWasCancelled())
                })
            }
        }
    }
}

实现非常简单,我们来一步步看下。

    1. 根据UIViewControllerAnimatedTransition协议返回动画时间
    1. 根据UIViewControllerContextTransition获取我们需要操作的即将切出的ViewController(fromVC)以及即将切入的页面(toView),为什么要用这种获取方式,稍微在重点分析会指出
    1. 当遇到是弹出模态窗口转场的时候,我们首先将toView加入到转场过程提供的一个containView中,然后改变fromVC的锚点,进行旋转,同时我们对toView进行了一个淡入淡出。当转场动画完成以后,在回调的closure中报告转场动画已经完成
    1. 当关闭模态转场的时候,这个时候转场的出和入就正好和弹出的时候截然相反。原先的fromVC成了现在的toVC,因此我们在这里将toVC旋转回原来的位置。当然别忘了我们的淡入淡出啦,原理是一样的,只要改变fromView的alpha即可。动画完成后,依然要报告我们的转场完成了。

第三部分,UIViewControllerTransitionDelegate的实现依然非常简单,我们仅仅需要告知转场发生时,我们具体要采用哪种转场效果就好了。

func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    self.animator.transitonTo = .Modal
    return self.animator
}

func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    self.animator.transitonTo = .Main
    return self.animator
}

在这里,我们复用了同一个转场效果,通过不同的transitionTo参数进行控制。你当然也可以给两个转场分别生成对应不同的转场效果。

iOS8的坑

嘿嘿,重头戏来了,千万别错过。

一开始实现这个转场效果的时候,压根没想到这么复杂,但是,突然发现了一个很大的问题:

当弹出模态窗口的时候,转场效果正常,最后成功显示模态界面。但是当关闭模态窗口的时候,转场效果依然正确,但是转场结束后,整个屏幕都黑了。

What the F*ck!!!

我以为是我自己实现有问题,但是我去Github上找了几个著名的转场效果跑了下,都存在这个问题,那我就百思不得其解了呀!!!

误打误撞

从网上搜寻了很久之后,我还是没有头绪,于是我首先尝试将如下代码中

var modalVC = UIStoryboard(name: "Main", bundle: nil).instantiateViewControllerWithIdentifier("ModalViewController") as! UIViewController
modalVC.transitioningDelegate = self
modalVC.modalPresentationStyle = .Custom
presentViewController(modalVC, animated: true, completion: nil)

modalVC.modalPresentationStyle = .Custom中的.Custom改成了.FullScreen。
这一下子就给我整好了!

所以,给大家提个醒,如果遇到相似的问题,解决方法很简单,就是.FullScreen即可。

深层原因

作为一个站在红旗下的三好学生,弄懂问题的深层原因才是最主要的,通过不过的debug,我终于弄懂了。

首先,我们要强调一个基本知识。一个UIView的superview最多只能由一个。当一个本身处于别的UIView下的subView被添加到另一个UIView上的时候,它就自动被从前一个UIView的Hierarchy中移除了。

还记得我们之前一个很奇怪的写法吗?
根据UIViewControllerContextTransition获取我们需要操作的即将切出的ViewController(fromVC)以及即将切入的页面(toView)

我们这么写的原因就是因为我们刚刚强调的基本知识,别急,让我们一步步来解析。

在iOS8中,苹果提供了一个新的API:

@availability(iOS, introduced=8.0)
func viewForKey(key: String) -> UIView?

并且在文档中明确强调了一点:

// Currently only two keys are defined by the
// system - UITransitionContextToViewControllerKey, and
// UITransitionContextFromViewControllerKey. 
// Animators should not directly manipulate a view controller's views and should
// use viewForKey: to get views instead.
func viewControllerForKey(key: String) -> UIViewController?

什么意思呢?
之前在iOS7中,开发者需要通过viewControllerForKey这个方法获取切入切出的ViewController,并直接操作ViewController对应的View来编写转场动画。而在iOS8以后,苹果规定必须使用viewForKey来获取fromView和toView来进行转场动画的操作。

那这个API的更改和黑屏有什么关联呢?

  • 在整个转场过程中,我们都依赖于转场上下文transitonContext提供的containerView容器进行view动画的操作。这是因为在转场完成前,即将切入的viewcontroller都不存在于当前的可视界面的视图层级内(View Hierarchy)。因此,苹果提供了一个过渡的容器给我们使用(如果大家debug下的话,就会发现在转场过程之中,UIWindow上多了一个UITransitionView,就是切换上下文的containerView)。
  • 在iOS7的实现中,我们需要将fromVC.view和toVC.view都通过addSubView的方式添加到容器View上进行动画展示。由于是直接操作了ViewController的view,因此,fromVC的view会被从当前的视图层级中移除
  • 但是,iOS7中,会在转场动画完成后,自动将fromVC的view添加回原先fromVC从属的父视图中
  • iOS8中不会
  • 在本文的初版实现中,在转场过程中当判断transitionTo == .Main的时候,将此时toViewController.view (也就是原先的主窗口) 添加到了containerView上。因此,当转场结束的时候,containerView从window可见视图层级中移除了,因此就变得不可见,从而变成黑屏了。

那么为什么模态窗口在转场过程后可见呢?

  • 因为这个特性依然正确。

containerView到底是啥?

  • 就是一个过渡的UITransitionView,一个转场效果对应生成一个(会复用)。
  • 在转场结束后自动从视图层级中移除,因此不需要大家手动进行removeFromSuperView。

那么viewForKey和viewControlelrForKey直接操纵view的区别呢?

  • viewForKey很可能返回的是一个完全克隆VC的view的对象。

到这,相信大家都弄懂了吧,我真是太佩服我自己了。