14
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SwiftUI の NavigationStack を活用する[追記あり]

Last updated at Posted at 2023-08-21

はじめに

以前、Qiitaにて、SwiftUI の NavigationLink(destination:,isActive:) を活用する
という記事を公開したのですが、その後、NavigationView も、 NavigationLink(destination:,isActive:)も、iOS16以降で deprecated になってしまいました。

NavigationViewの後継として登場したのが、NavigationStackです。
NavigationStackによって、画面遷移を直接的に管理することが可能になり、より「宣言的」に記述できるようになりました。

上記記事でも書いたように、個人開発のオリジナルアプリは画面遷移を多用しているのですが、これもNavigationStack で全て書き直しました(公開準備中)。
本稿では、その時の知見をもとに、NavigationStackのやや実践的な活用方法について説明したいと思います。

開発環境等

  • MacBook Air (M1 2020)
  • macOS Ventura 13.4.1 (c)
  • Xcode Version 14.3.1 (14E300c)
  • iOS 16.0

1階層の遷移

図1のようにTeamリストからひとつのTeamを選び、そのデータを渡して子Viewに遷移する方法を説明します。

‎NavTestAppFigures.‎001.png

1階層分だけの遷移であれば実はもう少しシンプルに実装できるのですが、後で複数階層の遷移を説明する準備のためにここではあえて回りくどい実装を説明しています。
なお、サンプルコードでは明示していませんが、teams は表示すべき Team型データが含まれた配列としています。

