iOS標準のセミモーダルを任意のサイズで表示するTips

前書き

こんにちは。withでiOSエンジニアをしている長尾です。

今回はiOSセミモーダルUIを、任意のサイズで表示する方法をご紹介します。

TL;DR

iOS標準のセミモーダルの背景を透過して、その上にビューを配置すれば、任意のサイズのセミモーダルを表示できます!

今回実装したいUI

今回実装したいUIはこんな感じです。

  • 直前に表示されていたコンテンツの一部を覆うセミモーダル
  • 背景のコンテンツは触れない
  • 引っ張って閉じられる
  • セミモーダルのサイズはコンテンツのサイズによって可変させる

ポイントは、最後の「セミモーダルのサイズはコンテンツのサイズによって可変させる」です。

何が難しいの?

iOSセミモーダルUIを実現したい場合、一番簡単なのは UIViewController.modalPresentationStyle.pageSheet に設定することです。

しかしこの方法だと、表示するセミモーダルビューのサイズを任意のサイズに変更することができません。常にUIViewControllerのルートのビューが画面いっぱいに(セミモーダル)表示されてしまいます。

別の案として、iOS15から使える UISheetPresentationController.detents を使用することで高さを可変させることができますが、detentsに指定できるのは .large().medium() のみで、コンテンツビューに合わせて任意のサイズにすることはできません。またiOS15未満をサポートしているプロジェクトでは使えません。

またOSSである SCENEE/FloatingPanelを使うという手もあります。こちらのOSSを利用すればコンテンツサイズによってハーフモーダルのサイズを可変させる事ができます。しかし、OSSを追加で導入しないといけないというハードルはありますし、遷移元の画面側からFloatingPanel、およびViewを生成する必要があり、遷移元の記述が複雑になりがちです。(専用のControllerを書くという手はありますが)

このように、サイズ可変のセミモーダルを表示するのは一筋縄ではいきません。そこで今回は、iOS標準のセミモーダルUIを、コンテンツサイズによって任意のサイズで表示する方法 をご紹介します。

実装

0. 完成イメージ

今回はサンプルとして、↓のような課金画面を作成してみます。

ビューのサイズによって、セミモーダルのサイズがうまく調整されています。

1. 表示元の実装

セミモーダルを表示する側の実装を見ていきます。

表示側としては、普通にUIViewControllerをセミモーダルで表示するようにUIViewController.present(_:, animated:)を利用します。

今回セミモーダルで表示する画面は PaymentViewController というUIViewController継承のクラスです。 普通にセミモーダル表示するように modalPresentationStyle.pageSheet (セミモーダル)に設定して表示するだけです。

@IBAction private func tappedCheckoutButton(_ sender: Any) {
    let paymentVC = PaymentViewController()
    paymentVC.modalPresentationStyle = .pageSheet
    paymentVC.tappedCancelHandler = {
        paymentVC.dismiss(animated: true)
    }
    present(paymentVC, animated: true)
}

2. 表示対象のView(PaymentView)の実装

表示対象のViewは、普通にAutoLayoutを利用して組みます。

幅によって高さが可変するように、制約を設定しましょう。

final class PaymentView: UIView {

    var tappedCancelHandler: (() -> Void)?

    // MARK: - initializer

    override init(frame: CGRect){
        super.init(frame: frame)
        loadNib()
    }

    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)!
        loadNib()
    }

    convenience init() {
        self.init(frame: .zero)
    }

    private func loadNib(){
        let className: String = .init(describing: type(of: self))
        let view = Bundle.main.loadNibNamed(className, owner: self, options: nil)?.first as! UIView
        view.frame = self.bounds
        self.addSubview(view)
    }

    @IBAction private func tappedCancel(_ sender: Any) {
        tappedCancelHandler?()
    }
}

xibを読み込むコードが大部分を占めています。特別な処理は必要ありません。

3. 表示対象のViewControllerを作成する

今回の話の肝はここです。

ViewControllerの背景色は透過に設定し、表示したいViewを画面下部にくっつけるような制約を設定します。

非常に単純ですが、これで評されるコンテンツのサイズによって、セミモーダルのサイズを可変させることができるようになります。

final class PaymentViewController: UIViewController {

    var tappedCancelHandler: (() -> Void)?

    override func loadView() {
        self.view = UIView()

        // 背景は透過する
        view.backgroundColor = .clear

        // 背景タップ時、キャンセル扱いにする
        view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tappedBackground)))

        // セミモーダル表示するビューを生成して、addSubviewする
        let paymentView = PaymentView()
        paymentView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(paymentView)
        view.addConstraints([
            paymentView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            paymentView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            paymentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
        // PaymentView自体のタップは処理しない
        paymentView.addGestureRecognizer(UITapGestureRecognizer())

        // 角丸設定
        paymentView.layer.cornerRadius = 10
        paymentView.layer.masksToBounds = true
        paymentView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]

        paymentView.tappedCancelHandler = { [weak self] in
            self?.tappedCancelHandler?()
        }
    }

    @objc private func tappedBackground() {
        tappedCancelHandler?()
    }
}

ビューの角丸はなくなってしまうので、自身で cornerRadiusmaskedCorners を設定してあげる必要があります。

課題

コンテンツのサイズによってセミモーダルのサイズを変える、というこの実装には、コンテンツサイズが画面サイズを越えてしまう場合に、画面の表示領域外にコンテンツがはみ出してしまうという課題があります。 必要に応じてUIScrollViewに載せたりしないといけないのですが、おそらく制御はまま大変かと思います。 採用するかは状況に応じてご判断ください。

ソースコード

今回のソースコードは↓のリポジトリにアップしてあります。もしお手元で動かしてみたい方はご利用ください。

https://github.com/zrn-ns/FlexibleSemiModalUISample/