iOS-Swift转场动画详解

一、什么是转场

转场:通俗讲,转场就是从当前页面跳转到下一个页面的过程
转场动画: 在当前视图消失和下一个视图出现的过程中,执行的动画,就是转场动画。
动画代理: UIViewControllerAnimatedTransitioning

二、转场的类型

按照页面切换类型分为(前两种属于容器VC转场):
1)、Modal转场: presentation、dismissal
转场代理: UIViewControllerTransitioningDelegate
2)、UINavigationController转场: push、pop
转场代理: UINavigationControllerDelegate
3)、UITabBarController转场: Tab切换
转场代理: UITabBarControllerDelegate

按照转场进度是否可控分为:
1)、非交互式转场
2)、交互式转场

三、转场结果

非交互式转场: 完成
交互式转场: 完成、取消

-------------这是一根牛逼闪闪放光彩的分割线------------------

上面扯了那么多,现在接上正文,下面我们将实现Navi、Modal、Tab、三种方式的转场动画,前两种都是模仿的系统动画,旨再阐述你如何实现转场动画。如果你要其他酷炫的动画,只要在UIViewControllerAnimatedTransitioning协议的方法里,修改成你需要的酷炫动画就行了。
首先,为了方便调用,我们来封装一个工具类,把三种转场类型的代理、转场动画封装到一起,也就是说这个工具类需要实现UIViewControllerTransitioningDelegate, UINavigationControllerDelegate, UITabBarControllerDelegate以及UIViewControllerAnimatedTransitioning四个代理方法。

封装转场工具类

///通用转场工具类
class TransitionUtil: NSObject {
    ///转场类型
    var transitionType: TransitionType?
    //交互转场
    var interactive = false
    let interactionTransition = UIPercentDrivenInteractiveTransition()
    
    override init() {
        super.init()
        
    }
}
///转场类型
enum TransitionType {
    //导航栏
    case navigation(_ operation: UINavigationController.Operation)
    //tabBar切换
    case tabBar(_ direction: TabBarOperationDirection)
    //模态跳转
    case modal(_ operation: ModalOperation)
}

enum TabBarOperationDirection {
    case left
    case right
}

enum ModalOperation {
    case presentation
    case dismissal
}
1)、模态转场代理: UIViewControllerTransitioningDelegate
///自定义模态转场动画时使用
extension TransitionUtil: UIViewControllerTransitioningDelegate {
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        self.transitionType = .modal(.presentation)
        return self
    }
    
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        self.transitionType = .modal(.dismissal)
        return self
    }
    
    //interactive false:非交互转场, true: 交互转场
    func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return interactive ? self.interactionTransition : nil
    }
    
    //interactive false:非交互转场, true: 交互转场
    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return interactive ? self.interactionTransition : nil
    }
}
2) 、导航转场代理: UINavigationControllerDelegate
/// 自定义navigation转场动画时使用
extension TransitionUtil: UINavigationControllerDelegate {
    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        self.transitionType = .navigation(operation)
        return self
    }
    
    func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return interactive ? self.interactionTransition : nil
    }
}
3)、TabBar转场代理: UITabBarControllerDelegate
/// 自定义tab转场动画时使用
extension TransitionUtil: UITabBarControllerDelegate {
    func tabBarController(_ tabBarController: UITabBarController, animationControllerForTransitionFrom fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        let fromIndex = tabBarController.viewControllers?.firstIndex(of: fromVC) ?? 0
        let toIndex = tabBarController.viewControllers?.firstIndex(of: toVC) ?? 0
        let direction: TabBarOperationDirection = fromIndex < toIndex ? .right : .left
        self.transitionType = .tabBar(direction)
        return self
    }
    
    func tabBarController(_ tabBarController: UITabBarController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return interactive ? self.interactionTransition : nil
    }
}
4)、转场动画代理

尽管三大转场代理协议的方法不尽相同,但它们返回的动画控制对象遵守的是同一个协议: UIViewControllerAnimatedTransitioning

