iOS动画指南 - 6.可以很酷的转场动画

在iOS开发中,界面间的跳转其实也就是控制器的跳转,跳转有很多种,最常用的有push,modal.

  • modal:任何控制器都能通过Modal的形式展⽰出来.效果:新控制器从屏幕的最底部往上钻,直到盖住之前的控制器为⽌.系统也会带有一个动画.
public func presentViewController(viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)?)
public func dismissViewControllerAnimated(flag: Bool, completion: (() -> Void)?)
  • push: 在push中,控制器的管理其实交给了UINavigationController,所以在push控制器的时候必须拿到对应的导航控制器. 效果:从右往左出现,系统会带有一个默认动画.
public func pushViewController(viewController: UIViewController, animated: Bool) 
public func popViewControllerAnimated(animated: Bool) -> UIViewController?

push和model都有系统提供转场动画效果,但有时系统提供的不一定能满足开发需求,这就需要去自定义,说实话自定义转场动画还是有些麻烦的.但原理其实很简单.

转场动画的原理:

当两个控制器发生push.pop或modal.dismiss的时候,系统会把原始的控制器放到负责转场的控制器容器中,也会把目标控制器放进去,但是目标控制器是不可见的,因此我们要做的就是把新的控制器显现出来,把老的控制器移除掉.很简单吧!

上面大概介绍了一下控制器的切换及原理,这篇文章我们打算说说自定义转场动画,一个push一个modal,虽然自定义modal转场动画用的比较多一点,但push也可以了解一下嘛!
先来看下效果,然后准备上车:


自定义modal转场动画
自定义push转场动画

1.自定义modal转场动画

1.简单的modal效果:


很简单的modal效果,首先有一个主控制器,主控制器底部有一个scrollView,对scrollView里面图片添加手势监听,并在监听方法里面modal一个背景图片一样的控制器,这样就可以开始自定义转场动画啦!

2.设置转场动画的代理:

  1. 新建一个继承 NSObject, UIViewControllerAnimatedTransitioning的PopAnimator文件,用于设置转场动画的代理方法,在里面添加UIViewControllerAnimatedTransitioning协议必须要实现的两个代理方法:
    // 设置转场动画持续时间
    func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
        return 0
    }
    // 执行转场动画
    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {  
    }
  1. 在ViewController(主控制器)中设置herbDetailsVc(每张图片对应的控制器)的 transitioningDelegate为自己,为刚刚新建的文件设置一个常量 let transition = PopAnimator(),新建一个extension遵守代理UIViewControllerTransitioningDelegate
extension ViewController: UIViewControllerTransitioningDelegate {

    func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {  
        return transition
    }

    func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return nil
    }
}

如果返回nil,那就使用系统默认的转场动画.

3. 创建转场动画.

在PopAnimator的transitionDuration:方法中设置好时间.设定转场动画的内容

 func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
       
       // 获得容器
       let containerView = transitionContext.containerView()!
       // 获得目标view
       // viewForKey 获取新的和老的控制器的view
       // viewControllerForKey 获取新的和老的控制器
       let toView = transitionContext.viewForKey(UITransitionContextToViewKey)!
       
       containerView.addSubview(toView)
       toView.alpha = 0.0
       
       UIView.animateWithDuration(duration, animations: { () -> Void in
           toView.alpha = 1.0
           }) { (_) -> Void in
               // 转场动画完成
           transitionContext.completeTransition(true)
       }
   }

内容是:通过修改透明度,达到一个渐变的效果,如下图所示.



这样一个简单的转场效果就实现啦!是不是很简单,只是这还不是我们想要的效果.

4.左上角弹出效果

注意:这一步是在第3步的基础上修改的.在设置PopAnimator中添加 var presenting = true.用于判断到底是弹出控制器,还是后退,因为前进和后退都会调用animateTransition这个代理方法.下一个实现效果虽然用不到,但我们先这样去设置.
还要设置一个 var originFrame = CGRect.zero 用于设置目的控制器的frame,将animateTransition中原来的代码更改为如下

