NavigationStack מול NavigationSplitView ב-SwiftUI: המדריך לניווט ב-iOS 26 (2026)

מדריך 2026 מלא לניווט ב-SwiftUI על iOS 26: מתי להשתמש ב-NavigationStack ומתי ב-NavigationSplitView, איך בונים ראוטר מבוסס @Observable, טיפול ב-Deep Links, שילוב עמודות ונגישות VoiceOver.

NavigationStack מול SplitView (iOS 26)

עודכן: 30 ביולי, 2026

ההבדל המהותי בין NavigationStack ל-NavigationSplitView ב-SwiftUI הוא הכיוון. NavigationStack מנהל היררכיה של Push/Pop בעמודה אחת ומתאים במיוחד ל-iPhone, בעוד ש-NavigationSplitView מציג פריסת שתיים או שלוש עמודות אדפטיבית ל-iPad, ל-Mac ול-Apple TV. ב-iOS 26 שני ה-API המודרניים הללו הם היחידים שנתמכים רשמית. NavigationView הישן סומן deprecated כבר מאז iOS 16, וצפוי להיעלם בגרסאות הבאות. במדריך הזה נסביר מתי להשתמש בכל אחד, איך לשלב ביניהם, איך בונים ראוטר מבוסס @Observable לניווט תכנותי, ומה החוקים החדשים ל-Deep Links, לנגישות ולשחזור state.

  • NavigationStack מיועד לניווט בעמודה אחת (iPhone, Watch), עם path תכנותי מסוג NavigationPath.
  • NavigationSplitView מיועד לפריסה של 2 או 3 עמודות (iPad, Mac), ומתקפל אוטומטית לעמודה אחת במסכים קטנים.
  • העיקרון הזהב ב-2026: לעולם אל תקננו NavigationStack בתוך NavigationStack. משלבים אותם רק כאשר NavigationSplitView משמש כשורש.
  • בנו ראוטר @Observable יחיד עם NavigationPath ו-enum מסוג Hashable לכל היעדים. זו הדרך הבטוחה לטיפוסים לניווט תכנותי ול-Deep Links.
  • נגישות: תמיד תנו ל-NavigationLink תווית ברורה עם .accessibilityLabel, ו-VoiceOver יטפל אוטומטית בהודעות הניווט.
  • מ-iOS 16 ואילך NavigationView deprecated. כל קוד חדש חייב להשתמש ב-NavigationStack או ב-NavigationSplitView.

מבחינה טכנית NavigationView עדיין קומפילית ב-Xcode 26, אבל היא מסומנת @available(*, deprecated) מאז iOS 16. כלומר, Apple מזהירה שהיא עלולה להיעלם בכל גרסה עתידית ואינה מקבלת יותר תיקוני באגים. ב-iOS 26 שלוש בעיות ידועות של NavigationView החמירו במיוחד: התנהגות בלתי צפויה של NavigationLink בתוך List עם Section, שבירה של swipe-back כאשר משתמשים ב-TabView, וסטיות ב-Layout בזמן שיתוף מסך עם Live Activities.

המשמעות המעשית פשוטה: כל קוד חדש חייב להתחיל מ-NavigationStack או NavigationSplitView. אם יש לכם codebase קיים שעדיין משתמש ב-NavigationView, כדאי לתכנן מיגרציה מבוקרת, בדיוק כמו שעשינו במעבר ל-Swift Testing מ-XCTest. Apple פרסמה מדריך מיגרציה רשמי המצורף למסמכי NavigationStack ב-Apple Developer Documentation, וכולל טבלת המרה של דפוסים ישנים לחדשים.

מה ההבדל בין NavigationStack ל-NavigationSplitView?