extension TransitionUtil: UIViewControllerAnimatedTransitioning {
    //控制转场动画执行时间
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.5
    }
    
    //执行动画的地方,最核心的方法。
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        transitionAnimation(transitionContext: transitionContext)
    }
}
//MARK:  ------------  不同类型的转场  ------------
    
    private func transitionAnimation(transitionContext: UIViewControllerContextTransitioning) {
        //获得容器视图(转场动画发生的地方)
        let containerView = transitionContext.containerView
        //动画执行时间
        let duration = self.transitionDuration(using: transitionContext)
        
        //fromVC (即将消失的视图)
        let fromVC = transitionContext.viewController(forKey: .from)!
        let fromView = fromVC.view!
        //toVC (即将出现的视图)
        let toVC = transitionContext.viewController(forKey: .to)!
        let toView = toVC.view!
        
        var offset = containerView.frame.width
        var fromTransform = CGAffineTransform.identity
        var toTransform = CGAffineTransform.identity
        
        switch transitionType {
        case .modal(let operation):
            offset = containerView.frame.height
            let fromY = operation == .presentation ? 0 : offset
            fromTransform = CGAffineTransform(translationX: 0, y: fromY)
            let toY = operation == .presentation ? offset : 0
            toTransform = CGAffineTransform(translationX: 0, y: toY)
            if operation == .presentation {
                containerView.addSubview(toView)
            }
            
        case .navigation(let operation):
            offset = operation == .push ? offset : -offset
            fromTransform = CGAffineTransform(translationX: -offset, y: 0)
            toTransform = CGAffineTransform(translationX: offset, y: 0)
            containerView.insertSubview(toView, at: 0)
            //containerView.addSubview(toView)
            
        case .tabBar(let direction):
            offset = direction == .left ? offset : -offset
            fromTransform = CGAffineTransform(translationX: offset, y: 0)
            toTransform = CGAffineTransform(translationX: -offset, y: 0)
            containerView.addSubview(toView)
            
        case nil:
            break
        }
        
        toView.transform = toTransform
        UIView.animate(withDuration: duration, animations: {
            fromView.transform = fromTransform
            toView.transform = .identity
        }) { (finished) in
            fromView.transform = .identity
            toView.transform = .identity
            //考虑到转场中途可能取消的情况,转场结束后,恢复视图状态。(通知是否完成转场)
            let wasCancelled = transitionContext.transitionWasCancelled
            transitionContext.completeTransition(!wasCancelled)
        }
    }

非交互转场跳转

封装完工具类,基本就大功搞成了,下面只需要在跳转的时候创建一个TransitionUtil对象,设置为代理就行了。
这里模态和导航跳转都是模仿的系统动画,如果把动画时间改长一点,你就能确实知道,这是我们自己设计的动画,而不是系统动画.
模态跳转:

@IBAction func modalTransitionClicked(_ sender: Any) {
        let vc = ModalTransitionViewController()
        //设置转场代理(必须在跳转前设置)
        //与容器 VC 的转场的代理由容器 VC 自身的代理提供不同,Modal 转场的代理由 presentedVC 提供
        vc.transitioningDelegate = transitionUtil
        /*
        .FullScreen 的时候,presentingView 的移除和添加由 UIKit 负责,在 presentation 转场结束后被移除,dismissal 转场结束时重新回到原来的位置;
        .Custom 的时候,presentingView 依然由 UIKit 负责,但 presentation 转场结束后不会被移除。
        */
        vc.modalPresentationStyle = .custom
        //animated: 一定要给true,不然不会出现转场动画
        self.present(vc, animated: true, completion: nil)
    }

导航跳转:

@IBAction func naviTransitionClicked(_ sender: Any) {
        let vc = NaviTransitionViewController()
        vc.hidesBottomBarWhenPushed = true
        //设置转场代理
        self.navigationController?.delegate = transitionUtil
        self.navigationController?.pushViewController(vc, animated: true)
    }

Tab切换:
创建一个继承UITabBarController的ScrollTabBarViewController类,设置代理为创建的TransitionUtil对象就行了。然后把项目中的系统类UITabBarController换成ScrollTabBarViewController就行了。
这样点击切换tab时就会又一个左滑右滑的动画,效果如下:

未命名.gif

交互转场跳转

交互转场无非就是用户自己控制转场进度,那么如何控制呢,一般都是通过一个滑动手势。不管是模态、导航、tab,这些转场类型要添加交互功能,都只需要在self.view上添加一个pan手势就行。
下面以tab的交互转场为例:

class ScrollTabBarViewController: UITabBarController {
    //滑动手势
    private var panGesture = UIPanGestureRecognizer()
    //转场动画
    private let transitionUtil = TransitionUtil()
    
    private var vcCount: Int {
        guard let vcs = self.viewControllers else { return 0 }
        return vcs.count
    }
    

    override func viewDidLoad() {
        super.viewDidLoad()
        //设置转场代理
        self.delegate = transitionUtil
        self.tabBar.tintColor = .green
        
        //添加交互手势
        panGesture.addTarget(self, action: #selector(panHandle(_:)))
        self.view.addGestureRecognizer(panGesture)
    }
  
}
@objc func panHandle(_ pan: UIPanGestureRecognizer) {
        let translationX = panGesture.translation(in: view).x
        let absX = abs(translationX)
        let progress = absX / view.frame.width
        
        switch panGesture.state {
        case .began:
            transitionUtil.interactive = true
            //速度
            let velocityX = panGesture.velocity(in: view).x
            if velocityX < 0 {
                if selectedIndex < vcCount - 1 {
                    selectedIndex += 1
                }
            }else {
                if selectedIndex > 0 {
                    selectedIndex -= 1
                }
            }
            
        case .changed:
            //更新转场进度,进度数值范围为0.0~1.0。
            transitionUtil.interactionTransition.update(progress)
            
        case .cancelled, .ended:
            /*
             这里有个小问题,转场结束或是取消时有很大几率出现动画不正常的问题.
             解决手段是修改交互控制器的 completionSpeed 为1以下的数值,这个属性用来控制动画速度,我猜测是内部实现在边界判断上有问题。
             这里其修改为0.99,既解决了 Bug 同时尽可能贴近原来的动画设定.
             */
            if progress > 0.3 {
                transitionUtil.interactionTransition.completionSpeed = 0.99
                //.finish()方法被调用后,转场动画从当前的状态将继续进行直到动画结束,转场完成
                transitionUtil.interactionTransition.finish()
            }else {
                //转场取消后,UITabBarController 自动恢复了 selectedIndex 的值,不需要我们手动恢复。
                transitionUtil.interactionTransition.completionSpeed = 0.99
                //.cancel()被调用后,转场动画从当前的状态回拨到初始状态,转场取消。
                transitionUtil.interactionTransition.cancel()
            }
            //无论转场的结果如何,恢复为非交互状态。
            transitionUtil.interactive = false
            
        default:
            break
        }
    }

效果如下:


未命名.gif

番外篇: 如何实现一个圆形转场动画

这种效果有两种方式:
1)、 不使用mask: 视图+缩放
2)、 使用mask: UIBezierPath + CAShapeLayer + maskLayer

