SwiftUIで選択中のタブにViewをプッシュ表示する

[qnote Advent Calendar 2022](https://qiita.com/advent-calendar/2022/qnote) の3日目です。

iOS 16が登場しそろそろSwiftUIでの開発が行われる現場も増えてきたのではないでしょうか？
弊社ではFlutterでの開発案件があったのでFlutterの今後の動向も気になっています。

---

昨今のアプリでは大体下部にタブがあるものが多いと思います。今回プッシュ通知やスキーム、音楽系のプレイヤーの常駐しているView等の通常の遷移以外の画面や動作から選択中のタブでアプリのコンテンツ詳細画面を開く機能を実装するにあたり、Swiftに比べるとSwiftUIでの実装情報はまだまだ多くないのでどう実装するのが良いか迷うかと思います。

少し考えて出てきた案としては
1. 画面毎にNotificationCenterを登録して画面が表示中で通知を受け取った場合にNavigationLinkを有効にする
2. UINavigationControllerをどうにかして取得してそこにプッシュする

1の案は画面毎に通知と表示中かどうかの処理を実装する必要があり面倒で実装漏れが発生したりする可能性もあるため、2の案で実装をしていこうと思います。
(コードだけみたい方は一番最後だけ確認してください。)

##### 環境
Xcode 14.1
iOS 13以降

##### アプリの構造
アプリの構造としては以下のようになっています。
ルートのViewにTabViewが配置されていて各タブにNavigationViewがある。
```SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let tabBarView = TabBarView()
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: tabBarView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }
}
```
※iOS13でも動作できるようにSceneDelegateを使っていますが、iOS14以上の場合はデフォルトの`WindowGroup`の方で問題ないです。
```TabBarView.swift
struct TabBarView: View {
    var body: some View {
        TabView {
            HomeView()
                .tabItem {
                    VStack {
                        Image(systemName: "house")
                        Text("ホーム")
                    }
                }
                .tag(1)
            SearchView()
                .tabItem {
                    VStack {
                        Image(systemName: "magnifyingglass")
                        Text("探す")
                    }
                }
                .tag(2)
            SettingView()
                .tabItem {
                    VStack {
                        Image(systemName: "gearshape")
                        Text("設定")
                    }
                }
                .tag(3)
        }
    }
}
```
```HomeView.swift
struct HomeView: View {
    var body: some View {
        NavigationView {
            Text("ホーム")
        }
    }
}
```
他のタブも同じようなものなので省略します。

##### UINavigationControllerを探す
基本的にはUIKitの時と同じようにwindowからUITabBarControllerやUINavigationControllerを取得します。

まずはwindowの取得
```swift
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
let window = windowScene?.windows.first
```
これでUIWindowの配列が取得できます。
マルチウィンドウ対応や複数UIWindowをセットしている場合は別途処理が必要になります。

次にUITabBarControllerを探します。
```swift
let tabBarController = window?.rootViewController?.children.first as? UITabBarController
```
windowのrootViewControllerはUIHostingController(TabBarViewを表示しているViewController)になっています。
UIHostingControllerの子の最初にUITabBarControllerがいます。

UITabBarControllerが取得できたので選択中のタブのViewControllerを取得する処理は以下のようになります。
(ついでにここまでの処理をまとめます)
```swift
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
      let tabBarController = windowScene.windows.first?.rootViewController?.children.first as? UITabBarController else {
    return nil
}
let tabIndex = tabBarController.selectedIndex
let selectedTabRootViewController = tabBarController.viewControllers![tabIndex]
```
`selectedTabRootViewController`が選択中のタブのルートのViewControllerになります。

UIKitを扱っている方なら`selectedTabRootViewController`がUINavigationControllerになっているかと予想すると思いますが、SwiftUIではUINavigationControllerになっておらずその中まで確認していく必要があります。
(`selectedTabRootViewController`はUIHostingControllerになっています)

UINavigationControllerを取得するためにViewControllerのchildrenを見ていきます。
```swift
    private func findNavigationController(_ viewController: UIViewController) -> UINavigationController? {
        if let navigationController = viewController as? UINavigationController {
            return navigationController
        }
        for child in viewController.children {
            if let navigationController = findNavigationController(child) {
                return navigationController
            }
        }
        return nil
    }
```
UINavigationControllerが取得できるまで引数のViewControllerからchildrenを探しています。

ヒエラルキーを確認すると以下のようになっていました。
![スクリーンショット 2022-11-29 13.16.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149731/e1947a8d-68ad-2cd1-d96c-ad0b652e96e5.png)
赤い部分のStyleContextSplitViewNavigationControllerがプライベートクラスですがUINavigationControllerにキャストすることができてUINavigationControllerとして扱うことができます。

UINavigationControllerが取得できたのであとはSwiftUIのViewからUIHostingControllerを生成しpushViewController()をするだけです！

---
##### 最終的なコード
最終的には以下のコードになります。
```UIApplication.swift
extension UIApplication {
    func pushView<Content: View>(_ view: Content) {
        // モーダルを表示中や遷移不可の画面を表示している場合の処理
        guard let navigationController = getCurrentTabNavigationController() else {
            return
        }
        let hostingController = UIHostingController(rootView: view)
        navigationController.pushViewController(hostingController, animated: true)
    }
    
    func getCurrentTabNavigationController() -> UINavigationController? {
        // マルチウィンドウ対応アプリや複数UIWindowをセットしている場合は取得方法を書き換える
        guard let windowScene = self.connectedScenes.first as? UIWindowScene,
              let tabBarController = windowScene.windows.first?.rootViewController?.children.first as? UITabBarController else {
            return nil
        }
        let tabIndex = tabBarController.selectedIndex
        let selectedTabRootViewController = tabBarController.viewControllers![tabIndex]

        return findNavigationController(selectedTabRootViewController)
    }
    
    private func findNavigationController(_ viewController: UIViewController) -> UINavigationController? {
        if let navigationController = viewController as? UINavigationController {
            return navigationController
        }
        
        for child in viewController.children {
            if let navigationController = findNavigationController(child) {
                return navigationController
            }
        }
        return nil
    }
}
```

使い方
```swift
UIApplication.shared.pushView(DetailView(id: 1))
```
※注意点
・マルチウィンドウ対応アプリや複数UIWindowをセットしている場合
・モーダルを表示中や遷移不可の画面を表示している場合
の考慮はしていないため適宜処理を追加してください。

何か間違い等がありましたらご指摘いただけると幸いです。