🈁

SwiftUIの直感的に画面遷移をできるライブラリを作りました

2024/03/08に公開

スターください

NavigationControllerというSwift Packageを公開したのでスターください。これより以降はおまけの記事です

iOS16からNavigationStackが追加されて、遷移の形跡を状態に持つことが出来てNavigationLinkを介さずに画面遷移を処理できるようになりました

struct ContentView: View {
  @State var path = NavigationPath()

  var body: some View {
    NavigationStack(path: $path) {
      StandardNavigationDemo()
    }
    .onChange(of: path) { oldValue, newValue in
      print("path changed. count: \(path.count)")
    }
  }
}

enum Route: Hashable {
  case item(id: Int)
  case product(id: Int)
}

struct StandardNavigationDemo: View {
  var body: some View {
    ForEach(0...10, id: \.self) { index in
      if index % 2 == 0 {
        NavigationLink("index:\(index)", value: Route.item(id: index))
      } else {
        NavigationLink("index:\(index)", value: Route.product(id: index))
      }
    }
    .navigationDestination(for: Route.self) { route in
      switch route {
      case .item(let id):
        ItemPage(id: id)
      case .product(id: let id):
        ProductPage(id: id)
      }
    }
  }
}

struct ItemPage: View {
  let id: Int

  var body: some View {
    Text("ItemPage \(id)")
  }
}

struct ProductPage: View {
  let id: Int

  var body: some View {
    Text("ProductPage \(id)")
  }
}

NavigationStackやそれに伴う仕組みの変化の解説は省略しますが、関連した他の重要なキーワードは NavigationPath.navigationDestination, そして NavigationLinkvalue 付きのinitializerでしょう。このコードを実際に動かし画面遷移することで path.count の変更がprintされるコードになっています。

このpath変数は直接追加・削除といった操作もできてNavigationLinkに頼らない画面遷移もできるようになっています。便利

不便だと思ったこと

今後はNavigationViewではなくてNavigationStackを使用する場面も圧倒的に増えるでしょう。
NavigataionStackの登場により画面遷移がより便利になりました。例えばURL Schemaからのアプリ起動のルーティングを考えた場合、今まではあまり適切な方法がありませんでしたがこれからはNavigationStackを使うのが。一方、実際使ってみると想像していたよりも使用感にギャップを感じました。

直面した課題

例えば、以前まではNavigationLinkの destination: に遷移先のViewを書くので書き忘れが発生しませんでしたが、 .navigationDestination の書き忘れや、このmodifierを宣言する位置によっては空振り(実際には変な画面に遷移します)が発生する可能性があります。

// 今までの書き方はLinkと遷移先のViewの距離が近い
NavigationLink {
  DestinationView(id: id)
} label: {
  Text("Next")
}

// Navigation(value:)の書き方
VStack {
  NavigationLink("Next", value: id)
}
// どこかにある.navigationDestinationを見ると遷移先がわかる
...
.navigationDestination(for: DestinationID.self) { id in
  DestinationView(id: id)
}

次に NavigaitonPath の状態を編集して画面遷移を実現する選択をとったとき、すべて NavigationPath を通した画面遷移か、.navigationDestination を通した画面遷移にしないと上手くいかないです。言い換えると以前までの NavigationLink(destination:...) の画面遷移を使用しない選択を取ることになります。ぱっと見動くことが多いのですが、NavigationLink(destination:) の遷移後に、NavigationPath#removeLastを使用して画面を1つ戻る操作をすると2つ戻ったりします。 実は NavigationLink(destination:) 経由の遷移は NavigationPathに追加されないため、.removeLast をしたら2つ前の画面のpathが削除されます。この変化により2つ前の画面に戻ったりします

