axiom-swiftui-nav について
SwiftUI 開発者向けのナビゲーションスキルです。ナビゲーション構造、ディープリンク、状態復元の選択と実装を支援し、判断基準や具体例、失敗につながりやすい設計の注意点を示します。
主な機能
- 01スタック、分割ビュー、タブ構成の選択
- 02NavigationPath によるプログラム制御
- 03ディープリンクと URL ルーティングの設定
- 04ナビゲーション状態の保存と復元
- 05ルーターパターンの適用とアンチパターン回避
活用シーン
- 01アプリのナビゲーション構造を選定
- 02ディープリンクから画面へ遷移
- 03アプリ再起動後もナビゲーション状態を保持
- 04リリース前にナビゲーションコードをレビュー
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-swiftui-navインストール後、Claude Code で/axiom-swiftui-navを入力して読み込まれたか確認します。
詳細
SwiftUI ナビゲーション
このスキルを使う場面
次のような場合に使用します:
- ナビゲーションアーキテクチャを選択する場合(NavigationStack、NavigationSplitView、TabView)
- NavigationPath を使ったプログラムによるナビゲーションを実装する場合
- ディープリンクと URL ルーティングを設定する場合
- ナビゲーションの状態復元を実装する場合
- タブ/サイドバーのパターンを採用する場合(iOS 18 以降)
- コーディネーター/ルーターのパターンを実装する場合
- リリース前にナビゲーションの実装についてコードレビューを依頼する場合
関連スキル
- ナビゲーションの問題を体系的にトラブルシューティングするには、
axiom-swiftui-nav-diagを使用します - タブのカスタマイズや iOS 26 以降の機能を含む包括的な API リファレンスと、すべての WWDC の例を参照するには、
axiom-swiftui-nav-refを使用します
プロンプトの例
このスキルが回答するように設計されている、開発者からの実際の質問を紹介します:
1. 「アプリでは NavigationStack と NavigationSplitView のどちらを使うべきですか?」
-> このスキルでは、対象デバイス、コンテンツ階層の深さ、マルチプラットフォームの要件に基づく判断フローを示します
2. 「SwiftUI でプログラムによるナビゲーションを行うにはどうすればよいですか?」
-> このスキルでは、プッシュ、ポップ、ルートへのポップ、ディープリンクに対応する NavigationPath の操作パターンを紹介します
3. 「ディープリンクが機能しません。アプリは開きますが、間違った画面が表示されます。」
-> このスキルでは、URL の解析パターン、パスの構築順序、onOpenURL のタイミングに関する問題を説明します
4. 「アプリがバックグラウンドに移行すると、ナビゲーションの状態が失われます。」
-> このスキルでは、Codable NavigationPath、SceneStorage による永続化、クラッシュに強い状態復元を紹介します
5. 「SwiftUI でコーディネーターパターンを実装するにはどうすればよいですか?」
-> このスキルでは、Router パターンの例に加え、コーディネーターが複雑さよりも価値をもたらす場面について説明します
危険な兆候 — 防止すべきアンチパターン
次のいずれかを行っている場合は、すぐに中止し、このスキルのパターンを使用してください:
❌ 重大 — 絶対に行わないでください
1. iOS 16 以降で非推奨の NavigationView を使用する
// ❌ WRONG — Deprecated, different behavior on iOS 16+
NavigationView {
List { ... }
}
.navigationViewStyle(.stack)
問題の理由 NavigationView は iOS 16 以降、非推奨です。NavigationPath に対応していないため、プログラムによるナビゲーションやディープリンクの信頼性が低くなります。iOS のバージョンによって動作が異なるため、バグの原因になります。
2. プログラムによるナビゲーションにビュー型の NavigationLink を使用する
// ❌ WRONG — Cannot programmatically control
NavigationLink("Recipe") {
RecipeDetail(recipe: recipe) // View destination, no value
}
問題の理由 ビュー型のリンクはプログラムから制御できません。ディープリンクを設定したり、この遷移先に戻ったりする方法がありません。iOS 16 以降、非推奨です。
3. 遅延コンテナ内に navigationDestination を配置する
// ❌ WRONG — May not be loaded when needed
LazyVGrid(columns: columns) {
ForEach(items) { item in
NavigationLink(value: item) { ... }
.navigationDestination(for: Item.self) { item in // Don't do this
ItemDetail(item: item)
}
}
}
問題の理由 遅延コンテナはすべてのビューをすぐには読み込みません。そのため navigationDestination が NavigationStack から認識されず、ナビゲーションが何も通知されないまま失敗する可能性があります。
4. 状態復元のために、完全なモデルオブジェクトを NavigationPath に保存する
// ❌ WRONG — Duplicates data, stale on restore
class NavigationModel: Codable {
var path: [Recipe] = [] // Full Recipe objects
}
問題の理由 モデル内にすでに存在するデータが重複します。状態復元時に、Recipe のデータが古くなっている可能性があります(別の場所で編集または削除された場合など)。ID を使用し、現在のデータを取得してください。
5. MainActor の外部で NavigationPath を変更する
// ❌ WRONG — UI update off main thread
Task.detached {
await viewModel.path.append(recipe) // Background thread
}
問題の理由 NavigationPath は UI にバインドされています。ナビゲーション状態が破損しないよう、変更は MainActor 上で行う必要があります。クラッシュや、何も通知されないままの失敗を引き起こすことがあります。
6. ナビゲーション状態に @MainActor による分離がない
// ❌ WRONG — Not MainActor isolated
class Router: ObservableObject {
@Published var path = NavigationPath() // No @MainActor
}
問題の理由 Swift 6 の厳格な並行処理では、SwiftUI ビューからアクセスされる @Published プロパティに MainActor による分離が必要です。データ競合の警告やクラッシュの原因になる可能性があります。
7. マルチタブアプリでナビゲーション状態を処理しない
// ❌ WRONG — Shared NavigationPath across tabs
TabView {
Tab("Home") { HomeView() }
Tab("Settings") { SettingsView() }
}
// All tabs share same NavigationStack — wrong!
問題の理由 タブを切り替えたときにナビゲーション状態を保持できるよう、各タブにそれぞれ独自の NavigationStack を設ける必要があります。状態を共有すると、分かりにくい UX になります。
8. NavigationPath のデコードエラーを無視する
// ❌ WRONG — Crashes on invalid data
let path = NavigationPath(try! decoder.decode(NavigationPath.CodableRepresentation.self, from: data))
問題の理由 パスに含まれていた項目がユーザーによって削除されている可能性があります。また、スキーマが変更されている可能性もあります。強制アンラップすると、状態復元時にクラッシュします。
必須の最初の手順
ナビゲーションを実装する前に、必ず次の手順を完了してください:
// Step 1: Identify your navigation structure
// Ask: Single stack? Multi-column? Tab-based with per-tab navigation?
// Record answer before writing any code
// Step 2: Choose container based on structure
// Single stack (iPhone-primary): NavigationStack
// Multi-column (iPad/Mac-primary): NavigationSplitView
// Tab-based: TabView with NavigationStack per tab
// Step 3: Define your value types for navigation
// All values pushed on NavigationStack must be Hashable
// For deep linking/restoration, also Codable
struct Recipe: Hashable, Codable, Identifiable { ... }
// Step 4: Plan deep link URLs (if needed)
// myapp://recipe/{id}
// myapp://category/{name}/recipe/{id}
// Step 5: Plan state restoration (if needed)
// Will you use SceneStorage? What data must be Codable?
簡単な判断フロー
Need navigation?
├─ Multi-column interface (iPad/Mac primary)?
│ └─ NavigationSplitView
│ ├─ Need drill-down in detail column?
│ │ └─ NavigationStack inside detail (Pattern 3)
│ └─ Selection-only detail?
│ └─ Just selection binding (Pattern 2)
├─ Tab-based app?
│ └─ TabView
│ ├─ Each tab needs drill-down?
│ │ └─ NavigationStack per tab (Pattern 4)
│ └─ iPad sidebar experience?
│ └─ .tabViewStyle(.sidebarAdaptable) (Pattern 5)
└─ Single-column stack?
└─ NavigationStack
├─ Need deep linking?
│ └─ Use NavigationPath (Pattern 1b)
└─ Simple push/pop?
└─ Typed array path (Pattern 1a)
Need state restoration?
└─ SceneStorage + Codable NavigationPath (Pattern 6)
Need coordinator abstraction?
├─ Complex conditional flows?
├─ Navigation logic testing needed?
├─ Sharing navigation across many screens?
└─ YES to any → Router pattern (Pattern 7)
NO to all → Use NavigationPath directly
パターン 1a: 基本的な NavigationStack
使用場面: シンプルな push/pop ナビゲーション、すべての遷移先が同じ型
所要時間: 5〜10 分
struct RecipeList: View {
@State private var path: [Recipe] = []
var body: some View {
NavigationStack(path: $path) {
List(recipes) { recipe in
NavigationLink(recipe.name, value: recipe)
}
.navigationTitle("Recipes")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
}
// Programmatic navigation
func showRecipe(_ recipe: Recipe) {
path.append(recipe)
}
func popToRoot() {
path.removeAll()
}
}
ポイント:
- すべての値が同じ型の場合は型付き配列
[Recipe] - 値ベースの
NavigationLink(title, value:) navigationDestination(for:)は遅延コンテナの外に配置する
パターン 1b: ディープリンク対応の NavigationStack
使用場面: 複数の遷移先型、URL ベースのディープリンク
所要時間: 15〜20 分
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
.onOpenURL { url in
handleDeepLink(url)
}
}
func handleDeepLink(_ url: URL) {
// URL: myapp://category/desserts/recipe/apple-pie
path.removeLast(path.count) // Pop to root first
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
let segments = components.path.split(separator: "/").map(String.init)
var index = 0
while index < segments.count - 1 {
switch segments[index] {
case "category":
if let category = Category(rawValue: segments[index + 1]) {
path.append(category)
}
index += 2
case "recipe":
if let recipe = dataModel.recipe(named: segments[index + 1]) {
path.append(recipe)
}
index += 2
default:
index += 1
}
}
}
}
ポイント:
- 異種の型には
NavigationPath - ディープリンクのパスを構築する前にルートまでポップする
- 正しい順序でパスを構築する (親 → 子)
パターン 2: 選択ベースの NavigationSplitView
使用場面: 詳細カラムが選択項目を表示するマルチカラムレイアウト
所要時間: 10〜15 分
struct MultiColumnView: View {
@State private var selectedCategory: Category?
@State private var selectedRecipe: Recipe?
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.name, value: category)
}
.navigationTitle("Categories")
} content: {
if let category = selectedCategory {
List(recipes(in: category), selection: $selectedRecipe) { recipe in
NavigationLink(recipe.name, value: recipe)
}
.navigationTitle(category.name)
} else {
Text("Select a category")
}
} detail: {
if let recipe = selectedRecipe {
RecipeDetail(recipe: recipe)
} else {
Text("Select a recipe")
}
}
}
}
ポイント:
- List の
selection: $bindingがカラムの選択に接続される - 値を提示するリンクは選択を自動的に更新する
- iPhone では単一スタックに適応する
パターン 3: 詳細にスタックを持つ NavigationSplitView
使用場面: 詳細カラムでドリルダウン可能なマルチカラム
所要時間: 20〜25 分
struct GridWithDrillDown: View {
@State private var selectedCategory: Category?
@State private var path: [Recipe] = []
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.name, value: category)
}
.navigationTitle("Categories")
} detail: {
NavigationStack(path: $path) {
if let category = selectedCategory {
RecipeGrid(category: category)
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
} else {
Text("Select a category")
}
}
}
}
}
ポイント:
- 詳細カラム内の NavigationStack
- サイドバーを維持したまま Grid → Detail のドリルダウン
- ドリルダウン用の個別パス、サイドバー用の選択
パターン 4: タブごとの NavigationStack を持つ TabView
使用場面: 各タブが独自のナビゲーションを持つタブベースのアプリ
所要時間: 15〜20 分
struct TabBasedApp: View {
var body: some View {
TabView {
Tab("Home", systemImage: "house") {
NavigationStack {
HomeView()
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
}
}
Tab("Search", systemImage: "magnifyingglass") {
NavigationStack {
SearchView()
}
}
Tab("Settings", systemImage: "gear") {
NavigationStack {
SettingsView()
}
}
}
}
}
ポイント:
- 各タブが独自の NavigationStack を持つ
- タブを切り替えてもナビゲーション状態が保持される
- systemImage を使った iOS 18 以降の Tab 構文
パターン 5: サイドバー適応型 TabView (iOS 18 以降)
使用場面: iPhone ではタブバー、iPad ではサイドバー
所要時間: 20〜25 分
struct AdaptableApp: View {
var body: some View {
TabView {
Tab("Watch Now", systemImage: "play") {
WatchNowView()
}
Tab("Library", systemImage: "books.vertical") {
LibraryView()
}
TabSection("Collections") {
Tab("Favorites", systemImage: "star") {
FavoritesView()
}
Tab("Recently Added", systemImage: "clock") {
RecentView()
}
}
Tab(role: .search) {
SearchView()
}
}
.tabViewStyle(.sidebarAdaptable)
}
}
ポイント:
.tabViewStyle(.sidebarAdaptable)で iPad のサイドバーが有効になるTabSectionでサイドバーに折りたたみ可能なグループを作成するTab(role: .search)は特別な配置になる
パターン 6: 状態の復元
使用場面: アプリ起動をまたいでナビゲーション状態を保持する
所要時間: 25〜30 分
@MainActor
class NavigationModel: ObservableObject, Codable {
@Published var selectedCategory: Category?
@Published var recipePath: [Recipe.ID] = [] // Store IDs, not objects
enum CodingKeys: String, CodingKey {
case selectedCategory, recipePath
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
try container.encode(recipePath, forKey: .recipePath)
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
selectedCategory = try container.decodeIfPresent(Category.self, forKey: .selectedCategory)
recipePath = try container.decode([Recipe.ID].self, forKey: .recipePath)
}
init() {}
var jsonData: Data? {
get { try? JSONEncoder().encode(self) }
set {
guard let data = newValue,
let model = try? JSONDecoder().decode(NavigationModel.self, from: data)
else { return }
selectedCategory = model.selectedCategory
recipePath = model.recipePath
}
}
}
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var data: Data?
var body: some View {
NavigationStack(path: $navModel.recipePath) {
// Content
}
.task {
if let data { navModel.jsonData = data }
for await _ in navModel.objectWillChange.values {
data = navModel.jsonData
}
}
}
}
ポイント:
- ID を保存し、現在のオブジェクトに解決する
- Swift 6 の並行処理安全性のために
@MainActor - シーンスコープの自動永続化には SceneStorage
- 削除された項目を処理するため、ID の解決には
compactMapを使う
パターン 7: Router/Coordinator
使用場面: 複雑なナビゲーションロジック、テスト容易性が必要
所要時間: 30〜45 分
enum AppRoute: Hashable {
case home
case category(Category)
case recipe(Recipe)
case settings
}
@Observable
@MainActor
class Router {
var path = NavigationPath()
func navigate(to route: AppRoute) {
path.append(route)
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
func showRecipeOfTheDay() {
popToRoot()
if let recipe = DataModel.shared.recipeOfTheDay {
path.append(AppRoute.recipe(recipe))
}
}
}
struct ContentView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .home: HomeView()
case .category(let cat): CategoryView(category: cat)
case .recipe(let recipe): RecipeDetail(recipe: recipe)
case .settings: SettingsView()
}
}
}
.environment(router)
}
}
Coordinator が価値を加える場面:
- 複雑な条件分岐のあるナビゲーションフロー
- ナビゲーションロジックに単体テストが必要
- 複数のビューが同じナビゲーションをトリガーする
- カスタムトランジションを伴う UIKit との相互運用
Coordinator が価値を加えず複雑さだけを生む場面:
- シンプルな直線的ナビゲーション
- 遷移先が 5 個未満
- ナビゲーションのテストが不要
- NavigationPath がすでにディープリンクを処理している
アンチパターン (これはやらないでください)
❌ NavigationStack の中に NavigationStack をネストする
// ❌ WRONG — Nested stacks
NavigationStack {
SomeView()
.sheet(isPresented: $showSheet) {
NavigationStack { // Creates separate stack — confusing
SheetContent()
}
}
}
問題 2 つのナビゲーションスタックが混乱を招く UX を生みます。戻るボタンの動作が不明確になります。 修正 単一の NavigationStack を使い、可能な場合はネストしたナビゲーションなしでシートを提示します。
❌ Button の中で NavigationLink を使う
// ❌ WRONG — Double navigation triggers
Button("Go") {
// Some action
} label: {
NavigationLink(value: item) { // Fires on button AND link
Text("Item")
}
}
問題 Button と NavigationLink の両方がタップに反応します。
修正 NavigationLink のみを使い、必要ならアクションを .simultaneousGesture に入れます。
❌ ビューの body 内で NavigationPath を生成する
// ❌ WRONG — Recreated every render
var body: some View {
let path = NavigationPath() // Reset on every render!
NavigationStack(path: .constant(path)) { ... }
}
問題 レンダリングのたびにパスが再生成され、ナビゲーション状態が失われます。
修正 ナビゲーション状態には @State または @StateObject を使います。
プレッシャーシナリオ: 「Instagram のようなナビゲーションにして」
問題
プロダクト/デザインから Instagram のような複雑なナビゲーションを求められます:
- 「タブごとのナビゲーションスタックを持つタブバー」
- 「すべてのフローに対するスムーズな coordinator パターン」
- 「任意の画面へのディープリンク」
- 「どこからでもアクセスできるプロフィール」
危険信号 — 過剰設計の圧力を見極める
次のいずれかを聞いたら、立ち止まって評価してください。
- 🚩 「ビューを作る前に、完全なコーディネーター層を構築しましょう」 → たいていは YAGNI です
- 🚩 「あらゆる状況に対応できるナビゲーションアーキテクチャが必要です」 → スコープクリープです
- 🚩 「Instagram/TikTok はこの方法でやっています」 → 100 人以上のエンジニアがいます
所要時間の比較
オプション A:過剰設計のコーディネーター
- コーディネーター層の構築時間:3-5 日
- 保守とデバッグにかかる時間:継続的
- 要件変更時にかかる時間:大幅なリファクタリング
オプション B:組み込みナビゲーション + シンプルなルーター
- Pattern 4(TabView + NavigationStack)の実装時間:2-3 時間
- 必要に応じて Router を追加する時間:1-2 時間
- 要件変更時にかかる時間:段階的な追加
プロフェッショナルな反論の方法
ステップ 1:現在のニーズを数値化する
"Let's list our actual navigation flows:
1. Home → Item Detail
2. Search → Results → Item Detail
3. Profile → Settings
That's 6 destinations. NavigationPath handles this natively."
ステップ 2:組み込みの解決策を示す
"Here's our navigation with NavigationStack + NavigationPath:
[Show Pattern 1b code]
This gives us:
- Programmatic navigation ✓
- Deep linking ✓
- State restoration ✓
- Type safety ✓
Without a coordinator layer."
ステップ 3:段階的な進め方を提案する
"If we find NavigationPath insufficient, we can add a Router
(Pattern 7) later. It's 30-45 minutes of work.
But let's start with the simpler solution and add complexity
only when we hit a real limitation."
実例:48 時間での機能リリース
シナリオ:
- PM:「2 日後のキャンペーン開始に向けて、ディープリンクが必要です」
- リード:「まず適切なコーディネーターを構築しましょう」
- 利用可能な時間:16 時間
誤ったアプローチ:
- 8 時間:コーディネーターのインフラを構築
- 4 時間:コーディネーターのエッジケースをデバッグ
- 4 時間:壊れた基盤の上でディープリンクを急いで実装
- 結果:バグだらけで、期限に間に合わない
正しいアプローチ:
- 2 時間:Pattern 1b(ディープリンクに対応した NavigationStack)を実装
- 1 時間:すべてのディープリンク URL をテスト
- 1 時間:SceneStorage による状態復元(Pattern 6)を追加
- 結果:4 時間でディープリンクが動作し、仕上げとテストに 12 時間を確保
圧力がかかるシナリオ:「NavigationView の後方互換性」
問題
チームリードの発言:「iOS 15 に対応するため、NavigationView を使いましょう」
危険信号
- 🚩 NavigationView は iOS 16(2022 年)以降、非推奨です
- 🚩 iOS のバージョンによって動作が異なり、バグの原因になります
- 🚩 NavigationPath に対応していないため、ディープリンクを適切に実装できません
共有するデータ
iOS 16+ adoption: 95%+ of active devices (as of 2024)
iOS 15: < 5% and declining
NavigationView limitations:
- No programmatic path manipulation
- No type-safe navigation
- No built-in state restoration
- Behavior varies by iOS version
反論のスクリプト
"NavigationView was deprecated in iOS 16 (2022). Here's the impact:
1. We lose NavigationPath — can't implement deep linking reliably
2. Behavior differs between iOS 15 and 16 — more bugs to maintain
3. iOS 15 is < 5% of users — we're adding complexity for small audience
Recommendation: Set deployment target to iOS 16, use NavigationStack.
If iOS 15 support is required, use NavigationStack with @available
checks and fallback UI for older devices."
コードレビューのチェックリスト
ナビゲーションアーキテクチャ
- ユースケースに適したコンテナー(Stack、SplitView、TabView)
- 値ベースの NavigationLink(ビュー形式ではない)
- navigationDestination が遅延コンテナーの外にある
- 各タブに個別の NavigationStack がある(タブベースの場合)
状態管理
- NavigationPath が @State または @StateObject にある(body 内で再作成されない)
- ナビゲーション状態に @MainActor の分離が適用されている(Swift 6)
- 復元用に ID が保存されている(完全なオブジェクトではない)
- デコード失敗時のエラー処理がある
ディープリンク
- onOpenURL ハンドラーがある
- パスを構築する前にルートに戻る
- パスが正しい順序(親 → 子)で構築されている
- データが見つからない場合も適切に処理される
iOS 26 以降の機能
- Liquid Glass に干渉するカスタム背景がない
- iPhone で画面下部に配置された検索が機能する
- 適切な場合はタブバーの最小化に対応している
トラブルシューティング早見表
| 症状 | 考えられる原因 | パターン |
|---|---|---|
| ナビゲーションをタップしても反応しない | NavigationLink が NavigationStack の外にある | 階層を確認 |
| タップ時に二重にナビゲーションする | NavigationLink を Button で囲んでいる | Button のラッパーを削除 |
| タブ切り替え時に状態が失われる | 複数のタブで NavigationStack を共有している | Pattern 4 |
| バックグラウンド移行時に状態が失われる | SceneStorage がない | Pattern 6 |
| ディープリンクで誤った画面が表示される | パスの構築順序が誤っている | Pattern 1b |
| 復元時にクラッシュする | デコード結果を強制アンラップしている | エラーを適切に処理 |
リソース
WWDC:2022-10054、2024-10147、2025-256、2025-323
スキル:axiom-swiftui-nav-diag、axiom-swiftui-nav-ref
最終更新 WWDC 2022-2025 のナビゲーションセッションに基づく プラットフォーム iOS 18 以降、iPadOS 18 以降、macOS 15 以降、watchOS 11 以降、tvOS 18 以降