🎉

NavigationViewの不満点をNavigationStackで解決する

2022/09/27に公開

はじめに

SwiftUIではiOS16からNavigationに関して大きなアップデートが入りました。
従来のSwiftUIでは、ナビゲーションバーを伴うドリルダウン型の画面遷移を実現するためにはNavigationViewというコンポーネントを利用していましたが、今回のアップデートでそれがdeprecatedになり、最新のSwiftUI以降ではNavigationStackというコンポーネントの利用が推奨されています。

  • iOS15以前:NavigationView(iOS16以降はdeprecated)
  • iOS16以降:NavigationStack

この新しく追加されたNavigationStackというコンポーネントは、データ駆動で遷移をコントロールできるもので、いくつかの点でNavigationViewよりも優れた特徴を持っています。

従来のNavigationの実装方法

比較として従来のNavigationの実装方法から見てみましょう。
実装ステップとしては大まかに以下のような流れになります。

  1. ルートのViewとしてNavigationViewを宣言する
  2. NavigationLinkコンポーネントを使い以下のViewを紐づける
    • タップで遷移を発火させるlabel用のView
    • 遷移先のViewを対応付ける

NavigationLinkの実装方法には複数のパターンが存在しますが、今回は最もシンプルな方法を選択しました。コードとしては以下のようになります。

struct FirstView: View {
   var body: some View {
       NavigationView {
           List(0..<10) { number in
               NavigationLink {
                   SecondView()
               } label: {
                   Text("\(number)")
               }
           }.navigationTitle("FirstView")
       }
   }
}

遷移先のView内にNavigationLinkを追加で設置することで、さらに別のViewに遷移させることも簡単に実現できます。

// 2枚目の画面
struct SecondView: View {
   var body: some View {
       List(0..<5) { number in
           NavigationLink {
               ThirdView()
           } label: {
               Text("\(number)")
           }
       }.navigationTitle("SecondView")
   }
}
 
// 3枚目の画面
struct ThirdView: View {
   var body: some View {
       List(0..<2) { number in
           Text("\(number)")
       }.navigationTitle("ThirdView")
   }
}

SwiftUIでは、たったこれだけの実装で、以下のような画面遷移を実現することが可能になります。

しかし、NavigationViewを用いた実装にはいくつかの課題がありました。具体的には以下のようなものが挙げられます。

  1. システム制御によって画面遷移処理を発火させる実装が煩雑になりやすい
  2. 遷移後の画面から、遷移元の特定画面にジャンプすることが難しい
    ※ UINavigationController(UIKit)ではpopToViewControllerやpopToRootViewControllerでそれが実現できる

実際にサンプルコードを見てみましょう。

課題点1

まず、1つ目の課題についてです。実装都合による任意のタイミングでの画面遷移を行うには、NavigationLinkのisActiveを用いたイニシャライザを利用します。ここで渡すBindingインスタンスに対して任意のタイミングでtrueをセットすることでプログラム的な画面遷移を行うことが可能になります。コードとしては以下のような形になります。

struct FirstView: View {
   // 遷移中フラグをStateで保持する
   @State private var isActive = false
  
   var body: some View {
       NavigationView {
           List(0..<10) { number in
               // 保持しているフラグをNavigationLinkに渡す
               NavigationLink(isActive: $isActive) {
                   SecondView()
               } label: {
                   Button("\(number)") {
                       // 遷移を発火させたい任意のタイミングでフラグを立てる
                       isActive = true
                   }.buttonStyle(.plain)
               }
           }.navigationTitle("FirstView")
       }
   }
}

この変更により、想定していた画面遷移は実現可能になりました。しかしこの実装には「特定のセルをタップした際に全てのNavigationLinkが反応してしまう」という問題があります。これは、各NavigationLinkに対して全て同じisActiveインスタンスを渡していることが原因です。
この問題に対応するには、各NavigationLinkを個別に動作させる必要があり、例えば以下のような実装で対処することが可能です。

struct FirstView: View {
   // 遷移が発火した対象を一意に特定できるデータを保持
   @State private var selectedNumber: Int?
  
   // 特定のNavigationLinkのみがactiveになるようにコントロールするためのBindingを定義
   private func isActiveDestination(_ number: Int) -> Binding<Bool> {
       .init(
           get: { selectedNumber == number },
           set: { selectedNumber = $0 ? number : nil }
       )
   }
  
