🐥

NavigationStackで画面遷移を一元管理してみた

2023/03/01に公開

アプリの遷移苦手

ios16からNavigationStackが使えるようになり、React Routerみたいな感じでページの遷移先を一つにまとめてみました。
アプリの遷移って結構めんどくさいみたいな印象で止まってたので、今回ちゃんと触ってみて案外使いやすいんじゃないかなということで開発で実際に遷移先をまとめて運用するようにしてみたので記事にしてみました。

遷移先をまとめたかった理由

色んなコンポーネントで遷移先(navigationDestination)を書くと、「どの遷移先が呼ばれているのか分かりづらいのでは?」と考えたためです。
個人開発なので正直良い実装かと言われれば分からんが、複数人で開発するってなった時に便利かなと思い修正してみました。

アプリ開発1年ほどなので、間違っているかもしれないですが何か参考になれば幸いです。

ワイ的まとめ

主なポイント

  • NavigationStackのViewBuilderとして作成(DestinationHolderView.swift)
  • ページ遷移をしたいViewの親元でラップしてPageRouterを初期化して渡す(ページごとにPath情報を保持できる)
  • DestinationHolderView内でEnvironmentObjectとして子ビューに渡し取得・遷移アクションをできるようにする

推したいメリット

  • 他ページでも使う共通の遷移先を毎回書かなくても良くなる
  • コンポーネントが複雑になってきてもどの遷移先が呼ばれてるからわかりやすい?(おそらく多分)
  • EnvironmentObjectで子ページからでもアクセスできて遷移処理できる

書いたコード

先に今回使ったコードをざっくりまとめました、ざっくりこんな感じです。

code
ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        TabView(selection: $selection) {
            HomeView()
                .tabItem {
                    Image("home")
                    Text("home title")
                }
                .tag(0)
            OtherView()
                .tabItem {
                    Image("other")
                    Text("other title)
                }
                .tag(1)
            AccountView()
                .tabItem {
                    Image("account")
                    Text("account title)
                }
                .tag(2)
        }
    }
}
DestinationHolderView.swift
import SwiftUI

final class PageRouter: ObservableObject {
    @Published var path = NavigationPath()
}

struct DestinationHolderView<Content:View>: View {
    @ObservedObject var router: PageRouter
    let content: Content

    init(router: PageRouter, @ViewBuilder content: () -> Content) {
        self.router = router
        self.content = content()
    }

    var body: some View {
        NavigationStack(path: $router.path) {
            content
                .navigationDestination(for: Int.self) { // - 2
                    int in
                    // 遷移先Viewを定義
                }
                .navigationDestination(for: String.self) { // - 1
                    string in
                    // 遷移先のViewを定義
                }
                .navigationTitle(tabViewSelector.selection.navigationTitle)
                .navigationBarTitleDisplayMode(.inline)
        }
        .environmentObject(router)
    }
}
HomeView.swift
import SwiftUI

struct HomeView: View {
  @StateObject var router = PageRouter()

  var body: some View {
    DestinationHolderView(router: router) {
      // ... 表示する中身
    }
  }
}
ChildView.swift
import SwiftUI

struct ChildView: View {
  @EnvironmentObject var router: PageRouter

  var body: some View {
    VStack {
        Button(action: {
          router.path.append("value")// - 1
        }) {
          Text("Textで遷移する")
        }
        NavigationLink(value: 32) { // - 2
          Text("Intで遷移する")
        }
      }
    }
  }
}

ざっくり説明

DestinationHolderView

DestinationHolderView.swift
struct DestinationHolderView<Content:View>: View {
    @ObservedObject var router: PageRouter
    let content: Content

    init(router: PageRouter, @ViewBuilder content: () -> Content) {
        self.router = router
        self.content = content()
    }

    var body: some View {
        NavigationStack(path: $router.path) {
            content
                .navigationDestination(for: Int.self) { // - 2
                    int in
                    // 遷移先Viewを定義
                }
                .navigationDestination(for: String.self) { // - 1
                    string in
                    // 遷移先のViewを定義
                }
        }
        .environmentObject(router)
    }
}

今回遷移先情報をまとめてるViewです。(名前は適当です)

このViewではNavigationStackとnavigationDestinationを定義していて、ここに遷移先を書いていってまとめてる形になります。ページごとのViewを表示したいため、ラップできるようにViewBuilderにしてます。ViewBuilderに関してあんまり詳しくないので説明が合ってるかは知らん。

各ViewでPageRouterを初期化して渡すため@ObservedObject var router: PageRouterとしてプロパティ定義をしてます。また、子ビューからのページ遷移や他のviewから呼び出せるようにしたいため.environmentObject(router)でEnvironmentObjectとして渡せるようにしてます。
個人的にこのenvironmentObjectの使い方が合ってるのかは疑問ですが、なんか洒落てる感出てる気がする..!

DestinationHolderView呼び出し

