iOSのセミモーダル/ハーフモーダルを最小限の機能で実装するには

みなさんこんにちは。ラクマでiOSエンジニアをしているdarquroです。 今回はiOSアプリの、セミモーダル/ハーフモーダル(以降セミモーダル)の実装について紹介したいと思います。 

はじめに

昨今のアプリではセミモーダルを用いたUI/UXが主流となってきました。 例えば、Facebook、Twitter、Slackなどでは以下のように使われています。 f:id:R-Hack:20210212120536j:plain

ラクマでも、セミモーダルを使用し、画面遷移せずにユーザーアクションを促したり、補足情報を表示したりしています。 f:id:R-Hack:20210212120604j:plain

iOSで有名なOSSだとFloatingPanelが有名です。

github.com

また、ラクマではSwiftUIはまだ導入できていませんが、SwiftUIだとPartialSheetなどがあります。

github.com

もちろん、これらOSSを導入することでも実現可能です。 しかしながら、UIのコンポーネントはカスタマイズ性やAnalyticsのためのイベントトラッキング実装など予測できない将来を考慮すると、自前で実装できる部分は実装した方がリスクを回避できるもしれません。

セミモーダルを実装する

では、実際セミモーダルを実装するにはどのような実装になるのか、サンプルを用いて説明していきます。 また、コンポーネントの機能は最小限に留め、なるべくシンプルな実装になることを目指しました。 今回使用するサンプルコードはGitHubからご確認いただけます。 github.com

今回使用するサンプルアプリは、このようなセミモーダルを表示するシンプルなアプリです。

[f:id:R-Hack:20210212120632p:plain:w250 ] f:id:R-Hack:20210212120652g:plain:w250

最終的にアプリ側で実装するセミモーダルを表示するコードは以下のようになります。

class MainViewController: UIViewController {
 
    private var semiModalPresenter = SemiModalPresenter()
 
    @IBAction func openModalButtonDidTap(_ sender: Any) {
        let viewController = ModalViewController
            .instantiateInitialViewControllerFromStoryboard()
        semiModalPresenter.viewController = viewController
        present(viewController, animated: true)
    }
}

まず、セミモーダルはどのようなViewレイアウトを構成しているかというと、モーダルのViewController、画面全体を覆うオーバーレイ、ドラッグできることを示すインジケーターの3つのViewに別れます。 f:id:R-Hack:20210212122442j:plain

では、実装クラスは全部で6つになります。

  • SemiModalPresenter

    呼び出す側からはインターフェースとなるメインのクラスです。

  • SemiModalPresentationController

    モーダル表示時に、Viewのレイアウトを実装しているクラスです。

  • SemiModalDismissAnimatedTransition

    UIViewControllerのdismissが呼ばれたときに、どのようなアニメーションをするか実装しているクラスです。

  • SemiModalDismissInteractiveTransition

    モーダルをドラッグしたときに、どのようなアニメーションでdismissするか実装しているクラスです。

  • SemiModalOverlayView

    画面全体を覆うオーバーレイのViewです。単純なViewなので、説明は割愛します。GitHubからコードをご確認ください。

  • SemiModalIndicatorView

    ドラッグできることを示すインジケーターのViewです。こちらも単純なViewなので、GitHubのコードをご確認ください。

それでは、詳細にポイントを絞って実装を見ていきましょう。

SemiModalPresenter

// ------ 省略 ------

extension SemiModalPresenter: UIViewControllerTransitioningDelegate {

    /// 画面遷移開始時に呼ばれる。カスタムビューを使用して表示する。
    /// - Parameters:
    ///   - presented: 呼び出し先ViewController
    ///   - presenting: 呼び出し元ViewController
    ///   - source: presentメソッドがプレゼンテーションプロセスを開始するために呼び出されたViewController
    /// - Returns: UIPresentationController
    public func presentationController(forPresented presented: UIViewController,
                                       presenting: UIViewController?,
                                       source: UIViewController)
                                       -> UIPresentationController? {
        return SemiModalPresentationController(
            presentedViewController: presented,
            presenting: presenting,
            overlayView: overlayView,
            indicator: indicator)
    }

    /// dismiss時に呼ばれる。dismissのアニメーション指定。
    /// - Parameter dismissed: dismissされるViewController
    /// - Returns: UIViewControllerAnimatedTransitioning
    public func animationController(forDismissed dismissed: UIViewController)
        -> UIViewControllerAnimatedTransitioning? {
        return SemiModalDismissAnimatedTransition()
    }

