Swift愛好会 Advent Calendar 2016 8日目の投稿です。なにか皆さん技術の話をあまりしていない気がしますが、気にせず技術の話をします。
iPhoneの写真アプリのように
iOS_UIトレース会という勉強会を主催しています。iOSエンジニアが世にあるアプリのUIを真似たアプリを作っていって開発能力をアップしていこうという目的の勉強会です。
11月と12月のお題がGohobeeというアプリのご褒美画面を真似しようということになっていました。
とてもリッチな画面です。特徴を幾つかあげると
- それぞれ異なるセルレイアウト
 - シームレスに次の画像が遷移
 - 遷移後画像がくるくるアニメーション
 - パララックススクロール
 
とたくさんあります。
すべてを真似するのはなかなか難しいと思い、シームレスに次の画像が遷移だけを真似しようかと思いました。
今回はiOS7から導入されたカスタムトランジションを利用して、画像をシームレスに移動しながら画面遷移をする方法を見てみたいと思います。
カスタムトランジション
iOSの画面遷移はiOS7から開発者カスタムができるようになりました。
仕組みとしては
- UIViewControllerAnimatedTransitioningプロトコルを適応したクラスを作る。このクラスでアニメーションを指定する
 - UINavigationControllerのサブクラスを作って、UINavigationControllerDelegateに適応させる。navigationController(_:animationControllerFor:from
)メソッドを実装。1のインスタンスを戻り値として返す。 
です。
実装
今回はUINavigationControllerを使ったトランジションを実装していきたいと思います。
まず、UIViewControllerAnimatedTransitioningプロトコルを適応したクラスを作ります。
class Transition: NSObject, UIViewControllerAnimatedTransitioning {
    // pushなら forward == true 
    var forward = false
    
    //アニメーションの時間
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.4
    }
    
    //アニメーションの定義
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        if self.forward {
            //push時のアニメーション
            forwardTransition(transitionContext)
        } else {
            backwardTransition(transitionContext)
        }
    }
    
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?)がアニメーションの時間を返します。今回は0.4秒にしました。
そして実際のアニメーションの操作をfunc animateTransition(using transitionContext: UIViewControllerContextTransitioning) で実装します。
今回はpushのときはforwardTransition メソッド、backwardTransition メソッドを実装しました。
forwardTransitionメソッド
    //push時のアニメーション
    fileprivate func forwardTransition (_ transitionContext: UIViewControllerContextTransitioning) {
        guard let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) else {
            return
        }
        guard let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else {
            return
        }
        
        let containerView = transitionContext.containerView
        
        //遷移先のviewをaddSubViewする(fromVC.viewは最初からcontainerViewがsubviewとして持っている
        containerView.addSubview(toVC.view)
        
        //アニメーション用のimageViewを新しく作成する
        guard let sourceImageView = (fromVC as? ViewController)?.createImageView() else {
            return
        }
        
        guard let destinationImageView = (toVC as? DetailViewController)?.createImageView() else {
            return
        }
        
        //遷移先のimageViewをaddSubviewする
        containerView.addSubview(sourceImageView)
        toVC.view.alpha = 0.0
        
        //addSubViewでレイアウトが崩れるため再レイアウトする
        toVC.view.layoutIfNeeded()
        
        UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0.05,
                       options: UIViewAnimationOptions.curveEaseIn,
                       animations: {
                        
                        //アニメーション開始
                        // 遷移もとのimageViewのframeとconteModeを遷移先のimageViewに代入
                        sourceImageView.frame = destinationImageView.frame
                        sourceImageView.contentMode = destinationImageView.contentMode
                        
                        // cellのimageViewを非表示にする
                        (fromVC as? SharedView)?.createImageView()?.isHidden = true
                        
                        toVC.view.alpha = 1.0
                        
                        
        }, completion: {
            finished in
            
            //アニメーション終了
            transitionContext.completeTransition(true)
        })
    }
    
