スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

SwiftUI の NavigationStack の実践的な使い方と制限の克服

あけましておめでとうございます。

スタディサプリの iOS エンジニアのヴァンサンです。

最近、スタディサプリ 小学・中学講座 iOS アプリ(以下スタサプ小中 iOS)で SwiftUI のナビゲーション周りの改善に取り組んできました。SwiftUI のナビゲーション APIiOS 16 で大きく変更され、スタサプ小中 iOS でもその対応が必要になりました。

これまでは、NavigationViewNavigationLink(isActive:...) を使用してナビゲーションを実装してきました(以前の実装については、こちらの記事をご覧ください)。しかし、アプリの iOS 15 のサポート終了に伴い、これらの API が deprecated となり、新しい API への移行が必要になりました。

この記事では、この移行作業で得た知見と、直面した課題、そしてその解決策についてご紹介します。

Apple公式移行ガイドによると、従来の NavigationView の代わりに NavigationStack を使用します。

ナビゲーションをコードで制御することは programmatic navigation と呼ばれています。以前は NavigationLink(isActive:...) を使用していましたが、新しい方式では NavigationStack に渡される path を自分で管理する必要があります。この記事では programmatic navigation に焦点を当て、その他のユースケースについては割愛します。

NavigationStackpath には、2種類の型を使用できます。いずれの場合も、path には画面を表す型が入り、その型は Hashable プロトコルに準拠する必要があります。一般的に、画面を表す型には struct または enum を使用します。

Hashable な型の配列の path

まずは pathHashable な型の配列であるケースを見てみましょう。navigationDestination(for:destination:) に渡されたクロージャーで、この配列の要素から画面を生成します。以下は簡単な使用例です:

struct ContentView: View {
    // 画面を表す型
    enum NavigationDestination: Hashable {
        case someScreen
    }
    @State var path: [NavigationDestination] = []
    var body: some View {
        NavigationStack(path: $path) {
            // ナビゲーションスタックのルート画面。path には含まれていない
            Button("Go!") {
                path.append(.someScreen)
            }.navigationDestination(for: NavigationDestination.self) { destination in
                switch destination {
                case .someScreen:
                    SomeScreen()
                }
            }
        }
    }
}

ナビゲーションスタックに画面をプッシュする場合、path を直接操作する方法以外に、value: を指定した新しい形式の NavigationLink も利用できます:

// Button("Go!") { path.append(.someScreen) } と同じだが、ビルド時に型チェックが行われない
NavigationLink("Go!", value: NavigationDestination.someScreen)

NavigationLink に指定された valuepath の型と合わなければ、ビルドは成功しますが、実行時にリンクをタップしても画面遷移が発生せず、Xcode のコンソールにエラーが出力されます。

また、path: が指定された NavigationStack 内では、value: を指定しない NavigationLink の利用を避けるべきです。リンクは動作するものの、path に何も追加されないため、ナビゲーションスタックの状態が不整合になってしまいます。

path の型には NavigationPath を使用することもできます。この場合、型消去により、異なる型の画面を同じナビゲーションスタックで管理できます:

struct Lesson: Hashable { /* ... */ }
struct Subject: Hashable { /* ... */ }
struct ContentView: View {
    @State var path = NavigationPath()
    var body: some View {
        NavigationStack(path: $path) {
            // ナビゲーションスタックのルート
            Button("Go!") {
                path.append(Lesson(title: "すうがく1"))
            }
            // 型ごとに navigationDestination(for:destination:) を呼ぶ
            .navigationDestination(for: Subject.self) { subject in
                SubjectScreen(subject: subject)
            }
            .navigationDestination(for: Lesson.self) { lesson in
                LessonScreen(lesson: lesson)
            }
        }
    }
}

このように、NavigationPath を使うことで、Subject 型と Lesson 型など、複数の型をナビゲーションスタックで扱うことができます。

プッシュされる値の型に対応する navigationDestination(for:destination:) がなければ、ビルドは成功しますが、プッシュ実行時に何もプッシュされず Xcode のコンソールにエラーが出力されます。

これで NavigationStack への移行が容易に見えるかもしれませんが、実際にはいくつかの課題があります。

SwiftUI の標準ナビゲーション機能における制限

SwiftUI の標準ナビゲーション機能には、いくつかの制限があります。これらの制限が、私たちが独自のナビゲーションツールを実装する必要性につながりました。以下でその詳細を見ていきましょう。

スタサプ小中 iOS では、デバッグ画面を除いて、以前からコードで制御できない NavigationLink の使用を避けています。ボタンタップ時のログ記録が NavigationLink では実装が困難なためです。

onChange()NavigationStack の path 変更を検知してログを記録できるかもしれませんが、プッシュかポップか区別が困難ですし、押されたボタン情報を path から推測する必要があるため、NavigationLink を使用しないことにしました。