    /// インタラクティブなdismissを制御する。
    /// - Parameter animator: animationController(forDismissed:)で指定したアニメーター
    /// - Returns: UIViewControllerInteractiveTransitioning
    public func interactionControllerForDismissal(
        using animator: UIViewControllerAnimatedTransitioning)
        -> UIViewControllerInteractiveTransitioning? {
        guard dismissInteractiveTransition.isInteractiveDismalTransition
        else { return nil }
        return dismissInteractiveTransition
    }
}

このクラスでポイントとなるのは、UIViewControllerTransitioningDelegate の実装部分です。

  • presentationController(forPresented:, presenting:, source:)
  • animationController(forDismissed:)
  • interactionControllerForDismissal(using:)

の3つメソッドを実装し、それぞれ、対応する

  • SemiModalPresentationController
  • SemiModalDismissAnimatedTransition
  • SemiModalDismissInteractiveTransition

をreturnで返してあげます。

SemiModalPresentationController

final class SemiModalPresentationController: UIPresentationController {

    // MARK: Override Properties

    /// 表示transitionの終わりのViewのframe
    override var frameOfPresentedViewInContainerView: CGRect {
        guard let containerView = containerView else { return CGRect.zero }
        var presentedViewFrame = CGRect.zero
        let containerBounds = containerView.bounds
        presentedViewFrame.size =
            self.size(forChildContentContainer: 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
    }

// ------ 省略 ------

    // MARK: Override Functions

    /// 表示されるViewのサイズ
    /// - Parameters:
    ///   - container: コンテナ
    ///   - parentSize: 親Viewのサイズ
    /// - Returns: サイズ
    override func size(forChildContentContainer container: UIContentContainer,
                       withParentContainerSize parentSize: CGSize) -> CGSize {
        // delegateで高さが指定されていれば、そちらを優先する
        if let delegate =
            presentedViewController as? SemiModalPresenterDelegate {
            return CGSize(width: parentSize.width,
                          height: delegate.semiModalContentHeight)
        }
        // 上記でなければ、高さは比率で計算する
        return CGSize(
            width: parentSize.width,
            height: parentSize.height * self.presentedViewControllerHeightRatio)
    }

    /// Subviewsのレイアウト
    override func containerViewWillLayoutSubviews() {
        guard let containerView = containerView else { return }

        // overlay
        // containerViewと同じ大きさで、一番上のレイヤーに挿入する
        overlay.frame = containerView.bounds
        containerView.insertSubview(overlay, at: 0)

        // presentedView
        // frameの大きさ設定、左上と右上を角丸にする
        presentedView?.frame = frameOfPresentedViewInContainerView
        presentedView?.layer.cornerRadius = 10.0
        presentedView?.layer.maskedCorners = [.layerMinXMinYCorner,
                                              .layerMaxXMinYCorner]

        // indicator
        // 中央上部に配置する
        indicator.frame = CGRect(x: 0, y: 0, width: 60, height: 8)
        presentedViewController.view.addSubview(indicator)
        indicator.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            indicator.centerXAnchor.constraint(
                equalTo: presentedViewController.view.centerXAnchor),
            indicator.topAnchor.constraint(
                equalTo: presentedViewController.view.topAnchor,
                constant: -16),
            indicator.widthAnchor.constraint(
                equalToConstant: indicator.frame.width),
            indicator.heightAnchor.constraint(
                equalToConstant: indicator.frame.height)
        ])
    }

    /// presentation transition 開始
    override func presentationTransitionWillBegin() {
        presentedViewController.transitionCoordinator?.animate(
            alongsideTransition: { _ in
                self.overlay.isActive = true
            },
            completion: nil)
    }

    /// dismiss transition 開始
    override func dismissalTransitionWillBegin() {
        self.presentedViewController.transitionCoordinator?.animate(
            alongsideTransition: { _ in
                self.overlay.isActive = false
            },
            completion: nil)
    }

    /// dismiss transition 終了
    /// - Parameter completed:
    override func dismissalTransitionDidEnd(_ completed: Bool) {
        if completed {
            overlay.removeFromSuperview()
        }
    }
}

このクラスは、UIPresentationControllerのoverrideの実装になります。 frameOfPresentedViewInContainerViewのプロパティで最終的にモーダルを表示した後のframeを返します。

中で呼ばれているsize(forChildContentContainer:,withParentContainerSize:)もoverrideのメソッドとして用意されているものです。 こちらで、実際にセミモーダルの高さを決定しています。

