📰

SwiftUIでハーフモーダルを表示してみる(iOS15~)

2021/09/11に公開
3

概要

iOS15からUIKitにUISheetPresentationControllerというクラスが追加されますが、SwiftUIにはそのようなAPIは追加されませんでした。
今回はUISheetPresentationControllerを使用しハーフモーダルを実現し、それをSwiftUIのコードで使用できるようにしていきます。

コードの全容はgithubにアップしているので良ければ参考にしてみてください。

完成イメージ

以下のようにボタンをタップした際、ハーフモーダルが表示されるような形を目指します。

今回はハーフシート内のViewはSwiftUIで書くことができるようにしていきます。
つまりハーフシート部分のみUIViewControllerRepresentableで表現していきます。

実装

インターフェース定義

UIViewControllerRepresentableで実装したハーフシート上に、SwiftUIでViewを定義できるようにするため、Viewのextensionに以下を追加します。

extension View {
  func halfModal<Sheet: View>(
    isShow: Binding<Bool>,
    @ViewBuilder sheet: @escaping () -> Sheet,
    onEnd: @escaping () -> ()
  ) -> some View {
    return self
      .background(
        HalfModalSheetViewController(
	 sheet: sheet(),
	 isShow: isShow,
	 onClose: onEnd
        )
      )
  }
}

引数について少し説明します。

引数名 内容
isShow ハーフモーダルの表示状態を持ちます。Bindingとしているのは、ViewUIViewControllerRepresentableの両方から表示状態を操作したいためです。
sheet Viewでハーフモーダル上に表示するUIを描画するためのクロージャです。@ViewBuilderをつけているのは複数のViewで構成されるのを想定するためです。
onEnd ハーフモーダルが閉じたことを通知するイベントリスナーです。

このインターフェースを定義することで、以下のように扱うことを想定しています。

@State var isShowHalfModal = false
// 省略...
ZStack {
  Button("Show half modal") {
    isShowHalfModal.toggle()
  }
}
.halfModal(isShow: $isShowHalfModal) {
  // ここにハーフモーダルシートに表示したいViewを定義する
  Text("Test")
} onEnd: {
  print("Dismiss half modal")
}

ハーフモーダルシートを実装

後はUISheetPresentationControllerを使用してハーフモーダルを表示するだけです。

まずUIViewControllerRepresentableを使用してハーフモーダルのViewを実装します。

struct HalfModalSheetViewController<Sheet: View>: UIViewControllerRepresentable {
  var sheet: Sheet
  @Binding var isShow: Bool
  var onClose: () -> Void
  
  func makeUIViewController(context: Context) -> UIViewController {
    UIViewController()
  }
  
  func updateUIViewController(
    _ viewController: UIViewController,
    context: Context
  ) {
  if isShow {
    let sheetController = CustomHostingController(rootView: sheet)
    sheetController.presentationController!.delegate = context.coordinator
    viewController.present(sheetController, animated: true)
  } else {
    viewController.dismiss(animated: true) { onClose() }
  }
  
  func makeCoordinator() -> Coordinator {
    Coordinator(parent: self)
  }
  
  class Coordinator: NSObject, UISheetPresentationControllerDelegate {
    var parent: HalfModalSheetViewController

    init(parent: HalfModalSheetViewController) {
      self.parent = parent
    }

    func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
      parent.isShow = false
    }
  }
  
  class CustomHostingController<Content: View>: UIHostingController<Content> {
    override func viewDidLoad() {
      super.viewDidLoad()
      if let sheet = self.sheetPresentationController {
        sheet.detents = [.medium(),]
        sheet.prefersGrabberVisible = true
      }
    }
  }
}

presentの引数にCustomHostingControllerというUIHostingControllerを継承したクラスのオブジェクトを渡しているのは、以下2つの理由のためです。

  1. presentの引数はUIViewControllerでなければならないため
  2. SwiftUIで定義したViewをモーダルシートに描画したいため

2について、UIHostingControllerのイニシャライザは以下のような定義であり、

init(rootView: Content)

ContentViewの制約があり且つUIKitの世界からSwiftUIの世界にViewを渡すことができるためです。

モーダルシートを表示

あとは以下の様な感じでモーダルシートを表示するだけです。

import SwiftUI

struct ContentView: View {
  @State var isShowHalfModal = false

  var body: some View {
    Button("show half modal") {
      isShowHalfModal.toggle()
    }
    .halfModal(isShow: $isShowHalfModal) {
      VStack {
        Text("Shown half modal!")
          .font(.title.bold())
          .foregroundColor(.black)
        Button("Close") {
          isShowHalfModal.toggle()
        }
      }
    } onEnd: {
      print("Dismiss half modal")
    }
  }
}

まとめ

今まではサードパーティー製のライブラリを使うなどしてハーフモーダルを実現していましたが、iOS15からはサポートしてくれるので嬉しいです。

参考URL

GitHubで編集を提案

Discussion

mixnutsmixnuts

ちょうどswiftuiを勉強し始めて、詰まっていたのでとても参考になりました!
ページトップのgithubリンクが切れているようなのですが、リポジトリは削除されてしまったのでしょうか。

Shun UematsuShun Uematsu

連絡ありがとうございます!ディレクトリ構成の変更に引きずられてURLも変わっていました。
URLを正しいものに設定し直したので参照できるようになっていると思います。