自定义视图控制器转场动画

TransitionXmind.png

iOS 提供了一些视图控制器转场动画(transition animation),如push、pop、present、dismiss。同时也提供了创建自定义转场动画方法。

这篇文章将介绍UIViewController自定义转场动画,涉及内容如下:

  • 转场动画的结构。
  • 如何使用自定义转场动画present、dismiss视图控制器。
  • 如何创建交互式转场动画。

1. 创建demo

pro648/BasicDemos-iOS/tree/master/TransitionAnimation下载demo模版,运行后如下:

TransitionPreview.gif

Demo 使用UIPageViewController显示多个卡片,点击卡片显示动物图片和名称。

目前视图导航部分使用的系统标准转场动画,后续部分会将其替换为自定义转场动画。

如果你对视图控制器转场还不了解,可以先查看我的另一篇文章:View Controller 转场

2. 转场相关 API

转场相关 API 由多个协议组成,方便为已有对象添加转场动画。下面图片显示了转场动画 API 主要组成部分:

TransitionAPI.jpg

2.1 The Transitioning Delegate

Transitioning Delegate 是转场动画的起点,是遵守UIViewControllerTransitioningDelegate协议的对象。使用 transitioning delegate 为 UIKit 提供以下对象:

  • 动画对象(Animator object):animator object 对象负责创建显示、隐藏视图控制器的视图。Transitioning delegate 可以为 present、dismiss 提供不同的 animator object。animator object 对象需遵守UIViewControllerAnimatedTransitioning协议。

  • 交互式动画对象(Interactive animator object):交互式动画对象使用触摸事件、手势,驱动动画的进度。交互式动画对象遵守UIViewControllerInteractiveTransitioning协议。

    继承UIPercentDrivenInteractiveTransition类是创建交互式动画最简单的方法。使用该类管理 animator object 动画进度。如果不继承自UIPercentDrivenInteractiveTransition,创建自己的 interactive animator,则需负责渲染动画的每一帧。

  • Presentation controller:管理视图控制器呈现在屏幕上的样式。系统提供了默认呈现样式,你可以使用UIPresentationController创建自定义样式。

为视图控制器的transitioningDelegate属性赋值后,UIKit 就会使用你提供的自定义动画。如果没有设置,UIKit 使用视图控制器的modalTransitionStyle属性指定的动画样式。

下图显示了 transitioning delegate、animator object 和 presented view controller 关系:

TransitionCustomPresentationAndAnimatorObjects.png

2.2 Animator object

Animator object 是一个遵守UIViewControllerAnimatedTransitioning协议的对象,负责具体的动画过程。Animator object 的关键是animateTransition(using:)方法,在该方法中创建动画。动画过程大致分为以下三个阶段:

  • 配置动画参数。
  • 使用 Core AnimationUIView等创建动画。
  • 清理视图并结束动画。

2.3 Transitioning Context

转场动画开始前,UIKit 创建了 transitioning context 对象。Transitioning context 对象实现了UIViewControllerContextTransitioning协议,并存储了动画涉及的视图控制器、视图等,以及是否是交互式动画。

构建自定义动画时,永远使用 transitioning context 提供的对象和数据,不要使用自己缓存的对象。转场可能发生于多种情况下,有些情况下动画参数可能发生变化。Transitioning context 可以确保获得正确的动画参数,而你缓存的信息可能会过期。

Animator object 对象从animateTransition(using:)方法接收 transitioning context。创建的动画应发生在 container view 上。例如,present 视图控制器时,将其视图作为子视图添加到 container view。Container view 可能是屏幕,也可能是一个普通的视图,但永远用于配置动画。下图显示了 transition context 对象与其他对象的关系:

TransitionTransitioningContextObject.png

3. 转场流程

3.1 Presentation 转场流程

当 presented view controller 的transitioningDelegate属性包含有效对象时,UIKit 使用自定义 animator object 呈现动画。准备动画时,UIKit 调用animationController(forPresented:presenting:source)方法获取自定义的 animator object。如果能获取到 animator object,UIKit执行以下步骤:

  1. UIKit 调用interactionControllerForPresentation(using:)方法,查看是否有交互动画对象。如果返回nil,则不使用交互式动画。

  2. UIKit 调用 animator object 的transitionDuration(using:)方法,获取动画时长。

  3. UIKit 调用相应方法,开始动画:

    • 对于非交互式动画,UIKit 调用 animator object 的 animateTransition(using:)方法。
    • 对于交互式动画,UIKit 调用UIViewControllerInteractiveTransitioningstartInteractiveTransition(_:)方法。
  4. UIKit 等待调用completeTransition(_:)方法。

    动画结束时,Animator 对象必须调用completeTransition(_:)方法,一般在 completion block 内调用。调用该方法后系统才知道动画已经结束,并调用present(_:animated:completion:)方法的completion handler,以及 animator 的animationEnded(_:)方法。

