画像がズームインしながら画面遷移するSwiftライブラリを公開しました

iOSエンジニアの庄司(@WorldDownTown)です。 iQONのiOSアプリ内部で使われている画面遷移処理をOSSライブラリ化したのでご紹介します。

TL;DR

  • UINavigationControllerでの遷移時に、タップした画像をズームして遷移するトランジション処理をSwiftライブラリ化しました。
  • エッジスワイプでもズームアウトして戻ることができます。

github.com

ライブラリ化した経緯

Pinterestをはじめ、画像がズームインしながら画面遷移するアプリは今や珍しくありません。 この表現を実現するライブラリはいくつか存在しますが、通常のUINavigationControllerのようにスワイプで戻れなくなったり、スワイプできても通常のスワイプとは違って指の動きに同期しないものが多い印象です。 iQONのアイテム詳細ページではこのジェスチャー周辺の実装がしっかりできているので、OSSとして公開したら需要があるかもと思い、ライブラリ化に踏み切りました。

f:id:vasilyjp:20160720121720g:plain:w250

特徴

エッジスワイプ

通常のUINavigationControllerと同様に、エッジスワイプで前のViewControllerに戻る事ができます。 上記のアニメーションGIFを見ていただくとわかりやすいと思います。

Objective-Cプロジェクトでも使えます

作成したクラスはすべてFoundation, UIKitのクラスを継承しているため、Objective-Cのコードからも利用できます。

使い方

このライブラリを使って画面遷移のアニメーションを実装するには3つのステップがあります。

  1. UINavigationControllerDelegate設定

  2. 画面遷移元のViewController設定

  3. 画面遷移先のViewController設定

1. UINavigationControllerDelegate 設定

UINavigationControllerDelegateで画面遷移対象のViewControllerをチェックしてアニメーションをします。 ZoomNavigationControllerDelegateオブジェクトをdelegateに設定するだけです。

class NavigationController: UINavigationController {

    private let zoomNavigationControllerDelegate = ZoomNavigationControllerDelegate()

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        delegate = zoomNavigationControllerDelegate
    }
}

2. 画面遷移元のViewController設定

アニメーション対象のUIImageViewを返したり、アニメーション中に元の画像を非表示にできるように画面遷移前後のZoomTransitionSourceDelegateのメソッドを実装します。

extension ImageListViewController: ZoomTransitionSourceDelegate {

    // アニメーション対象のUIImageViewを返す
    func transitionSourceImageView() -> UIImageView {
        return selectedImageView
    }

    // スクリーンに対するアニメーション開始位置を返す
    func transitionSourceImageViewFrame(forward forward: Bool) -> CGRect {
        guard let selectedImageView = selectedImageView else { return CGRect.zero }
        return selectedImageView.convertRect(selectedImageView.bounds, toView: view)
    }

    // 画面遷移直前
    func transitionSourceWillBegin() {
        selectedImageView?.hidden = true
    }

    // 画面遷移完了後
    func transitionSourceDidEnd() {
        selectedImageView?.hidden = false
    }

    // 画面遷移キャンセル後
    func transitionSourceDidCancel() {
        selectedImageView?.hidden = false
    }
}

3. 画面遷移先のViewController設定

ZoomTransitionSourceDelegateと同様の目的で画面遷移先のViewController向けの設定のため、ZoomTransitionDestinationDelegateのメソッドを実装します。

extension ImageDetailViewController: ZoomTransitionDestinationDelegate {

    // 画面遷移完了後、及び、ポップ時のUIImageViewの配置
    func transitionDestinationImageViewFrame(forward forward: Bool) -> CGRect {
        if forward {
            let x: CGFloat = 0.0
            let y = topLayoutGuide.length
            let width = view.frame.width
            let height = width * 2.0 / 3.0
            return CGRect(x: x, y: y, width: width, height: height)
        } else {
            return largeImageView.convertRect(largeImageView.bounds, toView: view)
        }
    }

    // 画面遷移直前
    func transitionDestinationWillBegin() {
        largeImageView.hidden = true
    }

    // 画面遷移完了後
    func transitionDestinationDidEnd(transitioningImageView imageView: UIImageView) {
        largeImageView.hidden = false
        largeImageView.image = imageView.image
    }

    // 画面遷移キャンセル後
    func transitionDestinationDidCancel() {
        largeImageView.hidden = false
    }
}

リポジトリにDemoプロジェクトがあるので、そちらもご覧ください。

ライブラリの内部実装

ズームアニメーションの仕組み

UIViewControllerAnimatedTransitioningプロトコルを採用したZoomTransitioningが画像がズームするアニメーション処理を実行しています。

UIViewControllerAnimatedTransitioningによる画面遷移アニメーションについては、下記のQiita記事が参考になったので、そちらをご覧ください。

qiita.com

スワイプで戻る

UIPercentDrivenInteractiveTransitionを継承し、UIGestureRecognizerDelegateを採用したZoomInteractiveTransitionというクラスがスワイプによる画面遷移を実現させています。

let zoomInteractiveTransition = ZoomInteractiveTransition()
let gesture = navigationController.interactivePopGestureRecognizer
gesture?.delegate = zoomInteractiveTransition
gesture?.addTarget(zoomInteractiveTransition, action: #selector(ZoomInteractiveTransition.handlePanGestureRecognizer(_:)))

UINavigationControllerinteractivePopGestureRecognizerというプロパティはUIScreenEdgePanGestureRecognizerクラスで、このGestureRecognizerが通常のエッジスワイプで戻る動作を可能にしています。 ZoomInteractiveTransitioninginteractivePopGestureRecognizerのジェスチャー処理を受け取ることで、ZoomTransitioningが実行するアニメーションを使ってエッジスワイプで戻れるようになります。

// UINavigationControllerDelegate
func navigationController(navigationController: UINavigationController, interactionControllerForAnimationController animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
    return zoomInteractiveTransition.interactive ? zoomInteractiveTransition : nil
}

エッジスワイプのジェスチャーを受け取ってナビゲーションを戻るときだけzoomInteractiveTransitionを返して、指の動きを反映したインタラクティブな画面遷移をします。

class ZoomInteractiveTransition: UIPercentDrivenInteractiveTransition {
    var interactive = false  // スワイプで戻るフラグ。ジェスチャーを受け取ったらtrueにする

    @objc func handlePanGestureRecognizer(recognizer: UIScreenEdgePanGestureRecognizer) {
        let view = recognizer.view!
        let progress = recognizer.translationInView(view).x / view.bounds.width

        switch recognizer.state {
        case .Changed:
            updateInteractiveTransition(progress)
        case .Cancelled, .Ended:
            if progress > 0.33 {
                finishInteractiveTransition()
            } else {
                cancelInteractiveTransition()
            }
        default:
            break
        }
    }

UINavigationControllerinteractivePopGestureRecognizerのジェスチャーで呼ばれるメソッドの方では、スワイプする指の位置ごとに、UIPercentDrivenInteractiveTransitionのメソッドを呼び出してアニメーションの進捗を反映させます。そうすると、ZoomTransitioningのアニメーションが指の位置に合わせて動作します。

複雑な処理に見えますが、もしUIPercentDrivenInteractiveTransitionを使わなかった場合、スワイプで戻る時もZoomTransitionigと同じアニメーション処理を別途実装しないといけません。

さいごに

UINavigationControllerでの遷移時に、タップした画像をズームインしながらアニメーションするZoomTransitioningを紹介しました。 このライブラリは、iQONでの仕様を基に最低限の機能で公開しています。 バグ報告や改善案など (OSS化の真の目的はこれだったり…)、Pull Request お待ちしております。