【SwiftUI】画面遷移を実装する【NavigationStack】

ロボット

この記事ではiOS16から利用可能になったNavigationStackを使用した画面遷移について解説しています。

iOS16からの画面遷移

WWDC2022で新しい画面遷移用のAPIであるNavigationStackが発表されました。

iOS16からは従来のNavigationViewが非推奨になり、NavigationStackを使用することが推奨されています。

まずはNavigationStack導入の背景から見ていきましょう。

NavigationStack導入の背景

従来のNavigationViewが非推奨になり、新しくNavigationStackが推奨されるようになったわけですが、なぜNavigationViewは非推奨になってしまったのでしょうか。

従来のNavigationViewにはコードによる画面遷移の実装が難しいという問題があり、複雑なディープリンクなどの実装が難しくなっていました。

それに対してNavigationStackの場合には配列を操作するのと同じ感覚で画面遷移が実装できるため、複雑なディープリンクなども楽に実装できるようになっています。

NavigationViewは以下のようにbody内に配置されたNavigationLinkがタップされた場合やisActiveによって画面遷移を実現していました。

// NavigationLinkタップ時に遷移する場合
var body: some View {
    NavigationView {
        NavigationLink(destination: Text("Destination")) {
            Text("Label")
        }
    }
}
// isActiveによって遷移する場合
@State private var isActive = false
var body: some View {
    NavigationView {
        VStack {
            NavigationLink(destination: Text("Destination"), isActive: $isActive) {
                EmptyView()
            }

            Button {
                isActive = true
            } label: {
                Text("Label")
            }
        }
    }
}

NavigationViewはこのようにViewの表示時にしか画面遷移が実行されないため、コードでの複雑なナビゲーションを実装することが難しくなっていました。

一方でNavigationStackを使用すると以下のように配列を操作するようにナビゲーションを実装できるため、ディープリンクなどの複雑なナビゲーションをコードで実装しやすくなっています。

@State private var path = [String]()
var body: some View {
    NavigationStack(path: $path) {
        VStack {
        }
        .navigationDestination(for: String.self) {
            Text($0)
        }
    }
}

// 特定の画面に遷移する
func showSpecificScreen() {
    path = ["Screen1", "Screen2"]
}

// 最初の画面に戻る
func popToRoot() {
    path.removeAll()
}

NavigationStackの実装方法

ここからは実際にNavigationStackの実装方法を見ていきましょう。

ロボット

この仕様を満たすアプリを考えます。

  • 最初に動物の絵文字の一覧画面を表示する
  • 動物の絵文字がタップされた場合にサーバからその動物の日本語名を取得する
  • 日本語名が取得できた場合のみ、詳細画面に遷移して日本語名を表示する

一覧画面で選択された動物の絵文字に対する日本語名をサーバから取得し、取得できた場合のみ詳細画面に遷移するため、コードで画面遷移を制御する必要があります。

NavigationViewを使用した実装例

ロボット

NavigationViewを使用する場合は以下のisActiveを持つNavigationLinkを使用します。

NavigationLink(destination: View, isActive: Binding<Bool>, label: () -> View)

isActiveを持つNavigationLinkはisActiveにBinding<Bool>を渡せるようになっていて、isActiveがtrueになった場合にdestinationに設定したViewへ遷移させることができます。

今回の例ではサーバから動物の絵文字に対する日本語名を取得できた場合にisActiveをtrueに変更してあげればコードでの画面遷移が実装できることになります。

実際にコードを見てみましょう。

import SwiftUI

struct ListView: View {
    @State private var isActive = false
    @State private var selectedAnimalName: String?

    let animals = ["🐱", "🐶", "🐥"]

    var body: some View {
        NavigationView {
            NavigationLink(
                destination: Text(selectedAnimalName ?? ""),
                isActive: $isActive
            ) {
                EmptyView()
            }

            List {
                ForEach(animals, id: \.self) { animal in
                    Button {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                            // 実際にはサーバから取得
                            let name = ["🐱": "ねこ", "🐶": "いぬ"]
                            guard let name = name[animal] else {
                                return
                            }
                            isActive = true
                            selectedAnimalName = name
                        }
                    } label: {
                        Text(animal)
                    }
                }
            }
        }
    }
}

Listで各動物の絵文字を一覧表示し、動物の絵文字がタップされた場合にisActiveをtrueに変更してNavigationLinkのdestinationに指定したTextに遷移しています。

コードで遷移するためのNavigationLinkはListの外に置き、EmptyViewを渡すことで実際には画面に何も描画されないようにしています。

NavigationLinkにEmptyViewを渡してコードで画面遷移する方法は以下の記事で詳しく解説されているのでさらに詳しく知りたい方は読んでみてください。

