Programmatic NavigationStack 備考録

これははてなエンジニアアドベントカレンダー2022 37日目の記事です。
2回目の登場 id:yutailang0119 です、おはこんハロチャオ~!
昨日は id:hogashiaタグで#topにリンクするとページ先頭にスクロールするのは仕様 - hogashi.* でした。

冬休みはあつ森で年越ししながら、SwiftUIの2022年OS対応をしていた

Migrating to new navigation typesNavigationStack を読んで、"programmatic navigation" のパターンをイメージしづらかったので、サンプルを用意しながら動きを見てみた備考録。

Enviromnent

  • Apple Swift version 5.7.2 (swiftlang-5.7.2.135.5 clang-1400.0.29.51)
  • Xcode Version 14.2 (14C18)
  • iOS 16.2

単一の型に対応した画面遷移

 まずはPark型を単独で扱う画面遷移パターン。

NavigationStackに記載されるコード断片の裏側を推測して進めていく。

対応する型 -- Park

 SwiftUIでナビゲーションに対応する型には Hashable にconformさせる。
Identifiable にもするのは、List で扱いやすくするため。

struct Park: Identifiable, Hashable {
    var id: String {
        name
    }

    var name: String
}

画面遷移の実装

 画面遷移にはNavigationStackiOS 16新API init(_:value:)navigationDestination(for:destination:)を使う。

import SwiftUI

struct ContentView: View {
    var parks: [Park] = [
        Park(name: "Yosemite"),
        Park(name: "Sequoia"),
    ]

    var body: some View {
        NavigationStack {
            List(parks) { park in
                NavigationLink(park.name, value: park)
            }
        }
        .navigationDestination(for: Park.self) { park in
            ParkDetails(park: park)
        }
        .navigationTitle(String(describing: type(of: self)))
    }
}

struct ParkDetails: View {
    var park: Park

    var body: some View {
        Text(park.name)
            .navigationTitle(String(describing: type(of: self)))
    }
}

 これで Park のリストをタップすると ParkDetails の画面に遷移する。

複数の型に対応した画面遷移

 ここから、上記の例を拡張して、複数の型を扱うことを想定してみる。
Location を同一画面に表示して、LocatonDetails画面に遷移できるようにする。

struct ContentView: View {
    var parks: [Park] = [
        Park(name: "Yosemite"),
        Park(name: "Sequoia"),
    ]

    var locations: [Location] = [
        Location(name: "Cupertino"),
        Location(name: "San Jose"),
    ]

    var body: some View {
        NavigationStack {
            List {
                Section {
                    ForEach(parks) { park in
                        NavigationLink(park.name, value: park)
                    }
                }
                Section {
                    ForEach(locations) { location in
                        NavigationLink(location.name, value: location)
                    }
                }
            }
            .navigationDestination(for: Park.self) { park in
                ParkDetails(park: park)
            }
            .navigationDestination(for: Location.self) { location in
                Text(location.name)
            }
            .navigationTitle(String(describing: type(of: self)))
        }
    }
}

 navigationDestination(for:destination:) は、メソッドチェーンで複数連結し、異なる型の遷移先を定義できる。

 NavigationStack の "Manage navigation state" にあるように、init(path:root:) を使うと画面スタックをprogrammaticに操作できる。
例えば、一度にスタックを2つ重ねて遷移させることで、遷移元と遷移先画面の間に画面を挟んでスタックできる。

データの配列をBindして、ナビゲーション操作

struct ContentView: View {
    (省略)

    @State private var presentedParks: [Park] = []

    var body: some View {
        NavigationStack(path: $presentedParks) {
            List {(省略)}
            .navigationDestination(for: Park.self) { park in
                ParkDetails(park: park)
            }
            .navigationDestination(for: Location.self) { location in
                LocationDetails(location: location)
            }
            .navigationTitle(String(describing: type(of: self)))
            .toolbar {
                ToolbarItem {
                    Button {
                        presentedParks = [
                            Park(name: "Yosemite"),
                            Park(name: "Sequoia"),
                        ]
                        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                            presentedParks.removeAll()
                        }
                    } label: {
                        Text("Yosemite+Sequoia")
                    }
                }
            }
        }
    }
}

 この場合、"Yosemite+Sequoia"ボタンを押すと ParkDetails が2つ重なるスタックで画面遷移した後、全画面 dismiss して元の画面に戻った遷移になる。
presentedParks の配列を操作することで、画面スタックをコントロールすることができるということだ。

 しかし、残念なことに LocationDetailsへの遷移は動かなくなってしまう。

 ここで登場するのが、NavigationPath

A type-erased list of data representing the content of a navigation stack.

とある通り、型消去が使われている。

 NavigationPathNavigationStack.init(path:root:) にバインドして、ナビゲーションを操作する。

struct ContentView: View {
    (省略)

    @State private var path: NavigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List {(省略)}
            .navigationDestination(for: Park.self) { park in
                ParkDetails(park: park)
            }
            .navigationDestination(for: Location.self) { location in
                LocationDetails(location: location)
            }
            .navigationTitle(String(describing: type(of: self)))
            .toolbar {
                ToolbarItem {
                    Button {
                        path.append(Park(name: "Yosemite"))
                        path.append(Location(name: "Cupertino"))
                        path.append(Park(name: "Sequoia"))
                    } label: {
                        Text("Yosemite+Cupertino+Sequoia")
                    }
                }
            }
        }
    }
}

 これで ParkDetailsLocationDetails の両方に遷移可能。
この場合、"Yosemite+Cupertino+Sequoia"ボタンを押すと ParkDetails > LocationDetails > ParkDetails の順にスタックで画面遷移をした状態になる。

データ型のBind vs NavigationPath

 一見すると NavigationPath で実装しておく方が、後々の拡張性が高そうに見える。
しかし、NavigationPath にも弱点はある。

 前述の通り、NavigationPath は型消去のため、詳細なデータに直接アクセスすることができない。
また、配列操作と違い、任意のindexにアクセスできないため、細やかなスタック操作の面では NavigationPath の分が悪そう。
要件に応じて、使い分けることになる。

まとめ

NavigationStack を用いて、programmaticに画面遷移を操作する方法を、備考録としてまとめた。
pathをencode/decodeすることで、画面のState Restorationも可能になる設計になっているので、非常にかしこい。

iOS 16のことをまとめて知りたいあなたへ

年末年始休暇が終わってしまって時間が足りない方に朗報。
WEB+DB PRESS Vol.132 特集2「iOS 16最前線」には、iOS 16についてのエッセンスが詰まっています!
WEB+DB PRESS Vol.132 特集2 「iOS 16最前線」に寄稿しました #wdpress - がんばってなんか書く