【SwiftUI】NavigationLinkとNavigationPathを使った遷移機能の実装

## はじめに

どうも、iOS駆け出しエンジニアの**はるさん**です。
今回は**NavigationLinkとNavigationPathを使った遷移**
について説明していきたいと思います。

私は今までUIKitをメインにアプリ開発を行ってきました。
この度、自社案件でSwiftUIを使ってアプリ開発を行うことになりました。
その過程でつまづいた遷移関連の処理についてアウトプットしていきたいと思います。
今回はその中でもNavigationStackの遷移処理機能であるNavigationLinkと
付随する機能であるNavigationPathを中心にお話していきます。

### 記事の対象者

- SwiftUIを使って開発をしている方
- UIKitから学び始めて、最近SwiftUIを学び始めた方
- NavigationLinkの遷移方法を学びたい方
- 複数の画面を跨いでの遷移を実装したい方

### 記事を執筆時点での筆者の環境
- macOS  14.0
 - Xcode 15.0.1
- Swift  5.9 
- iOS 17.1

## 1. NavigationStackとは？
NavigationStackは画面遷移処理を管理する機能を持ったビューコンテナです。
UIKitから始めた人であればUINavigationControllerを想像してもらえれば大体合ってると思います。
iOS15までは*NavigationView*を使用していました。
iOS16からはDeprecatedとなって置き換わったのがこのNavigationStackです。
そしてその機能の一つにプッシュ遷移を一実装するNavigationLinkと
遷移処理がカスタマイズできるNavigationPathがあります。

公式ドキュメント

https://developer.apple.com/documentation/swiftui/navigationstack

## 2. サンプルコードについて
今回の記事で紹介しているコードを使ったサンプルプロジェクトを作成しています。
プロジェクトの実装内容は以下の通りです。
- リンクをタップするとタップしたデータを持って次の画面にプッシュ遷移する
- 次の画面で前画面で選択した文字を表示する
- 画面構成は全部で４画面
- 画面を跨いで進む遷移機能が最初の画面にある
- 画面を跨いで戻る遷移機能が最後の画面にある
- あえてダメな例も載せている

サンプルコードを以下のリンクに載せておきます。