このようにNavigationViewを使用した方法だと、コードで画面遷移する場合にはbody内に記述したNavigationLinkで制御するため実装が煩雑になります。

NavigationStackを使用した実装例

ここからはNavigationStackを使用した実装例を見ていきます。

NavigationStackを使用した画面遷移にはコードでpathを変更する方法とNavigationLinkをタップする方法があるため順番に見ていきます。

コードでpathを変更する方法での画面遷移

NavigationStackを使用する場合は配列を操作するのと同じ感覚でナビゲーションを実装できます。

NavigationStackを使用した画面遷移を実装する手順は以下のようになります。

  1. NavigationViewをNavigationStackに置き換える
  2. NavigationStackに@Stateを適用したpath(配列)を渡す
  3. navigationDestinationで遷移先を指定する
  4. 画面遷移したいタイミングでpathを変更する

この手順で実装したコードです。

import SwiftUI

struct ListView: View {
    let animals = ["🐱", "🐶", "🐥"]

    @State private var path = [String]()

    var body: some View {
        NavigationStack(path: $path) {
            List {
                ForEach(animals, id: \.self) { animal in
                    Button {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                            // 実際にはサーバから取得
                            let name = ["🐱": "ねこ", "🐶": "いぬ"]
                            guard let name = name[animal] else {
                                return
                            }
                            path.append(name)
                        }
                    } label: {
                        Text(animal)
                    }
                }
            }
            .navigationDestination(for: String.self) { name in
                Text(name)
            }
        }
    }
}

NavigationStackは今までNavigationViewを記述していた場所で使用します。

NavigationStackのinitializerには任意の型のBindingが渡せるようになっていて、今回の例ではStringの配列を渡しています。

このようにNavigationStackに配列のBindingを渡しておくと、配列に値を追加したり、配列から値を削除することで画面遷移を実現させることができます。

今回の例ではDispatchQueue.main.asyncAfterの中のpath.append(name)で値を追加しています。

実際の遷移先はnavigationDestinationで指定します。

navigationDestinationはpathのTypeと遷移先を指定するためのclosureを渡せるようになっています。

今回はpathにStringの配列を渡しているため、navigationDestinationの第一引数にはString.selfを渡し、第2引数のclosureにはpathの値が渡されるため、この値を使用して画面遷移を行います。

今回は動物の絵文字に対する日本語名をそのままpathに追加しているため、この値をclosureで受け取りそのままTextに渡して画面遷移しています。

pathに値を追加する以外にも、配列を操作することで様々な画面遷移を実現することができます。

例えばWWDC2022のThe SwiftUI cookbook for navigationの中では以下の例が紹介されています。

配列に値を代入して遷移する例

func showRecipeOfTheDay() {
    path = [dataModel.recipeOfTheDay]
}

配列に値を代入することで今まで保持していたpathを完全に入れ替えることができます。

配列の値を全て削除してナビゲーションの最初の画面に戻る例

func popToRoot() {
    path.removeAll()
}

ナビゲーションの最初の画面に戻りたい場合はremoveAllして全ての要素を削除するだけで実現できます。

このように完全にコードでナビゲーションを制御できるので複雑なディープリンクなども実装しやすそうですね。

NavigationLinkをタップする方法での画面遷移

ここまではコードでナビゲーションを制御する例を見てきましたが、もちろんNavigationViewと同じようにNavigationLinkをタップすることでナビゲーションを実現することもできます。

NavigationLinkのタップによる画面遷移を実現するには以下のようなvalueを持つNavigationLinkを使用します。

NavigationLink(value: P?, label: () -> Label)

valueを持つNavigationLinkを使用すると、NavigationLinkをタップした際にvalueに設定した値がnavigationDestinationのclosureに渡されるため、この値を使用して画面遷移を実装することができます。

実際にNavigationLinkをタップする方法による画面遷移の実装例を見てみましょう。

struct ListView: View {
    let animals = ["🐱", "🐶", "🐥"]

    @State private var path = [String]()

    var body: some View {
        NavigationStack(path: $path) {
            List {
                ForEach(animals, id: \.self) { animal in
                    NavigationLink(value: animal) {
                        Text(animal)
                    }
                }
            }
            .navigationDestination(for: String.self) { animal in
                Text(animal)
            }
            .onChange(of: path) {
                print($0)
            }
        }
    }
}

この例ではNavigationLinkのvalueに動物の絵文字(animal)を渡しているため、タップした際に動物の絵文字がnavigationDestinationのclosureに渡ってきます。

またこの例でも引き続きNavigationStackにpathを渡しています。