3.2 自定义 presentation 转场

创建以下动画效果:

  • 点击卡片,卡片翻转显示宠物图片。
  • 翻转后放大图片至全屏。
3.2.1 创建 animator

Present 视图控制器时,presented view controller 的视图使用动画进入最终位置。其它视图可能也会使用动画一起呈现,但动画的主要部分是 presented view controller 的视图。配置动画的主要步骤是相同的,从 transitioning context 获取所需对象和数据,并创建动画。

配置 present 动画:

  • 使用viewController(forKey:)view(forKey:)方法获取动画视图控制器、视图。

    from 是动画开始前显示的,to 是动画结束后显示的。

  • 设置 toView 起始位置,设置其他属性的初始值。

  • 使用finalFrame(for:)方法获取 toView 的终点位置。

  • 添加 toView 到 container view。

  • 创建动画:

    • 在 animation block 设置 toView 的终点位置,设置其它属性的终点值。
    • 在 completion block,调用completeTransition(_:)方法告诉系统动画已经结束,执行其他清理工作。

首先创建 animation controller,其必须遵守UIViewControllerAnimatedTransitioning协议。

class PresentAnimationController: NSObject, UIViewControllerAnimatedTransitioning {

}

动画需要使用卡片frame作为起点,添加以下代码存储相关信息:

    private let originFrame: CGRect
    
    init(originFrame: CGRect) {
        self.originFrame = originFrame
    }

实现transitionDuration(using:)方法,指定动画时长。

    /// 动画时长
    private let duration: TimeInterval = 0.6
    
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return duration
    }

开发过程中,可以将 duration 设置长一些,也可以开启 Simulator 的 Debug > Slow Animations。

animateTransition(using:)方法中,设置动画:

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // 获取被替换的和即将显示的视图控制器,以及转场后视图快照。
        guard let fromVC = transitionContext.viewController(forKey: .from),
            let toVC = transitionContext.viewController(forKey: .to),
            let snapshot = toVC.view.snapshotView(afterScreenUpdates: true) else { return }
        
        // 所有动画发生于 containerView 上,获取 containerView 和最终视图大小。
        let containerView = transitionContext.containerView
        let finalFrame = transitionContext.finalFrame(for: toVC)
        
        // 配置 snapshot frame和其它属性,以刚好覆盖在 card 上。
        snapshot.frame = originFrame
        snapshot.layer.cornerRadius = CardViewController.cardCornerRadius
        snapshot.layer.masksToBounds = true
        
        // containerView 只包含 fromVC 的view,添加toView和snapshot。
        containerView.addSubview(toVC.view)
        containerView.addSubview(snapshot)
        toVC.view.isHidden = true
        
        AnimationHelper.perspectiveTransform(for: containerView)
        snapshot.layer.transform = AnimationHelper.yRotation(.pi/2)
        
        // UIView动画时长必须与转场动画时长一致。
        UIView.animateKeyframes(withDuration: duration, delay: 0, options: .calculationModeCubic, animations: {
            // 绕y轴旋转 fromView 90度,以隐藏 fromView
            UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 1/3) {
                fromVC.view.layer.transform = AnimationHelper.yRotation(-.pi/2)
            }
            
            // 再次旋转 snapshot,以显示它。
            UIView.addKeyframe(withRelativeStartTime: 1/3, relativeDuration: 1/3) {
                snapshot.layer.transform = AnimationHelper.yRotation(0.0)
            }
            
            // 放大 snapshot 至全屏。
            UIView.addKeyframe(withRelativeStartTime: 2/3, relativeDuration: 1/3) {
                snapshot.frame = finalFrame
                snapshot.layer.cornerRadius = 0
            }
        }) { (_) in
            // snapshot已经是全屏,隐藏snapshot显示出 toView。
            toVC.view.isHidden = false
            snapshot.removeFromSuperview()
            fromVC.view.layer.transform = CATransform3DIdentity
            
            // 调用completeTransition(_:)告诉系统动画已经结束。
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
    }

如果想直接绘制内容,不使用视图,使用animateTransition(using:)方法配置CADisplayLink

3.2.2 使用 animator

UIKit 使用视图控制器的transitioningDelegate属性指定对象提供的 animator。因此,视图控制器需遵守UIViewControllerTransitioningDelegate协议。这这个demo中,CardViewController将作为 transitioning delegate。

打开CardViewController.swift,添加以下 extension:

extension CardViewController: UIViewControllerTransitioningDelegate {
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return PresentAnimationController(originFrame: cardView.frame)
    }
}

最后,为CardViewControllertransitioningDelegate属性赋值。在handleTap()present(_:animated:completion:)方法前添加以下代码:

        revealVC.transitioningDelegate = self

这里是为 presented view controller 的transitioningDelegate属性赋值,而非 presenting view controller。

