[iOS 8] UIPresentationController でカスタムのモーダル表示を実装する

UIPresentationController とは

UIPresentationController は iOS 8 から追加された、View Controller の上のレイヤーにモーダルのような形で画面を表示する機能を提供する View Controller です。

iPad ではよく目にする機会が多いですが、下図のように View Controller の上に重なる感じで表示される画面のことです。

uipresentationcontroller01

このような機能は、これまでは UIPopoverController のように、カスタマイズ不可能な形で提供されていました。iOS 8 では UIPresentationController が追加され、自由な表示・アニメーションのモーダルが表示できるようになりました。

uipresentationcontroller03

なお、UIPresentationController は抽象クラスで、標準では上記 UIPopoverController の代替として UIPopoverPresentationController が実装クラスとして提供されています。この抽象クラスを実装することで自由な表示・アニメーションのモーダルが実現できます。

実装ソースは気まぐれで GitHub に公開したので、お急ぎのかたはこちらを Clone していただければと思います。

suwa-yuki/SamplePresentation

2015/11/21 Xcode 7.1.1 で動作するように修正しました。

実装する

早速実装します。まずは View Controller から実装です。

class ViewController: UIViewController, UIViewControllerTransitioningDelegate {

    @IBAction func buttonDidTouch(sender: AnyObject) {
        // 新しい View Controller をモーダル表示する
        let controller: UINavigationController! = self.storyboard?.instantiateViewControllerWithIdentifier("NavigationController") as? UINavigationController
        controller.modalPresentationStyle = .Custom
        controller.transitioningDelegate = self
        self.presentViewController(controller, animated: true, completion: {
        })
    }
    
    // MARK: UIViewControllerTransitioningDelegate
    
    func presentationControllerForPresentedViewController(presented: UIViewController, presentingViewController presenting: UIViewController, sourceViewController source: UIViewController) -> UIPresentationController? {
        return CustomPresentationController(presentedViewController: presented, presentingViewController: presenting)
    }

}

ポイントは2点です。まず UIViewControllerTransitioningDelegate というプロトコルを追加します。このプロトコルの presentationControllerForPresentedViewController:presentingViewController:sourceViewController: メソッドを実装し、UIPresentationController の実装クラスのインスタンスを返します。こうすることで presentViewController:animated:completion: を実行するときの振る舞いに UIPresentationController の実装クラスが適用されます。

上記メソッドで presented やら presenting やら出てきていますが、presented が呼び出し先の View Controller、presenting が呼び出し元の View Controller です。よく出てくるので記憶しておいてください。

次に UIPresentationController を実装します。ちょっと長いですが、コメントを随所に書いているので読んでいくと理解できると思います。

class CustomPresentationController: UIPresentationController {
    
    // 呼び出し元の View Controller の上に重ねるオーバーレイ View
    var overlay: UIView!
    
    // 表示トランジション開始前に呼ばれる
    override func presentationTransitionWillBegin() {
        let containerView = self.containerView!
        
        self.overlay = UIView(frame: containerView.bounds)
        self.overlay.gestureRecognizers = [UITapGestureRecognizer(target: self, action: "overlayDidTouch:")]
        self.overlay.backgroundColor = UIColor.blackColor()
        self.overlay.alpha = 0.0
        containerView.insertSubview(self.overlay, atIndex: 0)
        
        // トランジションを実行
        presentedViewController.transitionCoordinator()?.animateAlongsideTransition({
            [unowned self] context in
            self.overlay.alpha = 0.5
        }, completion: nil)
    }
    
    // 非表示トランジション開始前に呼ばれる
    override func dismissalTransitionWillBegin() {
        self.presentedViewController.transitionCoordinator()?.animateAlongsideTransition({
            [unowned self] context in
            self.overlay.alpha = 0.0
        }, completion: nil)
    }
    
    // 非表示トランジション開始後に呼ばれる
    override func dismissalTransitionDidEnd(completed: Bool) {
        if completed {
            self.overlay.removeFromSuperview()
        }
    }
    
    // 子のコンテナのサイズを返す
    override func sizeForChildContentContainer(container: UIContentContainer, withParentContainerSize parentSize: CGSize) -> CGSize {
        return CGSize(width: parentSize.width / 2, height: parentSize.height)
    }
    
    // 呼び出し先の View Controller の Frame を返す
    override func frameOfPresentedViewInContainerView() -> CGRect {
        var presentedViewFrame = CGRectZero
        let containerBounds = containerView!.bounds
        presentedViewFrame.size = self.sizeForChildContentContainer(self.presentedViewController, withParentContainerSize: containerBounds.size)
        presentedViewFrame.origin.x = containerBounds.size.width - presentedViewFrame.size.width
        presentedViewFrame.origin.y = containerBounds.size.height - presentedViewFrame.size.height
        return presentedViewFrame
    }
    
    // レイアウト開始前に呼ばれる
    override func containerViewWillLayoutSubviews() {
        overlay.frame = containerView!.bounds
        self.presentedView()!.frame = self.frameOfPresentedViewInContainerView()
    }
    
    // レイアウト開始後に呼ばれる
    override func containerViewDidLayoutSubviews() {
    }
    
    // オーバーレイの View をタッチしたときに呼ばれる
    func overlayDidTouch(sender: AnyObject) {
        self.presentedViewController.dismissViewControllerAnimated(true, completion: nil)
    }
    
}

解説します。上記で実装しているメソッドは、overlayDidTouch: 以外はオーバーライドしたメソッドになります。

presentationTransitionWillBegin:dismissalTransitionWillBegin: は呼び出し先の View Controller (presentedViewController) を表示/非表示するトランジション開始前に呼ばれます。ここではオーバーレイの View (黒い半透明の View) をフェードイン/フェードアウトするアニメーションを実装しています。dismissalTransitionDidEnd: では、非表示にするトランジションの終了後にオーバーレイで重ねている View を削除しています。なお、 overlayDidTouch: でオーバーレイをタッチすると非表示にできるようにしています。

frameOfPresentedViewInContainerView: では、呼び出し先の View Controller (presentedViewController) の表示位置を決めています。sizeForChildContentContainer:withParentContainerSize: で決めたサイズを利用し、左側にちょっと出るように設定しています。

ここまで実装すると次のように動作します。

uipresentationcontroller02

まとめ

表示位置もアニメーションも自由自在に決めれるので、UI や UX に幅が広がりました。デザイナーも覚えておいたほうが良いと思います。

おまけ

アニメーションは UIViewControllerAnimatedTransitioning の実装クラスを使うと書き換えることができます。

class ViewController: UIViewController, UIViewControllerTransitioningDelegate {
    
    // MARK: UIViewControllerTransitioningDelegate
    
    func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CustomAnimatedTransitioning(isPresent: true)
    }
    
    func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CustomAnimatedTransitioning(isPresent: false)
    }

}
class CustomAnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning {
    
    let isPresent: Bool
    
    init(isPresent: Bool) {
        self.isPresent = isPresent
    }
    
    func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimeInterval {
        return 0.3
    }
    
    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
        if isPresent {
            animatePresentTransition(transitionContext)
        } else {
            animateDissmissalTransition(transitionContext)
        }
    }
    
    func animatePresentTransition(transitionContext: UIViewControllerContextTransitioning) {
        let presentingController: UIViewController! = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)
        let presentedController: UIViewController! = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)
        let containerView: UIView! = transitionContext.containerView()
        containerView.insertSubview(presentedController.view, belowSubview: presentingController.view)
        //適当にアニメーション
        UIView.animateWithDuration(self.transitionDuration(transitionContext), animations: {
            presentedController.view.frame.origin.x -= containerView.bounds.size.width
            }, completion: {
                finished in
                transitionContext.completeTransition(true)
        })
    }
    
    func animateDissmissalTransition(transitionContext: UIViewControllerContextTransitioning) {
        let presentedController: UIViewController! = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)
        let containerView: UIView! = transitionContext.containerView()
        //適当にアニメーション
        UIView.animateWithDuration(self.transitionDuration(transitionContext), animations: {
            presentedController.view.frame.origin.x = containerView.bounds.size.width
            }, completion: {
                finished in
                transitionContext.completeTransition(true)
        })
    }
    
}

こんな感じです。左側からニュッと出てくるようにしてみました。

uipresentationcontroller03

参考