SwiftUI の NavigationLink(destination:,isActive:) を活用する[追記あり]

## はじめに

SwiftUI において画面遷移を行うための簡単な方法は `NavigationView` と `NavigationLink(destination:)` を使うものです。
なんらかの条件を満たしたときに強制的に画面遷移を行うなど、より細かい制御を行いたい場合は、`NavigationLink(destination:, isActive:)` を使います。

オリジナルアプリを個人開発している中で、この `NavigationLink(destination:, isActive:)` の使い方に関してかなりハマった経験があったので、備忘録がわりに記事としました。

## 追記(2023年8月11日)

`NavigationView`と`NavigationLink(destination:, isActive:)`は、iOS16から deprecated となりました。

`NavigationStack(path:)`と`NavigationLink(value:)`、そして`.navigationDestination(for:)`モディファイアを使いましょう。
「隠れNavigationLink」というトリッキーな技を使う必要はなくなります。

~~余裕があれば、別途記事にしたいところですが...~~
`NavigationStackについて記事を書きました。「[SwiftUI の NavigationStack を活用する](https://qiita.com/surfinhamster/items/a1c4495af9b739ffcccd)」をご覧ください。(2023年8月21日)


## 開発環境等
- MacBook Air (M1 2020)
- macOS Monterey Version 12.1
- Xcode Version 13.2.1 (13C100)
- iOS 15.0

## 基本的な使い方

`NavigationLink(destination: ,isActive:)` の典型的な使い方は次のとおりです。

```swift:ContentView.swift
    struct ContentView: View {
        @State private var toFirstView = false // このフラグで画面遷移を制御
        
        var body: some View {
            NavigationView {
                VStack {
                    NavigationLink(destination: FirstView(),
                                   isActive: $toFirstView) {
                        EmptyView()
                    }
                    
                    Button(action: { // このボタンをタップすると FirstView に遷移する。
                        toFirstView = true
                    }) {
                        Text("Go To the FirstView")
                    }
                }.navigationTitle("ROOT")
            }
        }
    }
```
NavigationLink の `label:` には `EmptyView()` を入れて隠しておきます。
Button をタップして `toFirstView` を `true` にすると、NavigationLink に設定された画面遷移が行われます。

ここで1点補足しておきます。

上記では、`toFirstView` を `NavigationLink` による画面遷移を引き起こすスイッチのように使っていますが、逆に画面遷移が生じた場合、`ToFirstView` は `true`になります。
この部分は `Toggle(isOn:)` の挙動と似ています。
SwiftUI らしい機構だと思いますが、「`isActive:` フラグを `false` にしているのに画面遷移し、フラグが `true` になってしまう。なぜ?」という質問を見かけたりもしたので、念の為書き記しておきます。

## 画面遷移先から戻る

### 1階層戻る場合
`NavigationView` では、前の画面に戻るための "<Back" ボタンが自動的に表示されますが、これに依らずに前の画面に戻ることもできます。
遷移先の画面で `isActive:` でセットしたフラグを `false` にすると画面遷移が強制的にキャンセルされて、前の画面に戻ります。

```swift:ContentView.swift
    struct ContentView: View {
        @State private var toFirstView = false
        
        var body: some View {
            NavigationView {
                VStack {
                    NavigationLink(destination: FirstView(isActive: $toFirstView),
                                   isActive: $toFirstView) {
                    }
                    
                    Button(action: {
                        toFirstView = true
                    }) {
                        Text("Go To the FirstView")
                    }
                }.navigationTitle("ROOT")
            }
        }
    }

    struct FirstView: View {
        @Binding var isActive: Bool
        
        var body: some View {
            VStack {
                Button(action: { // このボタンをタップすると前画面に戻る。
                    isActive = false
                }) {
                    Text("Back To the ROOT")
                }
            }.navigationTitle("First View")
        }
    }
```

### ２階層以上戻る場合

上記方法は、多段階の画面遷移を行った後に２階層以上前の画面にジャンプする場合も有効です。
なんらかの方法で `isActive:` でセットしたフラグを持ち回して、false にすればよいのです。

上記のように引数で持ち回してもよいですが、コードが分かりにくくなるので、下記のように `EnvironmentObject` を使う方がスマートかと思います。

まずはこんな感じで `EnvironmentObject` を定義しておきます。

```swift:NavigationFlags.swift
    class NavigationFlags: ObservableObject {
        @Published public var toFirstView = false
    }
```

ROOTに近いViewでそのインスタンスを生成します。App生成時に生成するならこんな感じです。

```swift:HogeApp.swift
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(AppSettings())

        }
    }
```

その上で必要な View で呼び出します(下記サンプルコードでは、`SecondView` で呼び出してます)。

```swift:ContentView.swift
    struct ContentView: View {
        @EnvironmentObject var navigationFlags: NavigationFlags
        
        var body: some View {
            NavigationView {
                VStack {
                    NavigationLink(destination: FirstView(),
                                   isActive: $navigationFlags.toFirstView) {
                    }
                    
                    Button(action: {
                        navigationFlags.toFirstView = true
                    }) {
                        Text("Go To the FirstView")
                    }
                }.navigationTitle("ROOT")
            }
        }
    }


    struct FirstView: View {
        var body: some View {
            VStack {
                NavigationLink(destination: SecondView()) {
                    Text("Go To the SecondView")
                }
            }.navigationTitle("First View")
        }
    }


    struct SecondView: View {
        @EnvironmentObject var navigationFlags: NavigationFlags
        
        var body: some View {
            VStack {
                Button(action: {
                    navigationFlags.toFirstView = false // 一気に ROOT に戻る。
                }) {
                    Text("Back To the ROOT")
                }
            }
            .navigationTitle("Second View")
        }
    }