   var body: some View {
       NavigationView {
           List(0..<10) { number in
               // 各NavigationLinkごとにそれぞれのBindingを受け渡す
               NavigationLink(isActive: isActiveDestination(number)) {
                   SecondView()
               } label: {
                   Button("\(number)") {
                       // 遷移を発火させたい任意のタイミングで、対象を一意に特定できるデータを保持する
                       selectedNumber = number
                   }.buttonStyle(.plain)
               }
           }.navigationTitle("FirstView")
       }
   }
}

結果として、このような簡単な要件を満たすだけなのに、とても複雑な実装が生まれてしまいました。

課題点2

2つ目の課題である、「遷移後の画面から、遷移元の特定画面にジャンプすることが難しい」という点についても見てみましょう。

これを実現するには、先ほど定義したisActiveフラグを子Viewに対してバケツリレーのように渡していき、それを任意のタイミングで制御するという対応が必要になります。

struct FirstView: View {
   // …
 
   var body: some View {
       NavigationView {
           List(0..<10) { number in
               NavigationLink(isActive: isActiveDestination(number)) {
                   // FirstViewが持つ遷移中フラグを子Viewに受け渡す
                   SecondView(isActiveDestination: isActiveDestination(number))
               } label: {
                   // …
               }
           }.navigationTitle("FirstView")
       }
   }
}
 
struct SecondView: View {
   @Binding var isActiveDestination: Bool
 
   var body: some View {
       List(0..<5) { number in
           NavigationLink {
               // FirstViewから受け渡された遷移中フラグをさらに子Viewに受け渡す
               ThirdView(isActiveDestination: $isActiveDestination)
           } label: {
               // …
           }
       }.navigationTitle("SecondView")
   }
}
 
struct ThirdView: View {
   @Binding var isActiveDestination: Bool
 
   var body: some View {
       Button("Rootに戻る") {
           // FirstViewから順番に受け渡されたフラグをこのタイミングで戻す
           isActiveDestination = false
       }.navigationTitle("ThirdView")
   }
}

このように画面制御を細かくコントロールしようとすると、途端にコードが冗長になり、複雑な実装が必要となってしまいます。

新しいNavigationの実装方法

ではこれらの問題が、新しく登場したNavigationStackによってどう解決されているかを見ていきます。

NavigationStackを用いた実装は以下のステップで行います。

  1. NavigationStack配下にNavigationLinkを定義する
  2. NavigationLinkのイニシャライザに、遷移対象画面に対応した値を受け渡す
  3. navigationDestinationModifierを宣言して、2で受け渡された値ごとの遷移先Viewを決定する

コードとしては下記のようになり、こちらがNavigationStackを用いた最低限の実装になります。

struct FirstView: View {
   var body: some View {
       // NavigationViewの代わりにNavigationStackを用いる
       NavigationStack {
           List(0..<10) { number in
               // NavigationLinkにはvalueとして遷移に関連する値を受け渡す
               NavigationLink(value: number) {
                   Text("\(number)")
               }
           }
           .navigationTitle(Text("First"))
           // navigationDestinationModifierでNavigationLinkに受け渡された値に対応する型を設定する
           .navigationDestination(for: Int.self) { value in
               // 必要に応じて受け渡された値を参照しつつ、対応するViewインスタンスを作成する
               SecondView()
           }
       }
   }
}
 
struct SecondView: View {
   var body: some View {
       List(0..<5) { number in
           Text("\(number)")
       }.navigationTitle(Text("Second"))
   }
}

ここから、さらに画面遷移をコントロールしやすくなるよう改修を加えてみましょう。
現状、NavigationLinkにはIntの値を受け渡していますが、遷移先が明確になるよう専用の型を定義することで保守性を向上します。

// 特定のルート画面からどういった画面遷移が発生するかを列挙するための型
enum NavigationDestination: Hashable {
   case second
   case third
}
 
struct FirstView: View {
   var body: some View {
       NavigationStack {
           List(0..<10) { number in
               // NavigationLinkとNavigationDestinationを対応付ける
               NavigationLink(value: NavigationDestination.second) {
                   Text("\(number)")
               }
           }
           .navigationTitle(Text("First"))
           .navigationDestination(for: NavigationDestination.self) { destination in
               // 子View内で遷移が発火したNavigationPathごとの遷移先をswitchで網羅
               switch destination {
               case .second:
                   SecondView()
               case .third:
                   ThirdView()
               }
           }
       }
   }
}
 
