教程:创建自定义的UIViewController转场动画

本教程使用: xcode 7和swift 2
翻译自: http://www.raywenderlich.com/110536/custom-uiviewcontroller-transitions


Push、pop、cover vertically... 我们可以从iOS内置的转场动画中得到这些很棒的效果,但是自己尝试自己的动画也是个很棒的体验!自定义转场动画可以极大地增加用户体验,并且让你的app和其他的看起来不一样。如果你之前都没有自定义过转场动画,通过阅读本教程你会发现,这比你想象的要简单很多!
在本教程中,我们会在一个demo中增加自定义转场动画,当我们完成此教程时,你应该学会了以下技能:

  • 了解转场API的结构
  • 如何利用转场动画来自定义present和dismiss动画
  • 开发出互动的转场动画

开始

下载起始项目。编译运行程序,我们会看到:


这个demo在page view controller中展示了几张卡片,每张卡片都是每种宠物的描述,点击卡片就会弹出该宠物的照片。
大部分的跳转逻辑都已经写好了,但是这个demo看起来还是有点平淡无奇,我们这就来用自定义的转场动画来增加效果。

浏览转场API

相比于具体的对象,转场API运用了大量的协议,在这个模块的最后,你会了解到每个协议的作用以及它们之间的联系。这个图表显示了API中主要的部分:


组成部分

尽管上面图表看起来比较复杂,但当你了解到各部分是如何工作之后,它看起来就直观很多了。

Transitioning Delegate

每个view controller都可以有一个transitioningDelegate代理,它实现了UIViewControllerTransitioningDelegate的协议。
不管你是present还是dismiss一个view controller,UIKit总会向transitioning delegate询问是否有代理可用。设置view controller的transitioningDelegate为我们自定义的类来作出自定义的转场动画。

Animation Controller

自定义的类实现UIViewControllerAnimatedTransitioning协议,来完成具体的转场动画。

Transitioning Context

这个context对象实现了UIViewControllerContextTransitioning协议,在转场过程中扮演重要的角色,它包含了view controllers的重要信息。
我们实际上不用去在我们自己的代码来实现它,当转场发生时,UIKit会提供给我们这样一个context的对象的。

转场过程

下面是一个present动画的步骤:
1.不管是用代码还是通过segue,我们需要触发转场。
2.UIKit向"to" view controller(将被显示的view controller)的transitioning delegate询问,如果没有代理,就会用内置的默认转场动画。
3.UIKit之后向transigioning delegate通过代理方法animationControllerForPresentedController(_:presentingController:sourceController:)询问一个animation controller,如果返回nil,仍然会用默认的动画。
4.一旦有一个有效的animation controller后,UIKit就会构建transitioning context。
5.UIKit之后向animation controller通过transitionDuration(_:)询问动画持续时长。
6.UIKit调用animateTransition(_:)来实现animation controller的转场动画。
7.最后,animation controller调用context的completeTransition(_:)方法来表示这次动画已经结束。

创建一个自定义的presentation动画

是时候来实践了!
你的目的是实现下面的动画:

  • 当用户点击卡片,它会翻转到第二个view,这个view的尺寸和卡片尺寸一样。
  • 随着翻转,第二个view放大到全屏。

创建Animator

我们由创建animation controller开始。
首先创建一个继承自NSObject的子类,并且语言为Swift,命名为FlipPresentAnimationController.swift,接着更新其声明:

import UIKit
class FlipPresentAnimationController: NSObject, UIViewControllerAnimatedTransitioning {

}

注意我们可能因为没有实现代理方法而受到编译错误的提示,我们现在来fix。


Compiler errors...don't panic...

在当用户点击图片时,我们会用到动画起始时的frame,在类的body中加入如下变量:

var originFrame = CGRect.zero

为了满足UIViewControllerAnimatedTransitioning的要求,我们还需要加入两个method:

func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimerInterval {
    return 2.0
}

根据名字也知道,这个方法指定了转场的持续时间,设置成2秒能让我们在开发过程中更容易观察动画。
现在加入另一个方法:

func animateTransition(transitionContext: UIViewControllerContextTransitioning) {

}

