LoginSignup
4

More than 1 year has passed since last update.

posted at

SwiftUI フルスクリーンモーダル(UIKit版)

安定の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にはonAppearonDisappearというViewが有効・無効になった時に処理をフックする機能がありますが、
これらはアニメーションの表示完了まで実行を待ってはくれません。

アニメーション完了時の通知を受け取れるようにしておくと何かと便利です。
この機能を実現するために、iOS13から新しく追加されたNotificationPublisherを利用します。

NotificationPublisherとして生成することで、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ファイルにまとめてあるためアクセス修飾子が若干変わっています。

Modal.swift
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")
            }
        }
    }
}

これは以下のように動作します。

RPReplay_Final1608911618 (1).gif


  1. 何故overlayの中に置くと大丈夫なのかは分かりません… 

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
4