struct SecondView: View {
   var body: some View {
       List(0..<5) { number in
           // NavigationLinkとNavigationPathを対応付ける
           NavigationLink(value: NavigationDestination.third) {
               Text("\(number)")
           }
       }.navigationTitle(Text("Second"))
   }
}
 
struct ThirdView: View {
   var body: some View {
       Text("")
           .navigationTitle(Text("Third"))
   }
}

このようにして、画面遷移の制御をより宣言的に行えることが可能になっています。
ここからはNavigationViewが抱えていた課題点が、NavigationStackの利用によりどのように改善できるかを見ていきます。

課題点1の解決

NavigationLinkを用いて実装都合による任意のタイミングでの画面遷移を行うには、状態管理のためのコードが煩雑になり、やりたいこと以上にコードが複雑化してしまうという課題が存在しました。

NavigationStackにはそのような状態管理の制御を最小限にできる仕組みが備わっています。
具体的には、NavigationStackのインスタンス作成時に、遷移先の情報を保持する配列のインスタンスを受け渡しておき、その配列を任意のタイミングで制御することで細かな遷移のコントロールが実現可能になっています。コードを見てみましょう。

struct FirstView: View {
   // 遷移先のデータを保持するためのStateを用意
   @State private var navigationPath: [NavigationDestination] = []
  
   var body: some View {
       // NavigationStackのイニシャライザで作成したStateを受け渡しておく
       NavigationStack(path: $navigationPath) {
           List(0..<10) { number in
               Button("\(number)") {
                   // NavigationLinkを使わずにnavigationPathの配列を制御することで遷移を発火させる
                   navigationPath.append(.second)
               }
           }
           .navigationTitle(Text("First"))
           .navigationDestination(for: NavigationDestination.self) { destination in
               switch destination {
               case .second:
                   SecondView()
               case .third:
                   ThirdView()
               }
           }
       }
   }
}

このように、NavigationStackのイニシャライザに渡しておいたStateオプジェクトを、システムから遷移を発火させたいタイミングで更新してあげるようにします。たったこれだけの実装で、任意のタイミングで画面遷移を実行することができるようになりました。

課題点2の解決

では画面を戻る制御についてはどうでしょうか。
こちらもNavigationStackを用いることで、とても簡単に実現することが可能です。

具体的には、navigationPathのBindingをサブビューに受け渡しておき、それを任意のタイミングでサブビュー側から制御することで、画面を戻る処理も簡単に実現することができます。

struct FirstView: View {
   @State private var navigationPath: [NavigationDestination] = []
  
   var body: some View {
       // NavigationStackのイニシャライザで作成したStateを受け渡しておく
       NavigationStack(path: $navigationPath) {
           List(0..<10) { number in
               Button("\(number)") {
                   navigationPath.append(.second)
               }
           }
           .navigationTitle(Text("First"))
           .navigationDestination(for: NavigationDestination.self) { destination in
               switch destination {
               case .second:
                   SecondView()
               case .third:
                   // 子Viewにpathのデータを受け渡しておく
                   ThirdView(navigationPath: $navigationPath)
               }
           }
       }
   }
}

struct ThirdView: View {
    @Binding var navigationPath: [NavigationDestination]
    
    var body: some View {
        Button("トップに戻る") {
            // 配列から遷移対象となる画面以降のインスタンスを除去することで、対象画面まで戻ることが可能
            navigationPath.removeAll()
        }.navigationTitle(Text("Third"))
    }
}

またこの機能のポイントは、遷移のコントロールが単に配列の操作をしているだけで実現できているということです。これにより、トップ画面や途中の特定画面にジャンプするなど、とても柔軟な画面制御が簡単にできるようになっています。

おわりに

このようにして、NavigationStackを活用して「データと遷移先を紐づける」ことで、さらに柔軟な画面制御がシンプルな記述で表現できるようになりました。
iOS15以前をサポートしている場合、まだNavigationStackに移行することは難しいとは思いますが、将来的により効率的な実装ができることは覚えておくと良いでしょう。

Discussion