TeamListView.swift
    struct TeamListView: View {
        @State private var path = [Team]() // (1) 画面遷移管理配列

        var body: some View {
            NavigationStack(path: $path) { // (2) NavigationStack に画面遷移管理配列を設定
                List {
                    ForEach(teams) { team in
                        NavigationLink(value: team) { // (3) 遷移先に渡すデータを設定
                            Text(team.name)
                        }
                    }
                }
                .navigationTitle("Team List")
                .navigationDestination(for: Team.self) { team in // (4) 遷移先を設定
                    TeamView(team: team, path: $path)
            }
        }
    }

(1) 画面遷移管理用の配列を用意しておきます。
(2) NavigationStack(path:)で、上記の配列を画面遷移管理配列として使うよう設定しています。

この画面遷移管理配列はNavigationStackでは大きな役割を果たします。
この配列は画面遷移の履歴を保持するためのものですが、逆にこの配列を操作(データの追加、削除)することによって、画面遷移をコントロールすることができます。
さらに、親Viewから子Viewに何らかのデータを受け渡す場合、そのデータはこの配列に格納されます。
配列というデータ構造を使いますが、「スタック」とみなして、Last-In-First-Outでデータの追加・削除を行います。

ここでは、TeamListView → TeamView という画面遷移時に Team型のデータを受け渡しているので、画面遷移管理配列はTeam型の配列となります。

(3) NavigationLink(value:)によって、遷移先の子Viewに受け渡すデータを設定しています。
リストのセルがタップされた時に、画面遷移が実行されると同時に、このデータが画面遷移管理配列に追加されます。

(4) .navigationDestination(for:)で遷移先のViewを指定しています。
遷移先のViewでNavigationLink(value:)で指定しておいたデータを受け取ることができます。
.navigationDestination(for:) の引数はデータ自体ではなく「型」であることには注意してください(詳しくは後述)。

従来のNavigationViewを使った記述より、一貫して「宣言的」な記述になっている点にも注目してください。

遷移先の子View(TeamView)のサンプルです。

TeamView.swift
    struct TeamView: View {
        var team: Team
        @Binding var path: [Team]

        var body: some View {
            VStack {
                Text(team.name)
                    .font(.title)

                Button(action: { // 〈戻る〉ボタン
                    path.removeLast()
                }) {
                    Text("Back")
                }
            }
            .navigationTitle(team.name)

        }
    }

ボタンをタップすると画面遷移配列の末尾のデータが削除され(path.removeLast())、前の画面に戻ることになります。

複数階層の遷移

次に、複数階層に渡って画面遷移する場合を考えてみます。
Team には複数の Member が含まれており、このデータを図2のように2段階の遷移をするViewでブラウズすることにします。
また、MemberViewから一気にTeamListViewに戻るボタンもつけることにします。

‎NavTestAppFigures.‎002.png

NavigationStack を起点とした一連の遷移においては、画面遷移管理配列には単一の型しか格納できません。
このように最初の遷移と2段目の遷移で異なる型のデータを渡したい時にはひと工夫が必要になります。
ここでは、associated value 付き enum を使うことにします。

Teamクラス、Memberクラスを格納できる Item をこのように定義しておきます。

Item.swift
    enum Item: Hashable {
        case team(Team)
        case member(Member)
    
    // Hashableプロトコルに必要なメソッドを記述(略)
    }

この配列を画面遷移管理用配列として NavigationStack に設定します。
親Viewはこんな感じ。

TeamListView.swift
    struct TeamListView: View {
        @State private var path = [Item]() // (1) 画面遷移管理配列

        var body: some View {
            NavigationStack(path: $path) { // (2) NavigationStack に画面遷移管理配列を設定
                List {
                    ForEach(teams) { team in
                        NavigationLink(value: Item.team(team)) { // (3) 遷移先に渡すデータを設定
                            Text(team.name)
                        }
                    }
                }
                .navigationTitle("Team List")
                .navigationDestination(for: Item.self) { item in // (4) 遷移先を設定
                    switch item {
                    case .team(let team):
                        TeamView(team: team, path: $path)
    
                    case .member(let member):
                        MemberView(member: member, path: $path)
                    }
                }
            }
        }
    }

(3)で、遷移先にTeam型のデータを渡したいのですが、いったん associated value として Item の皮を被せています。
(4)では、一度に複数の遷移先を設定していることに注意してください。後述します。

子Viewはこうなります。

TeamView.swift
    struct TeamView: View {
        var team: Team
        @Binding var path: [Item]

        var body: some View {
            VStack {
                Text(team.name)
                    .font(.title)

                List {
                    ForEach(team.members) { member in
                        NavigationLink(value: Item.member(member)) { // (5) 孫Viewに渡すデータを設定
                            Text("\(member.name)")
                        }
                    }
                }

                Button(action: { // 〈戻る〉ボタン
                    path.removeLast()
                }) {
                    Text("Back")
                }
            }
        }
    }

(5) 孫View(MemberView)に Member型データを渡したいのですが、ここでも Item の皮を被せています。
このように emum の associated value を利用することで、単一のItem型の配列を使いながら、Team型と、Member型のように異なる型のデータを遷移先に渡すことができるようになります。

さて、TeamView → MemberView への遷移に関して、普通に考えれば、TeamView に下記のように .navigationDestination(for:) を記述すれば良いと思うのですが、これはうまくいきません。

TeamView.swift
    struct TeamView: View {
        var team: Team
        @Binding var path: [Item]

        var body: some View {
            VStack {
                ...
            }
            .navigationDestination(for: Item.self) { item in
                if case Item.member(let member) = item { // Item.member から associated value を取得する
                    MemberView(member: member, path: $path)
                }
            }
        }
    }

.navigationDestination(for:)は、引数に「型」を取っていることに注意してください。
この画面遷移ツリーの中で、TeamListView と TeamView の両方に .navigationDestination(for: Item.self) が存在している場合、
親View側、つまりTeamListViewの.navigationDestination(for: Item.self) の方が先に「Item型」にマッチしてしまうので、
子Viwe側、TeamViewの方の .navigationDestination(for: Item.self) は有効になることがないのです。

よって、上記のように「親 → 子 → 孫」というように複数階層の画面遷移がある場合、「子 → 孫」の遷移の分も親Viewの .navigationDesination(for:) にまとめて記述する必要があります。
私自身がハマってしまったポイントですので、ここで強調しておきます。

TeamListView まで一気に戻るボタンをつけた MemberView はこんな感じです。

TeamView.swift
    struct MemberView: View {
        var member: Member
        @Binding var path: [Item]

        var body: some View {
            VStack {
                Text("\(member.name)")

                Button(action: {
                    path.removeLast()
                }) {
                    Text("Back")
                }
    
                Button(action: {
                    path.removeAll()
                }) {
                    Text("Back to Top")
                }
            }
        }
    }

おわりに

Swift、SwiftUI は進化が早くて、せっかく書いた記事の内容がすぐ古くなってしまい徒労を感じることも多いのですが、気を取り直して、NavigationView 後継の NavigationStack についての記事を書きました。
少しでも参考になれば幸いです。

補足(1) 画面遷移管理配列を使い回す

上記サンプルコードでは、画面遷移管理配列を、各Viewの引数として受け取り、受け渡していますが、Viewの入れ子が複雑になってくると煩雑になります。

そこで下記のように、ObservableObject に押し込み、

    class MyNavigation: ObservableObject {
        @Published var path = [Item]()
    }

各View では environment object として使い回すのが楽です。

TeamListView.swift
    struct TeamListView: View {
        @EnvironmentObject var myNavigation: MyNavigation // (1) 画面遷移管理配列を保持しているEnvironmentObject

        var body: some View {
            NavigationStack(path: $myNavigation.path) { // (2) NavigationStack に画面遷移管理配列を設定
                ...(以下略)...

さらに複数のTabがあるアプリでは、Tabごとに画面遷移の系列が並行して存在することになるので、以下のようにするのが楽です。

    class MyNavigation: ObservableObject {
        @Published var pathForTabA = [Item]()
        @Published var pathForTabB = [Team]()
    }

補足(2) パンくずリスト

画面遷移を多段階に渡って行うアプリでは、「パンくずリスト」をつけたくなります。
NavigationView を使っていた時は、別途、遷移履歴を記録し、View間で使い回す必要がありましたが、NavigationStack では、画面遷移管理配列をそのまま使えば良いので、たとえば以下のように書くだけです。

    HStack {
        ForEach(path, id: \.self) { item in
            Text(">"+item.name()) // item の中身が Team でも Member でもその name を返すメソッド
        }
    }

それぞれの項目をボタンにして、path.removeLast(Int) を使えば任意の階層に戻る機能も簡単に実装できます。

補足(3) 画面遷移管理配列操作の注意点(2023年8月31日追記)

上記では、画面遷移管理配列は、スタックとしてLast-In-First-Outとなるよう操作すべきと書きましたが、この原則を守らずに配列の途中の要素を削除したらどうなるのでしょうか?

実際にコードを書いて試してみたところ、画面遷移を戻っていく過程で、該当する画面がスキップされました。
想定通りの挙動ではありますが、ユーザーとしてはわかりにくい動作となるので、実際のアプリでこのような操作を行うには注意が必要だと思います。

14
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?