func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
       
       // 获得容器
       let containerView = transitionContext.containerView()!
       // 获得目标view
       // viewForKey 获取新的和老的控制器的view
       // viewControllerForKey 获取新的和老的控制器
       let toView = transitionContext.viewForKey(UITransitionContextToViewKey)!
       let fromView = transitionContext.viewForKey(UITransitionContextFromViewKey)!
       
       // 拿到需要做动画的view
       let herbView = presenting ? toView : fromView
       
       // 获取初始和最终的frame
       let initialFrame = presenting ? originFrame : herbView.frame
       let finalFrame = presenting ? herbView.frame : originFrame
       
       // 设置收缩比率
       let xScaleFactor = presenting ? initialFrame.width / finalFrame.width : finalFrame.width / initialFrame.width
       let yScaleFactor = presenting ? initialFrame.height / finalFrame.height : finalFrame.height / initialFrame.height
       let scaleTransform = CGAffineTransformMakeScale(xScaleFactor, yScaleFactor)
       // 当presenting的时候,设置herbView的初始位置
       if presenting {
           herbView.transform = scaleTransform
           herbView.center = CGPoint(x: CGRectGetMidX(initialFrame), y: CGRectGetMidY(initialFrame))
           herbView.clipsToBounds = true
       }
       
       containerView.addSubview(toView)
       // 保证在最前,不然添加的东西看不到哦
       containerView.bringSubviewToFront(herbView)
       
       // 加了个弹性效果
       UIView.animateWithDuration(duration, delay: 0.0, usingSpringWithDamping: 0.4, initialSpringVelocity: 0.0, options: [], animations: { () -> Void in
           
           herbView.transform = self.presenting ? CGAffineTransformIdentity : scaleTransform
           herbView.center = CGPoint(x: CGRectGetMidX(finalFrame), y: CGRectGetMidY(finalFrame))
           
           }) { (_) -> Void in
               transitionContext.completeTransition(true)
       }
   }

效果如下:


仔细观察发现无论是presentViewController还是dismissViewController视图都是从左上角弹出来的.因为我们将originFrame设置为CGRect.zero了.



5. 最终效果实现.

刚刚我们把originFrame设置为了CGRect.zero,但我们可以拿到目标控制器的原始尺寸啊,这样就不会突兀的从左上角弹出来了.

1.在ViewController的extension里面添加修改如下代码
    func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        
        transition.originFrame = selectedImage!.superview!.convertRect(selectedImage!.frame, toView: nil)
        transition.presenting = true
        selectedImage!.hidden = true
        
        return transition
    }
    func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        
        transition.presenting = false
        return transition
    }
2.当dismiss的时候,刚点击的图片会消失,因为我们在modal的时候设置为了隐藏,所以在dismiss完成后要将selectedImage显示出来.可以这样做,在PopAnimator中添加一个闭包
    var dismissCompletion: (()->())?

然后在animateTransition的UIView.animateWithDuration完成闭包中调用

UIView.animateWithDuration(duration, delay: 0.0, usingSpringWithDamping: 1, initialSpringVelocity: 0.0, options: [], animations: { () -> Void in
            
            herbView.transform = self.presenting ? CGAffineTransformIdentity : scaleTransform
            herbView.center = CGPoint(x: CGRectGetMidX(finalFrame), y: CGRectGetMidY(finalFrame))
            
            }) { (_) -> Void in
                if !self.presenting {
                    self.dismissCompletion?()
                }
                transitionContext.completeTransition(true)
        }

最后在ViewController中的viewDidLoad中实现

 transition.dismissCompletion = {
            self.selectedImage!.hidden = false
        }

3.由于scrollview上的图片有圆角,所以在转场动画中我们也要实现图片圆角与控制器直角的切换.
在PopAnimator中的animateTransition方法中添加如下代码:

        // 设置圆角
        let round = CABasicAnimation(keyPath: "cornerRadius")
        round.fromValue = !presenting ? 0.0 : 20.0/xScaleFactor
        round.toValue = presenting ? 0.0 : 20.0/xScaleFactor
        round.duration = duration / 2
        herbView.layer.addAnimation(round, forKey: nil)
        herbView.layer.cornerRadius = presenting ? 0.0 : 20.0/xScaleFactor

最终自定义modal转场效果就完成啦!


自定义modal转场动画

2. 自定义push转场动画

相比自定义modal转场动画,自定义push转场动画的场景不是很多,原理其实都差不多的.

1. 简单的push效果.

push是需要导航控制器的,所以在AppDelegate中加载控制器的时候,给它套一个导航控制器.代码中,ViewController和DetailViewController就是对应切换的两个控制器.
给ViewController添加一个手势监听进行跳转.


2.老样子,设置转场动画的代理

  1. 新建一个继承 NSObject, UIViewControllerAnimatedTransitioning的RevealAnimator文件,用于设置转场动画的代理方法,在里面添加UIViewControllerAnimatedTransitioning协议必须要实现的两个代理方法:
    let animationDuration = 2.0
    // 用于判断push或者pop
    var operation: UINavigationControllerOperation = .Push

    // 设置转场动画持续时间
    func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
        return 0
    }
    // 执行转场动画
    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {  
    }
  1. 在ViewController文件中的viewDidLoad方法中拿到导航控制器将它设置为代理
       navigationController?.delegate = self