push時のソースです。
画面遷移中はtransitionContext.containerViewという特殊なコンテナビューの上で操作されます。
これは最初から遷移元のビュー(transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from))がaddSubViewされている状態です。
そこに、遷移中にシームレスに移動させる画像、sourceImageViewを取得して、それをアニメーションします。
最後にtransitionContext.completeTransition(true)を実行します。これはシステムに画面遷移を知らせるメソッドです。
忘れると画面先のビューの状態がおかしくなるのでアニメーションが終わった段階で呼ぶようにします。
戻るときのアニメーションは以下のようにしました。
    //pop時のアニメーション
    fileprivate func backwardTransition(_ transitionContext: UIViewControllerContextTransitioning) {
        // Pushと逆のアニメーション
        guard let fromeVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) else {
            return
        }
        
        guard let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else {
            return
        }
        
        let containerView = transitionContext.containerView
        
        //前回遷移したさいのImageViewが残っているので一度全てを外す
        containerView.subviews.forEach {
            view in
            view.removeFromSuperview()
        }
        
        // toView -> fromViewの順にaddSubView
        containerView.addSubview(toVC.view)
        containerView.addSubview(fromeVC.view)
        guard let sourceImageView = (fromeVC as? DetailViewController)?.createImageView() else {
            return
        }
        
        guard let destinationImageView = (toVC as? ViewController)?.createImageView() else {
            return
        }
        
        //戻るときに一度ViewControllerのcellのimageViewを非表示にする
        guard let viewController = toVC as? ViewController, let cell = viewController.collectionView.cellForItem(at: viewController.selectedIndexPath!) as? CollectionViewCell  else {
            return
        }
        
        cell.imageView.isHidden = true
        
        containerView.addSubview(sourceImageView)
        containerView.layoutIfNeeded()
        
        UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0.05, options: UIViewAnimationOptions.curveEaseIn,
                       animations: {
                        sourceImageView.frame = destinationImageView.frame
                        
                        fromeVC.view.alpha = 0.0
                        
        }, completion: {
            finished in
            sourceImageView.isHidden = true
            
            (toVC as? ViewController)?.selectedImageView?.isHidden = false
            cell.imageView.isHidden = false
            sourceImageView.removeFromSuperview()
            fromeVC.view.removeFromSuperview()
            transitionContext.completeTransition(true)
        })
    }
基本的にpush時と逆のアニメーション同じですが、注意としてtransitionContext.containerViewのサブビューに対して一度全てビュー階層から外しています。
//前回遷移したさいのImageViewが残っているので一度全てを外す
containerView.subviews.forEach {
    view in
    view.removeFromSuperview()
}
transitionContext.containerViewはシングルトンのようで、遷移をするたびに同じオブジェクトが使われるようにです。
なので、遷移する際にaddSubviewをすると次回遷移するときにそのビューが残ってしまうようです。
UINavigationControllerにカスタムトランジションできるようにする
UINavigationControllerのサブクラスを作って作ったTransitionクラスのインスタンスのアニメーションになるように設定します。
UINavigationControllerDelegateを適応します。
そしてUINavigationControllerDelegateのnavigationController(_:animationControllerFor:from:to:)にTransitionクラスのインスタンスを返すようにします。
class TransitionNavigationController: UINavigationController, UINavigationControllerDelegate {
    override func viewDidLoad() {
        super.viewDidLoad()
        self.delegate = self 
        // Do any additional setup after loading the view.
    }
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
    
    func navigationController(_ navigationController: UINavigationController,
                              animationControllerFor operation: UINavigationControllerOperation,
                              from fromVC: UIViewController,
                              to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        let transition = Transition()
        switch operation {
        case .push:
             transition.forward = true
            return transition
        case .pop:
            transition.forward = false
            return transition
        default:
            return nil
        
        }
        
    }
}
これで画像をシームレスに移動しながら画面遷移ができます。
完成品
ソース
Githubにあげています。
https://github.com/SatoTakeshiX/SwiftSharedViewTransition