はじめに
どうも、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があります。
公式ドキュメント
2. サンプルコードについて
今回の記事で紹介しているコードを使ったサンプルプロジェクトを作成しています。
プロジェクトの実装内容は以下の通りです。
- リンクをタップするとタップしたデータを持って次の画面にプッシュ遷移する
- 次の画面で前画面で選択した文字を表示する
- 画面構成は全部で4画面
- 画面を跨いで進む遷移機能が最初の画面にある
- 画面を跨いで戻る遷移機能が最後の画面にある
- あえてダメな例も載せている
サンプルコードを以下のリンクに載せておきます。
Gitgist(コードのみ) → PracticeNavigationStack.swift
GitHubリンクはこちら
記事内ではコードを抜粋していますので、合わせて上記のリンクをご覧いただければと思います。
またGifだとわかりづらいので、ぜひお手元で動かしてみてください。
3. NavigationLinkを使った通常の遷移
ひとまず、ただ単にプッシュ遷移させる場合はNavigationLink(destination:label:)
を使用します。
- 親画面、最初の画面で
NavigationStack
を宣言します -
NavigationStack
内でNavigationLink(destination:label:)
を宣言します - 引数
destination:
に遷移先のViewのインスタンスを渡します - 引数
label:
は表示したいラベルです
NavigationsStack
の宣言は親画面のみで良いです。
子画面や孫画面では宣言しません。
var body: some View {
NavigationStack {
NavigationLink {
//ここに遷移させたいViewのインスタンスを渡す
IPadSelectView()
} label: {
//ここに表示させたいテキストを書く
Text("次の画面へ")
}
} // NavigationStack
} // body
今回はNavigationStackのオプションで(path:)
を使っているので、フッターのテキストにあるように次画面での遷移処理に不具合が生じます。
本来(path:)
を指定しなければ子画面でNavigationLink(destination:label:)
を宣言すれば
子画面→孫画面と画面遷移させる実装は可能です。
4. NavigationLink + NavigationPathを使った遷移
3-1. NavigationPathとは?
NavigationPathとは、簡単に説明するとある画面からある画面への
遷移記録のようなものです。
親画面から子画面に遷移する際の通行記録をNavigationPath構造体に
記録していきます。
3-2. NavigationPathを使うための下準備
NavigationPathに記録する値はHashable
プロトコルに準拠していなければなりません。
Hashable
プロトコルとは簡単にいうとハッシュ値を使ってデータを高速に検索できる機能を持たせるプロトコルです。
簡単にまとめた私のnotionリンクを載せておきますので、詳しくはこちらを参考にしてみください。
なので、今回の場合はまず各画面毎に対応するHashableプロトコルに対応したenumを定義します。
/// 選択した名称で使えるようにStringに準拠
/// 尚且つNavigationPathで使えるようにHashableに準拠したStringに準拠する
enum MacType: String {
case MacBook, iMac, MacStudio
}
ここで注意点としては先に述べたように一つの画面につき、一つのenumを定義しなければなりません。
上記のenum MacTypeをA画面とB画面の両方では使えないということです。
3-3. 親画面でNavigationPathを宣言する
大元の画面である親画面でNavigationPath
をプロパティとして宣言します。
この時、@State
を付与しましょう。また、バインディング以外での変更を
されないようにアクセス修飾子にprivate
もつけましょう。
/// 親画面
struct MacSelectView: View {
/// 大元のViewでNavigationPathを@ Stateで宣言して、子Viewにバインディングしていく
@State private var path: NavigationPath = NavigationPath()
そしてNavigationStackの引数(path:)に宣言したプロパティを$
付きで宣言します。
var body: some View {
NavigationStack(path: $path) { // ここで使用するパスを設定
3-4. NavigationLinkを宣言する
次に今回はListにNavigationLinkを貼り付けていきます。
ForEachを使ってあらかじめ定義したstruct Macで作ったダミー配列、modelsを利用します。
ここは実際にはRealmやFireStoreのデータになることも多いでしょう。
/// 選択した名称で使えるように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を使うのかを
指定しているのと、
リンクに表示するラベルを定義しているだけです。
遷移先の宣言は後述します。
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:
が変わるように実装しています。
} // 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
}
ちなみに次画面はこのようなプロパティです。
struct IPhoneSelectView:View {
@Binding var path: NavigationPath
var selectedMac: MacType
// 省略
}
今回はpathが違っても同じ画面にしていますが、当然別々の画面にもできます。
また遷移のpathが一つの場合はswitch文による分岐も不要です。
.navigationDestination(for: MacType.self) { _ in
IPadSelectView(path: $path, selectedMac: .MacBook)
} // navigationDestination
3-6. 小画面にも設定していく
あとはほぼ同じように小画面にも実装していきます。
親画面と異なる点は以下の通りです。
- pathは
@Binding
で親画面からバインディングする
/// 2つ目の画面
struct IPadSelectView:View {
/// パスをバインディング
@Binding var path: NavigationPath
- NavigationStackは宣言しない
var body: some View {
List {
Section {
- 親画面とは異なるenumを使用する
enum IPadType: String, Hashable {
case Mini, Air, Pro
}
4. 画面を跨いだ遷移
プッシュ遷移の場合、4つの画面に潜っていくとすると、
A画面→B画面→C画面→D画面と進みます。
戻る時はD画面→C画面→B画面→A画面と遷移します。
これをA画面→D画面や逆にD画面→A画面のように跨いで遷移する方法が
NavigationPathを使えば可能です。
4-1. 跨いで進む遷移
NavigationPathはデータ型の配列となっています。
なので、各画面に設定しているpathをappend
関数で配列に追加してあげれば
任意の画面まで一気に遷移させることが可能です。
更に言うと、NavigationLink
と.navigationDestination(for:)
使用せず、
pathを追加させる処理だけで遷移機能を実装することも可能です。
/// 親画面
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
}
最後のボタンで書いてあるように任意の画面まで遷移させる際に
途中にあるpathを追加し忘れると遷移できなくなります。
途中のpathは全て追加しなければ正常に機能しません。
Button("最後の画面へ飛ぶSEだけ選択(バグる)") {
// バグります
path.append(IPhoneType.SE)
}
.foregroundStyle(.red)
4-2. 跨いで戻る遷移
こちらは進むのとは逆に配列からpathを削除すれば戻すことができます。
専用のremoveLast()
関数を使って配列を操作します。
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) // 最後とその前に追加したパスを削除する、つまり2つ画面を閉じる
}
Button("最初に戻る Root") {
// パスに格納されている分だけ削除、つまり全部削除、つまり全部閉じる
path.removeLast(path.count)
}
Button("dismissで閉じる") {
dismiss() // この画面を閉じる
}
}
.navigationTitle("Last")
} // VStack
} // body
}
今回の実装とは関係ありませんが、画面を閉じる実装の一つに
環境変数である@Environment(\.dismiss) var dismiss
を使う方法があります。
この環境変数を宣言してdismiss()
関数を実行すれば現在開いている画面を
閉じることができます。
終わりに
いかがだったでしょうか?
NavigationPathの仕組みを理解する上で、Hashable
プロトコルを学習したり
NavigationStackの仕様を調べたりと大変学びにつながりました。
一方で、仕様を理解した上で実装しないと思うような挙動になりません。
私はこれを理解して実装するまでかなり苦戦しました。
この記事がNavigationPathの実装に悩む方々に、少しでもお役にたてれば幸いです。
参考記事