見出し画像

SwiftUIのViewの高さに合うハーフモーダルをいい感じに出せるようにした Now In REALITY Tech #122

こんにちは!
REALITYでエンジニアリングマネージャーをしているヤザキです。

モーダルに腰掛けるヤザキ

iOSアプリでよくある画面下部からニョキっと出てくるハーフモーダルを、SwiftUIのViewの高さに合わせていい感じに表示できる便利クラスを作ったので、今回はその紹介をしたいと思います。

標準APIだけでハーフモーダルをいい感じに出したい

画面下部からシートがニョキっと出てきて、下へスワイプすることで閉じられる「ハーフモーダル」はREALITYでもいくつかの箇所で採用されています。

REALITYでのハーフモーダルの例

このような画面の一部を覆うシートの表示は、iOS 15.0から標準でサポートされており、UIKitではUISheetPresentationControllerを使うことで簡単に実装可能です。SwiftUIではpresentationDetents(_:) というAPIがiOS 16から追加されました。

また、シートの高さを指定するDetentは、iOS 15まではmediumとlargeの2種類しかありませんでしたが、iOS 16.0からはcustomが追加されて、シートの高さを任意の値に変えられるようになりました。

今回はこれらのAPIを使い、シートの高さがViewの高さに自動でフィットする便利クラスを実装したので紹介します。

SheetHostingControllerを作る

REALITYでは、SwiftUIの画面でも、画面遷移はUIKitから行う方針となっているため、UIHostingControllerをUIKitからpresentするユースケースを前提として実装しています。
UIHostingControllerのサブクラスであるSheetHostingControllerクラスを以下のように作成しました。

class SheetHostingController<Content: View>: UIHostingController<Content> {
  private var currentHeight: CGFloat = 0.0

  override func viewDidLoad() {
    super.viewDidLoad()
    sheetPresentationController?.detents = [.custom(resolver: { [weak self] _ in
      self?.currentHeight ?? 0.0
    })]
  }
  
  override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    // rootViewの高さを取得
    let viewHeight: CGFloat = sizeThatFits(in: CGSize(width: view.bounds.width, height: CGFloat.greatestFiniteMagnitude)).height

    // SafeAreaとNavigationBarの高さを考慮する
    let navBarHight: CGFloat = (parent as? UINavigationController)?.navigationBar.frame.size.height ?? 0.0
    let height = viewHeight - view.safeAreaInsets.bottom + navBarHight

    if currentHeight != height {
      currentHeight = height
      
      // モーダルの高さを更新する
      sheetPresentationController?.animateChanges {
        sheetPresentationController?.invalidateDetents()
      }
    }
  }
}

使用するときは、以下のように任意のSwiftUIのViewを入れてpresentするだけです。中身のViewの高さに自動調整されたシートが表示されます。

// 任意のSwiftUIのViewを作る
struct ContentView: View {
  ...
}

// UIKit側でpresentする
let vc = SheetHostingController(rootView: ContentView())
present(vc, animated: true)

中身のViewの高さに合わせて動的にシートの高さを変える仕組み

SheetHostingControllerでは中身のViewのサイズが確定するたびに以下のような処理を行っています。

  • UIHostingControllerの中にあるViewの高さをsizeThatFits(in:)で取得

  • .invalidateDetents() を呼び出してdetentsを更新する

  • .custom() のクロージャが呼び出され、シートの高さが調整される

ポイントはiOS 16.0で追加された invalidateDetents() というメソッドです。
このメソッドを呼び出すとシートの高さが再計算されて、.custom()のクロージャが呼び出されます。

viewDidLayoutSubviewsをトリガーにすることで、SwiftUI.Viewの高さが動的に変化するようなパターンでも、コンテンツの高さに合わせて、シートの高さが自動調整されます。

iOS 18.0からはPresentationSizingが追加されて、コンテンツにフィットしたシート表示がより簡単になりそうです。(アプリのサポートOSバージョン的に使えるようになるのはしばらく先ですが…)

まとめ

今までREALITYでは、ハーフモーダルを実現するためにOSSライブラリのFloatingPanelを利用していました。

しかし、iOS 16から追加された標準のAPIを使うことで、簡単なユースケースであればライブラリを使うことなくシンプルに実装することができるようになりました。

余談ですが、invalidateDetents() というメソッドをGoogleで検索しても数件しかヒットしなかったので、このAPIを見つけた時はちょっと嬉しくなりました(笑)

是非、みなさんのiOSプロジェクトでも活用してみてください!