SwiftUI画面遷移をViewから分離させて書く方法

# はじめに
### 記事を読んでほしい対象の方

SwiftUIでライブラリを使わずに...

- **画面遷移のコードをViewから分離** させたい方
- **UIKitのように画面遷移したい** けど, せっかくなので ```UIKit``` は使いたくない方
- **アーキテクチャ** に悩んでいる方

# 結論
ちょっと残念ですが現時点では ```UIKit``` を使わないままで理想に近づけることはほぼ不可能だと思われます。
いくつか海外の記事も見て回りましたが、同様の質問をAppleに投げた人が見つかったものの、 **Appleからの正式な回答** を持ってしても要件としてはまだ不十分でした。
[参考 Stack Overflow](https://stackoverflow.com/questions/61304700/swiftui-how-to-avoid-navigation-hardcoded-into-the-view)

そこで本記事では、 ```UIKit``` のクラスを使うコードを1ファイルのみに限定し、その中身は編集せずに固定、それ以外は全て純粋な ```SwiftUI``` で書く方法を紹介します。

### 固定ファイル

まずはこの固定コードをそのまま丸々コピーして、プロジェクトのどこかにおいてください。 ```Coordinator.swift```

<font color="DarkCyan">\>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>></font>
<details><summary>
<font color="DarkCyan">ここをクリックしてCoordinator.swift をみる</font>
</summary><div>

```Coordinator.swift
// MARK: - Coordinator

import SwiftUI

typealias Coordinator = Navigator & ViewProvider
class Navigator {
    var navigation: Navigation = UINavigation()
}
protocol ViewProvider {
    associatedtype ViewType: View
    func view() -> ViewType
}
protocol Navigation {
    func push<C: Coordinator>(_ coordinator: C, animated: Bool)
    func set(_ coordinators: [AnyCoordinator], animated: Bool)
    func pop(animated: Bool)
    func popToRoot(animated: Bool)
}
class DefaultCoordinator: Coordinator {
    let content: ViewType
    init(@ViewBuilder content: () -> ViewType) {
        self.content = content()
    }
    func view() -> AnyView {
        self.content
    }
}
extension ViewProvider {
    func anyCoordinator<V: View>(_ view: V) -> AnyCoordinator {
        return AnyCoordinator(DefaultCoordinator {AnyView(view)} )
    }
}
class AnyCoordinator : Coordinator {
    init<C: Coordinator>(_ base: C) {
        _view = { AnyView(base.view()) }
    }
    init<V: View>(_ view: V) {
        _view = { AnyView(view) }
    }
    func view() -> AnyView {
        _view()
    }
    private let _view: () -> ViewType
}

// MARK: - Wrapped UIKit

import UIKit

struct Window<C: Coordinator>: UIViewControllerRepresentable {
    var coordinator: C
    init(coordinator: C) {
        self.coordinator = coordinator
        let hosting = UIHostingController(rootView: self.coordinator.view())
        (self.coordinator.navigation as! UINavigation).controller = UINavigationController(rootViewController: hosting)
    }
    
    // UIViewControllerRepresentable
    typealias UIViewControllerType = UINavigationController
    func makeUIViewController(context: UIViewControllerRepresentableContext<Window>) -> Window.UIViewControllerType {
        return (self.coordinator.navigation as! UINavigation).controller!
    }
    func updateUIViewController(_ uiViewController: Window.UIViewControllerType, context: UIViewControllerRepresentableContext<Window>) {
    }
}
private class UINavigation: Navigation {
    var controller: UINavigationController?
    func push<C: Coordinator>(_ coordinator: C, animated: Bool) {
        let hosting = UIHostingController(rootView: coordinator.view())
        (coordinator.navigation as! UINavigation).controller = self.controller
        self.controller?.pushViewController(hosting, animated: animated)
    }
    func set(_ coordinators: [AnyCoordinator], animated: Bool) {
        for c in coordinators {
            (c.navigation as! UINavigation).controller = self.controller
        }
        self.controller?.setViewControllers(coordinators.map {UIHostingController(rootView: $0.view())}, animated: animated)
    }
    func pop(animated: Bool) {
        self.controller?.popViewController(animated: animated)
    }
    func popToRoot(animated: Bool) {
        self.controller?.popToRootViewController(animated: animated)
    }
}
```
</div></details> 
<font color="DarkCyan">\>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>></font>  

# 使用コード例

iOS界で有名な **Coordinator(コーディネーター)** と言うパターンで遷移を書きます。
今回は簡単なサンプルなので、 **ビューモデル** に **Coordinator** の役割もさせちゃいます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982650/8df892cf-bd14-49c8-0518-534cdf3c8b50.png" width="80%">

## ビューA

**Up** ボタンでカウント数を増やす。 **Go** ボタンを押すと、カウント数を渡しながら ```ビューB``` へ進む。 

``` ViewA.swift
import SwiftUI

/// ビュー A
struct ViewA: View {
    /// ビューモデル は @StateObject を付ける
    @StateObject var vm: ViewModelA
    var body: some View {
        // テキスト
        Text("A counts: \(vm.count)")
        // ボタン
        Button(action: vm.up) { Text("Up") }
        // ボタン
        Button(action: vm.go) { Text("Go") }
    }
}

/// ビューモデル A
class ViewModelA: Coordinator, ObservableObject {
    // @Published この値と一緒に ビュー A の表示が変わる
    @Published var count: Int = 0
    
    // カウントを 1 増やす
    func up() {
        count += 1
    }
    
    // カウントを渡して B へ遷移 (Push)
    func go() {
        self.navigation.push(
            ViewModelB(count),
            animated: true
        )
    }

    // Coordinator に必要なメソッド
    func view() -> ViewA {
        return ViewA(vm: self)
    }
}
```

## ビューB

```ビューA``` からカウント数を受け取って表示する。

``` ViewB.swift
import SwiftUI

/// ビュー B
struct ViewB: View {
    /// ビューモデル は @StateObject を付ける
    @StateObject var vm: ViewModelB
    var body: some View {
        // テキスト
        Text("B counts: \(vm.count)")
    }
}

/// ビューモデル B
class ViewModelB: Coordinator, ObservableObject {
    let count: Int
    init(_ count: Int) { self.count = count }

    // Coordinator に必要なメソッド
    func view() -> ViewB {
        return ViewB(vm: self)
    }
}
```

## ビューC

```ビューモデル``` を持たないミニマムな ```ビュー```
あとのサンプルで使います。

``` ViewC.swift
import SwiftUI

/// ビュー C
struct ViewC: View {
    var body: some View {
        Text("C")
    }
}
```

# 実行

```xxxApp.swift(アプリによってxxxは異なる)``` WindowGroup の中に 
```Window(coordinator: ViewModelA())``` を書いておきましょう。

```xxxApp.swift
import SwiftUI

@main
struct xxxApp: App {
    var body: some Scene {
        WindowGroup {
            Window(coordinator: ViewModelA()) // ここを書き換えた。
        }
    }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982650/74ecf6ba-0321-f916-0131-8e9646f4c3d3.gif" width="50%">

無事にやりたかったことができました。
今回のコードでは他にも以下のような遷移メソッドを提供しています。
サンプルは ```ViewA``` の ```go()``` の中を変えてみてください。


### サンプル

- ```push``` : 今の画面へ戻って来られるように状態を保ったまま次の画面へ進む

<details><summary>
<font color="DarkCyan">ここをクリックして サンプル1 をみる</font>
</summary><div>

```Sample_01_push.swift
...
self.navigation.push(
    ViewModelB(count),
    animated: true
)
...
```
</div></details> 


- ```set``` : いくつかの画面を繋がった状態で用意して、その一番最後の画面を表示する

<details><summary>
<font color="DarkCyan">ここをクリックして サンプル2 をみる</font>
</summary><div>

```Sample_02_set.swift
...
// set を使うときは AnyCoordinator で囲んで型消去をする
self.navigation.set(
    [
        AnyCoordinator(ViewModelB(1)),
        AnyCoordinator(ViewC()),
        AnyCoordinator(ViewModelB(2)),
        AnyCoordinator(ViewC()),
        AnyCoordinator(ViewModelB(3)),
    ],
    animated: true)
...
```

</div></details> 


- ```pop``` : 今いる画面を削除しながら、前の画面に戻る

<details><summary>
<font color="DarkCyan">ここをクリックして サンプル3 をみる</font>
</summary><div>

```Sample_03_pop.swift
...
self.navigation.pop(animated: true)
...
```

</div></details> 


- ```popToRoot``` : 一番最初の画面(ルート)まで戻る。

<details><summary>
<font color="DarkCyan">ここをクリックして サンプル4 をみる</font>
</summary><div>

```Sample_04_popToRoot.swift
...
self.navigation.popToRoot(animated: true)
...
```

</div></details> 


# さいごに
長い記事になってしまいました。最後まで読んでいただきありがとうございます。
また機能の追加とか修正ができれば、記事を更新しようかと思います。
それでは、良いSwiftUIライフを!