Skip to content

Custom Container View Controller Transition

seedante edited this page Mar 21, 2016 · 1 revision

自定义容器控制器转场

本文是 iOS 视图控制器转场详解的压轴章节,由于原文章太长,而本文的内容绝大多数人不需要,单独成文。

效果图:

ButtonTransition ContainerVC Interacitve Transition

Demo 地址:CustomContainerVCTransition

分析一下思路,这个控制器和 UITabBarController 在行为上比较相似,只是 TabBar 由下面跑到了上面。我们可以使用 UITabBarController 子类,然后打造一个伪 TabBar 放在顶部,原来的 TabBar 则隐藏,行为上完全一致,使用 UITabBarController 子类的好处是可以减轻实现转场的负担,不过,有时候这样的子类不是你想要的,UIViewController 子类能够提供更多的自由度,好吧,一个完全模仿 UITabBarController 行为的 UIViewController 子类,实际上我没有想到非得这样做的原因,但我想肯定有需要定制自己的容器控制器的场景,这正是本节要探讨的。Objc.io 也讨论过这个话题,文章的末尾把实现交互控制当做作业留了下来。珠玉在前,我就站在大牛的肩上继续这个话题吧。Objc.io 的这篇文章写得较早使用了 Objective-C 语言,如果要读者先去读这篇文章再继续读本节的内容,难免割裂,所以本节还是从头讨论这个话题吧,最终效果如上面所示,在自定义的容器控制器中实现交互控制切换子视图,也可以通过填充了 UIButton 的 ButtonTabBar 来实现 TabBar 一样行为的 Tab 切换,在通过手势切换页面时 ButtonTabBar 会实现渐变色动画。ButtonTabBar 有很大扩展性,改造或是替换为其他视图还是有很多应用场景的。

实现分析

既然这个自定义容器控制器和 UITabBarController 行为类似,我便实现了一套类似的 API:viewControllers数组是容器 VC 维护的子 VC 数组,初始化时提供要显示的子 VC,更改selectedIndex的值便可跳转到对应的子视图。利用 Swift 的属性观察器实现修改selectedIndex时自动执行子控制器转场。下面是实现子 VC 转场的核心代码,转场结束后遵循惯例将 fromView 移除:

class SDEContainerViewController: UIViewController{
    ...
    //发生转场的容器视图,是 root view 的子视图。
    private let privateContainerView = UIView()
    var selectedIndex: Int = NSNotFound{
        willSet{
            transitionViewControllerFromIndex(selectedIndex, toIndex: newValue)
        }
    }
    //实现 selectedVC 转场:
    private func transitionViewControllerFromIndex(fromIndex: Int, toIndex: Int){
        //添加 toVC 和 toView
        let newSelectedVC = viewControllers![toIndex]
        self.addChildViewController(newSelectedVC)
        self.privateContainerView.addSubview(newSelectedVC.view)
        newSelectedVC.didMoveToParentViewController(self)
    
        UIView.animateWithDuration(transitionDuration, animations: {
            /*转场动画*/
            }, completion: { finished in
                //移除 fromVC 和 fromView。
                let priorSelectedVC = viewControllers![fromIndex]
                priorSelectedVC.willMoveToParentViewController(nil)
                priorSelectedVC.view.removeFromSuperview()
                priorSelectedVC.removeFromParentViewController()
        })
    }
}

当然,这里还有另外一个选择:

transitionFromViewController:toViewController:duration:options:animations:completion:

为什么不用这个方法呢?嗯,主要是我当初实现的时候没意识到这个方法,马后炮地想一下,不知道实现交互控制时会遇到什么问题,如果你不需要实现交互控制,完全可以使用这个方法,有兴趣的话,你可以自己尝试一下。

