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?() } }
ビューの角丸はなくなってしまうので、自身で cornerRadius
と maskedCorners
を設定してあげる必要があります。
課題
コンテンツのサイズによってセミモーダルのサイズを変える、というこの実装には、コンテンツサイズが画面サイズを越えてしまう場合に、画面の表示領域外にコンテンツがはみ出してしまうという課題があります。 必要に応じてUIScrollViewに載せたりしないといけないのですが、おそらく制御はまま大変かと思います。 採用するかは状況に応じてご判断ください。