NavigationStackpath: を指定すると、その path を自分で管理する必要があります。プッシュされた画面などから path にどうアクセスすれば良いでしょうか?

前の画面に戻る場合、environment から dismiss を取得できます。取得された場面によって、dismiss() を呼ぶと、モーダルを閉じるか、NavigationStack 内であれば現在の画面をスタックからポップします。

注意: 私たちのアプリでは、Xcode 15.4 でビルドされたアプリを iOS 18.0 で実行すると、NavigationStack 内で dismiss() を呼ぶと2画面がポップされる場面がありました。この問題は Xcode 16 または iOS 18.1 以降では発生しないようです。

dismiss でポップはできますが、UIKit の UINavigationController のような以下の操作もしたいです:

  • プッシュ
  • ルートまでポップ
  • 現在表示中の画面の取得

これらの操作には path へのアクセスが必要です。できればどの画面にも path への Binding を手動で渡すのは避けたいです。

直接 path の Binding を environment に入れることもできますが、Equatable でない struct を environment に入れると、ビューが不必要に再評価されることがあるようです。

NavigationPath を使う場合、画面を表す型が型消去されるので、異なる型を同じナビゲーションスタックで管理できます。ただし、一部の型の navigationDestination(for:destination:) 定義を忘れてもコンパイラーに教えてもらえないのがデメリットではあります。

NavigationPath を使用しないことにした主な理由は別にあります。UIKit の UINavigationController と比較すると、以下のような重要な機能が不足しています:

  • 特定の画面までポップする
    • 例:都道府県選択画面→市区町村選択画面→元の画面という遷移で、市区町村選択後に元の画面に戻りたい場合
    • removeLast(_:) は数値しか受け付けず、どの画面までポップするか指定できません。
  • 現在表示中の画面の取得
    • 画面が表示されるたびにログを取りたかったら、各画面に onAppear を入れるより、path を onChange で監視して一元管理する方が確実です。

ルートまでのポップについては path.removeLast(path.count) で実現可能です。

CustomNavigationPath

上記の制限を避け、また path の操作を制限するために、CustomNavigationPath を作成しました。

注意:以下のコードは iOS 16 の deployment target を想定しています。iOS 17 になると、推奨される environment object の扱い方が変わります。

CustomNavigationPath 本体

public final class CustomNavigationPath: ObservableObject {
    // NavigationDestination がプッシュされる全画面を表す enum
    public typealias Element = NavigationDestination
    // onChange(of: navigationPath.path) で監視するために public が必要
    @Published public fileprivate(set) var path: [Element]

    public init() {
        path = []
    }

    public var isEmpty: Bool { path.isEmpty }
    public var count: Int { path.count }
    public var last: Element? { path.last }
}

NavigationPath 同様、Collection に合わせた命名のメソッドを提供しています。

extension CustomNavigationPath {
    // 画面をプッシュ
    public func append(_ element: Element) {
        path.append(element)
    }
    // 最後尾から k 個の画面をポップ
    public func removeLast(_ k: Int = 1) {
        if k <= path.count {
            path.removeLast(k)
        } else {
            assertionFailure("only \(path.count) elements left, cannot remove \(k)")
            path.removeAll()
        }
    }
    // ルート画面まですべてポップ(ルートが path に含まれていない)
    public func removeAll() {
        path.removeAll()
    }
}

特定の画面までポップするためのメソッドも用意しています:

extension CustomNavigationPath {
    public func removeLast(downTo destination: NavigationDestination, removeAllIfNotFound: Bool = false) {
        if let index = path.lastIndex(of: destination) {
            path.removeSubrange((index + 1)...)
        } else if removeAllIfNotFound {
            path.removeAll()
        } else {
            assertionFailure("Could not find \(destination) in navigation path: \(path)")
        }
    }
}

CustomNavigationStack

CustomNavigationPath を使う際、ミスを避けるために、NavigationStack を直接使うのではなく、以下の CustomNavigationStack を使います。CustomNavigationStackCustomNavigationPath を environment に入れ、他の画面から簡単にアクセスできるようにします。

public struct CustomNavigationStack<Root: View>: View {
    @ObservedObject private var path: CustomNavigationPath
    private let root: Root

    public init(
        path: CustomNavigationPath,
        @ViewBuilder root: () -> Root
    ) {
        self.path = path
        self.root = root()
    }

    public var body: some View {
        NavigationStack(
            path: $path.path,
            root: { root }
        ).environmentObject(path)
    }
}

使い方

ナビゲーションスタックの入った画面が以下のように構成されます。

struct ContentView: View {
    @StateObject private var navigationPath = CustomNavigationPath()