שני הטיפוסים פותרים בעיות שונות ומתחילים משורש מתודולוגי שונה. NavigationStack חושב על ניווט כמו על מחסנית של screens שנפתחים אחד מעל השני. המשתמש מקבל תנועה של push/pop, ואתם שולטים במחסנית עם NavigationPath. NavigationSplitView לעומת זאת חושב על ניווט כמו על עמודות מקבילות בפריסת master-detail (Sidebar, Content, Detail), ומתאים אוטומטית את מספר העמודות הנראות למידת המסך.

יכולתNavigationStackNavigationSplitView
סוג ניווטהיררכי push/popאדפטיבי multi-column
פלטפורמות עיקריותiPhone, WatchiPad, Mac, TV
שליטה תכנותיתNavigationPathBindings לעמודות
מספר עמודות12 או 3
הסתגלות למסך קטןלא רלוונטימתקפל לעמודה אחת
תמיכה ב-Deep Linksמצוינתטובה (דורש coordinator)
Best fitflow לינארי, wizardאפליקציית ספרייה, מייל

המלצה שלי מהשטח: אם האפליקציה שלכם היא iPhone-only ולעולם לא תרוץ על iPad, NavigationStack מספיק לחלוטין. ברגע שאתם חושבים על iPad, Mac (כולל Catalyst ו-Designed for iPad) או visionOS, התחילו מ-NavigationSplitView ותנו לו להתקפל אוטומטית לעמודה יחידה על iPhone. שילוב הפוך, כלומר NavigationStack בשורש עם התאמה ידנית ל-iPad, יגרום לכם לכתוב את הקוד האדפטיבי בעצמכם. זו עבודה מיותרת שקשה מאוד לתחזק.

NavigationStack ב-iOS 26 עובד עם שני מבנים משלימים: NavigationLink(value:) שדוחף ערך למחסנית, ו-.navigationDestination(for:) שממפה טיפוס לתצוגה שתיווצר עבורו. השילוב הזה הופך את הניווט לtype-safe. הקומפיילר יודע איזה טיפוס מגיע לאיזה יעד, ובכך מונע קריסות בזמן ריצה שהיו רגילות ב-NavigationLink הישן, מבוסס-הסגרים.

import SwiftUI

// 1. הגדרת enum לכל היעדים
enum Route: Hashable {
    case articleDetail(id: UUID)
    case authorProfile(handle: String)
    case settings
}

// 2. שורש עם NavigationStack
struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List(articles) { article in
                NavigationLink(article.title, value: Route.articleDetail(id: article.id))
                    .accessibilityHint("פתיחת פרטי המאמר")
            }
            .navigationTitle("מאמרים")
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .articleDetail(let id):
                    ArticleDetailView(id: id)
                case .authorProfile(let handle):
                    AuthorProfileView(handle: handle)
                case .settings:
                    SettingsView()
                }
            }
        }
    }
}

שני עקרונות שנחקקו לי לזיכרון אחרי כמה אפליקציות בפרודקשן. ראשית, מקמו את .navigationDestination גבוה ככל האפשר בהיררכיה, עדיף על השורש של ה-NavigationStack ולא בתוך תא של List. שנית, אל תפצלו את ה-Route לכמה enums קטנים. enum אחד גדול הוא הרבה יותר קל לתחזק, ומאפשר לראוטר יחיד לטפל בכל האפליקציה. אם הגעתם לעשרות cases, זה הזמן לפצל לפי sub-flow ולא לפי type.

NavigationSplitView תומך בשני מבנים: שתי עמודות (sidebar + detail) או שלוש עמודות (sidebar + content + detail). ב-2026 המבנה תלת-עמודתי הפך לסטנדרט באפליקציות פרודוקטיביות כמו Notes, Reminders ו-Mail. ב-visionOS הוא הפריסה המומלצת גם עבור אפליקציות בילוי. הקסם המרכזי: על iPhone האפליקציה מתקפלת אוטומטית ל-NavigationStack וירטואלי, בלי שורת קוד נוספת. באמת בלי שום דבר, זה פשוט עובד.

struct LibraryView: View {
    @State private var selectedCategory: Category?
    @State private var selectedArticle: Article?