containerViewWillLayoutSubviews()はその名の通り、呼び出し元のsubViewsを決定するメソッドなので、オーバーレイとインジケーターをaddViewしています。

  • presentationTransitionWillBegin()
  • dismissalTransitionWillBegin()
  • dismissalTransitionDidEnd(_:)

の3つのメソッドでオーバーレイの表示/非表示/削除を制御します。

SemiModalDismissAnimatedTransition

// ------ 省略 ------

extension SemiModalDismissAnimatedTransition:
    UIViewControllerAnimatedTransitioning {

    /// transitionの時間
    /// - Parameter transitionContext:
    /// - Returns:
    func transitionDuration(
        using transitionContext: UIViewControllerContextTransitioning?)
        -> TimeInterval {
        return 0.4
    }

    /// アニメーションtransition
    /// - Parameter transitionContext:
    func animateTransition(
        using transitionContext: UIViewControllerContextTransitioning) {
        UIView.animate(
            withDuration: transitionDuration(using: transitionContext),
            delay: 0.0,
            options: .curveEaseInOut,
            animations: {
                guard let fromView = transitionContext.view(forKey: .from)
                else { return }
                // Viewを下にスライドさせる
                fromView.center.y
                    = UIScreen.main.bounds.size.height 
                    + fromView.bounds.height / 2
            },
            completion: { _ in
                transitionContext.completeTransition(
                   !transitionContext.transitionWasCancelled)
            }
        )
    }
}

こちらは、UIViewControllerAnimatedTransitioningのprotocolに準拠し、モーダルのViewControllerのdismissが呼ばれたときのアニメーションを実装します。 0.4杪は感覚値なのですが、より拘るなら、表示しているセミモーダルの高さに応じて変えてみてもいいかもしれません。

SemiModalDismissInteractiveTransition 

final class SemiModalDismissInteractiveTransition: UIPercentDrivenInteractiveTransition {

    // MARK: Private Properties

    /// 完了閾値(0 ~ 1.0)
    private let percentCompleteThreshold: CGFloat = 0.3

    // MARK: Override functions

    override func cancel() {
        completionSpeed = self.percentCompleteThreshold
        super.cancel()
    }

    override func finish() {
        completionSpeed = 1.0 - self.percentCompleteThreshold
        super.finish()
    }

// ----- 省略 -----

    /// Panジェスチャー
    /// - Parameter recognizer:
    @objc private func dismissalPanGesture(recognizer: UIPanGestureRecognizer) {
        guard let viewController = viewController else { return }

        isInteractiveDismalTransition = 
            recognizer.state == .began || recognizer.state == .changed

        switch recognizer.state {
        case .began:
            gestureDirection = GestureDirection(recognizer: recognizer,
                                                      view: viewController.view)
            if gestureDirection == .down {
                viewController.dismiss(animated: true, completion: nil)
            }
        case .changed:
            // インタラクティブな制御のために、Viewの高さに応じた画面更新を行う
            let translation = recognizer.translation(in: viewController.view)
            var progress =
                translation.y / viewController.view.bounds.size.height
            switch gestureDirection {
            case .up:
                progress = -max(-1.0, max(-1.0, progress))
            case .down:
                progress = min(1.0, max(0, progress))
            }
            update(progress)
        case .cancelled, .ended:
            if percentComplete > percentCompleteThreshold {
                finish()
            } else {
                cancel()
            }
        default:
            break
        }
    }
}

  こちらのクラスでは、インタラクティブなdismissの処理を実装します。 UIPercentDrivenInteractiveTransitionに準拠し、cancel()finish()の閾値設定や、UIPanGestureRecognizerで、ジェスチャーに応じた変化量を与えています。

また、最後に、delegateプロトコルを用意して、モーダル側の画面サイズに応じて高さが決まるようにしました。

extension ModalViewController: SemiModalPresenterDelegate {

    var semiModalContentHeight: CGFloat {
        return contentView.frame.height
    }
}

上記以外の説明しきれなかった部分については、GitHubをご確認ください。

おわりに

今回は、iOSでセミモーダル/ハーフモーダルの実装方法について、紹介させて頂きました。 アプリに導入できると、UI/UXの幅がぐっと広がり、使い勝手のいいコンポーネントとなると思います。 参考になれば幸いです。

また、ラクマでは、User Firstをコア・バリューの一つに掲げ、一緒にアプリ開発をしてくれるメンバーを募集しています。 www.wantedly.com カジュアル面談で、私達のチームについて紹介させて頂きます。