[SwiftUI] NavigationStackをネストしたときに発生する不具合と代替案について。

# NavigationStackの予期せぬ挙動とその回避策

iOS16からナビゲーション管理手法として登場した`NavigationStack`は、ページ遷移をスタックで管理でき、より直感的なコントロールが可能になりました。しかし、まだ歴史が浅く文献も少ないため、特定の使い方で予期せぬ挙動が発生することがあったので、その際に見られた挙動と回避案をご紹介します。

最初に申し上げますが、不具合の原因や、そもそも不具合であるのかについての情報は本記事には記載していません。今回は私が経験した挙動とその対処方法に関する話です。

---

## アプリの前提

私が作成したアプリでは、アプリのトップレベルで`TabView`を使用し、各タブで`NavigationStack`を用いてページ遷移を管理していました。
### アプリ構成のイメージ

![アプリ構成のイメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287608/5c21935d-7ac9-263d-3c8a-9e2db81a677a.png)

---

## サンプルコード



```
import SwiftUI

@main
struct SimpleApp: App {
    var body: some Scene {
        WindowGroup {
            MainTabView()
        }
    }
}

struct MainTabView: View {
    var body: some View {
        TabView {
            HomeView()
                .tabItem {
                    Label("Home", systemImage: "house.fill")
                }
            
            SearchView()
                .tabItem {
                    Label("Search", systemImage: "magnifyingglass")
                }
            
            ProfileView()
                .tabItem {
                    Label("Profile", systemImage: "person.fill")
                }
        }
    }
}

struct HomeView: View {
    var body: some View {
        NavigationStack {
            VStack {
                Text("Home View")
                    .font(.largeTitle)
            }
            .navigationTitle("Home")
        }
    }
}

struct SearchView: View {
    var body: some View {
        NavigationStack {
            Text("Search View")
                .font(.largeTitle)
                .navigationTitle("Search")
        }
    }
}

struct ProfileView: View {
    var body: some View {
        NavigationStack {
            Text("Profile View")
                .font(.largeTitle)
                .navigationTitle("Profile")
                .toolbar {
                    ToolbarItem(placement: .navigationBarLeading) {
                        Button(action: {
                            print("Settings tapped")
                        }) {
                            Image(systemName: "gear")
                        }
                    }
                }
        }
    }
}
```

---

## サンプル画面

### Home View

![Home View](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287608/96d5748d-f965-3055-82cf-18d1c127da55.png)

### Search View

![Search View](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287608/1d7c3a40-9add-b327-8361-35647a86581f.png)

### Profile View

![Profile View](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287608/9f4ee8d3-c806-c6ce-8cbb-d56c02f39408.png)

---

## 課題と挙動の問題

新たな要件として、アプリの通知やDeep Linkをタップして特定のページに遷移させる機能を追加する必要がありました。当初は、`TabView`の外側に`NavigationStack`をラップしてその中でパスを操作することで実現できると考えました。しかし、結果的に予期せぬ挙動が発生しました。

以下のような問題が発生しました：

- 上位の`NavigationStack`のスタック操作で遷移した後、戻るボタンを押すと`TabView`内の変数が初期化される。
- ナビゲーションタイトルが表示されない。
- `Toolbar`が設定した画面以外でも残り続ける。

### 問題のコード例

```
import SwiftUI

@main
struct SimpleApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationStack {
                MainTabView()
            }
        }
    }
}

class AppState: ObservableObject {
    @Published var navigationPath: [AppDestination] = []

    func navigateToFullScreen() {
        navigationPath.append(.fullScreen)
    }

    func dismissEventDetail() {
        navigationPath.removeLast()
    }
}

enum AppDestination: Hashable {
    case fullScreen
}

struct MainTabView: View {
    @StateObject private var appState = AppState()

    var body: some View {
        TabView {
            NavigationStack(path: $appState.navigationPath) {
                Button("Show Full Screen") {
                    appState.navigateToFullScreen()
                }
                .navigationTitle("Home")
                .navigationDestination(for: AppDestination.self) { destination in
                    switch destination {
                    case .fullScreen:
                        FullScreenView()
                            .toolbar {
                                ToolbarItem(placement: .navigationBarLeading) {
                                    Button("Close") {
                                        appState.dismissEventDetail()
                                    }
                                }
                            }
                    }
                }
            }
            .tabItem {
                Label("Home", systemImage: "house.fill")
            }

            NavigationStack {
                Text("Search View")
                    .navigationTitle("Search")
            }
            .tabItem {
                Label("Search", systemImage: "magnifyingglass")
            }

            NavigationStack {
                Text("Profile View")
                    .navigationTitle("Profile")
                    .toolbar {
                        ToolbarItem(placement: .navigationBarLeading) {
                            Button(action: {
                                print("Settings tapped")
                            }) {
                                Image(systemName: "gear")
                            }
                        }
                    }
            }
            .tabItem {
                Label("Profile", systemImage: "person.fill")
            }
        }
        .environmentObject(appState)
    }
}

struct FullScreenView: View {
    var body: some View {
        VStack {
            Text("Full Screen View")
                .font(.largeTitle)
                .padding()
        }
    }
}
```