    var body: some View {
        NavigationSplitView {
            // עמודה 1: Sidebar
            List(categories, selection: $selectedCategory) { category in
                Label(category.name, systemImage: category.icon)
                    .tag(category)
            }
            .navigationTitle("קטגוריות")
        } content: {
            // עמודה 2: Content
            if let selectedCategory {
                List(selectedCategory.articles, selection: $selectedArticle) { article in
                    Text(article.title).tag(article)
                }
                .navigationTitle(selectedCategory.name)
            } else {
                ContentUnavailableView("בחרו קטגוריה", systemImage: "square.stack")
            }
        } detail: {
            // עמודה 3: Detail
            if let selectedArticle {
                ArticleDetailView(article: selectedArticle)
            } else {
                ContentUnavailableView("בחרו מאמר", systemImage: "doc.text")
            }
        }
    }
}

שימו לב לשני דברים. העמודות מקבלות state נפרד (selectedCategory, selectedArticle), מה שמאפשר לכם לשמור אותו ב-@SceneStorage לצורך שחזור אחרי חזרה מרקע. שנית, ContentUnavailableView (שהוצג ב-iOS 17 והורחב ב-iOS 26) הוא לא רק קישוט. הוא הופך את חוויית ה-empty state לנגישה ל-VoiceOver, ומעביר הודעה סמנטית ברורה שהמסך מחכה לפעולה.

איך עושים ניווט תכנותי ב-SwiftUI?

ניווט תכנותי פירושו שהקוד מחליט לאן ללכת, לא רק לחיצה של המשתמש. הדפוס המומלץ ב-2026 הוא ראוטר מבוסס @Observable (המקרו החדש של Swift 6.2) שמחזיק את NavigationPath ומספק API ברור לניווט. הדפוס הזה משתלב יפה עם בידוד אקטור ברירת מחדל ב-Swift 6.2, כי הראוטר יכול לרוץ על ה-MainActor בבטחה.

import SwiftUI
import Observation

@Observable
@MainActor
final class AppRouter {
    var path = NavigationPath()

    func push(_ route: Route) {
        path.append(route)
    }

    func pop() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

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

    func replace(with routes: [Route]) {
        path.removeLast(path.count)
        routes.forEach { path.append($0) }
    }
}

@main
struct SwiftCraftedApp: App {
    @State private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $router.path) {
                HomeView()
                    .navigationDestination(for: Route.self) { route in
                        RouteView(route: route)
                    }
            }
            .environment(router)
        }
    }
}

עכשיו כל תצוגה יכולה לקרוא @Environment(AppRouter.self) ולהזמין ניווט ללא NavigationLink: router.push(.settings). הדפוס הזה קריטי בשלושה תרחישים: (1) אחרי פעולה אסינכרונית (למשל, "אחרי שההזמנה נשלחה, קפוץ למסך אישור"), (2) מתוך push notification שהמשתמש לחץ עליו, (3) בזמן טיפול ב-Deep Link (הסעיף הבא). על הביצועים אין מה לדאוג. NavigationPath משתמש ב-type erasure יעיל, ואפשר לדחוף אליו עשרות ערכים בלי impact מדיד.

Deep Link הוא URL חיצוני (למשל swiftcrafted://article/42, או Universal Link מסוג HTTPS) שצריך להוביל ישירות למסך פנימי, לרוב עם היררכיה שלמה של מסכי אב. עם NavigationPath וראוטר @Observable, הטיפול הופך לפשוט: parse ה-URL להפקת [Route], ואז router.replace(with:).

extension AppRouter {
    func handle(_ url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }

        switch components.host {
        case "article":
            let idString = url.lastPathComponent
            guard let id = UUID(uuidString: idString) else { return }
            replace(with: [.articleDetail(id: id)])
        case "author":
            let handle = url.lastPathComponent
            replace(with: [.authorProfile(handle: handle)])
        default:
            break
        }
    }
}

