SwiftUI フルスクリーンモーダル（UIKit版）

安定のUIKit版モーダルをSwiftUI上で実現します。
当然、中のコンテンツはSwiftUIで記述可能です。

## UIKitモーダル用のUIViewControllerRepresentableを作る

```swift
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]

```swift
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`の生成時にこれをコンテンツに差し込むことで、コンテンツ側からならどこからでもモーダルを閉じることが出来るようにします。

```swift
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を差し込みます。

```diff
-             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`の機能を使って通知を受け取ることが出来るようになります。

```swift
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に関する機能は１ファイルにまとめてあるためアクセス修飾子が若干変わっています。

```swift: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()
            })
        }
    }
}
```

これを利用したサンプルコードが以下となります。

```swift
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28406/13e27d62-324c-30b3-a552-ecb0a788b3e2.gif)


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