📛

iOS 16からのNavigation(画面遷移)のベストプラクティス(NavigationStack)

2023/06/26に公開3
ベストプラクティスと言い切る

ベストプラクティスって言うと怖いんですが、勇気を持って言い切っています(前置き逃げ)

ベストプラクティス

@EnvironmentObjectnavigationDestination(for:destination:)を主に使用し構成していきます。

実装のポイント

1. NavigationのItemはenumで扱う

navigationDestination(for:destination:)は遷移アイテムを全て設定しなければいけないのですが、設定し忘れてしまうと遷移できません。

そこでenumを使うことでより見通しの良く、設定し忘れなく遷移先を設定することができます。

2. NavigationPathを@EnvironmentObjectで扱う()

どの子Viewでも画面遷移する可能性はあり、全てのViewに対してBindingで渡すことはあまりにも不便です。

そこで@EnvironmentObjectを使うことで、大元で一度セットするだけで全ての子Viewで画面遷移が利用可能になります。

実装

final class NavigationRouter: ObservableObject {
  @MainActor @Published var items: [Item] = []
  
  enum Item: Hashable {
    case sub1(id: Int)
    case sub2(id: Int)
  }
}

struct ContentView: View {
  @StateObject var router = NavigationRouter()
  
  var body: some View {
    NavigationStack(path: $router.items) {
      List {
        Button("SubView1") {
          router.items.append(.sub1(id: 1))
        }
        Button("SubView2") {
          router.items.append(.sub2(id: 1))
        }
      }
      .navigationDestination(for: NavigationRouter.Item.self) { item in
        switch item {
        case .sub1(id: let id):
          SubView1(id: id)
            .navigationTitle("SubView1(\(id))")
        case .sub2(id: let id):
          SubView2(id: id)
            .navigationTitle("SubView2(\(id))")
        }
      }
      .navigationTitle("Main")
    }
    // NavigationStackの中のViewに適用ではなく、NavigationStack自体に、environmentObjectを設定する
    .environmentObject(router)
  }
}

struct SubView1: View {
  @EnvironmentObject var router: NavigationRouter
  let id: Int
  
  var body: some View {
    List {
      Button("SubView2") {
        router.items.append(.sub2(id: id + 1))
      }
      
      Button("Back") {
        router.items.removeLast()
      }
    }
  }
}

struct SubView2: View {
  @EnvironmentObject var router: NavigationRouter
  let id: Int
  
  var body: some View {
    List {
      Button("SubView1") {
        router.items.append(.sub1(id: id + 1))
      }
      Button("Back") {
        router.items.removeLast()
      }
    }
  }
}

できないこと(ViewModelが画面遷移が持てない)

@EnvironmentObjectを使っているため、ViewModelに画面遷移を持たせづらいです。
持たせづらいだけでできない事はないです。

final class SubView3Model: ObservableObject {
  private func doSomething1() throws { }
  
  func doSomething(push: (NavigationRouter.Item) -> ()) {
    do {
      try doSomething1()
      push(.sub1(id: 1))
      try doSomething2()
    } catch {
      print(error)
    }
  }
}

struct SubView3: View {
  @EnvironmentObject var router: NavigationRouter
  @StateObject var viewModel = SubView3Model()
  
  var body: some View {
    Button("DO SOMETHING") {
      viewModel.doSomething { item in
        router.items.append(item)
      }
    }
  }
}

Tips 1: ViewModelの初期化

アプリのアーキテクチャでViewModel的なものを採用している場合switch case内でViewModelの初期化が可能です。

.navigationDestination(for: NavigationRouter.Item.self) { item in
  switch item {
  ...
  case .sub4(id: let id):
    // ここでViewModelの初期化
    let viewModel = SubView4Model(id: id)
    SubView4(viewModel: viewModel)
      .navigationTitle("SubView4(\(id))")
  }
}

final class SubView4Model: ObservableObject {
  let id: Int
  
  @Published var isPresented: Bool

  init(id: Int) {
    self.id = id
    self.isPresented = false
  }
}

struct SubView4: View {
  @EnvironmentObject var router: NavigationRouter
  @StateObject var viewModel: SubView4Model
  
  var body: some View {
    ....
  }
}

Discussion

mizumizu

こんにちは。参考になる記事をありがとうございます。
質問ですが、遷移先のViewに@Bindingがあった場合、どのように変数を渡すのが良いと思いますか?

zundazunda

一応ItemにBinding<T>を持たせることで@Bindingを渡すことができました。

enum Item: Hashable {
    case sub1(id: Int, text: Binding<String>)
    case sub2(id: Int)
  }

ただBinding自体がHashableに準拠していないので、ItemをHashableに準拠させるために、こういったextensionを用意しておくといいかもしれません。(全てのケースでうまく動くか微妙ですが...)

extension Binding: Equatable where Value: Equatable {
  public static func == (lhs: Binding<Value>, rhs: Binding<Value>) -> Bool {
    lhs.wrappedValue == rhs.wrappedValue
  }
}

extension Binding: Hashable where Value: Hashable {
  public func hash(into hasher: inout Hasher) {
    hasher.combine(self.wrappedValue) 
  }
}
mizumizu

返信ありがとうございました。
家に帰ったら、挑戦してみます。