【セミモーダル】iOS標準のセミモーダルを任意のサイズで表示するTips【なるべく楽したい】

:::note
サービス移行に伴い、記事を転載しました。オリジナルの記事は[こちら](https://withjpinc.hatenablog.com/entry/ios-semimodal-tips)
:::

## 前書き

こんにちは。withでiOSエンジニアをしている @zrn-ns です。

iOSにおける標準のセミモーダル（ハーフモーダル）って、自由度が低くてデザイン仕様を満たすのが地味に大変なことがありますよね。
今回はiOS標準のセミモーダルUIを、簡単に任意のサイズで表示する方法をご紹介します。

<img width="300pt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/6e14072a-0d79-9d2d-0c43-37d46e29de69.png" />

## TL;DR

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

## 今回実装したいUI

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

- 直前に表示されていたコンテンツの一部を覆うセミモーダル
- 背景のコンテンツは触れない
- 引っ張って閉じられる
- <span style="color: #ff0000">**セミモーダルのサイズはコンテンツのサイズによって可変させる**</span>

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

## 何が難しいの？

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

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

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

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

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

## 実装

### 0. 完成イメージ

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

<img width="300pt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/07153609-ee05-9d8f-9ee7-c2f1df10b3b9.gif" />

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

### 1. 表示元の実装

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

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

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

```swift
@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を利用して組みます。

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

<img width="500px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/8c2a736a-ea18-59f9-cc5a-09f98cf0d313.png">

```swift
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を作成する

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

**<span style="color: #ff0000">ViewControllerの背景色は透過に設定し、表示したいViewを画面下部にくっつけるような制約を設定します。</span>**

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

```swift
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?()
    }
}
```

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

## 課題

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

## ソースコード

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

[https://github.com/zrn-ns/FlexibleSemiModalUISample/](https://github.com/zrn-ns/FlexibleSemiModalUISample)