第一种实现看起比较丝滑,下面就说一下其如何实现:
其他地方根上面的实现一模一样,唯一的不同是UIViewControllerAnimatedTransitioning协议里animateTransition(using transitionContext: UIViewControllerContextTransitioning)的实现不同。

//执行动画的地方,最核心的方法。
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        //圆形遮罩,方式一
        bubble(transitionContext: transitionContext)
    }

具体的动画实现:

func bubble(transitionContext: UIViewControllerContextTransitioning) {
        //获得容器视图(转场动画发生的地方)
        let containerView = transitionContext.containerView
        
        //动画执行时间
        let duration = self.transitionDuration(using: transitionContext)
        
        //fromVC (即将消失的视图)
        let fromVC = transitionContext.viewController(forKey: .from)!
        let fromView = fromVC.view!
        //toVC (即将出现的视图)
        let toVC = transitionContext.viewController(forKey: .to)!
        let toView = toVC.view!
        let originalCenter = toView.center
        let originalSize = toView.frame.size
        
        //半径
        let radius = self.getRadius(startPoint: startPoint, originalSize: originalSize)
        
        switch transitionType {
        case .modal(let operation):
            if operation == .presentation {
                let bubble = UIView()
                bubble.frame = CGRect(x: 0, y: 0, width: radius*2, height: radius*2)
                bubble.layer.cornerRadius = bubble.frame.size.height / 2
                bubble.center = startPoint
                bubble.transform = CGAffineTransform(scaleX: 0.001, y: 0.001)
                bubble.backgroundColor = toView.backgroundColor
                containerView.addSubview(bubble)
                
                toView.center = startPoint
                toView.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
                toView.alpha = 0
                containerView.addSubview(toView)
                
                                
                UIView.animate(withDuration: duration) {
                    bubble.transform = .identity
                    toView.transform = .identity
                    toView.alpha = 1
                    toView.center = originalCenter
                } completion: { (isFinished) in
                    transitionContext.completeTransition(true)
                    bubble.isHidden = true
                    if toVC.modalPresentationStyle == .custom {
                        toVC.endAppearanceTransition()
                    }
                    fromVC.endAppearanceTransition()
                }
            }else {
                if fromVC.modalPresentationStyle == .custom {
                    fromVC.beginAppearanceTransition(false, animated: true)
                }
                toVC.beginAppearanceTransition(true, animated: true)
                
                let bubble = UIView()
                bubble.frame = CGRect(x: 0, y: 0, width: radius*2, height: radius*2)
                bubble.layer.cornerRadius = bubble.frame.size.height / 2
                bubble.backgroundColor = fromView.backgroundColor
                bubble.center = startPoint
                bubble.isHidden = false
                containerView.insertSubview(bubble, at: 0)
                
                UIView.animate(withDuration: duration) {
                    bubble.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
                    fromView.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
                    fromView.center = self.startPoint
                    fromView.alpha = 0
                } completion: { (isFinished) in
                    transitionContext.completeTransition(true)
                    fromView.removeFromSuperview()
                    bubble.removeFromSuperview()
                    if fromVC.modalPresentationStyle == .custom {
                        fromVC.endAppearanceTransition()
                    }
                    toVC.endAppearanceTransition()
                }
            }
        default:
            break
        }
    }

计算圆的半径:

///获得半径 (不明白的自己画个图,看一下哪一条应该是半径)
    private func getRadius(startPoint: CGPoint, originalSize: CGSize) -> CGFloat {
        let horizontal = max(startPoint.x, originalSize.width - startPoint.x)
        let vertical = max(startPoint.y, originalSize.height - startPoint.y)
        //勾股定理计算半径
        let radius = sqrt(horizontal*horizontal + vertical*vertical)
        return radius
    }

效果如下(可交互):


未命名22222.gif

附参考文章:
iOS 视图控制器转场详解: https://blog.devtang.com/2016/03/13/iOS-transition-guide/

有不明白的可以查看demo:

demo地址: https://github.com/jps2782316/JTransition

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

推荐阅读更多精彩内容