// ב-Scene:
WindowGroup {
    NavigationStack(path: $router.path) { /* ... */ }
        .environment(router)
        .onOpenURL { url in router.handle(url) }
}

למי שרוצה להעמיק, מדריך Universal Links של Apple מסביר איך לחבר את הדומיין (apple-app-site-association) ואת Associated Domains ב-Xcode. שילוב טוב לתמיכה בזרימה מלאה: אחרי שהמשתמש חוזר לאפליקציה מ-Push Notification, קראו ל-router.handle(url) באותו אופן כדי לחזור בדיוק לאותו מסך. עשיתי את זה לאחרונה באפליקציית שירותי מזון, וזה חסך המון לוגיקה כפולה.

שילוב NavigationSplitView עם NavigationStack

הכלל הזהב: NavigationSplitView תמיד בשורש, NavigationStack בתוך עמודת ה-detail. זה מאפשר לקבל את היתרון הטוב משני העולמות. פריסה אדפטיבית ל-iPad, עם היכולת לדחוף מסכי sub-detail בתוך אותה עמודה.

NavigationSplitView {
    SidebarView(selection: $router.selectedCategory)
} detail: {
    NavigationStack(path: $router.detailPath) {
        if let category = router.selectedCategory {
            ArticleListView(category: category)
                .navigationDestination(for: Route.self) { route in
                    RouteView(route: route)
                }
        } else {
            ContentUnavailableView("בחרו קטגוריה", systemImage: "sidebar.left")
        }
    }
}

אזהרה: אל תניחו NavigationStack בשורש ואז NavigationSplitView בפנים. SwiftUI לא יודע להתמודד עם המבנה הזה, ותקבלו התנהגות תלוית פלטפורמה (על iPad מסך יתקפל, על iPhone תראו double back button). כאמור, אחד או השני בשורש, בדיוק כמו שממליצים מסמכי NavigationSplitView הרשמיים של Apple.

נגישות VoiceOver בניווט

ניווט הוא אחד המקומות הבודדים ב-SwiftUI שבהם VoiceOver מקבל טיפול אוטומטי מעולה, אבל רק אם משתמשים ב-API נכון. ברירת המחדל של NavigationLink קוראת את הטקסט של ה-Label כתווית, אבל היא לא מזכירה בהכרח שמדובר בקישור ניווט. הפתרון: לצרף .accessibilityHint קצר לכל NavigationLink, ובמסך היעד להשתמש ב-.accessibilityAddTraits(.isHeader) על הכותרת הראשית כדי לתת ל-VoiceOver עוגן לקפוץ אליו.

NavigationLink(value: Route.articleDetail(id: article.id)) {
    VStack(alignment: .leading) {
        Text(article.title).font(.headline)
        Text(article.excerpt).font(.subheadline).foregroundStyle(.secondary)
    }
}
.accessibilityElement(children: .combine)
.accessibilityLabel(article.title)
.accessibilityHint("פתיחת פרטי המאמר, \(article.readTime) דקות קריאה")

// במסך היעד:
Text(article.title)
    .font(.largeTitle)
    .accessibilityAddTraits(.isHeader)

ל-NavigationSplitView יש בונוס נגישות שמעט אנשים מנצלים: כל עמודה מקבלת סמנטיקה של Landmark. כך שמשתמשי VoiceOver יכולים לנווט בין sidebar, content, ו-detail עם swipe שמאלה/ימינה בשתי אצבעות. הגדירו .navigationTitle לכל עמודה, כי הכותרות האלו הן הטקסט שנקרא בקפיצה בין Landmarks. ואם אתם עובדים עם Foundation Models ב-iOS 26 ליצירת תיאורי accessibility דינמיים לתמונות בתוך מסכי detail, זו הזדמנות מצוינת לשלב את שני העולמות.

מלכודות נפוצות ופתרונן

1. NavigationLink בתוך List עם onTapGesture