这个协议方法就是用来实现转场动画本身的,从这开始我们加入下面的代码:

// 1
guard let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey), 
    let containerView = transitionContext.containerView(),
    let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) else {
    return
}

// 2
let initialFrame = originFrame
let finalFrame = transitionContext.finalFrameForViewController(toVC)

// 3
let snapshot = toVC.view.snapshotViewAfterScreenUpdates(true)
snapshot.frame = initailFrame
snapshot.layer.cornerRadius = 25
snapshot.layer.masksToBounds = true

上面代码做了这些:

  1. transitioning context提供了参与转场动画的view controllers以及views,我们可以通过合适的key来获取它们。
  2. 接着我们指定"to" view的起始和最终的frame,在这个例子中,转场从卡片的frame开始,然后放大到全屏。
  3. UIView的snapshotting捕获了"to" view的截屏,这样我们利用截屏来做动画,截屏也是从卡片的frame开始,圆角也设成和卡片一样的。

继续加入如下代码:

containerView.addSubview(toVC.view)
containerView.addSubview(snapshot)
toVC.view.hidden = true

AnimationHelper.perspectiveTransformForContainerView(containerView)
snapshot.layer.transform = AnimationHelper.yRotation(M_PI_2)

一个新的成员出现了:container view。把它想象成一个舞台而转场在上面跳舞。container view已经包含了"from" view,但是我们需要负责向其中加入"to" view。
我们并且加入了snapshot view并且隐藏了真的"to" view,完整的动画会旋转snapshot然后把它隐藏。

注意:不用太在意AnimationHelper,这只是个小的工具类,负责给view增加perspective和rotation的transforms的。

是时候来具体实现我们的动画了,继续增加这些代码:

// 1
let duration = transitionDuration(transitionContext) 

UIView.animateKeyframesWithDuration( 
    duration, 
    delay: 0, 
    options: .CalculationModeCubic, 
    animations: { 
        // 2 
        UIView.addKeyframeWithRelativeStartTime(0.0, relativeDuration: 1/3, animations: { 
            fromVC.view.layer.transform = AnimationHelper.yRotation(-M_PI_2) 
        })  

        // 3 
        UIView.addKeyframeWithRelativeStartTime(1/3, relativeDuration: 1/3, animations: { 
            snapshot.layer.transform = AnimationHelper.yRotation(0.0) 
        })  
        
        // 4 
        UIView.addKeyframeWithRelativeStartTime(2/3, relativeDuration: 1/3, animations: { 
            snapshot.frame = finalFrame })
    }, 
    completion: { _ in 
        // 5 
        toVC.view.hidden = false 
        fromVC.view.layer.transform = AnimationHelper.yRotation(0.0) 
        snapshot.removeFromSuperview() 
        transitionContext.completeTransition(!transitionContext.transitionWasCancelled()) 
    })

我们用注释来把代码分成各区域:

  1. 首先我们指定了动画的持续时间,注意使用transitionDuration(_:)方法,我们需要让我们的动画和这个持续时间保持一致,这样UIKit才能同步。
  2. 我们根据y轴旋转"from" view 90度来隐藏from view。
  3. 接着我们用同样的方法来恢复snapshot的transform。
  4. 设置shapshot的frame来充满屏幕。
  5. 最后,我们remove掉snapshot,显示"to" view,还原"from" view,否则,它会一直隐藏着。 调用completeTransition来通知transition context动画已经结束,UIKit会保证最后remove掉"from" view。

现在我们可以使用我们写的animtion controller了!

使用animator

打开CardViewController.swift并且加入如下声明:

private let filpPresentAnimationController = FlipPresentAnimationController()

UIKit希望用代理来返回animation controller,所以我们必须提供一个实现了UIViewControllerTransitioningDelegate的对象。
在这个demo中,CardViewController会扮演这个transitioning delegate,加入如下的extension来让该类实现UIViewControllerTransitioningDelegate

extension CardViewController: UIViewControllerTransitioningDelegate {

}

接下来,在上述extension中加入:

func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {  
    flipPresentAnimationController.originFrame = cardView.frame 
    return flipPresentAnimationController
}

