iOS实现Pinterest的转场动画

我们先来看看效果

screenshot.gif

实现原理

你可以先在github上下载本文的demo

我们要实现自定义转场动画,最通用的方法就是自定义实现转场动画的类,并使这个类遵守UIViewControllerAnimatedTransitioning协议。

Pinterest的这个转场动画主要是通过UINavigationController的push和pop实现的,所以我们的目的就是自定义push和pop的转场动画,demo中的HXPinterestTransition类就是具体实现转场动画的类,它遵守了UIViewControllerAnimatedTransitioning协议。

实现步骤

1、定义一个协议HXPinterestTransitionView

这个协议的主要目的就是让需要转场动画的ViewController实现它,并管理需要做动画的View。协议很简单:


// MARK: -  要实现转场动画的ViewController必须遵守此协议
protocol HXPinterestTransitionView {

    func fromTransitionView() -> UIView?

    func toTransitionView() -> UIView?

}

2、定义HXPinterestTransition,并遵守UIViewControllerAnimatedTransitioning协议

这是实现转场动画的核心类,本类中实现了Pinterest转场的push和pop方法。

在效果图中,Pinterest控制器的瀑布流中点击到的cell上的imageView会放大并平移到Detail控制器的imageView的位置上,实现完美重合,在此期间,Pinterest控制器的collectionView也会跟随着放大,就好像是collectionView放大且平移着带动点击中的cell移动到Detail控制器的imageView上,整个过程非常平滑。

所以push方法的代码是这样的:

    /// push
    private func pushAnimateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        /// 首先对参数进行校验
        guard let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
            let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to),
            let fromTargetView = (fromVC as? HXPinterestTransitionView)?.fromTransitionView(),
            let toTargetView = (toVC as? HXPinterestTransitionView)?.toTransitionView() else {
                transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
                return
        }
        let containerView = transitionContext.containerView
        /// 计算动画view的初始frame和结束frame
        let fromFrame = fromTargetView.convert(fromTargetView.bounds, to: UIApplication.shared.keyWindow)
        let toFrame = toTargetView.convert(toTargetView.bounds, to: UIApplication.shared.keyWindow)
        let animationScale = toFrame.width / fromFrame.width
        let toScale = 1 / animationScale
        /// 定义一个UIImageView来做动画
        let snapImageView = UIImageView(image: fromTargetView.getScreenImage())
        snapImageView.frame = fromFrame
        /// 设置动画的初始状态
        toVC.view.alpha = 0
        toVC.view.transform = CGAffineTransform(scaleX: toScale, y: toScale)
        toVC.view.frame.origin = CGPoint(x: -toFrame.origin.x * toScale + fromFrame.origin.x, y: -toFrame.origin.y * toScale + fromFrame.origin.y)
        /// 添加一个白背景
        let bgView = UIView(frame: UIScreen.main.bounds)
        bgView.backgroundColor = .white
        /// 添加相应的view
        containerView.addSubview(bgView)
        containerView.addSubview(toVC.view)
        containerView.addSubview(fromVC.view)
        containerView.addSubview(snapImageView)
        
         UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: .curveEaseOut, animations: {
            /// 1. 放大snapImageView,并使snapImageView的frame.origin处于一个正确的位置
            snapImageView.transform = CGAffineTransform(scaleX: animationScale, y: animationScale)
            snapImageView.frame.origin = toFrame.origin
            /// 2. 同时放大fromVC.view,并使fromVC.view的frame.origin处于一个正确的位置, 并改变透明度
            fromVC.view.alpha = 0
            fromVC.view.transform = CGAffineTransform(scaleX: animationScale, y: animationScale)
            fromVC.view.frame.origin = CGPoint(x: -fromFrame.origin.x * animationScale + toFrame.origin.x, y: -fromFrame.origin.y * animationScale + toFrame.origin.y)
            /// 3. 还原toVC.view的状态
            toVC.view.alpha = 1
            toVC.view.transform = CGAffineTransform.identity
            toVC.view.frame = UIScreen.main.bounds
        }) { (_) in
            /// 动画结束,移除多余的view
            bgView.removeFromSuperview()
            snapImageView.removeFromSuperview()
            /// 还原fromVC.view的状态
            fromVC.view.alpha = 1
            fromVC.view.transform = CGAffineTransform.identity
            fromVC.view.frame = UIScreen.main.bounds
            /// 结束动画
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
        
    }

代码中首先对参数进行校验,fromVC和toVC必须是要遵守了HXPinterestTransitionView且实现了相应方法的类。

接下来就是动画的具体实现了,别看代码很长,其实结构很清晰:计算动画view的初始frame和结束frame,定义来做动画的snapImageView,并设置toVC的初始状态,并将需要在动画中展示的view添加到containerView上。在 UIView.animate方法中,主要做了三步,在代码中已经注释过了,这里就不多啰嗦了。