实现转场就是这么十几行代码而已,转场协议这套 API 将这个过程分割为五个组件,这套复杂的结构带来了可高度自定义的动画效果和交互控制。我们温习下转场协议,来看看如何在既有的转场协议框架下实现自定义容器控制器的转场动画以及交互控制:

  1. 转场代理:既有的转场代理协议并没有直接支持我们这种转场方式,没关系,我们自定义一套代理协议来提供动画控制器和交互控制器;
  2. 动画控制器:动画控制器是可复用的,这里采用动画控制器章节封装的 Slide 动画控制器,可以拿来直接使用而不用修改;
  3. 交互控制器:官方封装了一个现成的交互控制器类,但这个类是与 UIKit 提供的转场环境对象配合使用的,而这里的转场显然需要我们来提供转场环境对象,因此UIPercentDrivenInteractiveTransition无法在这里使用,需要我们来实现这个协议;
  4. 转场环境:在官方支持的转场方式中,转场环境是由 UIKit 主动提供给我们的,既然现在的转场方式不是官方支持的,显然需要我们自己提供这个对象以供动画控制器和交互控制器使用;
  5. 转场协调器:在前面的章节中我提到过,转场协调器(Transition Coordinator)的使用场景有限而关键,也是由系统提供,我们也可以重写相关方法来提供。这个部分我留给读者当作是本文的一道作业吧。

下面我们来将上面的十几行代码(不包括实际的动画代码)使用协议封装成本文前半部分里熟悉的样子。

协议补完

模仿 UITabBarControllerDelegate 协议的 ContainerViewControllerDelegate 协议:

//在 Swift 协议中声明可选方法必须在协议声明前添加 @objc 修饰符。
@objc protocol ContainerViewControllerDelegate{
    func containerController(containerController: SDEContainerViewController, animationControllerForTransitionFromViewController 
                                          fromVC: UIViewController, 
                           toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning?
    optional func containerController(containerController: SDEContainerViewController, interactionControllerForAnimation 
                                      animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning?
}

在容器控制器SDEContainerViewController类中,添加转场代理属性:

weak var containerTransitionDelegate: ContainerViewControllerDelegate?

代理的定位就是提供动画控制器和交互控制器,系统打包的UIPercentDrivenInteractiveTransition类只是调用了转场环境对象的对应方法而已,执行navigationController.pushViewController(toVC, animated: true)这类语句触发转场后 UIKit 就接管了剩下的事情,再综合文档的描述,可知转场环境便是实现这一切的核心。

在文章前面的部分里转场环境对象的作用只是提供涉及转场过程的信息和状态,现在需要我们实现该协议,并且实现隐藏的那部分职责。 <UIViewControllerContextTransitioning>协议里的绝大部分方法都是必须实现的,不过现在我们先实现非交互转场的部分,实现这个是很简单的,主要是调用动画控制器执行转场动画。在「实现分析」一节里我们看到实现转场的代码只有十几行而已,动画控制器需要做的只是处理视图和动画的部分,转场环境对象则要负责管理子 VC,通过SDEContainerViewController提供 containerView 以及 fromVC 和 toVC,实现并不是难事。显然由我们实现的自定义容器 VC 来提供转场环境对象是最合适的,并且转场环境对象应该是私有的,其初始化方法极其启动转场的方法如下:

class ContainerTransitionContext: NSObject, UIViewControllerContextTransitioning{
    init(containerViewController: SDEContainerViewController, 
                   containerView: UIView, 
       fromViewController fromVC: UIViewController, 
           toViewController toVC: UIViewController){...}
           
    //非协议方法,是启动非交互式转场的便捷方法。
    func startNonInteractiveTransitionWith(delegate: ContainerViewControllerDelegate){
        //转场开始前添加 toVC,转场动画结束后会调用 completeTransition: 方法,在该方法里完成后续的操作。
        self.privateContainerViewController.addChildViewController(privateToViewController)
        //通过 ContainerViewControllerDelegate 协议定义的方法生成动画控制器,方法名太长了略去。
        self.privateAnimationController = delegate.XXXmethod
        //启动转场并执行动画。
        self.privateAnimationController.animateTransition(self)
    }
    //协议方法,动画控制器在动画结束后调用该方法,完成管理子 VC 的后续操作,并且考虑交互式转场可能取消的情况撤销添加的子 VC。
    func completeTransition(didComplete: Bool) {
        if didComplete{
            //转场完成,完成添加 toVC 的工作,并且移除 fromVC 和 fromView。
            self.privateToViewController.didMoveToParentViewController(privateContainerViewController)
            self.privateFromViewController.willMoveToParentViewController(nil)
            self.privateFromViewController.view.removeFromSuperview()
            self.privateFromViewController.removeFromParentViewController()
        }else{
            //转场取消,移除 toVC 和 toView。
            self.privateToViewController.didMoveToParentViewController(privateContainerViewController)
            self.privateToViewController.willMoveToParentViewController(nil)
            self.privateToViewController.view.removeFromSuperview()
            self.privateToViewController.removeFromParentViewController()
        }
        //非协议方法,处理收尾工作:如果动画控制器实现了 animationEnded: 方法则执行;如果转场取消了则恢复数据。
        self.transitionEnd()
    }
}

SDEContainerViewController类中,添加转场环境属性:

private var containerTransitionContext: ContainerTransitionContext?

并修改transitionViewControllerFromIndex:toIndex方法实现自定义容器 VC 转场动画:

private func transitionViewControllerFromIndex(fromIndex: Int, toIndex: Int){
    if self.containerTransitionDelegate != nil{
        let fromVC = viewControllers![fromIndex]
        let toVC = viewControllers![toIndex]
        self.containerTransitionContext = ...//利用 fromVC 和 toVC 初始化。
        self.containerTransitionContext?.startNonInteractiveTransitionWith(containerTransitionDelegate!)
    }else{/*没有提供转场代理的话,则使用最初没有动画的转场代码,或者提供默认的转场动画*/}
}

这样我们就利用协议实现了自定义容器控制器的转场动画,可以使用第三方的动画控制器来实现不同的效果。

不过要注意这几个对象之间错综复杂的引用关系避免引用循环,关系图如下:

Reference in Transition

交互控制

交互控制器的协议<UIViewControllerInteractiveTransitioning>仅仅要求实现一个必须的方法:

func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning)

根据文档的描述,该方法用于配置以及启动交互转场。我们前面使用的UIPercentDrivenInteractiveTransition类提供的更新进度的方法只是调用了转场环境对象的相关方法。所以,是转场环境对象替交互控制器把脏活累活干了,我们的实现还是维持这种关系好了。正如前面说的,「交互手段只是表现形式,本质是驱动转场进程」,让我们回到转场环境对象里实现对动画进度的控制吧。

怎么控制动画的进度?这个问题的本质是怎么实现对 UIView 的 animateWithDuration:animations:completion:这类方法生成的动画的控制。能够控制吗?能。

动画控制和 CAMediaTiming 协议

这个协议定义了一套时间系统,是控制动画进度的关键。UIView Animation 是使用 Core Animation 框架实现的,也就是使用 UIView 的 CALayer 对象实现的动画,而 CALayer 对象遵守该协议。

在交互控制器的小节里我打了一个比方,交互控制器就像一个视频播放器一样控制着转场动画这个视频的进度。依靠 CAMediaTiming 这套协议,我们可以在 CALayer 对象上对添加的动画实现控制。官方的实现很有可能也是采用了同样的手法。CAMediaTiming 协议中有以下几个属性:

//speed 作用类似于播放器上控制加速/减速播放,默认为1,以正常速度播放动画,为0时,动画将暂停。
var speed: Float 
//修改 timeOffset 类似于拖动进度条,对一个2秒的动画,该属性为1的话,动画将跳到中间的部分。
//但当动画从中间播放到预定的末尾时,会续上0秒到1秒的动画部分。
var timeOffset: CFTimeInterval
//动画相对于父 layer 延迟开始的时间,这是一个实际作用比字面意义复杂的属性。 
var beginTime: CFTimeInterval  

Core Animation 的文档中提供了如何暂停和恢复动画的示例:How to pause the animation of a layer tree。我们将之利用实现对进度的控制,这种方法对其中的子视图上添加的动画也能够实现控制,这正是我们需要的。假设在 containerView 中的 toView 上执行一个简单的沿着 X 轴方向移动 100 单位的位移动画,由executeAnimation()方法执行。下面是使用手势控制该动画进度的核心代码:

func handlePan(gesture: UIPanGestureRecognizer){
    switch gesture.state{
    case .Began:
        //开始动画前将 speed 设为0,然后执行动画,动画将停留在开始的时候。
        containerView.layer.speed = 0
        //在transitionContext里,这里替换为 animator.animateTransition(transitionContext)。
        executeAnimation() 
    case .Changed:
        let percent = ...
        //此时 speed 依然为0,调整 timeOffset 可以直接调整动画的整体进度,这里的进度控制以时间计算,而不是比例。
        containerView.layer.timeOffset = percent * duration
    case .Ended, .Cancelled:
        if progress > 0.5{
            //恢复动画的运行不能简单地仅仅将 speed 恢复为1,这是一套比较复杂的机制。
            let pausedTime = view.layer.timeOffset
            containerView.layer.speed = 1.0 
            containerView.layer.timeOffset = 0.0
            containerView.layer.beginTime = 0.0
            let timeSincePause = view.layer.convertTime(CACurrentMediaTime(), fromLayer: nil) - pausedTime
            containerView.layer.beginTime = timeSincePause
        }else{/*逆转动画*/}
        default:break
    }
}
取消转场

交互控制动画时有可能被取消,这往往带来两个问题:恢复数据和逆转动画。

这里需要恢复的数据是selectedIndex,我们在交互转场开始前备份当前的selectedIndex,如果转场取消了就使用这个备份数据恢复。逆转动画反而看起来比较难以解决。

在上面的 pan 手势处理方法中,我们如何逆转动画的运行呢?既然speed为0时动画静止不动,调整为负数是否可以实现逆播放呢?不能,效果是视图消失不见。不过我们还可以调整timeOffset属性,从当前值一直恢复到0。问题是如何产生动画的效果?动画的本质是视图属性在某段时间内的连续变化,当然这个连续变化并不是绝对的连续,只要时间间隔够短,变化的效果就会流畅得看上去是连续变化,在这里让这个变化频率和屏幕的刷新同步即可,CADisplayLink可以帮助我们实现这点,它可以在屏幕刷新时的每一帧执行绑定的方法:

//在上面的/*逆转动画*/处添加以下两行代码:
let displayLink = CADisplayLink(target: self, selector: "reverseAnimation:")
displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSDefaultRunLoopMode)

func reverseAnimation(displayLink: CADisplayLink){
    //displayLink.duration表示每一帧的持续时间,屏幕的刷新频率为60,duration = 1/60。
    //这行代码计算的是,屏幕刷新一帧后,timeOffset 应该回退一帧的时间。
    let timeOffset = view.layer.timeOffset - displayLink.duration
    if timeOffset > 0{
        containerView.layer.timeOffset = timeOffset
    }else{
        //让 displayLink 失效,停止对当前方法的调用。
        displayLink.invalidate()
        //回到最初的状态。
        containerView.layer.timeOffset = 0
        //speed 恢复为1后,视图立刻跳转到动画的最终状态。
        containerView.layer.speed = 1
    }
}

最后一句代码会令人疑惑,为何让视图恢复为最终状态,与我们的初衷相悖。speed必须恢复为1,不然后续发起的转场动画无法顺利执行,视图也无法响应触摸事件,直接原因未知。但speed恢复为1后会出现一个问题:由于在原来的动画里 fromView 最终会被移出屏幕,尽管 Slide 动画控制器 UIView 动画里的 completion handle 里会恢复 fromView 和 toView 的状态,这种状态的突变会造成闪屏现象。怎么解决?添加一个假的 fromView 到 containerView替代已经被移出屏幕外的真正的 fromView,然后在很短的时间间隔后将之移除,因为此时 fromView 已经归位。在恢复speed后添加以下代码:

let fakeFromView = privateFromViewController.view.snapshotViewAfterScreenUpdates(false)
containerView.addSubview(fakeFromView)
performSelector("removeFakeFromView:", withObject: fakeFromView, afterDelay: 1/60)
//在 Swift 中动态调用私有方法会出现无法识别的选择器错误,解决办法是将私有方法设置为与 objc 兼容,需要添加 @objc 修饰符。
@objc private func removeFakeFromView(fakeView: UIView){
    fakeView.removeFromSuperview()
}

经过试验,上面用来控制和取消 UIView 动画的方法也适用于用 Core Animation 实现的动画,毕竟 UIView 动画是用 Core Animation 实现的。不过,我们在前面提到过,官方对 Core Animation 实现的交互转场动画的支持有缺陷,估计官方鼓励使用更高级的接口吧,因为转场动画结束后需要调用transitionContext.completeTransition(!isCancelled),而使用 Core Animation 完成这一步需要进行恰当的配置,实现的途径有两种且实现并不简单,相比之下 UIView 动画使用 completion block 对此进行了封装,使用非常方便。转场协议的结构已经比较复杂了,选择 UIView 动画能够显著降低实现成本。

上面的实现忽略了一个细节:时间曲线。逆转动画时每一帧都回退相同的时间,也就是说,逆转动画的时间曲线是线性的。交互控制器的协议<UIViewControllerInteractiveTransitioning>还有两个可选方法:

optional func completionCurve() -> UIViewAnimationCurve
optional func completionSpeed() -> CGFloat

这两个方法记录了动画采用的动画曲线和速度,在逆转动画时如果能够根据这两者计算出当前帧应该回退的时间,那么就能实现完美的逆转,显然这是一个数学问题。恩,我们跳过这个细节吧,因为我数学不好,讨论这个问题很吃力。推荐阅读 Objc.io 的交互式动画一文,该文探讨了如何打造自然真实的交互式动画。

最后的封装

接下来要做的事情就是将上述代码封装在转场环境协议要求实现的三个方法里:

func updateInteractiveTransition(percentComplete: CGFloat)
func finishInteractiveTransition()
func cancelInteractiveTransition()

正如系统打包的UIPercentDrivenInteractiveTransition类只是调用了 UIKit 提供的转场环境对象里的同名方法,我实现的SDEPercentDrivenInteractiveTransition类也采用了同样的方式调用我们实现的ContainerTransitionContext类的同名方法。

引入交互控制器后的转场引用关系图:

Reference in Transition with Interactor

回到SDEContainerViewController类里修改转场过程的入口处:

private func transitionViewControllerFromIndex(fromIndex: Int, toIndex: Int){
    ...
    if containerTransitionDelegate != nil{
        let fromVC = viewControllers![fromIndex]
        let toVC = viewControllers![toIndex]
        self.containerTransitionContext = ...//利用 fromVC 和 toVC 初始化。
        //interactive 属性标记是否进入交互状态,由手势来更新该属性的状态。
        if interactive{
            priorSelectedIndex = fromIndex //备份数据,以备取消转场时使用。
            self.containerTransitionContext?.startInteractiveTranstionWith(containerTransitionDelegate!)
        }else{
            self.containerTransitionContext?.startNonInteractiveTransitionWith(containerTransitionDelegate!)
        }
    }else{/*没有提供转场代理的话,则使用最初没有动画的转场代码,或者提供默认的转场动画*/}
} 

实现手势控制的部分就如前面的交互控制器章节里的那样,完整的代码请看 Demo。

顺便说下 ButtonTabButton 在交互切换页面时的渐变色动画,这里我只是随着转场的进度更改了 Button 的字体颜色而已。那么当交互结束时如何继续剩下的动画或者取消渐变色动画呢,就像交互转场动画的那样。答案是CADidplayLink,前面我使用它在交互取消时逆转动画,这里使用了同样的手法。

关于转场协调器,文档表明在转场发生时transitionCoordinator()返回一个有效对象,但系统并不支持当前的转场方式,测试表明在当前的转场过程中这个方法返回的是 nil,需要重写该方法来提供。该对象只需要实现前面提到三个方法,其中在交互中止时执行绑定的闭包的方法可以通过通知机制来实现,有点困难的是两个与动画控制器同步执行动画的方法,其需要精准地与动画控制器中的动画保持同步,这两个方法都要接受一个遵守<UIViewControllerTransitionCoordinatorContext>协议的参数,该协议与转场环境协议非常相似,这个对象可以由我们实现的转场环境对象来提供。不过既然现在由我们实现了转场环境对象,也就知道了执行动画的时机,提交并行的动画似乎并不是难事。这部分就留给读者来挑战了。

原文: iOS 视图控制器转场详解