安定のUIKit版モーダルをSwiftUI上で実現します。
当然、中のコンテンツはSwiftUIで記述可能です。
UIKitモーダル用のUIViewControllerRepresentableを作る
struct Modal<Content: View>: UIViewControllerRepresentable {
typealias Context = UIViewControllerRepresentableContext<Modal>
/// モーダルの表示状態
@Binding var isPresented: Bool
/// モーダル内に描画するViewの生成関数
let content: () -> Content
func makeUIViewController(context: Context) -> some UIViewController {
// モーダルを提供するための空のUIViewControllerを生成
UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
if self.isPresented {
// モーダルとして提供するViewControllerを生成し、UIViewControllerのpresentメソッドでモーダルを表示する
let content = self.content()
let host = UIHostingController(rootView: content)
host.modalPresentationStyle = .fullScreen
uiViewController.present(host, animated: true, completion: nil)
} else {
// 表示状態がfalseで更新されたら閉じる
uiViewController.presentedViewController?.dismiss(animated: true, completion: nil)
}
}
}
Extensionを利用して提供可能にする
上記で作成したModal
ですが、Viewの中で生成すると、UIViewControllerが画面の全面に張り付いてしまい何も見えなくなってしまいます。
これは、overlay
の中にこのModal
を配置することで回避出来ますのでoverlay
を通してモーダルを表示するためのメソッドを拡張します。1
extension View {
func present<Content: View>(isPresented: Binding<Bool>, content: @escaping () -> Content) -> some View {
self.overlay(
Modal(isPresented: isPresented, content: content)
// フレームを0にして画面に何も被らないようにします、
// Modal自体はpresent/dismissの機能を提供するだけなので画面に表示される必要はありません。
.frame(width: 0, height: 0)
)
}
}
EnvironmentValueを提供して、内部のコンテンツからモーダルを閉じれるようにする
モーダルの表示状態変数をバインドしてもいいのですが、これだとViewを小さいパーツに分けていき、そのパーツからViewを閉じたいといったケースで、変数をひたすらバインドしていかないといけなくなるため、もっと便利な方法が必要です。
独自のEnvironmentValueを定義し、Modal
の生成時にこれをコンテンツに差し込むことで、コンテンツ側からならどこからでもモーダルを閉じることが出来るようにします。
struct ModalEnvironmentKey: EnvironmentKey {
static var defaultValue: Binding<Bool> = .constant(false)
}
extension EnvironmentValues {
// modalというKeyPathでアクセス出来るようにする
var modal: Binding<Bool> {
get { self[ModalEnvironmentKey.self] }
set { self[ModalEnvironmentKey.self] = newValue }
}
}
Modal
の実装を修正して、EnvironmentValueを差し込みます。
- let content = self.content()
+ let content = self.content().environment(\.modal, self.$isPresented)
let host = UIHostingController(rootView: content)
モーダルの表示完了、非表示完了時の通知を発行出来るようにする
モーダルはアニメーションを伴って表示されます。
SwiftUIのViewにはonAppear
やonDisappear
というViewが有効・無効になった時に処理をフックする機能がありますが、
これらはアニメーションの表示完了まで実行を待ってはくれません。
アニメーション完了時の通知を受け取れるようにしておくと何かと便利です。
この機能を実現するために、iOS13から新しく追加されたNotification
のPublisher
を利用します。
Notification
をPublisher
として生成することで、SwiftUIからはonReceive
の機能を使って通知を受け取ることが出来るようになります。
struct ModalNotification {
// MARK: 表示完了時の通知
private static let modalDidPresented = Notification.Name("modalDidPresented")
static var modalDidPresentedSubject: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: modalDidPresented)
}
static func notifyModalDidPresented() {
let notification = Notification(name: modalDidPresented)
NotificationCenter.default.post(notification)
}
// MARK: 表示終了時の通知
private static let modalDidDismissed = Notification.Name("modalDidDismissed")
static var modalDidDismissedSubject: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: modalDidDismissed)
}
static func notifyModalDidDismissed() {
let notification = Notification(name: modalDidDismissed)
NotificationCenter.default.post(notification)
}
}
完成
これまでのコードのまとめです、Modalに関する機能は1ファイルにまとめてあるためアクセス修飾子が若干変わっています。
import Combine
import SwiftUI
extension View {
func present<Content: View>(isPresented: Binding<Bool>, content: @escaping () -> Content) -> some View {
self.overlay(
Modal(isPresented: isPresented, content: content)
.frame(width: 0, height: 0)
)
}
}
// MARK: - EnvironmentValue
private struct ModalEnvironmentKey: EnvironmentKey {
static var defaultValue: Binding<Bool> = .constant(false)
}
extension EnvironmentValues {
var modal: Binding<Bool> {
get { self[ModalEnvironmentKey.self] }
set { self[ModalEnvironmentKey.self] = newValue }
}
}
// MARK: - Notification
struct ModalNotification {
private static let modalDidPresented = Notification.Name("modalDidPresented")
static var modalDidPresentedSubject: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: modalDidPresented)
}
fileprivate static func notifyModalDidPresented() {
let notification = Notification(name: modalDidPresented)
NotificationCenter.default.post(notification)
}
private static let modalDidDismissed = Notification.Name("modalDidDismissed")
static var modalDidDismissedSubject: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: modalDidDismissed)
}
fileprivate static func notifyModalDidDismissed() {
let notification = Notification(name: modalDidDismissed)
NotificationCenter.default.post(notification)
}
}
// MARK: - Modal
private struct Modal<Content: View>: UIViewControllerRepresentable {
typealias Context = UIViewControllerRepresentableContext<Modal>
@Binding var isPresented: Bool
let content: () -> Content
func makeUIViewController(context: Context) -> some UIViewController {
let vc = UIViewController()
return vc
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
if self.isPresented {
let content = self.content().environment(\.modal, self.$isPresented)
let host = UIHostingController(rootView: content)
host.modalPresentationStyle = .fullScreen
uiViewController.present(host, animated: true, completion: {
ModalNotification.notifyModalDidPresented()
})
} else {
uiViewController.presentedViewController?.dismiss(animated: true, completion: {
ModalNotification.notifyModalDidDismissed()
})
}
}
}
これを利用したサンプルコードが以下となります。
import SwiftUI
struct ContentView: View {
@State var isPresented = false
var body: some View {
VStack {
Button("Show") {
self.isPresented = true
}
}
.present(isPresented: self.$isPresented) {
ModalContent()
}
.onReceive(ModalNotification.modalDidDismissedSubject) { _ in
print("modal dismissed")
}
}
}
struct ModalContent: View {
@Environment(\.modal) var isPresented
var body: some View {
NavigationView {
VStack {
Spacer()
HStack {
Spacer()
Text("It is modal contents")
.foregroundColor(.white)
Spacer()
}
Spacer()
}
.navigationBarTitle("Modal", displayMode: .inline)
.navigationBarItems(leading: Button("Close") {
self.isPresented.wrappedValue = false
})
.background(Color.black.edgesIgnoringSafeArea(.all))
.onReceive(ModalNotification.modalDidPresentedSubject) { _ in
print("modal presented")
}
}
}
}
これは以下のように動作します。
-
何故overlayの中に置くと大丈夫なのかは分かりません… ↩