הוספה של .onTapGesture על שורת List שמכילה NavigationLink "גונבת" את הלחיצה, וה-NavigationLink לעולם לא יופעל. הפתרון: השתמשו ב-.simultaneousGesture, או, עדיף, העבירו את הלוגיקה לתוך פעולת ה-NavigationLink עצמו.

2. path.append עם טיפוס לא רשום

אם דחפתם ל-NavigationPath ערך שאין לו .navigationDestination(for:) מתאים גבוה בהיררכיה, SwiftUI פשוט יתעלם ממנו בשקט. תמיד רשמו את כל הטיפוסים באותו מקום, עדיף מיד אחרי NavigationStack. איבדתי כמעט שעה על הבאג הזה בפרויקט אחד שלי, אז אני מזהיר.

3. שכחת @MainActor בראוטר

מאז Swift 6.2 עם approachable concurrency, ראוטר שלא מסומן @MainActor יגרום ל-runtime warning ("Publishing changes from background threads"). הפתרון פשוט: @MainActor final class AppRouter.

4. מודלים לא Hashable

ערך שנדחף ל-NavigationPath חייב להיות Hashable ו-Equatable. עם מודלי SwiftData או ObservableObject, דחפו רק את ה-id (למשל UUID), ולא את האובייקט עצמו. כך תשמרו על תאימות עם State Restoration.

5. יותר מ-100 קפיצות ב-Path

שימוש ב-NavigationPath כזיכרון היסטוריה אינסופי הוא anti-pattern. אחרי כ-100 ערכים הביצועים מתחילים לרדת. אם אתם צריכים היסטוריה אמיתית (למשל דפדפן), שמרו אותה ב-@Observable נפרד ופנו את ה-path רק ל-flow הנוכחי.

שאלות נפוצות

מתי להשתמש ב-NavigationSplitView במקום NavigationStack?

השתמשו ב-NavigationSplitView בכל פעם שהאפליקציה שלכם רצה על iPad, Mac או visionOS, ובכל פעם שיש לכם מבנה של אב-פרט (מייל, מאמרים, לקוחות). היא מתקפלת אוטומטית ל-NavigationStack על iPhone, כך שאין סיבה להימנע ממנה מלכתחילה.

האם אפשר לקנן NavigationStack בתוך NavigationStack?

לא. שני NavigationStack מקוננים גורמים לשתי שורות ניווט, סטיות ב-swipe-back והתנהגות בלתי צפויה. אם צריך פריסה מורכבת, השתמשו ב-NavigationSplitView כשורש, ובתוך עמודת ה-detail הניחו NavigationStack אחד.

איך מטפלים ב-Deep Link כאשר האפליקציה סגורה לחלוטין?

ב-App הראשי, השתמשו במודיפייר .onOpenURL על ה-WindowGroup. הוא מופעל גם כאשר האפליקציה מופעלת מ-URL וגם כאשר היא כבר רצה. חשוב לקרוא ל-router.replace(with:) ולא append, וכך תמנעו הצטברות של מסכים משיחות קודמות.

איך שומרים את מצב הניווט אחרי סגירה מחדש של האפליקציה?

NavigationPath תומך ב-Codable אם כל טיפוסי ה-Route הם Codable. שמרו את path.codable ב-@SceneStorage או ב-UserDefaults, וב-onAppear טענו אותו בחזרה עם path = NavigationPath(codable).

האם NavigationLink הישן עם destination view עדיין עובד?

הוא עובד לצרכי תאימות, אבל אי-אפשר לשלוט בו תכנותית עם NavigationPath. עברו ל-NavigationLink(value:) עם .navigationDestination(for:) בכל קוד חדש. זה גם מהיר יותר וגם מאפשר Deep Links, testing וראוטר מרכזי.

Ava Thompson
אודות הכותב Ava Thompson

SwiftUI engineer focused on declarative animations and accessibility. Will fight you about navigation stacks.