在ViewController文件中添加一个转场动画控制器容器属性

    let transition = RevealAnimator()

在ViewController文件中添加一个extension,实现代理方法

extension ViewController : UINavigationControllerDelegate { 
    /**
     - parameter navigationController: 拿到设置代理的导航控制器
     - parameter operation:            .Push .Pop
     - parameter fromVC:               原来的控制器
     - parameter toVC:                 目标控制器
     - returns: 返回设置好的转场动画
     */
    func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        
        transition.operation = operation
        return transition
    }
}

3.添加转场动画

  1. 在RevealAnimator中添加一个变量保存animateTransition方法的transitionContext,以后会用到
   weak var storedContext: UIViewControllerContextTransitioning?

老样子在animateTransition添加一些初始化代码

            let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey) as! ViewController
            
            let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) as! DetailViewController
            
            transitionContext.containerView()?.addSubview(toVC.view)
  1. 添加LOGO的放大动画
    1.新建一个RWLogoLayer文件,用贝塞尔曲线画出logo
import UIKit
class RWLogoLayer {
   class func logoLayer() -> CAShapeLayer {
       let layer = CAShapeLayer()
       layer.geometryFlipped = true
       
       let bezier = UIBezierPath()
       bezier.moveToPoint(CGPoint(x: 0.0, y: 0.0))
       bezier.addCurveToPoint(CGPoint(x: 0.0, y: 66.97), controlPoint1:CGPoint(x: 0.0, y: 0.0), controlPoint2:CGPoint(x: 0.0, y: 57.06))
       bezier.addCurveToPoint(CGPoint(x: 16.0, y: 39.0), controlPoint1: CGPoint(x: 27.68, y: 66.97), controlPoint2:CGPoint(x: 42.35, y: 52.75))
       bezier.addCurveToPoint(CGPoint(x: 26.0, y: 17.0), controlPoint1: CGPoint(x: 17.35, y: 35.41), controlPoint2:CGPoint(x: 26, y: 17))
       bezier.addLineToPoint(CGPoint(x: 38.0, y: 34.0))
       bezier.addLineToPoint(CGPoint(x: 49.0, y: 17.0))
       bezier.addLineToPoint(CGPoint(x: 67.0, y: 51.27))
       bezier.addLineToPoint(CGPoint(x: 67.0, y: 0.0))
       bezier.addLineToPoint(CGPoint(x: 0.0, y: 0.0))
       bezier.closePath()
       
       layer.path = bezier.CGPath
       layer.bounds = CGPathGetBoundingBox(layer.path)
       
       return layer
   }
}
  1. 设置RWLogoLayer的位置尺寸,分别添加到fromVC和toVC上
    在ViewController的viewDidAppear中添加
       logo.position = CGPoint(x: view.layer.bounds.size.width/2,
           y: view.layer.bounds.size.height/2 + 30)
       logo.fillColor = UIColor.whiteColor().CGColor
       view.layer.addSublayer(logo)

在DetailViewController的viewDidLoad中添加

maskLayer.position = CGPoint(x: view.layer.bounds.size.width/2, y: view.layer.bounds.size.height/2)
        view.layer.mask = maskLayer

这边有个注意点,记得添加

   override func viewDidAppear(animated: Bool) {
        super.viewDidAppear(animated)
        
        view.layer.mask = nil
    }

移除mask

  1. 设置动画,在RevealAnimator的animateTransition方法中添加
            let animation = CABasicAnimation(keyPath: "transform")
            animation.fromValue = NSValue(CATransform3D: CATransform3DIdentity)
            // 添加一个阴影效果
            animation.toValue = NSValue(CATransform3D:CATransform3DConcat(CATransform3DMakeTranslation(0.0, -10.0, 0.0), CATransform3DMakeScale(150.0, 150.0, 1.0)))
            
            animation.duration = animationDuration
            animation.delegate = self
            animation.fillMode = kCAFillModeForwards
            animation.removedOnCompletion = false
            animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn)
        
            // 同时添加到两个控制器上
            toVC.maskLayer.addAnimation(animation, forKey: nil)
            fromVC.logo.addAnimation(animation, forKey: nil)
        
            // 给目的控制器设置一个渐变效果
            let fadeIn = CABasicAnimation(keyPath: "opacity")
            fadeIn.fromValue = 0.0
            fadeIn.toValue = 1.0
            fadeIn.duration = animationDuration
            toVC.view.layer.addAnimation(fadeIn, forKey: nil)