HomeView.swift
  @StateObject var router = PageRouter()

  var body: some View {
    DestinationHolderView(router: router) {
      // ... 表示する中身
    }
  }

ページ遷移を使いたい一番親のViewでStateObjectとして初期化してDestinationHolderViewに渡します。

子ビューで遷移呼び出し

ChildView.swift
struct ChildView: View {
  @EnvironmentObject var router: PageRouter

  var body: some View {
    VStack {
        Button(action: {
          router.path.append("value") // - 1
        }) {
          Text("Textで遷移する")
        }
        NavigationLink(value: 32) { // - 2
          Text("Intで遷移する")
        }
      }
    }
  }
}

DestinationHolderView内部でenvironmentObjectにrouterの情報が渡るので、子のChildViewでNavigationPathやNavigationLinkを使って遷移できるようになります。

  1. NavigationPathで遷移(String.selfに値が渡る)
  2. NavigationLinkで遷移(Int.selfに値が渡る)

EnvironmentObjectだとDestinationHolderViewが使われてない場合呼び出せないので、わかりづらい気もするが他にいい方法わからん

NavigationPathかNavigationLinkどっちを使って遷移するか?

個人的にどちらでもいいかなと思うのですが、pathで遷移するやり方だと他の処理を埋め込める、valueだとenviornmentObjectで定義しなくても使えるけど処理を埋め込めないくらいの違いで使い分けてます。細かいところだともう少しあるのかも?

違いを表示してみた

違い NavigationPath NavigationLink
呼び出し EnvironmentObject(router)の定義が必要 必要ない
処理できるか できる 多分できない

PageRouter

PageRouter.swift
final class PageRouter: ObservableObject {
    @Published var path = NavigationPath()
    // ... 省略
}

Pathを保持してるPageRouterではただ単にpathプロパティにNavigationPath()を入れてるだけです

NavigationPathを使用すると、型消去されて違う型でもpathの中に突っ込んでいけるので今回の例だとString, Intどちらも突っ込める感じです。他の構造体でもHashableに準拠してたらappendメソッドで追加できるみたいです。

注意点

sheetの中で遷移したい場合

DestinationHolderViewでの遷移はあくまでページ全体としての遷移を管理するRouterみたいな感じで使いたかったので.sheet modifierなどを使う場合は別途NavigationStackを定義してあげるのがいいかなと思います。

TabViewを囲むと使えない

最初こんな風にTabViewに被せて使ってたのですが、これだとうまくいきません

ContentView.swift
struct ContentView: View {
    @StateObject var router: PageRouter
    var body: some View {
      DestinationHolderView(router: router) {
          TabView(selection: $selection) {
              HomeView()
                  .tabItem {
                      Image("home")
                      Text("home title")
                  }
                  .tag(0)
              OtherView()
                  .tabItem {
                      Image("other")
                      Text("other title)
                  }
                  .tag(1)
              AccountView()
                  .tabItem {
                      Image("account")
                      Text("account title)
                  }
                  .tag(2)
          }
        }
    }
}

正確には遷移自体は別にできるのですが、TabViewで表示してくれるアプリ下に出るTabアイテムバーが遷移時に消えていきます。
また、.fullScreenCover modifierからの遷移も同様にTabViewが消えてしまうため、fullScreenCoverで遷移する場合は注意したほうがよさそう。

そのため、各ページでPageRouterを初期化して渡すようにしました。このやり方だとTabViewでページを切り替えても表示されたままで、Path情報が保持されるようになるためです。

fullScreenCoverからの遷移でTabViewのアイテムが消えるのは結局解決できてないですが、fullScreenCoverではなく遷移ビューにしてしまえばいいため特に困ってないので一旦放置しました。

Listなどの要素内ではnavigationDestinationが使えない

Listの要素内でnavigationDestinationを記載するとエラーになります。
https://developer.apple.com/documentation/swiftui/view/navigationdestination(ispresented:destination:)

Do not put a navigation destination modifier inside a “lazy” container, like List or LazyVStack. These containers create child views only when needed to render on screen. Add the navigation destination modifier outside these containers so that the navigation stack can always see the destination.

遅延読み込みするタイプのものにnavigationDestinationを記載してしまうと、要素がロードされないと表示されないため書けないみたいです。

navigationDestinationの仕様上どうなってるのかいまいちわかってないですが、navigationDestinationが複数あると先に呼び出されるほうが使われてそう?
なので例えば子ビューのNavigationLinkに関しては、この子ビューに別のNavigationStackがあったりすると適切に呼び出せなくなります。

ChildView.swift
  var body: some View {
    NavigationStack {
      VStack {
        NavigationLink(value: 32) { // - 2
          Text("Intで遷移する")
        }
      }
    }
  }

この場合だとDestinationHolderViewのnavigationDestinationが呼ばれず、またNavigationLinkの遷移先が見当たらないのでリンクが無効になる。

参考

終わりに

最近個人開発を始めたので良かったらフォローしてください✨
https://twitter.com/kcash510

Discussion