このようにNavigationStackにpathを渡しておくと、NavigationLinkがタップされて画面遷移した際や遷移先からナビゲーションバーの戻るボタンをタップして戻った場合などに自動的にpathに値を追加したり削除したりしてくれます。

onChangeでpathを監視してprintしているため気になる方は実際に実行して確認してみてください。

その他Tips

ナビゲーションの状態を永続化する

WWDC2022ではナビゲーションの状態を永続化する手法が紹介されています。

ナビゲーションの状態を永続化することで一度アプリから離れてまた戻って来た際にリストアすることで同じナビゲーションの状態を復元することができます。

ナビゲーションの永続化には以下の2つを使用します。

  • Codable
  • SceneStorage

また以下の手順でナビゲーションの状態を永続化していきます。

  1. ナビゲーションの状態をModelに移す
  2. ModelをCodableにする
  3. SceneStorageを使用して状態を永続化/リストアする

まずはWWDC2022で紹介されているナビゲーションの永続化に対応する前のコードを見てみましょう。

struct NavigationPathView: View {
    @State private var selectedCategory: Category?
    @State private var path: [Recipe] = []

    var body: some View {
        NavigationSplitView {
            List(Category.allCases, selction: $selectedCategory) { category in
                NavigationLink(category.localizedName, value: category)
            }
            .navigationTitle("Categories")
        } detail: {
            NavigationStack(path: $path) {
                RecipeGrid(category: selectedCategory)
            }
        }
    }
}

このコードでやっているのは以下の2つです。

  • レシピのカテゴリ一覧を表示し、選択されたカテゴリをselectedCategoryに保持する
  • Recipeの配列をpathとしてNavigationStackに渡してナビゲーションする

ここから手順1つ目の「ナビゲーションの状態をModelに移す」を適用していきます。

ナビゲーションの状態をModelに移す

先ほど示したコード内でナビゲーションの状態として保持されているのは以下の2つです。

  • 選択されたカテゴリを保持するselectedCategory
  • NavigationStackのpathとして使用しているpath

この2つを以下のようにNavigationModelに移していきます。

class NavigationModel: ObservableObject {
    @Published var selectedCategory: Category?
    @Published var path: [Recipe] = []
}
struct NavigationPathView: View {
    @StateObject private var navModel = NavigationModel()

    var body: some View {
        NavigationSplitView {
            List(Category.allCases, selction: $navModel.selectedCategory) { category in
                NavigationLink(category.localizedName, value: category)
            }
            .navigationTitle("Categories")
        } detail: {
            NavigationStack(path: $navModel.path) {
                RecipeGrid(category: navModel.selectedCategory)
            }
        }
    }
}

ナビゲーションの状態をNavigationModelに移動し、NavigationModelが保持する情報を参照するように変更しています。

ModelをCodableにする

次に先ほど作成したNavigationModelをCodableにしていきます。

WWDC2022ではナビゲーションの状態としてNavigationModelに移動したpathが保持しているRecipeがDBに保存されている設定だったため以下の点に注意しています。

  • Recipeを保持しているpathをそのまま永続化しない
  • Recipeのidの配列を永続化する

Recipeをそのまま永続化すると、DB内のデータと永続化するデータで2ヶ所に同じ情報を保持することになり、DBを個別に更新できなくなってしまうため、今回はRecipeのidを永続化し、リストア後にDBから取得する流れにしています。

class NavigationModel: ObservableObject {
    @Published var selectedCategory: Category?
    @Published var path: [Recipe] = []

    enum CodingKeys: String, CodingKey {
        case selectedCategory
        case recipePathIds
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(selectedCategory, forKey: .selectedCategory)
        try container.encode(path.map(\.id), forKey: .recipePathIds)
    }

    required init(from decoder: Decoder) throws {
        let contaienr = try decoder.container(keyedBy: CodingKeys.self)
        self.selectedCategory = try container.decodeIfPresent(Category.self, forKey: .selectedCategory)

        let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)
        self.path = recipePathIds.compactMap { DataModel.shared[$0] }
    }
}

また最後に、実装の詳細は省略されていますがNavigationModelのJSONを取得/リストアするためのjsonDataプロパティを追加しています。

class NavigationModel: ObservableObject {
    ...

    var jsonData: Data? { ... }
}

SceneStorageを使用して状態を永続化/リストアする

最後にSceneStorageを使用して状態を永続化/リストアしていきます。

ViewにSceneStorageを付与したdataを追加して型はData?にします。

最初にSceneが作成された時点ではdataは作成されていないためOptionalになっています。

あとはtaskを使用してViewの表示時にdataをリストアして、NavigationModelのobjectWillChangeSequenceで変更を監視し、変更があった際にdataを更新すれば永続化/リストアが実装できます。