    var body: some View {
        CustomNavigationStack(path: navigationPath) {
            content
                .navigationDestination(for: NavigationDestination.self) { destination in
                    // destination から画面を作成
                }
        }.onChange(of: navigationPath.path) { navigationPath in
            log(navigationPath.last?.screen ?? rootScreen)
        }
    }
}

プッシュされる画面側では、@EnvironmentObjectCustomNavigationPath にアクセスできます:

struct ContentView: View {
    @EnvironmentObject private var navigationPath: CustomNavigationPath

    var body: some View {
        Button("My Button") {
            logClick(.myButton)
            navigationPath.append(.myScreen)
        }
    }
}

画面を表す型を固定する懸念点

CustomNavigationPathNavigationDestination 型を固定することにはメリットとデメリットがあります。

メリット:

デメリット:

  • 状況に応じて NavigationDestination を変更できない
  • 共通の型であるため、NavigationDestination に入ったどの型も public である必要がある

ジェネリック型を使用することで柔軟性を高められますが、@EnvironmentObject の扱いに注意が必要です:

  • @EnvironmentObject は型によってオブジェクトを区別する
  • ジェネリックパラメータが異なると別の型として扱われる
  • 各画面で使用する型を慎重に管理する必要がある

もっと柔軟な実装があるかもしれませんが、この記事では固定の NavigationDestination に焦点を当てます。

CustomNavigationStack で多くの画面を移行できましたが、次に直面した課題は、どうやって値を返すかということでした。

値を返却する仕組み

プッシュされた画面から値を前の画面に返したい場合、Bindingクロージャーを使用することはできません。なぜなら、navigation destinationHashable である必要があるためです。また、値を返す際に何階層戻るべきかの判断も難しい場合があります。

navigationLink スタイルPicker に似た機能がありますが、このスタイルはナビゲーションスタック内での使用が推奨されていません。また、Picker では実現できることが限られています。

SwiftUI に必要な機能がなかったので、以下のような navigation destination に入れられる ReturnSlot を実装しました:

public final class ReturnSlot<ReturnValue> {
    private let handler: (ReturnValue) -> Void

    public init(callback: @escaping (ReturnValue) -> Void) {
        self.handler = callback
    }

    public func returnValue(_ value: ReturnValue) {
        handler(value)
    }
}

// navigation destination に入れるために、`ReturnSlot` が(`Equatable` を含む)`Hashable` である必要がある
extension ReturnSlot: Hashable {
    // 各 `ReturnSlot` がユニークである
    public static func == (lhs: ReturnSlot<ReturnValue>, rhs: ReturnSlot<ReturnValue>) -> Bool {
        lhs === rhs
    }

    public func hash(into hasher: inout Hasher) {
        hasher.combine(ObjectIdentifier(self))
    }
}

値を受け取る側は以下のように使用します。

let returnSlot = ReturnSlot { value in
    navigationPath.removeLast(downTo: myCurrentScreenNavigationDestination)
}
// ReturnSlot が Hashable なので navigation destination に入れられる
navigationPath.append(.someScreen(returnSlot: returnSlot))

値を返している側では、ReturnSlotreturnValue() を呼ぶだけです。

returnSlot.returnValue(value)

これは暫定的なワークアラウンドであり、本来は SwiftUI が提供すべき機能です。クロージャーを Hashable にする必要があるため、理想的な解決策とは言えません。

いまの SwiftUI の課題のまとめ

上記の説明で触れた点も含め、現在の SwiftUI のナビゲーションで最も不足していると感じる機能をまとめました:

  • プッシュの標準化

    • dismiss のように environment から画面をプッシュする手段が必要
    • 現在の NavigationLink ではログ記録などができない場合がある
    • path の型チェックは技術的に難しくても、プッシュ操作自体の標準化は重要
  • 値を返却する仕組み

    • プッシュされた画面から前の画面へ値を返す標準的な方法が存在しない
    • 現状では独自実装が必要
  • NavigationPath から現在表示中の画面の取得

    • ログ記録や UI の動的な調整に有用
  • 遷移の制御

    • 遷移アニメーション中の path 操作が不安定
      • アニメーション途中での変更により表示が乱れる
      • 空の画面が表示されたり、意図しない画面遷移が発生する
    • アニメーション完了の待機機能がない
      • 例:deep link 処理時に画面を戻してから新しい画面へ遷移する場合
      • ポップアニメーション完了を待ってからプッシュしたいが、現状では制御できない
      • Task を跨がずにポップした直後にプッシュするしかない

これらの機能が SwiftUI に追加されれば、より安全で楽なナビゲーション実装が可能になるでしょう。

最後に

SwiftUI の NavigationStack への移行により、コードの管理が容易になり、型安全性も向上しました。しかし、基本的な機能の不足や、アニメーション制御の課題など、まだ改善の余地が残されています。SwiftUI のナビゲーション機能が今後さらに進化することを期待したいです。

  翻译: