読者です 読者をやめる 読者になる 読者になる
pixiv insideは移転しました! ≫ http://inside.pixiv.blog/

SwiftでTumblrのリアクションみたいにモーダルウィンドウがにゅっと出るやつを作る

この記事は ピクシブ株式会社 Advent Calendar 2015 16日目の記事です。

qiita.com

昨日は、id:catatsuyピクシブ社内広告サーバーに新機能を追加するためにボクがやったこと - pixiv inside でした!


f:id:FromAtom:20151215203937p:plain

どうもどうも、エンジニアのid:FromAtomです。pixiv SketchというサービスのiOSアプリデベロッパーをしております。 いろんなイベントで話をするたびに「本当に新卒ですか?」「3年目ぐらいの貫禄がある。」と言われますが新卒です。よろしくお願いいたします。

さて、今日は『SwiftでTumblrのリアクションみたいにモーダルウィンドウがにゅっと出るやつを作る』方法を紹介します。 文字で読むと「なんのこっちゃ?」という感じだと思うので、まずはこちらを御覧ください。

https://gyazo.com/6122f46ff55242a2301c43746c2052ae

TumblrのiOSアプリでは、このようにリアクションをタップするとモーダルがにゅっと出てきて、リアクションの一覧を確認することができます。ちなみに、このかわいいネコは我が家の愛猫しじみです。Tumblrでしじみ生活という猫記録を公開していますので、ぜひフォローして激かわな写真・動画をお楽しみ下さい。

猫の話はさておき、にゅっと出るモーダルの作り方についてです。

サンプル

GitHubからcloneして試したい方はこちら。

github.com

環境

  • Swift2.1
  • Xcode Version 7.2
  • iOS8以降

モーダルを作る

まずはじめに、UIButtonをTapしたらモーダルが表示されるようにしましょう。Xcodeから新規プロジェクトを作成します。テンプレートはSingle View Applicationを選択しましょう。 これからいじっていくファイルは次の4つです。

  • Main.storyboard → 元からある
  • ViewController.swift → 元からある
  • ModalViewController.swift → 新しく作る
  • CustomPresentationController.swift → 新しく作る

Main.storyboard

Main.storyboardにはUIButtonを設置しておきます。

f:id:FromAtom:20151214162236p:plain

ViewController.swift

ViewController.swiftはこのように記載します。@IBAction func OpenButtonTouchUpInside(sender: UIButton)には先程Main.storyboardで設置したUIButtonを紐付けましょう。

class ViewController: UIViewController {
    var atButton: UIButton?

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

    @IBAction func OpenButtonTouchUpInside(sender: UIButton) {
        atButton = sender

        let modalViewController = ModalViewController()
        modalViewController.modalPresentationStyle = .Custom
        modalViewController.transitioningDelegate = self
        presentViewController(modalViewController, animated: true, completion: nil)
    }
}

extension ViewController: UIViewControllerTransitioningDelegate {
    func presentationControllerForPresentedViewController(presented: UIViewController, presentingViewController presenting: UIViewController, sourceViewController source: UIViewController) -> UIPresentationController? {
        return CustomPresentationController(presentedViewController: presented, presentingViewController: presenting)
    }
}

ModalViewController.swift

モーダルとして表示される画面です。この中で特殊なコードを書く必要はありません。わかりやすくなるように、背景色を緑色にする処理だけ書いておきましょう。

class ModalViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = UIColor.greenColor()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}

CustomPresentationController.swift

ViewController.swiftで実装したpresentationControllerForPresentedViewController:から呼ばれます。

final class CustomPresentationController: UIPresentationController {
    var overlayView = UIView()

    // 表示トランジション開始前に呼ばれる
    override func presentationTransitionWillBegin() {
        guard let containerView = containerView else {
            return
        }

        overlayView.frame = containerView.bounds
        overlayView.gestureRecognizers = [UITapGestureRecognizer(target: self, action: "overlayViewDidTouch:")]
        overlayView.backgroundColor = UIColor.blackColor()
        overlayView.alpha = 0.0
        containerView.insertSubview(overlayView, atIndex: 0)

        presentedViewController.transitionCoordinator()?.animateAlongsideTransition({ [weak self] context in
            self?.overlayView.alpha = 0.7
            }, completion: nil)
    }

    // 非表示トランジション開始前に呼ばれる
    override func dismissalTransitionWillBegin() {
        presentedViewController.transitionCoordinator()?.animateAlongsideTransition({ [weak self] context in
            self?.overlayView.alpha = 0.0
            }, completion: nil)
    }

    // 非表示トランジション開始後に呼ばれる
    override func dismissalTransitionDidEnd(completed: Bool) {
        if completed {
            overlayView.removeFromSuperview()
        }
    }

    let margin = (x: CGFloat(30), y: CGFloat(220.0))
    override func sizeForChildContentContainer(container: UIContentContainer, withParentContainerSize parentSize: CGSize) -> CGSize {
        return CGSize(width: parentSize.width - margin.x, height: parentSize.height - margin.y)
    }

    override func frameOfPresentedViewInContainerView() -> CGRect {
        var presentedViewFrame = CGRectZero
        let containerBounds = containerView!.bounds
        let childContentSize = sizeForChildContentContainer(presentedViewController, withParentContainerSize: containerBounds.size)
        presentedViewFrame.size = childContentSize
        presentedViewFrame.origin.x = margin.x / 2.0
        presentedViewFrame.origin.y = margin.y / 2.0

        return presentedViewFrame
    }

    // レイアウト開始前に呼ばれる
    override func containerViewWillLayoutSubviews() {
        overlayView.frame = containerView!.bounds
        presentedView()!.frame = frameOfPresentedViewInContainerView()
    }

    // レイアウト開始後に呼ばれる
    override func containerViewDidLayoutSubviews() {
    }

    // overlayViewをタップしたときに呼ばれる
    func overlayViewDidTouch(sender: AnyObject) {
        presentedViewController.dismissViewControllerAnimated(true, completion: nil)
    }
}

sizeForChildContentContainer:ではModalViewControllerがどのくらいのサイズで表示されるかを決めています。そしてそれを元にframeOfPresentedViewInContainerView:で表示位置の調整をしています。ここの処理で、上下左右にマージンをとったModalが画面中央に表示されるようになります。

下からにゅっと出る様子

ここまでの実装で、モーダルが下からにゅっと出るようになりました。このトランジション自体は標準的なものですね。

https://gyazo.com/f4adeef92975661dc302ff3f5f13beb0

ボタンからにゅっと出るようにする

ここまでの実装でモーダルとしての見た目が整いました。しかし、このままではボタンからではなく画面下から出てきてしまいます。それでは、ボタンからにゅっと出すためにUIViewControllerAnimatedTransitioningを使っていきましょう。 いじるファイルは以下の2つです。

  • ViewController.swift → 元からある
  • CustomAnimatedTransitioning.swift → 新しく作る

ViewController.swift

次のextensionを追記します。

extension ViewController {
    func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CustomAnimatedTransitioning(isPresent: true, atButton: atButton)
    }

    func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CustomAnimatedTransitioning(isPresent: false, atButton: atButton)
    }
}

CustomAnimatedTransitioning.swift

ViewController.swiftで呼ばれているCustomAnimatedTransitioning(isPresent: Bool, atButton: UIButton)を実装します。

final class CustomAnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning {
    let isPresent: Bool
    let atButton: UIButton

    init(isPresent: Bool, atButton: UIButton?) {
        self.isPresent = isPresent
        self.atButton = atButton ?? UIButton(frame: CGRectZero)
    }

    func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
        return 0.5
    }

    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
        if isPresent {
            animatePresentTransition(transitionContext)
        } else {
            animateDissmissalTransition(transitionContext)
        }
    }

    // 表示する時のアニメーション
    func animatePresentTransition(transitionContext: UIViewControllerContextTransitioning) {
        guard
            let presentingController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey),
            let presentedController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey),
            let containerView = transitionContext.containerView()
            else {
                return
        }

        presentedController.view.layer.cornerRadius = 4.0
        presentedController.view.clipsToBounds = true
        presentedController.view.alpha = 0.0
        presentedController.view.transform = CGAffineTransformMakeScale(0.01, 0.01)

        containerView.insertSubview(presentedController.view, belowSubview: presentingController.view)

        UIView.animateWithDuration(transitionDuration(transitionContext), delay: 0.0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.6, options: .CurveLinear, animations: {
            presentedController.view.alpha = 1.0
            presentedController.view.frame.origin.x = containerView.bounds.size.width - self.atButton.frame.origin.x
            presentedController.view.frame.origin.y = containerView.bounds.size.height - self.atButton.frame.origin.y
            presentedController.view.transform = CGAffineTransformIdentity
            }, completion: { finished in
                transitionContext.completeTransition(true)
        })
    }

    // 非表示する時のアニメーション
    func animateDissmissalTransition(transitionContext: UIViewControllerContextTransitioning) {
        guard let presentedController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey) else {
            return
        }

        UIView.animateWithDuration(transitionDuration(transitionContext), delay: 0.0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.6, options: .CurveLinear, animations:{
            presentedController.view.alpha = 0.0
            presentedController.view.transform = CGAffineTransformMakeScale(0.01, 0.01)
            presentedController.view.frame.origin.x = self.atButton.frame.origin.x
            presentedController.view.frame.origin.y = self.atButton.frame.origin.y

            }, completion: { finished in
                transitionContext.completeTransition(true)
        })
    }
}

ボタンからにゅっと出る様子

ちゃんとUIButtonからにゅっと出てるか確認するために、いくつかUIButtonを追加して、@IBAction func OpenButtonTouchUpInside(sender: UIButton)にひもづけましょう。 これでボタンからモーダルがにゅっと出るようになりました!やった!

f:id:FromAtom:20151214163212g:plain:w300

参考文献

クラスメソッドさんの記事が大変参考になりました。

同じ手法でこんな画面遷移を実装することも可能です。

まとめ

UIPresentationControllerを使うと、自分でカスタムしたモーダルウィンドウを作ることができます。 また、UIViewControllerAnimatedTransitioningを使うと、モーダルを表示する際のアニメーションを設定することができることがわかりました。

今回は、これらを組み合わせてTumblrのリアクションの様なにゅっと出るモーダルを作成しましたが、 実装を変えることでTwitter公式アプリの投稿画面で使われているトランジションも実現できます。

また、今回はUIButtonから緑のUiViewが出てきましたが、サムネイル画像をタップするとにゅっと画像を詳細表示をする遷移も同じ仕組みで実装可能です。 他にも、色々とおしゃれなことができそうですね。

ちなみにピクシブ株式会社では、にゅっとしてしゅっとしたアプリを一緒に作ってくれるエンジニア・アルバイトを募集中です。こちらからしゅっとエントリーできます。

明日は同じく新卒の @RinKeiHotmanが担当です!お楽しみに!