struct NavigationPathView: View {
    @StateObject private var navModel = NavigationModel()
    @SceneStorage("navigation") private var data: Data?

    var body: some View {
        NavigationSplitView { ... }
            .task {
                if let data = data {
                    navModel.jsonData = data
                }
                for await _ in navModel.objectWillChangeSequence {
                    data = navModel.jsonData
                }
            }
    }
}

NavigationStackに渡す配列に異なる型を使用する

ここまで見てきた例ではNavigationStackにStringの配列を渡していましたが、IntとStringなど異なる型を渡すためのNavigationPathが用意されています。

実際に例を見てみましょう。

今回もコードでpathを変更する実装方法とNavigationLinkをタップする実装方法どちらも見ていきます。

NavigationPath(NavigationLinkをタップする)

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

    var body: some View {
        NavigationStack(path: $path) {
            HStack {
                NavigationLink(value: 1) {
                    Text("Int")
                }

                NavigationLink(value: "test") {
                    Text("String")
                }
            }
            .navigationDestination(for: Int.self) { int in
                Text(String(int))
            }
            .navigationDestination(for: String.self) { string in
                Text(string)
            }
        }
    }
}

NavigationPathもここまで見てきたString配列のpathのようにNavigationStackのinitializerに渡します。

NavigationPathからは完全に型が消去されているためHashableに準拠した任意の型を追加することができます。

今回の例ではIntとStringを持つNavigationLinkがタップされた場合にそれぞれIntとStringがNavigationPathに追加されます。

Hashableに準拠した型であれば自作のstructなどを追加することもできます。

遷移先はここまで見て来た例と同じようにnavigationDestinationで指定し、第一引数に渡すTypeによって型を分岐できます。

NavigationPath(コードでpathを変更する)

NavigationPathには以下のようにappend/removeLast/countのようなcollection-likeなメソッドが用意されていて、配列を操作する感覚で画面遷移を実装できます。

path.append("test")
path.append(1)
path.append(true)
path.append(1.2)
path.removeLast()
path.count // 3
path.removeLast(2)
path.count // 1

NavigationPathを永続化する

NavigationPathからは要素の型がHashableであるということを除いて完全に型が消去されていますが、JSONEncoderやJSONDecoderを使用してencode/decodeすることができます。

func encode() {
    path.append(1)
    path.append(true)
    let json = try! JSONEncoder().encode(path.codable)
    print(json) // 37 bytes
}

NavigationPathにはcodableプロパティが用意されていて、このcodableをJSONEncoder().encodeに渡すことでNavigationPathの内容をJSONにencodeしたDataを取得できます。

codableの型はNavigationPath.CodableRepresentation?のようにOptionalになっていて、NavigationPathが保持している型にCodableではない型が含まれている場合はnilになります。

struct User: Hashable {
    let name: String
}

func encode() {
    path.append(1)
    path.append(true)
    path.append(User(name: "test"))
    print(path.codable) // nil
}

この場合は単純にstructをCodableに準拠させればcodableを取得することができます。

struct User: Codable, Hashable {
    let name: String
}

func encode() {
    path.append(1)
    path.append(true)
    path.append(User(name: "test"))
    let json = try! JSONEncoder().encode(path.codable)
    print(json) // 97 bytes
}

このようにencodeした後はただのData型になっているため簡単に永続化することができます。

decodeする際はJSONDecoder.decodeを使用します。

func decode() {
    let json = ... // 永続化したNavigationPath.codable
    let path = try! NavigationPath(
        JSONDecoder().decode(
            NavigationPath.CodableRepresentation.self,
            from: json)
    )
}

型を消去しているのにencodeやdecodeができるのは不思議ですよね。

どうやってるんだろう?と気になった方はPoint-Freeが解説してくれていますので、以下の記事を読んでみてください。

まとめ

今回はiOS16から新しく追加されたNavigationStackについて導入の背景や実装方法を見てきました。

NavigationStackはdata-drivenな方法で実装できるため複雑なディープリンクなど、コードでの画面遷移がしやすくなっています。

実装する際は従来NavigationViewを使用していた箇所でNavigationStackを使用し、navigationDestinationを使用して遷移先を指定し、コードでpathを変更したりNavigationLinkをタップすることによって画面遷移を実現することができます。

またpathを永続化してアプリに戻って来た際にナビゲーションを復元したり、NavigationPathを使用してpath内に異なる型を保持することも可能です。

ここまで見て来たようにNavigationStackはNavigationViewを完全に置き換えるものになるためiOS16からは積極的に使っていきたいですね。

それではここまでご覧いただきありがとうございました。