到这里push的转场效果基本完成了,但会发现还有问题,在自定义modal转场动画的时候,当转场动画完成后 需要设置transitionContext.completeTransition(true),而这边也是,这样做是为了在pop的之前把该清理的都清理掉.so重写animationDidStop

    override func animationDidStop(anim: CAAnimation, finished flag: Bool) {
        if let context = storedContext {   
            context.completeTransition(!context.transitionWasCancelled())
            let fromVc = context.viewControllerForKey(UITransitionContextFromViewControllerKey) as! ViewController
            fromVc.logo.removeAllAnimations()
        }
        storedContext = nil
    }
  1. push已经完成啦!现在当我们点击左上角的start按钮返回时会发生崩溃,是因为我们没有在RevealAnimator的animateTransition方法中作判断,到底是push还是pop.
    定义一个属性判断是pop还是push,将animateTransition中push相关的代码放到push判断语句中去
    var operation: UINavigationControllerOperation = .Push
        if operation == .Push { // push

        }else { // pop

        }
  1. 给pop添加一个缩小的效果
            let fromView = transitionContext.viewForKey(UITransitionContextFromViewKey)!
            let toView = transitionContext.viewForKey(UITransitionContextToViewKey)!
            
            transitionContext.containerView()?.insertSubview(toView, belowSubview: fromView)
            
            UIView.animateWithDuration(animationDuration, delay: 0.0, options: .CurveEaseIn, animations: {
                fromView.transform = CGAffineTransformMakeScale(0.01, 0.01)
                }, completion: {_ in
                    transitionContext.completeTransition(!transitionContext.transitionWasCancelled())
            })

至此,就完成啦!不过离最终效果还有一定距离!

4.根据滑动手势切换控制器

  1. 在ViewController底部添加一个label,设置好滑动解锁这几个字,这只是提醒,我们的会为整个view添加滑动手势的.
  2. 将ViewController的点击监听事件改为拖动事件.
    let pan = UIPanGestureRecognizer(target: self, action: Selector("didPan:"))
        view.addGestureRecognizer(pan)
   func didPan(recognizer: UIPanGestureRecognizer) {
    }
  1. 根据滑动的偏移量来调整转场动画的进度,也就是说转场动画要是可以交互的.之前所有的转场动画都是开始后,自动结束,不会随着人的交互而发生任何改变.所以原来的方法不能满足需要.
    这时需要在ViewController的extension中添加
  // 返回一个可以交互的转场动画
    func navigationController(navigationController: UINavigationController, interactionControllerForAnimationController animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { 
        if !transition.interactive {
            return nil
        }
        return transition
    }

在RevealAnimator中添加一个属性,用于设置是否可以交互

    // 是否需要交互
    var interactive = false

4.在ViewController的didPan方法中添加手势识别,这边只处理一部分,其余的传到RevealAnimator中进行

     switch recognizer.state {
        case .Began:
            transition.interactive = true
            navigationController?.pushViewController(DetailViewController(), animated: true)
        default:
            transition.handlePan(recognizer)
        }

5.在RevealAnimator的handlePan方法中,根据偏移量计算progress

func handlePan(recognizer: UIPanGestureRecognizer) {
       let translation = recognizer.translationInView(recognizer.view!.superview!)
       var progress: CGFloat = abs(translation.x / 200.0)
       progress = min(max(progress, 0.01), 0.99)
       switch recognizer.state {
       case .Changed:
           // 更新当前转场动画播放进度
           updateInteractiveTransition(progress)
       case .Cancelled, .Ended:
           if operation == .Push { // push
               let transitionLayer = storedContext!.containerView()!.layer
               transitionLayer.beginTime = CACurrentMediaTime()
               if progress < 0.5 {
                   completionSpeed = -1.0
                   cancelInteractiveTransition() // 停止转场动画,回到from状态
               } else {
                   completionSpeed = 1.0
                   finishInteractiveTransition() // 完成转场动画,到to状态
               }
           } else { // pop
               if progress < 0.5 {
                   cancelInteractiveTransition()
               } else {
                   finishInteractiveTransition()
               }
           }
           // 使得返回可交互的转场动画为nil,重置动画
           interactive = false
       default:
           break
       }
   }
自定义push转场动画

至此自定义push的转场动画也完成啦!

虽然转场动画不难,但从头到尾这一整套逻辑,还是有点繁琐的,可能有的同学看的有些蒙圈,这是很正常的,因为知识是网状的啊,线性的逻辑表述并不能表达清楚网状知识的每一个连接! so,我在下面给出了源码,给有兴趣推敲的同学.

本文整理自 : iOS.Animations.by.Tutorials.v2.0
源码 : https://github.com/DarielChen/DemoCode
如有疑问,欢迎留言 :-D

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

推荐阅读更多精彩内容