然后就是pop动画了,在效果图中,pop动画的效果就好像是push动画反过来了一样,所以,实现的代码跟push的代码差别不大:

     /// pop
    private func popAnimateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
            let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to),
            let fromTargetView = (fromVC as? HXPinterestTransitionView)?.toTransitionView(),
            let toTargetView = (toVC as? HXPinterestTransitionView)?.fromTransitionView() else {
                transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
                return
        }
        let containerView = transitionContext.containerView
        
        let fromFrame = fromTargetView.convert(fromTargetView.bounds, to: UIApplication.shared.keyWindow)
        let toFrame = toTargetView.convert(toTargetView.bounds, to: UIApplication.shared.keyWindow)
        let animationScale = fromFrame.width / toFrame.width
        
        let snapImageView = UIImageView(image: toTargetView.getScreenImage())
        snapImageView.frame = toFrame
        snapImageView.transform = CGAffineTransform(scaleX: animationScale, y: animationScale)
        snapImageView.frame.origin = fromFrame.origin
        
        toVC.view.transform = CGAffineTransform(scaleX: animationScale, y: animationScale)
        toVC.view.frame.origin = CGPoint(x: -toFrame.origin.x * animationScale + fromFrame.origin.x, y: -toFrame.origin.y * animationScale + fromFrame.origin.y)
        
        let bgView = UIView(frame: UIScreen.main.bounds)
        bgView.backgroundColor = .white
        
        containerView.addSubview(toVC.view)
        containerView.addSubview(bgView)
        containerView.addSubview(snapImageView)
        
        UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: .curveEaseOut, animations: {
            snapImageView.transform = CGAffineTransform.identity
            snapImageView.frame.origin = toFrame.origin
            toVC.view.transform = CGAffineTransform.identity
            toVC.view.frame = UIScreen.main.bounds
            bgView.alpha = 0
        }) { (_) in
            snapImageView.removeFromSuperview()
            bgView.removeFromSuperview()
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
    }

需要注意的是fromTargetView是fromVC.toTransitionView(),toTargetView是toVC.fromTransitionView()。

3、定义HXPinterestTransitionManager

为了更加易于使用,笔者还定义了一个HXPinterestTransitionManager类来专门管理是否需要执行转场动画,只需要将navigationController的delegate设置为HXPinterestTransitionManager的实例就可以了。
代码是这样的:

// MARK: -  为转场类定制的manager
class HXPinterestTransitionManager: NSObject, UINavigationControllerDelegate {
    
    public func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        /// 如果fromVC和toVC都遵守HXPinterestTransitionView协议,就使用Pinterest转场动画,否则使用系统转场动画
        guard let _ = fromVC as? HXPinterestTransitionView, let _ = toVC as? HXPinterestTransitionView else { return nil }
        switch operation {
        case .push:
            return HXPinterestTransition(.push)
        case .pop:
            return HXPinterestTransition(.pop)
        case .none:
            return nil
        }
    }
    
}

4、应用场景实例

这里有两个ViewController,分别是PinterestViewController和DetailViewController。
在PinterestViewController中设置navigationController的代理为HXPinterestTransitionManager的实例,最好是将其设置为属性,这样可以保证在navigationController的生命周期中一直有效。

  
  private let pinterestTransitionManager = HXPinterestTransitionManager()

    // MARK: -  Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        /// 设置导航控制器代理为pinterestTransitionManager
        navigationController?.delegate = pinterestTransitionManager
        /// 其他代码
        ......
    }

分别在PinterestViewController和DetailViewController中遵守HXPinterestTransitionView协议。

// MARK: -  HXPinterestTransitionView
extension PinterestViewController: HXPinterestTransitionView {
    
    func fromTransitionView() -> UIView? {
        ///这里取collectionView中被选中的cell
        guard let selectedItem = collectionView.indexPathsForSelectedItems?.first,
            let cell = collectionView.cellForItem(at: selectedItem) as? PintersetCell else { return nil }
        return cell.imageView
    }
    
    func toTransitionView() -> UIView? {
        return nil
    }
    
}
// MARK: -  HXPinterestTransitionView
extension DetailViewController: HXPinterestTransitionView {
    
    func fromTransitionView() -> UIView? {
        return nil
    }
    
    func toTransitionView() -> UIView? {
        return imageView
    }
    
}

具体的实现就是这么简单,只需要设置navigationController的代码,然后在需要做动画的viewController中分别遵守HXPinterestTransitionView协议就行了。

总结

自定义转场动画是iOS开发中比较常见的需求,这里给各位看官提供一种思路,如果有什么错误的地方,请指正。
github的代码在这里,如果觉得不错,请不要吝啬star,谢谢。

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

推荐阅读更多精彩内容