axiom-swiftui-nav-diag について
SwiftUI の予期しないナビゲーション動作を調べる開発者向け診断スキルです。パスのログ記録で原因を絞り、一つずつ修正を検証します。
主な機能
- 01ナビゲーションパスのログ記録
- 02症状別の診断パターン
- 03ディープリンクとタブ状態の確認
- 04段階的な修正検証
活用シーン
- 01リンクをタップしても画面が遷移しない場合
- 02ディープリンクで別の画面が開く場合
- 03タブ切り替え後に状態が失われる場合
- 04遷移先が一瞬表示されて戻る場合
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-swiftui-nav-diagインストール後、Claude Code で/axiom-swiftui-nav-diagを入力して読み込まれたか確認します。
詳細
SwiftUI ナビゲーションの診断
概要
基本原則 ナビゲーションの問題の85%は、パスの状態管理エラー、ビューのアイデンティティの問題、または配置ミスが原因であり、SwiftUI の不具合が原因ではありません。
SwiftUI のナビゲーションシステムは何百万ものアプリで使用されており、複雑なナビゲーションパターンも確実に処理します。ナビゲーションが機能しない、反応しない、または予期しない動作をする場合、ほとんどの場合、問題はフレームワーク自体ではなく、ナビゲーションの状態管理方法にあります。
このスキルでは、根本原因を数時間ではなく数分で特定するための体系的な診断を提供します。
危険信号 — ナビゲーションの問題を疑う
次のいずれかが見られる場合は、フレームワークの故障ではなく、コードの問題を疑ってください。
-
ナビゲーションをタップしても何も起きない(リンクはあるが画面が遷移しない)
-
戻るボタンを押すと、誤った画面またはルート画面に戻る
-
ディープリンクでアプリは開くが、誤った画面が表示される
-
タブの切り替え時にナビゲーション状態が失われる
-
アプリがバックグラウンドに移行するとナビゲーション状態が失われる
-
同じ NavigationLink によって画面が2回遷移する
-
ナビゲーションのアニメーションが止まる、またはぎこちなくなる
-
スタックトレースに
navigationDestinationが含まれるクラッシュが発生する -
❌ 禁止 「SwiftUI のナビゲーションは壊れているので、UINavigationController でラップしましょう」
- NavigationStack は Apple 自身のアプリでも使用されています
- UIKit でラップすると複雑さが増し、SwiftUI の状態管理の利点が失われます
- UIKit との相互運用には独自のエッジケースがあり、把握するのに何週間もかかります
- 問題はほぼ間違いなくパスの管理にあり、フレームワークの不具合ではありません
重要な区別 NavigationStack の動作は決定論的です。機能しない場合は、状態を誤って変更しているか、ビューのアイデンティティに問題があるか、navigationDestination の配置が適切ではありません。
必須の最初の手順
必ず最初に(コードを変更する前に)次の確認を実行してください。
// 1. Add NavigationPath logging
NavigationStack(path: $path) {
RootView()
.onChange(of: path.count) { oldCount, newCount in
print("📍 Path changed: \(oldCount) → \(newCount)")
// If this never fires, link isn't modifying path
// If it fires unexpectedly, something else modifies path
}
}
// 2. Check navigationDestination is visible
// Put temporary print in destination closure
.navigationDestination(for: Recipe.self) { recipe in
let _ = print("🔗 Destination for Recipe: \(recipe.name)")
RecipeDetail(recipe: recipe)
}
// If this never prints, destination isn't being evaluated
// 3. Check NavigationLink is inside NavigationStack
// Visual inspection: Trace from NavigationLink up view hierarchy
// Must hit NavigationStack, not another container first
// 4. Check path state location
// @State must be in stable view (not recreated each render)
// Must be @State, @StateObject, or @Observable — not local variable
// 5. Test basic case in isolation
// Create minimal reproduction
NavigationStack {
NavigationLink("Test", value: "test")
.navigationDestination(for: String.self) { str in
Text("Pushed: \(str)")
}
}
// If this works, problem is in your specific setup
この結果からわかること
| 観察結果 | 診断 | 次の手順 |
|---|---|---|
| タップしても onChange が一度も呼ばれない | NavigationLink が NavigationStack の階層内にない | パターン 1a |
| onChange は呼ばれるがビューが遷移しない | navigationDestination が見つからない、または読み込まれていない | パターン 1b |
| onChange が呼ばれ、ビューが遷移した後、すぐに戻る | ビューのアイデンティティの問題、またはパスの変更 | パターン 2a |
| パスが予期せず変更される(タップ以外が原因) | 外部コードがパスを変更している | パターン 2b |
| Deep link の path.append() で遷移しない | タイミングの問題、または誤ったスレッド | パターン 3b |
| タブの切り替え時に状態が失われる | NavigationStack がタブ間で共有されている | パターン 4a |
| 初回は動作するが、戻ると失敗する | ビューの再生成の問題 | パターン 5a |
必須の解釈
コードを変更する前に、次のいずれかを特定してください。
- リンクをタップしても何も起きず、onChange も呼ばれない → NavigationStack の外部にリンクがあります(階層を確認)
- onChange は呼ばれるが何も遷移しない → navigationDestination がスコープ内にありません(配置を確認)
- 遷移後すぐに戻る → ビューのアイデンティティの変更、またはパスのリセット(@State の場所を確認)
- ディープリンクが失敗する → タイミング、または MainActor の問題(スレッドを確認)
- クラッシュする → パスのデコード時の強制アンラップ、または型の登録漏れ
診断結果に矛盾がある、または不明瞭な場合
- 停止してください。まだパターンに進んではいけません
- パスを変更する箇所すべてに print 文を追加してください
- 最小限の再現ケースを作成してください
- まず String 値でテストしてください(最もシンプルなケース)
判断ツリー
これを使って、2分で適切な診断パターンを特定してください。
Navigation problem?
├─ Navigation tap does nothing?
│ ├─ NavigationLink inside NavigationStack?
│ │ ├─ No → Pattern 1a (Link outside Stack)
│ │ └─ Yes → Check navigationDestination
│ │
│ ├─ navigationDestination registered?
│ │ ├─ Inside lazy container? → Pattern 1b (Lazy Loading)
│ │ ├─ Type mismatch? → Pattern 1c (Type Registration)
│ │ └─ Blocked by sheet/popover? → Pattern 1d (Modal Blocking)
│ │
│ └─ Using view-based link?
│ └─ → Pattern 1e (Deprecated API)
│
├─ Unexpected pop back?
│ ├─ Immediate pop after push?
│ │ ├─ View body recreating path? → Pattern 2a (Path Recreation)
│ │ ├─ @State in wrong view? → Pattern 2a (State Location)
│ │ └─ ForEach id changing? → Pattern 2c (Identity Change)
│ │
│ ├─ Pop when shouldn't?
│ │ ├─ External code calling removeLast? → Pattern 2b (Unexpected Modification)
│ │ ├─ Task cancelled? → Pattern 2b (Async Cancellation)
│ │ └─ MainActor issue? → Pattern 2d (Threading)
│ │
│ └─ Back button behavior wrong?
│ └─ → Pattern 2e (Stack Corruption)
│
├─ Deep link not working?
│ ├─ URL not received?
│ │ ├─ onOpenURL not called? → Check URL scheme in Info.plist
│ │ └─ Universal Links issue? → Check apple-app-site-association
│ │
│ ├─ URL received, path not updated?
│ │ ├─ path.append not on MainActor? → Pattern 3a (Threading)
│ │ ├─ Timing issue (app not ready)? → Pattern 3b (Initialization)
│ │ └─ NavigationStack not created yet? → Pattern 3b (Lifecycle)
│ │
│ └─ Path updated, wrong screen shown?
│ ├─ Wrong path order? → Pattern 3c (Path Construction)
│ ├─ Wrong type appended? → Pattern 3c (Type Mismatch)
│ └─ Item not found? → Pattern 3d (Data Resolution)
│
├─ State lost?
│ ├─ Lost on tab switch?
│ │ ├─ Shared NavigationStack? → Pattern 4a (Shared State)
│ │ └─ Tab recreation? → Pattern 4a (Tab Identity)
│ │
│ ├─ Lost on background/foreground?
│ │ ├─ No SceneStorage? → Pattern 4b (No Persistence)
│ │ └─ Decode failure? → Pattern 4c (Decode Error)
│ │
│ └─ Lost on rotation/size change?
│ └─ → Pattern 4d (Layout Recreation)
│
├─ NavigationSplitView issue?
│ ├─ Sidebar not visible on iPad?
│ │ ├─ columnVisibility not set? → Pattern 6a (Column Visibility)
│ │ └─ Compact size class? → Pattern 6a (Automatic Adaptation)
│ │
│ ├─ Detail shows blank on iPad?
│ │ ├─ No default detail view? → Pattern 6b (Missing Detail)
│ │ └─ Selection binding nil? → Pattern 6b (Selection State)
│ │
│ └─ Works on iPhone, broken on iPad?
│ └─ → Pattern 6c (Platform Adaptation)
│
└─ Crash?
├─ EXC_BAD_ACCESS in navigation code?
│ └─ → Pattern 5a (Memory Issue)
│
├─ Fatal error: type not registered?
│ └─ → Pattern 5b (Missing Destination)
│
└─ Decode failure on restore?
└─ → Pattern 5c (Restoration Crash)
パターンの選択ルール(必須)
パターンに進む前に、次を行ってください。
- ナビゲーションをタップしても何も起きない → まず onChange のログを追加し、その後パターン 1
- 予期しない画面遷移の解除 → パスを変更しているものを特定(ログを確認)し、その後パターン 2
- ディープリンクが失敗する → URL を受け取っていることを確認(onOpenURL 内で print)し、その後パターン 3
- 状態が失われる → いつ失われるか(タブ切り替え時か、バックグラウンド移行時か)を特定し、その後パターン 4
- クラッシュ → スタックトレース全体を取得し、その後パターン 5
一度に適用するパターンは1つだけ
- 1つのパターンの修正を実装してください
- 十分にテストしてください
- 問題が続く場合に限り、次のパターンを試してください
- 複数のパターンを同時に適用してはいけません(原因を切り分けられなくなります)
禁止事項
- 診断せずに解決策を推測すること
- 一度に複数の変更を加えること
- 「SwiftUIが壊れているから」という理由でUINavigationControllerでラップすること
- 理由を理解せずに遅延やDispatchQueue.main.asyncを追加すること
- 「パスの問題を避けるため」にビュー形式のNavigationLinkに切り替えること
診断パターン
パターン1a:NavigationStackの外にあるNavigationLink
所要時間 5~10分
症状
- NavigationLinkをタップしても何も起きない
- 画面遷移が発生せず、エラーもない
- onChange(of: path)が一度も呼び出されない
診断
// Check view hierarchy — NavigationLink must be INSIDE NavigationStack
// ❌ WRONG — Link outside stack
struct ContentView: View {
var body: some View {
VStack {
NavigationLink("Go", value: "test") // Outside stack!
NavigationStack {
Text("Root")
}
}
}
}
// Check: Add background color to NavigationStack
NavigationStack {
Color.red // If link is on red, it's inside
}
修正
// ✅ CORRECT — Link inside stack
struct ContentView: View {
var body: some View {
NavigationStack {
VStack {
NavigationLink("Go", value: "test") // Inside stack
Text("Root")
}
.navigationDestination(for: String.self) { str in
Text("Pushed: \(str)")
}
}
}
}
検証
- リンクをタップすると、画面遷移が発生する
- タップ時にonChange(of: path)が呼び出される
パターン1b:遅延コンテナー内のnavigationDestination
所要時間 10~15分
症状
- NavigationLinkをタップしても何も起きない、または動作が不安定
- onChangeは呼び出される(パスは更新される)が、ビューがプッシュされない
- コンソールに「A navigationDestination for [Type] was not found」と表示されることがある
診断
// ❌ WRONG — Destination inside lazy container (may not be loaded)
ScrollView {
LazyVStack {
ForEach(items) { item in
NavigationLink(item.name, value: item)
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item) // May not be evaluated!
}
}
}
}
修正
// ✅ CORRECT — Destination outside lazy container
ScrollView {
LazyVStack {
ForEach(items) { item in
NavigationLink(item.name, value: item)
}
}
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item) // Always available
}
検証
- 遷移先クロージャーにprintを追加すると、画面遷移のたびに必ず出力される
- スクロール位置にかかわらず動作する
パターン1c:型の登録不一致
所要時間 10分
症状
- 画面遷移をタップしても何も起きない
- 値の型に一致するnavigationDestinationがない
- 一部のリンクでは動作するが、ほかのリンクでは動作しない場合がある
診断
// Check: Value type must EXACTLY match destination type
// Link uses Recipe
NavigationLink(recipe.name, value: recipe) // value is Recipe
// Destination registered for... Recipe.ID?
.navigationDestination(for: Recipe.ID.self) { id in // ❌ Wrong type!
RecipeDetail(id: id)
}
修正
// Match types exactly
NavigationLink(recipe.name, value: recipe) // Recipe
.navigationDestination(for: Recipe.self) { recipe in // ✅ Recipe
RecipeDetail(recipe: recipe)
}
// OR change link to use ID
NavigationLink(recipe.name, value: recipe.id) // Recipe.ID
.navigationDestination(for: Recipe.ID.self) { id in // ✅ Recipe.ID
RecipeDetail(id: id)
}
検証
- 遷移先で型を出力する:
print(type(of: value)) - 型は完全に一致している必要がある(継承は不可)
パターン2a:レンダリングのたびにNavigationPathが再生成される
所要時間 15~20分
症状
- 画面がプッシュされた直後に、すぐ元の画面に戻る
- 遷移先ビューが一瞬だけ「ちらついて」表示される
- 一度は動作するが、その後失敗する、または最初から失敗する
診断
// ❌ WRONG — Path created in view body (reset every render)
struct ContentView: View {
var body: some View {
let path = NavigationPath() // Recreated every time!
NavigationStack(path: .constant(path)) {
// ...
}
}
}
// ❌ WRONG — @State in child view that gets recreated
struct ParentView: View {
@State var showChild = true
var body: some View {
if showChild {
ChildView() // Recreated when showChild toggles
}
}
}
struct ChildView: View {
@State var path = NavigationPath() // Lost when ChildView recreated
// ...
}
修正
// ✅ CORRECT — @State at stable level
struct ContentView: View {
@State private var path = NavigationPath() // Persists across renders
var body: some View {
NavigationStack(path: $path) {
RootView()
}
}
}
// ✅ CORRECT — @StateObject for ObservableObject
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
var body: some View {
NavigationStack(path: $navModel.path) {
RootView()
}
}
}
検証
- onChangeのログを追加する。パスが予期せずリセットされないこと
- 画面遷移後に待機し、path.countが安定していること
パターン2d:MainActor以外でのパス変更
所要時間 10~15分
症状
- 画面遷移が動作することもあれば、失敗することもある
- MainActorの分離に関するSwift 6の警告が表示される
- 予期しない画面遷移の戻りや状態破損が発生する
診断
// ❌ WRONG — Modifying path from background task
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // ⚠️ Not on MainActor!
}
// Check: Search for path.append, path.removeLast outside @MainActor context
修正
// ✅ CORRECT — Ensure MainActor
@MainActor
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // ✅ MainActor isolated
}
// OR explicitly dispatch
func loadAndNavigate() async {
let recipe = await fetchRecipe()
await MainActor.run {
path.append(recipe)
}
}
// ✅ BEST — Use @Observable with @MainActor
@Observable
@MainActor
class Router {
var path = NavigationPath()
func navigate(to value: any Hashable) {
path.append(value)
}
}
検証
- Swift 6の並行処理に関する警告がない
- タイミングにかかわらず、画面遷移が安定して動作する
パターン3a:ディープリンクのスレッド処理の問題
所要時間 15~20分
症状
- ディープリンクのURLを受信する(onOpenURLが呼び出される)
- path.appendが呼び出されるが、画面遷移が発生しない
- アプリがフォアグラウンドにあるときは動作するが、コールドスタート時には失敗する
診断
// ❌ WRONG — May be called before NavigationStack exists
.onOpenURL { url in
handleDeepLink(url) // NavigationStack may not be rendered yet
}
func handleDeepLink(_ url: URL) {
path.append(parsedValue) // Modifies path that doesn't exist yet
}
修正
// ✅ CORRECT — Defer deep link handling
@State private var pendingDeepLink: URL?
@State private var isReady = false
var body: some View {
NavigationStack(path: $path) {
RootView()
.onAppear {
isReady = true
if let url = pendingDeepLink {
handleDeepLink(url)
pendingDeepLink = nil
}
}
}
.onOpenURL { url in
if isReady {
handleDeepLink(url)
} else {
pendingDeepLink = url // Queue for later
}
}
}
検証
- コールドスタート時(アプリを終了した状態)にディープリンクをテストする
- アプリがバックグラウンドにあるときにディープリンクをテストする
- アプリがフォアグラウンドにあるときにディープリンクをテストする
パターン3c:ディープリンクのパス構築順序
所要時間 10~15分
症状
- ディープリンクで画面遷移するが、誤った画面が表示される
- 最終的な遷移先ではなく、中間の画面が表示される
- パスは正しいように見えるが、誤ったビューが表示される
診断
// ❌ WRONG — Wrong order (child before parent)
// URL: myapp://category/desserts/recipe/apple-pie
func handleDeepLink(_ url: URL) {
path.append(recipe) // Recipe pushed first
path.append(category) // Category pushed second — WRONG ORDER
}
// User sees Category screen, not Recipe screen
修正
// ✅ CORRECT — Parent before child
func handleDeepLink(_ url: URL) {
path.removeLast(path.count) // Clear existing
// Build hierarchy: parent → child
path.append(category) // First: Category
path.append(recipe) // Second: Recipe (shows this screen)
}
// For complex paths, build array first
var newPath: [any Hashable] = []
// Parse URL segments...
newPath.append(category)
newPath.append(subcategory)
newPath.append(item)
// Then apply
path = NavigationPath(newPath)
検証
- 構築後のパスを出力する
- パスの最後の項目が遷移先画面であること
パターン4a:タブ間でNavigationStackを共有
所要時間 15~20分
症状
- タブAで画面遷移し、タブBに切り替える
- タブAに戻ると、画面遷移の状態が失われている(ルートに戻っている)
- または、タブAからの画面遷移がタブBに表示される
診断
// ❌ WRONG — Single NavigationStack wrapping TabView
NavigationStack(path: $path) {
TabView {
Tab("Home") { HomeView() }
Tab("Settings") { SettingsView() }
}
}
// All tabs share same navigation — state mixed/lost
// ❌ WRONG — Same @State used across tabs
@State var path = NavigationPath() // Shared
TabView {
Tab("Home") {
NavigationStack(path: $path) { ... } // Uses shared path
}
Tab("Settings") {
NavigationStack(path: $path) { ... } // Same path!
}
}
修正
// ✅ CORRECT — Each tab has own NavigationStack
TabView {
Tab("Home", systemImage: "house") {
NavigationStack { // Own stack
HomeView()
.navigationDestination(for: HomeItem.self) { ... }
}
}
Tab("Settings", systemImage: "gear") {
NavigationStack { // Own stack
SettingsView()
.navigationDestination(for: SettingItem.self) { ... }
}
}
}
// For per-tab path tracking:
struct HomeTab: View {
@State private var path = NavigationPath() // Tab-specific
var body: some View {
NavigationStack(path: $path) {
HomeView()
}
}
}
検証
- タブAで画面遷移し、タブを切り替えて戻ると、状態が保持されている
- 各タブが独立した画面遷移履歴を保持する
パターン4b:バックグラウンド移行時の状態永続化なし
所要時間 15~20分
症状
- 画面に遷移し、アプリをバックグラウンドに移行する
- アプリを終了するか、システムによる終了を待つ
- 再起動すると、画面遷移の状態が失われている(ルートに戻っている)
診断
// ❌ WRONG — No persistence mechanism
@State private var path = NavigationPath()
// Path lost when app terminates
修正
// ✅ CORRECT — Use SceneStorage + Codable
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var savedData: Data?
var body: some View {
NavigationStack(path: $navModel.path) {
RootView()
}
.task {
// Restore on appear
if let data = savedData {
navModel.restore(from: data)
}
// Save on changes
for await _ in navModel.objectWillChange.values {
savedData = navModel.encoded()
}
}
}
}
@MainActor
class NavigationModel: ObservableObject {
@Published var path = NavigationPath()
func encoded() -> Data? {
guard let codable = path.codable else { return nil }
return try? JSONEncoder().encode(codable)
}
func restore(from data: Data) {
guard let codable = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data
) else { return }
path = NavigationPath(codable)
}
}
検証
- 深い階層まで画面遷移し、アプリをバックグラウンドに移行する
- Xcodeでアプリを終了する
- 再起動すると、状態が復元される
パターン5b:navigationDestinationの登録漏れ
所要時間 10~15分
症状
- クラッシュ:「[Type] の遷移先が見つかりません」
- または、画面遷移が何も起きずに失敗する
- 特定の型をプッシュすると発生する
原因の特定
// Every type pushed on path needs a destination
// You push Recipe
path.append(recipe) // Recipe type
// But only registered Category
.navigationDestination(for: Category.self) { ... }
// No destination for Recipe!
修正
// Register ALL types you might push
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
.navigationDestination(for: Chef.self) { chef in
ChefProfile(chef: chef)
}
}
// Or use enum route type for single registration
enum AppRoute: Hashable {
case category(Category)
case recipe(Recipe)
case chef(Chef)
}
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .category(let cat): CategoryView(category: cat)
case .recipe(let recipe): RecipeDetail(recipe: recipe)
case .chef(let chef): ChefProfile(chef: chef)
}
}
確認
- パスにプッシュするすべての型を一覧にします
- それぞれに対応する
navigationDestinationがあることを確認します
パターン 5c:状態復元時のデコードクラッシュ
所要時間 15~20 分
症状
- アプリの起動時にクラッシュする
- スタックトレースに JSON のデコード失敗が表示される
- アプリのアップデート後、またはデータモデルの変更後に発生する
原因の特定
// ❌ WRONG — Force unwrap decode
func restore(from data: Data) {
let codable = try! JSONDecoder().decode( // 💥 Crashes!
NavigationPath.CodableRepresentation.self,
from: data
)
path = NavigationPath(codable)
}
// Crash reasons:
// - Saved path contains type that no longer exists
// - Codable encoding changed between versions
// - Saved item was deleted
修正
// ✅ CORRECT — Graceful decode with fallback
func restore(from data: Data) {
do {
let codable = try JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data
)
path = NavigationPath(codable)
} catch {
print("Navigation restore failed: \(error)")
path = NavigationPath() // Start fresh
// Optionally clear bad saved data
}
}
// ✅ BETTER — Store IDs, resolve to objects
class NavigationModel: ObservableObject, Codable {
var selectedIds: [String] = [] // Store IDs
func resolvedPath(dataModel: DataModel) -> NavigationPath {
var path = NavigationPath()
for id in selectedIds {
if let item = dataModel.item(withId: id) {
path.append(item)
}
// Missing items silently skipped
}
return path
}
}
確認
- 保存された状態を削除してアプリを起動します。クラッシュしないことを確認します
- 不正なデータを再現し、適切にフォールバックすることを確認します
- データモデルを変更してアプリを起動し、不一致に対処できることを確認します
本番環境での危機的状況
状況:iOS のアップデート後に画面遷移がランダムに動作しなくなる
状況
- 火曜日に iOS 18 がリリースされる
- 水曜日までにサポートへの問い合わせが急増:「画面遷移が壊れた」
- ユーザーの 20% が、リンクをタップしても何も起きないと報告する
- 一部のユーザーが、画面遷移が「ランダムにリセットされる」と報告する
- CTO が「修正の予定は?」と尋ねる
プレッシャーの兆候
- 🚨 本番環境の問題 ユーザーの 20% に影響
- ⏰ 時間的なプレッシャー 「ユーザーが低評価のレビューを投稿しています」
- 👔 経営陣の注目 CTO が個人的に進捗を追跡
- 📱 プラットフォームの変更 新しい iOS バージョン
陥りやすい誤った判断(絶対に陥らないでください)
-
「iOS 18 のバグなので、Apple の修正を待とう」
- ユーザーの 80% で正常に動作するなら、iOS が原因ではありません
- Apple のアプリも同じ NavigationStack を使っています
- iOS の変更によって顕在化したエッジケースがコードにあります
-
「UINavigationController でラップしよう」
- 書き換えに 2~3 週間かかります
- SwiftUI の状態管理を失います
- UIKit にも iOS 18 固有の変更があります
- 根本原因の解決にはなりません
-
「画面遷移にリトライ処理を追加しよう」
- 画面遷移は同期処理なので、リトライしても役に立ちません
- 症状を隠すだけで、原因は修正されません
- デバッグが難しくなります
-
「iOS 18 より前のバージョンにロールバックしよう」
- ユーザーの iOS バージョンは制御できません
- App Store 版は iOS 18 をサポートする必要があります
- 問題の解決にはなりません
必須の診断手順
CTO に次の内容を伝えるまでの猶予は 2 時間です。
- 根本原因
- 修正のタイムライン
- 影響を受けるユーザー向けの回避策
ステップ 1:パターンを特定する(30 分)
// Release build with diagnostic logging
#if DEBUG || DIAGNOSTIC
NavigationStack(path: $path) {
// ...
}
.onChange(of: path.count) { old, new in
Analytics.log("nav_path_change", ["old": old, "new": new])
}
#endif
// Check analytics for:
// - path.count going to 0 unexpectedly → Path recreation
// - path.count increasing but no push → Missing destination
// - No path changes at all → Link not firing
ステップ 2:iOS 18 の変更点と照合する(15 分)
// iOS 18 changes that affect navigation:
// 1. Stricter MainActor enforcement
// 2. Changes to view identity in TabView
// 3. New navigation lifecycle timing
// Most common iOS 18 issue:
// Code that worked by accident now fails
// Check: Any path modifications in async contexts without @MainActor?
Task {
let result = await fetch()
path.append(result) // ⚠️ iOS 18 stricter about this
}
ステップ 3:的を絞った修正を適用する(30 分)
// Root cause found: NavigationPath modified from async context
// iOS 17 was lenient, iOS 18 enforces MainActor properly
// ❌ Old code (worked on iOS 17, breaks on iOS 18)
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // Race condition
}
// ✅ Fix: Explicit MainActor isolation
@MainActor
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // ✅ Safe
}
// OR: Annotate entire class
@Observable
@MainActor
class Router {
var path = NavigationPath()
func navigate(to value: any Hashable) {
path.append(value)
}
}
ステップ 4:検証してデプロイする(45 分)
// 1. Test on iOS 17 device — still works
// 2. Test on iOS 18 device — now works
// 3. Test all navigation paths
// 4. Submit expedited review
// Expedited review justification:
// "Critical bug fix for iOS 18 compatibility affecting 20% of users"
プロフェッショナルなコミュニケーション用テンプレート
CTO への連絡(作業開始から 45 分後)
Root cause identified: Navigation code wasn't properly isolated
to the main thread. iOS 18 enforces this more strictly than iOS 17.
Fix: Add @MainActor annotation to navigation code.
Already tested on iOS 17 (no regression) and iOS 18 (fixes issue).
Timeline:
- Fix ready: Now
- QA validation: 1 hour
- App Store submission: Today
- Available to users: 24-48 hours (expedited review)
Workaround for affected users: Force quit and relaunch app
often clears the issue temporarily.
エンジニアリングチームへの連絡
iOS 18 Navigation Fix
Root cause: NavigationPath modifications in async contexts
without @MainActor isolation. iOS 17 was permissive, iOS 18 enforces.
Fix applied:
- Added @MainActor to Router class
- Updated all path.append/removeLast calls to be MainActor-isolated
- Added Swift 6 concurrency checking to catch future issues
Files changed: Router.swift, ContentView.swift, DeepLinkHandler.swift
Testing needed:
- All navigation flows
- Deep links from cold start
- Tab switching with navigation state
- Background/foreground with navigation state
パターン 6:NavigationSplitView のプラットフォーム固有の問題
所要時間 10~20 分
NavigationSplitView は、コンパクト(iPhone)とレギュラー(iPad)のサイズクラスに合わせて自動的に適応します。ほとんどの問題は、iPhone でしかテストしないことが原因です。iPhone では NavigationStack に折りたたまれます。
パターン 6a:サイドバー/列が表示されない
症状
- iPad でサイドバーが表示されない
- アプリに詳細ビューだけが表示され、戻る方法がない
- iPhone では問題なく動作する(単一列に折りたたまれる)
原因の特定
// Check 1: Is columnVisibility controlling visibility?
@State private var columnVisibility: NavigationSplitViewVisibility = .all
NavigationSplitView(columnVisibility: $columnVisibility) {
// sidebar
} detail: {
// detail
}
// Check 2: Are you in compact size class? (iPhone or iPad slide-over)
// In compact, NavigationSplitView collapses to NavigationStack automatically
// The sidebar becomes the root of the stack
修正
columnVisibilityをバインドして初期状態(.all、.doubleColumn、.detailOnly)を制御します- iPad のフルスクリーンとスライドオーバー(コンパクト)の両方でテストします
navigationSplitViewStyle(.balanced)または.prominentDetailを使って列の比率を制御します
パターン 6b:iPad で詳細ビューが空白になる
症状
- iPad の起動時に右側のパネルが空白になる
- サイドバーに一覧が表示されるが、詳細エリアは空になる
- iPhone では問題なく動作する(選択するまで詳細が表示されない)
修正 — デフォルトの詳細ビューを用意する
NavigationSplitView {
List(items, selection: $selectedItem) { item in
Text(item.name)
}
} detail: {
if let selectedItem {
ItemDetailView(item: selectedItem)
} else {
ContentUnavailableView("Select an Item",
systemImage: "sidebar.left",
description: Text("Choose an item from the sidebar"))
}
}
重要なポイント:iPad では起動直後から詳細列が表示されます。デフォルトのビューがないと、空白になります。iPhone ではサイドバーから起動するため、この問題は発生しません。
パターン 6c:プラットフォーム適応の問題
症状
- あるプラットフォームでは画面遷移が動作するが、別のプラットフォームでは動作しない
- リストの選択動作が iPhone と iPad で異なる
診断
NavigationSplitView は、サイズクラスごとに異なるナビゲーションモデルを使用します。
- レギュラー(iPad のフルスクリーン):列を横並びに表示し、選択項目によって詳細を表示します
- コンパクト(iPhone、iPad のスライドオーバー):NavigationStack に折りたたまれ、選択項目をプッシュします
// Common mistake: using NavigationLink inside NavigationSplitView sidebar
// This creates DOUBLE navigation on iPad (link push + selection)
// Fix: Use List(selection:) binding, not NavigationLink
NavigationSplitView {
List(items, selection: $selectedID) { item in // ✅ selection binding
Text(item.name)
}
} detail: {
// driven by selectedID
}
リリース前に、iPhone と iPad の両方でテストしてください。 NavigationSplitView のバグの多くは、プラットフォーム固有です。
クイックリファレンス表
| 症状 | 考えられる原因 | 最初に確認すること | パターン | 修正時間 |
|---|---|---|---|---|
| リンクをタップしても何も起きない | リンクがスタックの外にある | ビュー階層 | 1a | 5〜10 分 |
| ナビゲーションが断続的に失敗する | 遅延コンテナ内に遷移先がある | 遷移先の配置 | 1b | 10〜15 分 |
| 一部の型では動作するが、ほかでは動作しない | 型の不一致 | type(of:) を出力する | 1c | 10 分 |
| プッシュ後、すぐにポップする | パスが再作成されている | @State の位置 | 2a | 15〜20 分 |
| 予期しないポップがランダムに起きる | パスが外部から変更されている | ログを追加する | 2b | 15〜20 分 |
| MainActor では動作するが、Task 内では失敗する | スレッドの問題 | @MainActor を確認する | 2d | 10〜15 分 |
| ディープリンクで遷移しない | MainActor 上にない | スレッドを確認する | 3a | 15〜20 分 |
| コールドスタート時にディープリンクが失敗する | タイミング/ライフサイクル | pendingDeepLink を追加する | 3b | 15〜20 分 |
| ディープリンクで別の画面が表示される | パスの順序が正しくない | パスの内容を出力する | 3c | 10〜15 分 |
| タブ切り替えで状態が失われる | NavigationStack が共有されている | Tab の構造を確認する | 4a | 15〜20 分 |
| バックグラウンド移行で状態が失われる | 永続化されていない | SceneStorage を追加する | 4b | 20〜25 分 |
| 起動時にクラッシュする(デコード) | デコードの強制アンラップ | エラー処理 | 5c | 15〜20 分 |
| 「No destination found」クラッシュ | 登録が不足している | すべての型を列挙する | 5b | 10〜15 分 |
| iPad でサイドバーが表示されない | columnVisibility | バインディングを確認する | 6a | 10〜15 分 |
| iPad で詳細が空白になる | デフォルトの詳細がない | ContentUnavailableView を追加する | 6b | 10 分 |
| iPhone では動作するが、iPad では動作しない | プラットフォームへの適応 | 両方のサイズクラスでテストする | 6c | 15〜20 分 |
よくある間違い
間違い 1:ForEach の中に navigationDestination を配置する
問題 遅延評価のため、必要なときに遷移先が読み込まれません。
失敗する理由 LazyVStack/ForEach は、すべての子ビューを評価するわけではありません。リンクがタップされたときに、遷移先が存在しない場合があります。
修正
// Move destination OUTSIDE lazy container
List {
ForEach(items) { item in
NavigationLink(item.name, value: item)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
間違い 2:iOS 16 以降で NavigationView を使用する
問題 NavigationView は非推奨であり、バージョンによって動作が異なります。
失敗する理由 NavigationPath をサポートしておらず、プログラムからのナビゲーションやディープリンクを確実に実行できません。
修正
NavigationViewをNavigationStackまたはNavigationSplitViewに置き換えます- ビュー形式ではなく、値ベースの
NavigationLink(title, value:)を使用します
間違い 3:計算プロパティ内で NavigationPath を作成する
問題 アクセスするたびにパスがリセットされます。
失敗する理由 var body は繰り返し呼び出されます。そこでパスを作成すると、常にリセットされます。
修正
// Use @State, not computed
@State private var path = NavigationPath() // ✅ Persists
// NOT
var path: NavigationPath { NavigationPath() } // ❌ Reset every time
間違い 4:復元時のデコードエラーを処理しない
問題 保存されたナビゲーションデータが無効な場合にクラッシュします。
失敗する理由 アプリのバージョン間で、データモデルが変更されたり、項目が削除されたり、エンコード形式が変更されたりするためです。
修正
- デコードには必ず
try?またはdo/catchを使用します - フォールバック(空のパス)を用意します
- ID を保存し、オブジェクトに解決する方法を検討します
間違い 5:ディープリンクがすぐに機能すると想定する
問題 コールドスタート時にディープリンクが失敗します。
失敗する理由 onOpenURL は NavigationStack がレンダリングされる前に実行される場合があります。
修正
- ディープリンク URL をキューに入れます
- NavigationStack の
onAppear後に処理します isReadyフラグのパターンを使用します
相互参照
予防的なパターンについて
swiftui-nav スキル — アンチパターンを防ぐための規律:
- 危険信号:NavigationView、ビュー形式のリンク、body 内のパス
- パターン 1a〜7:適切な実装パターン
- プレッシャーのかかる状況:アーキテクチャへの圧力に対処する方法
API リファレンス向け
swiftui-nav-ref スキル — API ドキュメント一式:
- NavigationStack、NavigationSplitView、NavigationPath の完全な API
- タイムスタンプ付きの WWDC コード例すべて
- テストを含む Router/Coordinator パターン
- iOS 26 以降の機能(Liquid Glass、下部検索)
関連する問題向け
swift-concurrency スキル — MainActor の問題がある場合:
- パターン 3:@MainActor の分離パターン
- UI 更新を伴う Async/await
- Task のキャンセル処理
最終更新日 2025-12-05 ステータス 本番環境で使用可能な診断機能 テスト済み 一般的なナビゲーションの問題に照らして診断パターンを検証済み