Gitgist（コードのみ）　→ [PracticeNavigationStack.swift](https://gist.github.com/HaruhikoMotokawa/dd37afbccc67499b3afc255b1f7bbabc)

GitHubリンクはこちら

https://github.com/HaruhikoMotokawa/NavigationPathApp

記事内ではコードを抜粋していますので、合わせて上記のリンクをご覧いただければと思います。
またGifだとわかりづらいので、ぜひお手元で動かしてみてください。
![PracticeNavigationStack.swift.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/e1a258fc-5af6-e577-f0bb-edcb6e9ca7db.gif)


## 3. NavigationLinkを使った通常の遷移
ひとまず、ただ単にプッシュ遷移させる場合は`NavigationLink(destination:label:)`
を使用します。
1. 親画面、最初の画面で`NavigationStack`を宣言します
1. `NavigationStack`内で`NavigationLink(destination:label:)`を宣言します
2. 引数`destination:`に遷移先のViewのインスタンスを渡します
3. 引数`label:`は表示したいラベルです

:::note info
`NavigationsStack`の宣言は親画面のみで良いです。
子画面や孫画面では宣言しません。
:::
```swift
  var body: some View {
    NavigationStack {
      NavigationLink {
        //ここに遷移させたいViewのインスタンスを渡す
        IPadSelectView()
      } label: {
        //ここに表示させたいテキストを書く
        Text("次の画面へ")
      }
    } // NavigationStack
  } // body
```
サンプルコード内では以下の部分で記述しています
![スクリーンショット 2023-11-12 22.40.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/928bf165-244a-849a-cfc3-5ce59f7dcea0.png)

:::note info
今回はNavigationStackのオプションで`(path:)`を使っているので、フッターのテキストにあるように次画面での遷移処理に不具合が生じます。
本来`(path:)`を指定しなければ子画面で`NavigationLink(destination:label:)`を宣言すれば
子画面→孫画面と画面遷移させる実装は可能です。
:::

## 4. NavigationLink + NavigationPathを使った遷移
### 3-1. NavigationPathとは？
NavigationPathとは、簡単に説明するとある画面からある画面への
遷移記録のようなものです。
親画面から子画面に遷移する際の通行記録をNavigationPath構造体に
記録していきます。

https://developer.apple.com/documentation/swiftui/navigationpath

### 3-2. NavigationPathを使うための下準備
NavigationPathに記録する値は`Hashable`プロトコルに準拠していなければなりません。
`Hashable`プロトコルとは簡単にいうとハッシュ値を使ってデータを高速に検索できる機能を持たせるプロトコルです。
簡単にまとめた私のnotionリンクを載せておきますので、詳しくはこちらを参考にしてみください。

https://energetic-catamaran-55e.notion.site/Hashable-dd7e8ef8da5142ec9c1f2714841062d1?pvs=4

なので、今回の場合はまず各画面毎に対応するHashableプロトコルに対応したenumを定義します。
```swift
/// 選択した名称で使えるようにStringに準拠
/// 尚且つNavigationPathで使えるようにHashableに準拠したStringに準拠する
enum MacType: String {
  case MacBook, iMac, MacStudio
}
```

:::note info
ここで注意点としては先に述べたように一つの画面につき、一つのenumを定義しなければなりません。
上記のenum MacTypeをA画面とB画面の両方では使えないということです。
:::

### 3-3. 親画面でNavigationPathを宣言する
大元の画面である親画面で`NavigationPath`をプロパティとして宣言します。
この時、`@State`を付与しましょう。また、バインディング以外での変更を
されないようにアクセス修飾子に`private`もつけましょう。
```swift
/// 親画面
struct MacSelectView: View {
  /// 大元のViewでNavigationPathを@ Stateで宣言して、子Viewにバインディングしていく
  @State private var path: NavigationPath = NavigationPath()
```

そしてNavigationStackの引数(path:)に宣言したプロパティを`$`付きで宣言します。

```swift
  var body: some View {
    NavigationStack(path: $path) { // ここで使用するパスを設定
 ```
### 3-4. NavigationLinkを宣言する
次に今回はListにNavigationLinkを貼り付けていきます。
ForEachを使ってあらかじめ定義したstruct Macで作ったダミー配列、modelsを利用します。
ここは実際にはRealmやFireStoreのデータになることも多いでしょう。
```swift
/// 選択した名称で使えるようにStringに準拠
/// 尚且つNavigationPathで使えるようにHashableに準拠したStringに準拠する
enum MacType: String {
  case MacBook, iMac, MacStudio
}
/// ダミーデータの構造体、ForEachで使用できるようにIdentifiableに準拠
struct Mac: Identifiable {
  var id = UUID()
  var pathName: MacType
}
/// ダミーデータとして宣言
extension Mac {
  static let models: [Mac] = [
    Mac(pathName: .MacBook),
    Mac(pathName: .MacStudio),
    Mac(pathName: .iMac)
  ]
}
```
List内で使用するNavigationLinkは前項で述べたものとは変わり、
今回は`NavigationLink(value:label:)`を使用します。
この引数`value:`に入る値が先ほど定義した`enum MacType`であり、
`Hashable`プロトコルに適用している必要があります。

ここで設定しているのは各リンクをタップしたときにどのpathを使うのかを
指定しているのと、
リンクに表示するラベルを定義しているだけです。
遷移先の宣言は後述します。
```swift
  var body: some View {
    NavigationStack(path: $path) { // ここで使用するパスを設定
      List {
      
        // 省略
     
        Section {
          ForEach(Mac.models) { model in
            // 今回はパスを使って遷移させるので(value:label:)を選択
            NavigationLink(value: model.pathName) {
              Text(model.pathName.rawValue)
            }
          }
        } header: {
          Text("ナビゲーションパスでの遷移")
        } footer: {
          Text("パスでの遷移を始めたら次の遷移もパスを指定しないと表示がバグります")
        }

        // 省略
      } // List
```
### 3-5. .navigationDestinationを使って遷移先を指定する
最後に`.navigationDestination`モディファイアを使って遷移先を指定します。
引数は`(for:)`を選択してください。
今回はどのpathであっても小画面である`IPadSelectView`に遷移しますが、
pathによって次の画面に渡す`selectedMac:`が変わるように実装しています。
```swift
      } // List
      // 以下で遷移先を指定する。使用するハッシュ値を(for:)で指定。
      // ハッシュ値によって渡す値を帰るのでswitch文で分岐
      .navigationDestination(for: MacType.self) { path in
        switch path {
          case .MacBook:
            IPadSelectView(path: $path, selectedMac: .MacBook)
          case .iMac:
            IPadSelectView(path: $path, selectedMac: .iMac)
          case .MacStudio:
            IPadSelectView(path: $path, selectedMac: .MacStudio)
        }
      } // navigationDestination
      .navigationTitle("Root")
    } // NavigationStack
  } // body
}
```
ちなみに次画面はこのようなプロパティです。
```swift
struct IPhoneSelectView:View {

  @Binding var path: NavigationPath

  var selectedMac: MacType

  // 省略
}
```
:::note info
今回はpathが違っても同じ画面にしていますが、当然別々の画面にもできます。
また遷移のpathが一つの場合はswitch文による分岐も不要です。

```swift
      .navigationDestination(for: MacType.self) { _ in
            IPadSelectView(path: $path, selectedMac: .MacBook)
      } // navigationDestination
```
:::
### 3-6. 小画面にも設定していく
あとはほぼ同じように小画面にも実装していきます。
親画面と異なる点は以下の通りです。


- pathは`@Binding`で親画面からバインディングする
```swift
/// ２つ目の画面
struct IPadSelectView:View {
  /// パスをバインディング
  @Binding var path: NavigationPath
```
- NavigationStackは宣言しない
```swift
 var body: some View {
    List {
      Section {
```
- 親画面とは異なるenumを使用する
```swift
enum IPadType: String, Hashable {
  case Mini, Air, Pro
}
```
## 4. 画面を跨いだ遷移
プッシュ遷移の場合、４つの画面に潜っていくとすると、

A画面→B画面→C画面→D画面と進みます。
戻る時はD画面→C画面→B画面→A画面と遷移します。

これをA画面→D画面や逆にD画面→A画面のように跨いで遷移する方法が
NavigationPathを使えば可能です。

### 4-1. 跨いで進む遷移
NavigationPathはデータ型の配列となっています。
なので、各画面に設定しているpathを`append`関数で配列に追加してあげれば
任意の画面まで一気に遷移させることが可能です。

更に言うと、`NavigationLink`と`.navigationDestination(for:)`使用せず、
pathを追加させる処理だけで遷移機能を実装することも可能です。

```swift
/// 親画面
struct MacSelectView: View {
  /// 大元のViewでNavigationPathを@ Stateで宣言して、子Viewにバインディングしていく
  @State private var path: NavigationPath = NavigationPath()

  var body: some View {
    NavigationStack(path: $path) { // ここで使用するパスを設定

        // 省略
        
        Section {
          Button("次の画面へ飛ぶ") {
            path.append(MacType.MacStudio)
          }
          Button("次のその次の画面へ飛ぶ") {
            path.append(MacType.MacBook)
            path.append(IPadType.Mini)
          }
          Button("最後の画面へ飛ぶ") {
            path.append(MacType.iMac)
            path.append(IPadType.Air)
            path.append(IPhoneType.SE)
          }
          Button("最後の画面へ飛ぶSEだけ選択（バグる）") {
            // バグります
            path.append(IPhoneType.SE)
          }
          .foregroundStyle(.red)

        } header: {
          Text("パスを追加で一気に進む")
        }
      } // List

    // 省略

    } // NavigationStack
  } // body
}
```
:::note info
最後のボタンで書いてあるように任意の画面まで遷移させる際に
途中にあるpathを追加し忘れると遷移できなくなります。
途中のpathは全て追加しなければ正常に機能しません。
```swift
          Button("最後の画面へ飛ぶSEだけ選択（バグる）") {
            // バグります
            path.append(IPhoneType.SE)
          }
          .foregroundStyle(.red)
```
:::
### 4-2. 跨いで戻る遷移
こちらは進むのとは逆に配列からpathを削除すれば戻すことができます。
専用の`removeLast()`関数を使って配列を操作します。
```swift
struct ResultView:View {
  /// 画面を閉じるための環境変数
  @Environment(\.dismiss) var dismiss

  @Binding var path: NavigationPath

  var selectedMac: MacType

  var selectedIPad: IPadType

  var selectedIPhone: IPhoneType

  var body: some View {
    VStack(spacing: 50) {
      Form {
        Section {
          Text("あなたが選択したMacは「 \(selectedMac.rawValue) 」")
          Text("あなたが選択したiPadは「 \(selectedIPad.rawValue) 」")
          Text("あなたが選択したiPhoneは「 \(selectedIPhone.rawValue) 」")
        }
        Button("一つ前に戻る　Third") {
          path.removeLast() // 最後に追加したパスを削除する、つまり閉じる
        }
        Button("二つ前に戻る　Second") {
          path.removeLast(2) // 最後とその前に追加したパスを削除する、つまり２つ画面を閉じる
        }
        Button("最初に戻る Root") {
          // パスに格納されている分だけ削除、つまり全部削除、つまり全部閉じる
          path.removeLast(path.count)
        }
        Button("dismissで閉じる") {
          dismiss() // この画面を閉じる
        }
      }
      .navigationTitle("Last")
    } // VStack
  } // body
}
```
:::note info
今回の実装とは関係ありませんが、画面を閉じる実装の一つに
環境変数である`@Environment(\.dismiss) var dismiss`を使う方法があります。
この環境変数を宣言して`dismiss()`関数を実行すれば現在開いている画面を
閉じることができます。
:::

## 終わりに
いかがだったでしょうか？
NavigationPathの仕組みを理解する上で、`Hashable`プロトコルを学習したり
NavigationStackの仕様を調べたりと大変学びにつながりました。
一方で、仕様を理解した上で実装しないと思うような挙動になりません。
私はこれを理解して実装するまでかなり苦戦しました。

この記事がNavigationPathの実装に悩む方々に、少しでもお役にたてれば幸いです。

## 参考記事
https://tech.amefure.com/swift-navigationstack-path

https://thwork.net/2022/09/19/swiftui_navigationstack_control_navigationpath_ios16-ipados16/

https://blog.code-candy.com/swiftui_navigationstack/

https://qiita.com/yoshi-eng/items/91666637cd7cdd8edf88

https://qiita.com/surfinhamster/items/a1c4495af9b739ffcccd