
Xcode 16.3以降でビルドしたアプリでNavigationStackのonAppearが予期しないタイミングで発火する問題
現在、Xcode 16.2を使用してアプリを開発しており、Xcode 16.4へのアップデートに向けて調査を行っている。
ところが、Xcode 16.3以降でビルドしたアプリにおいて、SwiftUIのNavigationStackを使用した画面遷移時にonAppear
が予期しないタイミングで呼び出される問題を発見した。
問題の概要
NavigationStackでカスタムBindingを使用している場合、Xcode 16.3以降で以下の異常な挙動が発生する。
- 中間画面での予期しないonAppear呼び出し
- 深い階層への遷移時に、無関係な中間画面の
onAppear
が呼び出される
- 深い階層への遷移時に、無関係な中間画面の
- 戻り遷移時の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を確認してほしい。
検証した結果
ルートの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の公式ドキュメントに明記されているわけではないため、以下は推測を含む。
- NavigationStackが画面インスタンスを予期しないタイミングで再利用する
get
/set
クロージャによる間接的なバインディングで同期ずれが発生する- 画面遷移時の内部最適化がカスタム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の仕様変更と捉える方が適切だろう。