---

### 問題の発生する画面例

#### 上位の`NavigationStack`にパスを追加した際

![上位のNavigationStackにパスを追加した際](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287608/cc540709-8cf4-584a-e5fa-43f8a9fbb502.png)

#### 元の画面に戻った際のタイトル消失

![タイトルが表示されない不具合](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287608/1bbb6f9e-b857-a395-f449-51d6cdab5911.png)
![ツールバーが残る不具合1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287608/4d0d9a8c-d639-c3e9-f1cb-3e6514fe525b.png)

#### Toolbarが他の画面にも残って表示される不具合

![ツールバーが残る不具合2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287608/fbc1e75a-44e8-7d0a-858f-34f2a16507e9.png)
![スクリーンショット 2024-11-07 19.42.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287608/207b3b52-f64a-10ff-c29c-4b28bc0a24ca.png)


---

## 回避策：fullScreenCoverの使用

ネストされた`NavigationStack`が原因で予期せぬ挙動が発生している可能性があるため、代替案として`fullScreenCover`を使用してページ遷移を管理しました。この方法では、`TabView`内部のスタックに影響を与えず、指定した画面をフルスクリーンで表示させています。

### 修正後のコード例

```
import SwiftUI

@main
struct SimpleApp: App {
    var body: some Scene {
        WindowGroup {
            MainTabView()
        }
    }
}

class AppState: ObservableObject {
    @Published var showFullScreen = false

    func dismissEventDetail() {
        showFullScreen = false
    }
}

struct MainTabView: View {
    @StateObject private var appState = AppState()

    var body: some View {
        content()
            .fullScreenCover(isPresented: $appState.showFullScreen) {
                NavigationStack {
                    FullScreenView()
                        .toolbar {
                            ToolbarItem(placement: .navigationBarLeading) {
                                Button("Close") {
                                    appState.dismissEventDetail()
                                }
                            }
                        }
                }
            }
    }

    @ViewBuilder
    private func content() -> some View {
        TabView {
            NavigationStack {
                Button("Show Full Screen") {
                    appState.showFullScreen = true
                }
                .navigationTitle("Home")
            }
            .tabItem {
                Label("Home", systemImage: "house.fill")
            }
            
            NavigationStack {
                Text("Search View")
                    .navigationTitle("Search")
            }
            .tabItem {
                Label("Search", systemImage: "magnifyingglass")
            }
            
            NavigationStack {
                Text("Profile View")
                    .navigationTitle("Profile")
                    .toolbar {
                        ToolbarItem(placement: .navigationBarLeading) {
                            Button(action: {
                                print("Settings tapped")
                            }) {
                                Image(systemName: "gear")
                            }
                        }
                    }
            }
            .tabItem {
                Label("Profile", systemImage: "person.fill")
            }
        }
    }
}

struct FullScreenView: View {
    var body: some View {
        VStack {
            Text("Full Screen View")
                .font(.largeTitle)
                .padding()
        }
    }
}

```

---

### 挙動の画像

#### 修正後の画面

![正常な挙動1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287608/743c62f6-961d-0c30-baee-37008ef10b54.png)  
![正常な挙動2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287608/8eb57d26-5fd7-2d97-2f77-b5966afa7264.png)

---

## まとめ

`NavigationStack`をネストして使用する場合、まだ想定外の挙動が発生することがあります。本記事ではそのような問題を回避するための一例として、`fullScreenCover`を活用した方法を紹介しました。

結局、`NavigationStack`のネストによる予期せぬ挙動の原因や、それが不具合であるかどうかについては明確な情報を得ることはできませんでした。このため、同様の問題を経験している方や知見をお持ちの方がいれば、ご連絡していただけると助かります！

---

## 参考文献

- [Explore navigation design for iOS - WWDC22 - Videos - Apple Developer](https://developer.apple.com/videos/play/wwdc2022/10001/)
- [iOS16固有のTabViewとNavigationStackを併用した際に生じるアニメーションの不具合と対応方法について - テコテック開発者ブログ](https://dev.classmethod.jp/articles/swiftui-why-tabbar-get-clear/)