这里我们返回了自定义的animation controller,并且这里我们设置了起始frame来保证动画位置的正确。
最后一步就是把CardViewController当作transitioning delegate,每个view controller都有transitioningDelegate属性,UIKit会根据这个属性来判断是否执行自定义的转场。
prepareForSeque(_:sender:)中,在card assignment后面加入:

destinationViewController.transitioningDelegate = self;

值得注意的是,是被presented的view controller设置代理,而不是主动去present的view controller来设置代理!
编译运行:



我们已经有了我们第一个自定义的转场动画,但是presenting只是一半,我们还需要dismiss的动画。

创建 Dismissing 动画

同样的,新建一个FlipDismissAnimationController,替换类的内容如下:

import UIKit 

class FlipDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning {  
    
    var destinationFrame = CGRectZero  

    func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimeInterval { 
        return 0.6 
    }  

    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {  

    }
}

这个类的目的是反向实现presenting的动画:

  • 缩小显示的view到卡片的大小,destinationFrame就是卡片的frame。
  • 翻转view,并且恢复卡片内容

加入如下代码到animateTransition(_:)

guard let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey), 
    let containerView = transitionContext.containerView(), 
    let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) else { 
    return
} 

// 1
let initialFrame = transitionContext.initialFrameForViewController(fromVC)
let finalFrame = destinationFrame 

// 2
let snapshot = fromVC.view.snapshotViewAfterScreenUpdates(false)
snapshot.layer.cornerRadius = 25
snapshot.layer.masksToBounds = true 

// 3
containerView.addSubview(toVC.view)
containerView.addSubview(snapshot)
fromVC.view.hidden = true 

AnimationHelper.perspectiveTransformForContainerView(containerView) 

//4
toVC.view.layer.transform = AnimationHelper.yRotation(-M_PI_2)

来解释一下:

  1. 因为动画会缩小view,我们需要设置好起始和最终的frame
  2. 这次我们截屏"from" view。
  3. 和之前一样,加入"to" view和snapshot到container view中,隐藏"from" view,这样不会和snapshot冲突。
  4. 最后通过旋转技巧隐藏"to" view

剩下的就是加入动画本身了。
直接在animateTransition(_:)直接加入如下代码:

let duration = transitionDuration(transitionContext) 

UIView.animateKeyframesWithDuration( 
    duration, 
    delay: 0, 
    options: .CalculationModeCubic, 
    animations: { 
        // 1 
        UIView.addKeyframeWithRelativeStartTime(0.0, relativeDuration: 1/3, animations: { 
            snapshot.frame = finalFrame 
        })  

        UIView.addKeyframeWithRelativeStartTime(1/3, relativeDuration: 1/3, animations: { 
            snapshot.layer.transform = AnimationHelper.yRotation(M_PI_2) 
        })  
        
        UIView.addKeyframeWithRelativeStartTime(2/3, relativeDuration: 1/3, animations: { 
            toVC.view.layer.transform = AnimationHelper.yRotation(0.0) 
        }) 
    }, completion: { _ in 
        // 2 
        fromVC.view.hidden = false 
        snapshot.removeFromSuperview() 
        transitionContext.completeTransition(!transitionContext.transitionWasCancelled())
    })

只就是在反向执行present的动画而已:

  1. 首先缩小view,用旋转90度来隐藏snapshot,接着也用旋转�恢复"to" view。
  2. 最后,remove掉snapshot和通知context转场已经完成,这会让UIKit更新view controller状态。

打开CardViewController.swift,声明如下属性:

private let flipDismissAnimationController = FlipDismissAnimationController()

接着加入如下extension:

func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { 
    flipDismissAnimationController.destinationFrame = cardView.frame 
    return flipDismissAnimationController
}

这样传递了正确的frame给dismiss animation controller。
最后一步,修改FlipPresentAnimationControllertransitionDuration,让动画更快一点:

func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimeInterval { 
    return 0.6
}

这时候编译运行:



我们的自定义转场动画看起来很酷,但是我们还能通过加入交互特性来优化它。

让转场具有交互性

iOS内置的设置应用,很好展示了交互式的转场动画:



这次我们的任务是通过慢慢在屏幕最左边滑动来返回界面,并且动画过程跟随着我们手指的滑动过程。

交互式转场如何工作的

一个交互式的controller能响应包括了加速、减速或者反转转场过程。为了能开启交互式转场功能,transitioning delegate必须能提供一个interaction controller。这个controller可以是任何对象,只要它实现了UIViewControllerInteractiveTransitioning的协议。我们已经实现了转场的动画,而interaction controller就负责把这个动画和我们的手势连在一起,而不是简单地像放电影一样的闪过。
Apple已经提供了一个UIPercentDrivenInteractiveTransition的类,它是一个具体的interaction controller的实现,我们会用这个类来实现我们的交互式转场。

创建一个交互式转场

我们第一个工作是要创建一个interaction controller,创建一个SwipeInteractionController,继承自UIPercentDrivenInteractiveTransition,选中语言为Swift,打开SwipeInteractionController.swift,加入如下属性定义:

var interactionInProgress = falseprivate 
var shouldCompleteTransition = false
private weak var viewController: UIViewController!

上述代码很直观:

  • ```interactionInProgress``表示一个交互转场是否正在进行中。
  • 我们用shouldCompleteTransition来控制转场,后面会看到。
  • 这个interaction controller会直接present和dismiss view controllers,所以我们需要持有viewController。
    然后加入到class的body中:
func wireToViewController(viewController: UIViewController!) { 
    self.viewController = viewController 
    prepareGestureRecognizerInView(viewController.view)
}

实现prepareGestureRecognizerInView(_:)

private func prepareGestureRecognizerInView(view: UIView) { 
    let gesture = UIScreenEdgePanGestureRecognizer(target: self, action: "handleGesture:") 
    gesture.edges = UIRectEdge.Left 
    view.addGestureRecognizer(gesture)
}

这里我们声明了一个gesture recognizer,它会触发屏幕左边缘的手势。
最后是加入handleGesture(_:)

func handleGesture(gestureRecognizer: UIScreenEdgePanGestureRecognizer) {  
    // 1 
    let translation = gestureRecognizer.translationInView(gestureRecognizer.view!.superview!) 
    var progress = (translation.x / 200) progress = CGFloat(fminf(fmaxf(Float(progress), 0.0), 1.0))  

    switch gestureRecognizer.state {  

    case .Began: 
        // 2 
        interactionInProgress = true 
        viewController.dismissViewControllerAnimated(true, completion: nil)  

    case .Changed: 
        // 3 
        shouldCompleteTransition = progress > 0.5 
        updateInteractiveTransition(progress)  

    case .Cancelled: 
        // 4 
        interactionInProgress = false 
        cancelInteractiveTransition()  

    case .Ended: 
        // 5 
        interactionInProgress = false  
        if !shouldCompleteTransition { 
            cancelInteractiveTransition() 
        } else { 
            finishInteractiveTransition() 
        }  

    default: 
        println("Unsupported") }
}

我们来解释下:

  1. 定义了一些局部的变量来控制整个过程,我们记录view的位移来计算progress,200的位移再松手就视为继续完成转场。
  2. 手势开始时,我们把interactionInProgress设为true,并且开始执行dismiss。
  3. 当手势进行中,我们不断地用progress更新updateInteractiveTransition
  4. 当手势被中断时,我们把interactionInProgress设回false,并且中断转场。
  5. 当手势结束时,根据之前逻辑判断的shouldCompleteTransition来完成或者中断转场。

打开CardViewController.swift,声明如下属性:

private let swipeInteractionController = SwipeInteractionController()

加入如下的extension:

func interactionControllerForDismissal(animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {  
    return swipeInteractionController.interactionInProgress ? swipeInteractionController : nil
}

这个实现先检查是否有手势,如果有的话则返回interaction controller,否则返回nil。
现在在prepareForSegue(_:sender:),在transitioningDelegate赋值代码后加入:

swipeInteractionController.wireToViewController(destinationViewController)

这样就绑定了interaction controller和view controller。
编译运行:



译者注:完整的demo可以从https://github.com/Mercy-Li/GuessThePet 获取。

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

推荐阅读更多精彩内容