Xcode 16.3以降でビルドしたアプリでNavigationStackのonAppearが予期しないタイミングで発火する問題

Xcode 16.3以降でビルドしたアプリでNavigationStackのonAppearが予期しないタイミングで発火する問題

Clock Icon2025.07.02

現在、Xcode 16.2を使用してアプリを開発しており、Xcode 16.4へのアップデートに向けて調査を行っている。

ところが、Xcode 16.3以降でビルドしたアプリにおいて、SwiftUIのNavigationStackを使用した画面遷移時にonAppearが予期しないタイミングで呼び出される問題を発見した。

問題の概要

NavigationStackでカスタムBindingを使用している場合、Xcode 16.3以降で以下の異常な挙動が発生する。

  1. 中間画面での予期しないonAppear呼び出し
    • 深い階層への遷移時に、無関係な中間画面のonAppearが呼び出される
  2. 戻り遷移時のonAppearスキップ
    • ナビゲーションバーの戻るボタンで戻る際に、一部画面のonAppearが呼び出されない

検証環境と結果

iPhone 16e / iOS 18.5を使用してテストを実施し、複数のXcodeバージョンで同一コードを実行した。結果は以下の通りである。

Xcodeバージョン カスタムBinding(修正前) 直接Binding(修正後) 対応iOS SDK
Xcode 16.2 ✅ 正常 ✅ 正常 iOS 18.2
Xcode 16.3 ❌ 異常 ✅ 正常 iOS 18.4
Xcode 16.4 ❌ 異常 ✅ 正常 iOS 18.5
Xcode 26.0 Beta 2 ❌ 異常 ✅ 正常 iOS 26.0 (Beta)

この結果から、iOS 18.4 SDK(Xcode 16.3)を境界として、NavigationStackの内部仕様が変更され、最新のXcodeまで継続していることが判明した。

検証用コード

以下の最小限のコードで問題を再現できる。

enum Route: Hashable {
    case screenA
    case screenB
    case screenC
    case screenD
}

class Router: ObservableObject {
    @Published
    private(set) var path = NavigationPath()

    func setPath(path: NavigationPath) {
        self.path = path
    }

    func present(route: Route) {
        path.append(route)
    }

    func pop() {
        path.removeLast()
    }

    func popToRoot() {
        path.removeLast(path.count)
    }
}

struct ContentView: View {
    @StateObject
    private var router = Router()

    var body: some View {
        NavigationStack(path: .init(
            get: { router.path },
            set: { router.setPath(path: $0) }
        )) {
            DummyScreen(screenName: "ScreenA") {
                router.present(route: .screenB)
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .screenA:
                    DummyScreen(screenName: "ScreenA") {
                        router.present(route: .screenB)
                    }
                case .screenB:
                    DummyScreen(screenName: "ScreenB") {
                        router.present(route: .screenC)
                    }
                case .screenC:
                    DummyScreen(screenName: "ScreenC") {
                        router.present(route: .screenD)
                    }
                case .screenD:
                    DummyScreen(screenName: "ScreenD", action: nil)
                }
            }
        }
        .environmentObject(router)
    }
}

struct DummyScreen: View {
    @EnvironmentObject var router: Router

    let screenName: String
    let action: (() -> Void)?

    var body: some View {
        VStack {
            Text(screenName)
                .font(.title)
            if let action {
                Button("Go to next screen", action: action)
            }
        }
        .navigationTitle(Text(screenName))
        .onAppear {
            Logger.standard.debug("\(screenName) の onAppear")
        }
    }
}

完全なソースコードについては、以下のgistを確認してほしい。

https://gist.github.com/CH3COOH/d7cff0c552200c1429dc4e53e8136165

検証した結果

ルートのAから順次画面遷移してDまで移動する。

  • A → B → C → D

Dから戻るボタンを押してルートのAまで戻る。

  • D → C → B → A

このシンプルな画面遷移にも関わらず、Xcode 16.3以降でビルドしたアプリでは異常な挙動を示した。

正常動作(Xcode 16.2)

// A → B → C → D への遷移
ScreenA の onAppear
ScreenB の onAppear
ScreenC の onAppear
ScreenD の onAppear

// D から A への戻り
ScreenC の onAppear
ScreenB の onAppear
ScreenA の onAppear

異常動作(Xcode 16.3以降)

ScreenDを表示した時に、「ScreenD の onAppear」と「ScreenB の onAppear」が発火した。また、戻りのScreenBの表示時に「ScreenB の onAppear」が発火しなかった。

// A → B → C → D への遷移
ScreenA の onAppear
ScreenB の onAppear
ScreenC の onAppear
ScreenD の onAppear
ScreenB の onAppear  ← 予期しない呼び出し!

// D から A への戻り
ScreenC の onAppear
ScreenA の onAppear  ← ScreenB がスキップされた!

根本原因の分析

問題の原因は、カスタムBindingの使用にあると考えられる。

// 問題のあるコード
NavigationStack(path: .init(
    get: { router.path },
    set: { router.setPath(path: $0) }
)) {
    // ...
}

Xcode 16.3(iOS 18.4 SDK)以降、NavigationStackの内部実装が変更された影響で、カスタムBindingを使用した場合にViewのライフサイクルとNavigationPathの同期にずれが生じるようになったと推測される。Appleの公式ドキュメントに明記されているわけではないため、以下は推測を含む。

  1. NavigationStackが画面インスタンスを予期しないタイミングで再利用する
  2. get/setクロージャによる間接的なバインディングで同期ずれが発生する
  3. 画面遷移時の内部最適化がカスタムBindingと相性が悪い

解決策

解決方法は非常にシンプルで、カスタムBindingの使用を停止し、@Publishedプロパティに直接バインドするだけである。

修正前(問題のあるコード)

現在の実装では、pathをカプセル化してsetter/getterで間接的にバインディングしている。

class Router: ObservableObject {
    @Published
    private(set) var path = NavigationPath()

    func setPath(path: NavigationPath) {
        self.path = path
    }

    // その他のメソッド...
}

// カスタムBinding
NavigationStack(path: .init(
    get: { router.path },
    set: { router.setPath(path: $0) }
)) {

修正後(解決されたコード)

class Router: ObservableObject {
    @Published
    var path = NavigationPath()  // publicに変更

    // setPathメソッドは不要

    func present(route: Route) {
        path.append(route)
    }

    func pop() {
        path.removeLast()
    }

    func popToRoot() {
        path.removeLast(path.count)
    }
}

// 直接Binding
NavigationStack(path: $router.path) {

動作確認

修正後のコードでは、すべてのXcodeバージョンにおいて期待される正常動作を確認できた。

// A → B → C → D への遷移
ScreenA の onAppear
ScreenB の onAppear
ScreenC の onAppear
ScreenD の onAppear

// D から A への戻り
ScreenC の onAppear
ScreenB の onAppear
ScreenA の onAppear

まとめ

  • Xcode 16.3以降では、NavigationStackのカスタムBindingによりonAppearの発火タイミングに異常が生じる
  • この問題は、Xcode 16.3(iOS 18.4 SDK)でのNavigationStack内部実装変更が原因と推測される

この問題は現在も継続しており、将来のXcodeバージョンでの修正予定は不明である。ただし、Xcode 16.3、16.4、26.0 Beta 2のすべてで再現することから、NavigationStackの仕様変更と捉える方が適切だろう。

参考リンク

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.