运行后效果如下:

TransitionPresentation.gif

3.3 Dismiss 转场流程

当 dismiss 视图控制器时,UIKit 调用 transitioning delegate 的animaitonController(forDismissed:)方法,并执行以下步骤:

  1. UIKit 调用transitioningDelegateinteractionControllerForDismissal(using:)方法,查看是否支持交互式动画。如果该方法返回nil,则不使用交互式动画。

  2. UIKit 调用 animator object 的transitionDuration(using:)方法,获取动画时长。

  3. UIKit 调用相应方法,开始动画:

    • 对于非交互式动画,UIKit 调用 animator object 的 animateTransition(using:)方法。
    • 对于交互式动画,UIKit 调用UIViewControllerInteractiveTransitioningstartInteractiveTransition(_:)方法。
  4. UIKit 等待调用completeTransition(_:)方法。

    动画结束时,Animator 对象必须调用completeTransition(_:)方法,一般在 completion block 内调用。调用该方法后系统才知道动画已经结束,并调用present(_:animated:completion:)方法的completion handler,以及 animator 的animationEnded(_:)方法。

可以看到,present 和 dismiss 转场动画流程很相似,只有获取 animation controller、interaction controller 的方法有所不同。

3.4 自定义 dismiss 转场

这里的 dismiss 转场动画按照呈现动画相反的顺序执行动画。

配置 dismiss 动画:

  • 使用viewController(forKey:)view(forKey:)方法获取动画视图控制器、视图。

  • 计算 fromView 的终点位置。

  • 添加 toView 到 container view。

    Present 时,动画结束后会移除 presenting view controller 视图。因此,dismiss 时需先添加视图到 container view。

  • 创建动画:

    • 在 animation block,设置 fromView 终点位置,设置其它属性终点值。
    • 在 completion block,从视图层级移除 fromView,调用completeTransition(_:)方法告诉系统动画已经结束,执行其他清理工作。

创建DismissAnimationController.swift文件,并添加以下代码:

class DismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
    
    /// 转场结束时动画终点
    private let destinationFrame: CGRect
    private let duration: TimeInterval = 0.6
    
    // 没有交互式
    init(destinationFrame: CGRect) {
        self.destinationFrame = destinationFrame
    }
    
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return duration
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    
    }
}

更新animateTransition(using:)方法如下:

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // 这次需要操控 fromView
        guard let fromVC = transitionContext.viewController(forKey: .from),
            let toVC = transitionContext.viewController(forKey: .to),
            let snapshot = fromVC.view.snapshotView(afterScreenUpdates: false) else { return }
        
        snapshot.layer.cornerRadius = CardViewController.cardCornerRadius
        snapshot.layer.masksToBounds = true
        
        // 视图层级需是:toView、fromView、snapshot
        let containerView = transitionContext.containerView
        containerView.insertSubview(toVC.view, at: 0)
        containerView.addSubview(snapshot)
        fromVC.view.isHidden = true
        
        // 先旋转 toView,以便动画开始时不立即显示 toView。
        AnimationHelper.perspectiveTransform(for: containerView)
        toVC.view.layer.transform = AnimationHelper.yRotation(-.pi/2)
        
        UIView.animateKeyframes(withDuration: duration, delay: 0, options: .calculationModeCubic, animations: { // 动画顺序与 present 相反
            UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 1/3) {
                snapshot.frame = self.destinationFrame
            }
            
            UIView.addKeyframe(withRelativeStartTime: 1/3, relativeDuration: 1/3) {
                snapshot.layer.transform = AnimationHelper.yRotation(.pi/2)
            }
            
            UIView.addKeyframe(withRelativeStartTime: 2/3, relativeDuration: 1/3) {
                toVC.view.layer.transform = AnimationHelper.yRotation(0.0)
            }
        }) { (_) in
            // completeTransition(_:)前,移除所有对视图层级的修改。
            fromVC.view.isHidden = false
            snapshot.removeFromSuperview()
            if transitionContext.transitionWasCancelled {
                toVC.view.removeFromSuperview()
            }
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
    }

当 dismiss 视图控制器时,transitioningDelegate负责提供动画对象。打开CardViewController.swift文件,为UIViewControllerTransitioningDelegateextension 添加以下方法:

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        // 没有交互的dismiss动画
        guard let _ = dismissed as? RevealViewController else { return nil }
        return DismissAnimationController(destinationFrame: cardView.frame)
    }

运行后效果如下:

TransitionDismiss.gif

3.5 交互式转场

为了支持交互式转场,transitioningDelegate需提供遵守了UIViewControllerInteractiveTransitioning协议的对象。

之前已经实现了转场动画,交互式转场根据手势控制动画进度,而非像播放视频一样让其自然播放。Apple 提供了UIPercentDrivenInteractiveTransition类控制动画时间,该类遵守了UIViewControllerInteractiveTransitioning协议。

UIPercentDrivenInteractiveTransition通过控制已有 animator 对象的时间使动画具有交互性,使用时只需处理交互事件,计算动画进度,并调用update(_:)更新动画即可。

可以直接使用UIPercentDrivenInteractiveTransition类,或继承后使用。如果继承了该类,在子类的initstartInteractiveTransition(_:)方法中配置交互手势。此后,使用交互手势计算动画进度,并调用update(_:)更新动画。当动画完成时,调用finish();取消动画时,调用cancel()

创建SwipeInteractionController.swift文件,并添加以下代码:

    var interactionInProgress = false
    private var shouldCompleteTransition = false
    
    /// 交互手势发生的视图控制器
    private weak var viewController: UIViewController!
    
    init(viewController: UIViewController) {
        super.init()
        self.viewController = viewController
        
        prepareGestureRecognizer(in: viewController.view)
    }

添加以下手势事件,当手势从屏幕左边缘滑动时触发:

    private func prepareGestureRecognizer(in view: UIView) {
        let gestureRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(self.handleGesture(_:)))
        gestureRecognizer.edges = .left
        view.addGestureRecognizer(gestureRecognizer)
    }

实现UIScreenEdgePanGestureRecognizer响应方法,如下所示:

    @objc func handleGesture(_ gestureRecognizer: UIScreenEdgePanGestureRecognizer) {
        // 使用局部变量计算滑动进度
        let translation = gestureRecognizer.translation(in: gestureRecognizer.view?.superview)
        guard let gestureView = gestureRecognizer.view else { return }
        let percent = translation.x / gestureView.bounds.size.width
        
        switch gestureRecognizer.state {
        case .began:
            // 手势开始时标记为动画进行中,并调用dismiss(animated:completion:)
            interactionInProgress = true
            viewController.dismiss(animated: true, completion: nil)
            
        case .changed:
            // 更新动画进度
            update(percent)
            
        case .cancelled:
            // 如果动画取消,更新interactionInProgress,并回滚动画。
            cancel()
            interactionInProgress = false
            
        case .ended:
            // 手势结束时,根据当前进度、速度决定结束还是取消动画。
            interactionInProgress = false
            let velocity = gestureRecognizer.velocity(in: gestureView)
            if percent > 0.5 || velocity.x > 0 {
                finish()
            } else {
                cancel()
            }
            
        default:
            break
        }
    }

打开RevealViewController.swift文件,添加以下属性:

    var swipeInteractionController: SwipeInteractionController?

viewDidLoad()底部添加以下代码:

        swipeInteractionController = SwipeInteractionController(viewController: self)

更新DismissAnimationControllerinit方法,如下所示:

    let interactionController: SwipeInteractionController?
    
    init(destinationFrame: CGRect, interactionController: SwipeInteractionController?) {
        self.destinationFrame = destinationFrame
        self.interactionController = interactionController
    }

打开CardViewController.swift文件,更新animationController(forDismissed:)方法如下:

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        guard let revealVC = dismissed as? RevealViewController else { return nil }
        return DismissAnimationController(destinationFrame: cardView.frame, interactionController: revealVC.swipeInteractionController)
    }

UIKit 最终会调用transitioningDelegateinteractionControllerForDismissal(using:)方法,查找是否有交互式动画。在UIViewControllerTransitioningDelegateextension 中增加以下方法:

    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        guard let dismissAnimator = animator as? DismissAnimationController,
            let interactionController = dismissAnimator.interactionController,
            interactionController.interactionInProgress else { return nil }

        return interactionController
    }

上述代码首先检查 animator 是否是DismissAnimationController,并确认交互式手势正在进行中。任一条件不满足,则返回nil,即采用非交互式动画。

运行后效果如下:

TransitionInteraction.gif

总结

这篇文章只关注了 presentation 和 dismissal 转场,但自定义转场也可以应用于容器控制器:

  • 当使用UINavigationController时,UINavigationControllerDelegate协议的navigationController(_:animationControllerFor:from:to:)方法负责提供 animator 对象。
  • 当使用了UITabBarController时,UITabBarControllerDelegate协议的tabBarController(_:animationControllerForTransitionFrom:to:)方法负责提供 animator 对象。

想要了解更多动画知识,可以查看我的 Core Animation 系列文章。

Demo名称:TransitionAnimation
源码地址:https://github.com/pro648/BasicDemos-iOS/tree/master/TransitionAnimation

参考资料:

  1. Custom UIViewController Transitions: Getting Started

  2. iOS custom transition tutorial in Swift

  3. Customizing the Transition Animations

欢迎更多指正:https://github.com/pro648/tips

本文地址:https://github.com/pro648/tips/blob/master/sources/自定义视图控制器转场动画.md

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容