📰

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

4 min read

概要

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

Discussion

ログインするとコメントできます