[iOS] UIPresentationController でドロワーメニューを作りたい

2019.07.06

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。きんくまです。

ドロワーメニューを作りたくて調べていたら、本ブログで記事が見つかりました!

[iOS 8] UIPresentationController でカスタムのモーダル表示を実装する
[iOS] UIPresentationControllerを使用してカスタムダイアログを実装する

こちらを参考に作ってみました。

作ったもの

GitHubのリポジトリ
cm-tsmaeda/DrawerMenuPresentationControllerSample

1. UIPresentationController のサブクラスを実装

ここでは何を設定するかというと、こんなやつです。

  • 新しく表示されるViewのframe(今回はドロワーなので、左に寄せて少し幅を小さくしました)
  • 新しく表示されるViewの他に、何かオプション的なViewがあれば表示
  • オプション的なViewのアニメーション

今回は、元のViewと新しく表示されるViewの間に、半透明の背景(Webで言うところのざぶとん的な)を追加しました。

実際にどのViewControllerを表示するかは、UIPresentationController自体は知らない(定義していない)ところがポイントですね。なのでいろいろなViewControllerをこのクラスで設定した表示方法で呼び出すことができます。

class DrawerMenuPresentationController: UIPresentationController {

    // 半透明の背景カバー
    private var coverBackgroundView: UIView!
    private var coverBackgroundTapGesture: UITapGestureRecognizer!

    override func presentationTransitionWillBegin() {
        super.presentationTransitionWillBegin()

        coverBackgroundView = UIView()
        coverBackgroundView.alpha = 0
        coverBackgroundView?.backgroundColor = UIColor.black
        containerView?.insertSubview(coverBackgroundView, at: 0)

        coverBackgroundTapGesture = UITapGestureRecognizer(target: self, action: #selector(coverBackgroundDidTap))
        coverBackgroundView.addGestureRecognizer(coverBackgroundTapGesture)

        // オプションViewのアニメーション
        let transitionCoordinator = presentingViewController.transitionCoordinator
        transitionCoordinator?.animate(alongsideTransition: { [weak self] context in
            self?.coverBackgroundView.alpha = 0.7
        }, completion: nil)
    }

    // 半透明の背景を押したら閉じる
    @objc func coverBackgroundDidTap() {
        presentingViewController.dismiss(animated: true) {

        }
    }

    override func presentationTransitionDidEnd(_ completed: Bool) {
        super.presentationTransitionDidEnd(completed)
    }

    override func dismissalTransitionWillBegin() {
        super.dismissalTransitionWillBegin()

        coverBackgroundView.removeGestureRecognizer(coverBackgroundTapGesture)

        let transitionCoordinator = presentingViewController.transitionCoordinator
        transitionCoordinator?.animate(alongsideTransition: { [weak self] context in
            self?.coverBackgroundView.alpha = 0
        }, completion: nil)
    }

    override func dismissalTransitionDidEnd(_ completed: Bool) {
        super.dismissalTransitionDidEnd(completed)

        if completed {
            coverBackgroundView.removeFromSuperview()
        }
    }

    override var frameOfPresentedViewInContainerView: CGRect {
        guard let containerSize = containerView?.frame.size else {
            return CGRect.zero
        }
        let width = containerSize.width * 0.85
        return CGRect(x: 0, y: 0, width: width, height: containerSize.height)
    }

    override func containerViewWillLayoutSubviews() {
        super.containerViewWillLayoutSubviews()

        coverBackgroundView.frame = containerView!.bounds
        presentedView!.frame = frameOfPresentedViewInContainerView
    }
}

2 UIViewControllerAnimatedTransitioning に適合したクラスを作る

ここで設定することは

  • 元のViewと新しく表示されるViewの切り替えアニメーション

になります。今回は、アニメーションを良さげな感じにしたかったので、前回作ったカスタムイージングを使って、UIViewPropertyAnimatorでアニメーションさせています。

表示されるときと、非表示されるときの秒数も切り替えています。ユーザーは非表示するときは速く消したいと思うので、0.1妙短くしています。

あとtransformでアニメーションさせてますが、そこはお好みでframeでやっても良いかと思います。

さらにいうと、setNeedsLayout()をアニメーションの前後で入れています。前に UIViewControllerAnimatedTransitioning を使った時に、表示がおかしくなってハマったことがあったので入れておきました。

ここでもUIPresentationControllerのときと同じく、元のView/ViewController, 新しく表示されるView/ViewController自体は設定されていないので、この切り替えアニメーションを他のところでも使うことが可能です。

class DrawerMenuTransition: NSObject, UIViewControllerAnimatedTransitioning {

    enum TransitionType {
        case present
        case dismiss
    }

    var animator: UIViewPropertyAnimator?
    var transitionType: TransitionType

    init(type: TransitionType) {
        self.transitionType = type
    }

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        switch transitionType {
        case .present:
            return 0.4
        case .dismiss:
            return 0.3
        }
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        switch transitionType {
        case .present:
            startPresentTransition(using: transitionContext)
        case .dismiss:
            startDismissTransition(using: transitionContext)
        }
    }

    private func startPresentTransition(using context: UIViewControllerContextTransitioning) {
        let timing = CubicTimingParametersCreator.createParameters(timingType: .quartOut)
        let animatorNotNil = UIViewPropertyAnimator(duration: transitionDuration(using: context), timingParameters: timing)

        let containerView = context.containerView
        guard let toView = context.view(forKey: .to) else { return }
        containerView.addSubview(toView)

        let originalToViewTrans = toView.transform
        var newToViewTrans = originalToViewTrans
        newToViewTrans = newToViewTrans.translatedBy(x: -containerView.bounds.width, y: 0)

        toView.transform = newToViewTrans
        // ここと!
        toView.setNeedsLayout()

        animatorNotNil.addAnimations {
            toView.transform = originalToViewTrans
            // ここ!
            toView.setNeedsLayout()
        }
        animatorNotNil.addCompletion { [weak self] _ in
            self?.animator = nil
            context.completeTransition(!context.transitionWasCancelled)
        }
        animator = animatorNotNil
        animatorNotNil.startAnimation()
    }

    private func startDismissTransition(using context: UIViewControllerContextTransitioning) {
        let timing = CubicTimingParametersCreator.createParameters(timingType: .quartIn)
        let animatorNotNil = UIViewPropertyAnimator(duration: transitionDuration(using: context), timingParameters: timing)

        let containerView = context.containerView
        guard let fromView = context.view(forKey: .from) else { return }

        let originalToViewTrans = fromView.transform
        var newFromViewTrans = originalToViewTrans
        newFromViewTrans = newFromViewTrans.translatedBy(x: -containerView.bounds.width, y: 0)

        fromView.setNeedsLayout()

        animatorNotNil.addAnimations {
            fromView.transform = newFromViewTrans
            fromView.setNeedsLayout()
        }
        animatorNotNil.addCompletion { [weak self] _ in
            self?.animator = nil
            context.completeTransition(!context.transitionWasCancelled)
        }
        animator = animatorNotNil
        animatorNotNil.startAnimation()
    }
}

3 UIViewControllerTransitioningDelegate に適合した、表示される ViewController を設定する

ようやく表示される ViewController を設定します。

今回は、表示されるViewControllerのルートがUINavigationControllerになるのでそこにいろいろと設定しました。
設定する項目としては、手順1と2で作ったものを指定します。

  • 自分が UIViewController.present するときに、どの UIPresentationController を使って呼び出されるか
  • present / dismiss する時の切り替えアニメーションは、どの UIViewControllerAnimatedTransitioning を使うか
class SettingsNavigationController: UINavigationController {

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

        /// 表示のされかたをセット
        modalPresentationStyle = .custom
        transitioningDelegate = self
    }
}

/// どうやって表示されるか
extension SettingsNavigationController:  UIViewControllerTransitioningDelegate {
    func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
        return DrawerMenuPresentationController(presentedViewController: presented, presenting: presenting)
    }

    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return DrawerMenuTransition(type: .present)
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return DrawerMenuTransition(type: .dismiss)
    }
}

4 使ってみる!

表示自体は、ふつうに present で可能です。

    @IBAction func didTapShowMenuButton() {
        let storyboad = UIStoryboard(name: "SettingsViewController", bundle: nil)
        let settingsNavi = storyboad.instantiateInitialViewController() as! UINavigationController
        // 表示開始はモーダルなので present
        present(settingsNavi, animated: true, completion: nil)
    }

感想

クラスごとに役割がきれいに別れているため、別のViewControllerをドロワーメニューとして表示することも簡単にできるのは良いと思いました。

また、UIPresentationControllerを使えば、非同期処理中に見せるXXHUD系のやつとかも、すぐに作れそうですね。

ではでは。