```


### 戻りたい画面が複数ある場合

戻りたい画面が複数ある場合は、戻りたい画面遷移ごとに異なるフラグをセットすれば大丈夫です。

ここで重要なのが、`NavigationView` に、`.navigationViewStyle(.stack)` というモディファイアをセットすることです。
`.stack`というのは、「順番に画面遷移を積み重ねる」みたいな意味です。
これを設定していないとまったく想定外の画面遷移をします。
私がハマって2週間ぐらい悩みに悩んでしまったところですので、本稿で最も重要なポイントです!

```swift:NavigationFlags.swift
    class NavigationFlags: ObservableObject {
        @Published public var toFirstView = false
        @Published public var toSecondView = false
    }
```

インスタンス作成は上記と同じなので割愛。
必要な View ごとに下記のようにBool値をセットします。

```swift:ContentView.swift
    struct ContentView: View {
        @EnvironmentObject var navigationFlags: NavigationFlags
        
        var body: some View {
            NavigationView {
                VStack {
                    NavigationLink(destination: FirstView(),
                                   isActive: $navigationFlags.toFirstView) {
                    }
                    
                    Button(action: {
                        navigationFlags.toFirstView = true
                    }) {
                        Text("Go To the FirstView")
                    }
                }.navigationTitle("ROOT")
            }
            .navigationViewStyle(.stack) // 重要!!
        }
    }


    struct FirstView: View {
        @EnvironmentObject var navigationFlags: NavigationFlags

        var body: some View {
            VStack {
                NavigationLink(destination: SecondView(),
                               isActive: $navigationFlags.toSecondView) {
                    Text("Go To the SecondView")
                }
            }.navigationTitle("First View")
        }
    }


    struct SecondView: View {
        @EnvironmentObject var navigationFlags: NavigationFlags
        
        var body: some View {
            VStack {
                Button(action: {
                    navigationFlags.toFirstView = false // ROOT に戻る。
                }) {
                    Text("Back To the ROOT")
                }
                
                Button(action: {
                    navigationFlags.toSecondView = false // FirstView に戻る。
                }) {
                    Text("Back To the First View")
                }
            }
            .navigationTitle("Second View")
        }
    }
```

## List と組み合わせる

List からアイテムを選択し、画面遷移したい、さらに遷移先画面から戻れるようにしたいという場合、上記の組み合わせでついつい下記のようのようなコードを書いてしまうと思います。

```swift:ContentView.swift
    // NG な例です!
    struct ContentView: View {
        @State private var toFirstView = false
        
        private var items = ["saru", "kiji", "inu"]

        var body: some View {
            NavigationView {
                List {
                    ForEach(items, id: \.self) { item in
                        VStack {
                            NavigationLink(destination: FirstView(item: item),
                                           isActive: $toFirstView) {
                                EmptyView()
                            }
                            
                            Button(action: {
                                toFirstView = true
                            }) {
                                Text(item)
                            }
                        }
                    }
                }
                .navigationTitle("ROOT")
            }
            .navigationViewStyle(.stack)
        }
    }
```

しかし、これはいけません。想定外のデタラメな挙動をします。
というのも、`ForEach`は同じViewを繰り返し生成するので、同一の `isActive` フラグが複数の `NavigationLink` に設定されていることになり、どれかひとつのセルを選択するだけで、全セルの画面遷移が発火することになるからです。

「`NavigationLink` は List の外に追い出せ」が原則です。
選択されたセルを格納する変数を別に設けて(下記では`selection`としています)、こう書き直しましょう。

```swift:ContentView.swift
    struct ContentView: View {
        @State private var toFirstView = false
        
        private var items = ["saru", "kiji", "inu"]
        @State private var selection: String?

        var body: some View {
            NavigationView {
                VStack {
                    if let selection = selection { // アイテムが選択されている時だけ有効
                        NavigationLink(destination: FirstView(item: selection),
                                       isActive: $toFirstView) {
                            EmptyView()
                        }
                    }
                    
                    List {
                        ForEach(items, id: \.self) { item in
                            VStack {
                                Button(action: {
                                    selection = item
                                    toFirstView = true
                                }) {
                                    Text(item)
                                }
                            }
                        }
                    }
                }
                .navigationTitle("ROOT")
            }
            .navigationViewStyle(.stack) // 念のため
        }
    }
```


## 最後に

約２年前に、iOS(iPadOS)アプリ[簡単便利な階層型情報メモアプリ HiMemo](https://apps.apple.com/jp/app/himemo/id1506694081)を個人開発でリリースしました。
最近、このアプリを SwiftUI で書き直しています(→2022年4月にリリースしました。[HiMemo2](https://apps.apple.com/app/himemo2/id1614965391)です。)。その中ではまさに本稿で述べた、リストからの画面遷移、多階層の画面遷移、階層を一気に戻るUIが必要となるのですが、いちいちハマってしまいました。
せっかく苦労して解決したのでまとめておこうと思いました。

この記事が少しでもお役に立てれば幸いです。

## 参考
- [ListでのNavigationLinkとButtonを使った画面遷移](https://qiita.com/tbskgm/items/c095c0c3749b85bf245b)
- [SwiftUIの隠しNavigationLinkを使って画面遷移をプログラムで制御する](https://blog.studysapuri.jp/entry/2021/09/18/iosdc-swiftui-navigationlink-push-navigation)
- [SwiftUI で複数の画面を一度に戻る方法](https://qiita.com/AkkeyLab/items/4833014c3918b64f40c8)