// NavigationPathを用意してNavigationStackにBinding
@State var path = NavigationPath()
NavigationStack(path: $path) {...

// この書き方だとpathに変化が起きない。要素数の変化なし
NavigationLink {
  DestinationView(id: id)
} label: {
  Text("Next")
}
.onChange(of: path) { oldValue, newValue in
  // 出力されない
  print(oldValue.count, newValue.count)
}

// この書き方だとpathに変化が起きる
VStack {
  NavigationLink("Next", value: id)
}
.onChange(of: path) { oldValue, newValue in
  // oldValue.count < newValue.count で出力
  print(oldValue.count, newValue.count)
}

移行時、移行後の問題点

ここまで聞いて1つ目の書き忘れの問題を防ぐ、さらにいくつかある .navigationDestination 系のmodifierに統一感と利便性を持たせるために NavigationLink(value:) による画面遷移用の enum の用意を思いつく人が多いと思います。賢明なアプローチだと思います。上述してある例の enum Route のような感じですね。そしてそれを単一の .navigationDestination に書き、switchを書く案が思いつくと思います。そしてすべての NavigationLink(destination:)NavigationLink(value:) に置き換えて、.navigationDestination に書き加えていく作業が発生すると思います。

ただ置き換えが機械的にできるわけではなく、置き換えの量に左右されますが大変なのと、置き換えた後も画面遷移のためにRouteにcaseを追加して、.navigationDestination に追加して、 という作業が実現したいことに関して手間に感じました。あと、NavigationLink(destination:) は遷移先のViewが一目瞭然。ちょっとしたViewに渡す変数の追加が柔軟にできる等の利点はありますが、それはNavigationStackでの画面遷移では実現が難しいです。Viewに渡す引数が増えるたびに .navigationDestination に届けるためにenumのassociatedValueも追加する必要があります。さらに、ちょっとした調査の時も都度Routeのcaseを覚えて、.navigationDestinationの定義までいき、case名でgrepしてViewを発見してViewのコードにジャンプするといった手間が都度発生することが想像できます。

// このリンク先のページを知りたい
NavigationLink("Next", value: Route.product(id: id))

// まずはここに定義ジャンプ
enum Route: Hashable {
  case item(id: Int)
  case product(id: Int)
}

// ここをなんとか探して、.productの内容を見る。そしてViewが判明する
.navigationDestination(for: Route.self) { route in
  switch route {
  case .item(let id):
    ItemPage(id: id)
  case .product(id: let id):
    ProductPage(id: id)
  }
}

ここまでで思ったことが NavigationLink(destination:) のように画面遷移先がViewのコードを見てぱっと見でわかり、Viewに素直にコードジャンプしたい。画面遷移先を増やすたびにenumのcaseやそれを埋める処理は書きたくない。でした

そこで作ったのが NavigationControllerでした

基本的な使い方だけ説明すれば全て理解できると思います。大体のclassやプロパティも公開しているので細かい制御をもしご希望の方はそれように書くこともできるはずです(細かいユースケースは思いついてない)

まず本来 NavigationStack(path:) を宣言したいViewに .withNavigation modifierをつけます。

struct ContentView: View {
  var body: some View {
    RootView()
      .withNavigation()
  }
}

.withNavigationのあとは@EnvironmentObject var navigationController: NavigationControllerが宣言できます。これを通じて画面遷移の操作を行います

struct RootView: View {
  // Retrieve navigationController via EnvironmentObject
  @EnvironmentObject var navigationController: NavigationController

  var body: some View {
    VStack {
      // Use navigationController ...
    }
  }
}

pushによる画面遷移の書き方です

Button {
  navigationController.push { 
    YourDestinationView() 
  }
} label: {
  Text("Push")
}

popによる画面遷移の書き方です

Button {
  navigationController.pop()
} label: {
  Text("Pop")
}

NavigationPath を公開しているので直接操作したい人はこれを利用します

Button {
  navigationController.path.removeLast()
} label: {
  Text("Edit path and pop")
}

まとめ

実は1ファイルで収まる程度の内容です。なので中が気になる方は覗くなり、Packageとして入れるまでもないと思う方は参考にしてプロジェクトに組み込むのも良いと思います。ObservableObject なのは単純にiOS16対応のためです。Observable に置き換えたい人はそれも良いでしょう

個人アプリのFocusというアプリで使用していたコードを今回はOSSにしました。 Focusの方もいわゆるScreenTime APIを使用した珍しいアプリになっているので気になる方はぜひ使ってください。

NavigationStackについて参考になった。NavigationStackを使えるようになったら参考にしたい。NavigationStack使っているけどこのアプローチ良さそう。NavigationController使うぜ。そんな方々もそうじゃない人たちもぜひNavigationControllerにスターください ⭐️

おしまい\(^o^)/

Discussion