NavigationStack در SwiftUI iOS 26: راهنمای کامل ناوبری، Deep Linking و بازیابی وضعیت

راهنمای عملی NavigationStack در SwiftUI برای iOS 26: از NavigationPath و مقصدهای تایپ‌شده تا Deep Linking، بازیابی وضعیت با SceneStorage و رفتار VoiceOver.

NavigationStack در SwiftUI: راهنمای iOS 26

به‌روزرسانی: ۱۰ ژوئیه ۲۰۲۶

NavigationStack در SwiftUI مؤلفهٔ استاندارد ناوبری پشته‌ای iOS از نسخهٔ ۱۶ به بعد است که با یک مسیر تایپ‌شده (NavigationPath) ناوبری برنامه‌نویسی، Deep Linking و بازیابی وضعیت را ممکن می‌کند و در iOS 26 با بهبود انیمیشن‌های Liquid Glass و رفتار VoiceOver همراه شده است. راستش را بخواهید، من یک اپلیکیشن کامل را از NavigationView به NavigationStack مهاجرت داده‌ام و در این راهنما همان تجربه را با شما به اشتراک می‌گذارم؛ یاد می‌گیرید چگونه یک پشتهٔ ناوبری قابل بازیابی، قابل تست و کاملاً در دسترس بسازید، بدون افتادن در دام‌هایی که NavigationView قدیمی داشت.

  • NavigationStack از iOS 16 جایگزین NavigationView در سبک stack شده و در iOS 26 با انتقال‌های Liquid Glass و رفتار پیش‌فرض بهتر VoiceOver ادغام شده است.
  • مقصدها با اصلاح‌گر navigationDestination(for:) بر اساس نوع داده ثبت می‌شوند نه بر اساس نمای مقصد؛ این کار Deep Linking را بی‌درد می‌کند.
  • NavigationPath یک صف تایپ‌شده و پاک‌کننده نوع (type-erased) است که push، pop و pop-to-root را با یک متغیر @State اداره می‌کند.
  • ذخیرهٔ مسیر با @SceneStorage و کدگذاری NavigationPath.CodableRepresentation بازیابی کامل جلسهٔ کاربر پس از بستن برنامه را ممکن می‌کند.
  • برای دستگاه‌های چندستونه (iPad و Mac) از NavigationSplitView استفاده کنید و NavigationStack را در ستون جزئیات لانه کنید.
  • VoiceOver در iOS 26 با accessibilityLabel و accessibilityHint روی لینک‌ها همراه با رفتار پیش‌فرض «Back» مقصد را اعلام می‌کند، بدون نیاز به فوکوس دستی.

NavigationStack از iOS 16 به‌طور کامل جایگزین حالت .stack از NavigationView شد و مدلی مبتنی بر داده به‌جای مدلی مبتنی بر نما ارائه کرد. تفاوت اصلی این است که در NavigationView مقصد به صورت نمای درون NavigationLink ساخته می‌شد، یعنی SwiftUI مجبور بود بدنهٔ همهٔ لینک‌ها را زودتر از موعد ارزیابی کند و در فهرست‌های بزرگ حافظه به‌شدت مصرف می‌شد. در NavigationStack، لینک‌ها فقط یک مقدار را به پشته اضافه می‌کنند و مقصد با navigationDestination(for:) بر اساس نوع آن مقدار ساخته می‌شود؛ ساخت مقصد به تأخیر می‌افتد و همان الگو نقطهٔ ورودی Deep Linking نیز هست.

تفاوت دوم مسیر تایپ‌شده است. حالا می‌توانید یک آرایه یا NavigationPath نگه دارید که وضعیت پشته را به شما می‌گوید و اجازه می‌دهد به‌صورت برنامه‌نویسی به هر لایه بروید یا از آن برگردید. در iOS 26 اپل انیمیشن‌های پیش‌فرض این پشته را با سیستم Liquid Glass در SwiftUI هماهنگ کرده و لبهٔ شفاف نوار ناوبری هنگام اسکرول محتوا به‌طور خودکار شفافیت مناسب می‌گیرد. توسعه‌دهندگانی که هنوز از NavigationView استفاده می‌کنند، در iOS 26 اخطارهای واقعی deprecation دریافت می‌کنند. در پروژه‌های Swift 6 سخت‌گیرانه، بازنویسی به NavigationStack تقریباً اجباری است.

ویژگیNavigationView (قدیمی)NavigationStack (iOS 16+)
مدل دادهمبتنی بر نمامبتنی بر مقدار / نوع
ناوبری برنامه‌نویسیضعیف (نیاز به Binding)NavigationPath / آرایه
ساخت مقصدEagerLazy
Deep Linkingپیچیدهبومی و ساده
بازیابی وضعیتنداردCodable + SceneStorage
iOS 26 Liquid Glassپشتیبانی نمی‌شودپشتیبانی پیش‌فرض

راه‌اندازی سریع NavigationStack با مقصد تایپ‌شده

ساده‌ترین NavigationStack یک پشته است که مقادیر Hashable را می‌پذیرد و مقصد را با navigationDestination(for:) ثبت می‌کند. من ترجیح می‌دهم مدل مسیر (مثلاً Route) را در همان اپلیکیشن به صورت یک enum تعریف کنم تا مسیرها متمرکز باشند و تست شوند. این کار همچنین به معنی این است که تنها یک نقطهٔ واحد وجود دارد که هر Route جدید را به یک نما ترجمه کند، دقیقاً همان جایی که Deep Linking بعداً وصل می‌شود.

import SwiftUI

enum Route: Hashable {
    case articleDetail(id: UUID)
    case authorProfile(handle: String)
    case settings
}

struct ContentView: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            ArticleListView(onSelect: { article in
                path.append(.articleDetail(id: article.id))
            })
            .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؛ SwiftUI اصلاح‌گر را در بالاترین لایهٔ ممکن ثبت می‌کند. دوم، همیشه یک نوع واحد (Route) برای پشته انتخاب کنید. مخلوط کردن انواع (مثلاً String و Int) به NavigationPath نیاز دارد و اشکال‌زدایی را دشوار می‌کند. سوم، مقادیر باید Hashable باشند. اگر داده‌های شما نیستند، معمولاً کافی است شناسه (UUID) را در enum قرار دهید نه مدل کامل.

وقتی مسیر شما فقط یک نوع را نگه می‌دارد، [Route] کافی است. اما اگر می‌خواهید همزمان چند نوع (مثلاً Route برای مقالات و UserRoute برای پروفایل کاربر) در یک پشته وجود داشته باشند، NavigationPath ابزار درست است: یک ظرف پاک‌کننده نوع که هر مقدار Hashable را می‌پذیرد و ترتیب push/pop را حفظ می‌کند. خب، من در پروژه‌های واقعی معمولاً NavigationPath را پشت یک @Observable router قرار می‌دهم تا هر جای برنامه بتواند از آن ناوبری کند بی‌آنکه با NavigationStack مستقیم گره بخورد.

import SwiftUI
import Observation

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

    func push<T: Hashable>(_ value: T) {
        path.append(value)
    }

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

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

struct RootView: View {
    @State private var router = AppRouter()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    RouteView(route: route)
                }
                .navigationDestination(for: UserRoute.self) { userRoute in
                    UserRouteView(route: userRoute)
                }
        }
        .environment(router)
    }
}

ترکیب @Observable (از فریم‌ورک Observation در Swift 5.9 و بالاتر) با NavigationPath یک الگوی تمیز و قابل تست است. می‌توانید در تست‌های واحد router را بسازید، چند مقدار push کنید و ادعا کنید که path.count برابر انتظار شماست. اگر با اکتورها و همزمانی مدرن Swift کار می‌کنید، حتماً به راهنمای همزمانی Swift 6 نگاهی بیندازید تا router خود را با @MainActor علامت‌گذاری کنید و از خطاهای data race در حالت strict concurrency جلوگیری کنید.

چگونه Deep Linking در SwiftUI پیاده‌سازی کنیم؟

Deep Linking در NavigationStack یعنی تبدیل یک URL به دنباله‌ای از مقادیر که مستقیماً به path اضافه می‌شود. چون مقصدها بر اساس نوع ثبت شده‌اند، اگر URL را به یک یا چند Route ترجمه کنید، NavigationStack خودش هرم صحیح از نماها را بازسازی می‌کند. در پروژه‌ای که اپلیکیشن ما اعلان push داشت، این کار را در onOpenURL در سطح ریشه انجام دادم تا هم Universal Link‌ها، هم پیوندهای اپلیکیشنی و هم Handoff را در یک نقطه اداره کنم.

struct RootView: View {
    @State private var router = AppRouter()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Route.self) { RouteView(route: $0) }
        }
        .environment(router)
        .onOpenURL { url in
            router.handle(url: url)
        }
    }
}

extension AppRouter {
    func handle(url: URL) {
        guard url.host == "swiftcrafted.dev" else { return }
        let parts = url.pathComponents.dropFirst()
        var newPath = NavigationPath()

        switch Array(parts) {
        case ["article", let id]:
            if let uuid = UUID(uuidString: id) {
                newPath.append(Route.articleDetail(id: uuid))
            }
        case ["author", let handle]:
            newPath.append(Route.authorProfile(handle: handle))
        case ["settings"]:
            newPath.append(Route.settings)
        default:
            break
        }

        path = newPath
    }
}

سه رفتار مهم که در مستندات رسمی NavigationStack اپل تأکید شده. (۱) تخصیص یک NavigationPath کاملاً جدید به path باعث می‌شود SwiftUI انیمیشن انتقال از ریشه به مقصد را اجرا کند؛ این همان چیزی است که کاربر انتظار دارد وقتی از یک اعلان push به عمق برنامه می‌رود. (۲) اگر بخواهید چند مقصد پشت سر هم (مثلاً article/:id/comments/:cid) push شود، هر دو را با append اضافه کنید. (۳) اگر URL نامعتبر است، سکوت نکنید. یک نمای «صفحه یافت نشد» push کنید تا کاربر گمراه نشود.

بازیابی وضعیت ناوبری با SceneStorage

iOS به‌طور تهاجمی صحنه‌های پس‌زمینه را می‌بندد. اگر کاربر در عمق پشتهٔ شماست و بعد از یک ساعت برمی‌گردد، هیچ‌کس دوست ندارد به ریشه پرت شود. NavigationStack همراه با NavigationPath.CodableRepresentation و @SceneStorage این مسئله را در چند خط حل می‌کند، به شرطی که همهٔ مقادیر پشتهٔ شما Codable باشند. این یکی از دلایلی است که من همیشه از enum Route: Hashable, Codable شروع می‌کنم. هزینه‌ای در ابتدا ندارد و بعداً بازیابی وضعیت را رایگان می‌کند.

struct RootView: View {
    @SceneStorage("nav.path") private var pathData: Data?
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: Route.self) { RouteView(route: $0) }
        }
        .onAppear(perform: restore)
        .onChange(of: path) { _, newPath in
            persist(newPath)
        }
    }

    private func restore() {
        guard let data = pathData,
              let repr = try? JSONDecoder().decode(
                NavigationPath.CodableRepresentation.self, from: data)
        else { return }
        path = NavigationPath(repr)
    }

    private func persist(_ path: NavigationPath) {
        guard let repr = path.codable else { return }
        pathData = try? JSONEncoder().encode(repr)
    }
}

در iOS 26 اپل رفتار @SceneStorage را برای iPad Stage Manager بهبود داده است. هر نمونهٔ صحنه مسیر خود را دارد، بنابراین کاربران می‌توانند دو نسخه از برنامهٔ شما را در دو حالت متفاوت باز نگه دارند بدون اینکه پشته‌ها با هم قاطی شوند. تنها نکته این است که اندازهٔ داده در @SceneStorage محدود است (حدود ۲KB)، پس اگر پشته‌های عمیق ساخته می‌شود، شناسه‌های کوتاه و شمارشی نگه دارید نه مدل‌های پر و پیمان.

پاسخ کوتاه: هر زمان که برنامهٔ شما روی iPad، Mac Catalyst یا نسخه‌های visionOS اجرا می‌شود و رابط کاربری شما دو یا سه ستون منطقی دارد (مثلاً فهرست ← جزئیات، یا نوار کناری ← فهرست ← جزئیات)، NavigationSplitView ابزار درست است. NavigationStack برای پشته‌های خطی iPhone و ستون آخر SplitView مناسب است، و هر دو با هم عالی کار می‌کنند. اپل در راهنمای Human Interface Guidelines برای ناوبری این تفکیک را روشن کرده است.

struct SplitRoot: View {
    @State private var selectedCategory: Category?
    @State private var articlePath = NavigationPath()

    var body: some View {
        NavigationSplitView {
            SidebarView(selection: $selectedCategory)
        } content: {
            if let category = selectedCategory {
                CategoryArticleList(category: category)
            } else {
                ContentUnavailableView(
                    "دسته‌بندی را انتخاب کنید",
                    systemImage: "sidebar.left"
                )
            }
        } detail: {
            NavigationStack(path: $articlePath) {
                ArticlePlaceholder()
                    .navigationDestination(for: Route.self) { RouteView(route: $0) }
            }
        }
    }
}

اگر برنامهٔ شما مانند SwiftData داده‌های ساختارمند دارد و می‌خواهید فهرستی از موجودیت‌ها را در ستون میانی و جزئیات را در ستون آخر نشان دهید، الگوی SplitView کاملاً طبیعی است. برای این نوع الگوها، راهنمای SwiftData در iOS 26 نشان می‌دهد چطور @Query را با ستون میانی ترکیب کنید و از fetching پرحجم اجتناب کنید.

دسترس‌پذیری و رفتار VoiceOver در iOS 26

خب، این جایی است که NavigationStack واقعاً می‌درخشد. وقتی کاربری با VoiceOver روی یک NavigationLink ضربه می‌زند، iOS 26 به‌طور خودکار «به عقب» را در نوار پیمایش اعلام می‌کند و فوکوس را روی عنوان مقصد قرار می‌دهد؛ رفتاری که در NavigationView قدیمی به‌طور قابل اطمینان کار نمی‌کرد. کاری که شما باید انجام دهید این است که هر لینک یا محرک ناوبری برنامه‌نویسی را با یک برچسب معنادار همراه کنید و در مقاصد، عنوان نوار ناوبری را با .navigationTitle() صریح تنظیم کنید. VoiceOver این عنوان را هنگام رسیدن اعلام می‌کند.

NavigationLink(value: Route.articleDetail(id: article.id)) {
    ArticleRow(article: article)
}
.accessibilityLabel("مقاله: \(article.title)")
.accessibilityHint("برای خواندن جزئیات فعال کنید")

// در مقصد:
ArticleDetailView(id: id)
    .navigationTitle(article.title)
    .navigationBarTitleDisplayMode(.inline)

سه رفتار پیش‌فرض مفید که باید بدانید. (۱) دکمهٔ Back در iOS 26 به‌طور خودکار «بازگشت به {عنوان قبلی}» را در VoiceOver اعلام می‌کند. (۲) وقتی به‌صورت برنامه‌نویسی push می‌کنید (مثلاً از onOpenURL)، فوکوس VoiceOver به عنوان صفحه مقصد منتقل می‌شود؛ پس باید navigationTitle را در مقصد تنظیم کنید. (۳) اگر انتقال Liquid Glass روی صفحه سریع اجرا می‌شود و کاربر Reduce Motion را روشن کرده است، iOS 26 به‌طور خودکار انیمیشن‌های نوار ناوبری را ساده می‌کند. کد شما نیاز به تغییر ندارد، اما نگرانی‌های عملکردی خودتان را روی صفحه‌های ضعیف‌تر آزمایش کنید.

اشتباهات رایج و الگوهای بهتر

در بازبینی‌های کد این چند اشتباه را بارها می‌بینم و همه در چند دقیقه قابل رفع هستند:

  1. ثبت navigationDestination در چند سطح: اگر همان نوع را در ریشه و دوباره در یک مقصد ثبت کنید، SwiftUI پیامی می‌دهد و رفتار غیرقابل پیش‌بینی می‌شود. هر نوع را فقط یک بار در ریشه ثبت کنید.
  2. استفاده از NavigationLink بدون مقدار: شکل قدیمی NavigationLink { Destination() } label: { ... } در iOS 26 هنوز کار می‌کند اما پیام‌های اخطار می‌دهد و ناوبری برنامه‌نویسی را نمی‌پذیرد. همیشه از فرم NavigationLink(value:) استفاده کنید.
  3. مدل‌های سنگین در مسیر: اگر کل مدل مقاله را در path می‌ریزید، بازیابی وضعیت هر بار داده‌های اضافه serialize می‌کند. فقط شناسه را نگه دارید و مدل را در مقصد از cache یا SwiftData بازیابی کنید.
  4. فراموش کردن Codable برای Route: اگر می‌خواهید SceneStorage کار کند، هر مقدار در پشته باید Codable باشد. یک ‌بار Route را Codable کنید تا برای همیشه راحت باشید.
  5. ترکیب TabView و NavigationStack به‌صورت اشتباه: NavigationStack باید درون هر تب باشد نه دور TabView. در غیر این صورت push از یک تب باعث مخفی شدن کل نوار تب می‌شود.

در نهایت، الگویی که در پروژه‌های تولیدی همیشه توصیه می‌کنم این است: یک @Observable router بسازید، مسیرها را در یک enum جمع کنید، Deep Linking را در onOpenURL اداره کنید و مسیر را با @SceneStorage ذخیره کنید. این چهار قطعه با هم کمتر از ۱۰۰ خط کد است اما نتیجه‌ای می‌دهد که هم قابل تست است، هم در پس‌زمینه زنده می‌ماند و هم برای VoiceOver مثل یک ساعت کار می‌کند. اگر برنامهٔ شما به Widget یا App Intents متصل است، همان Route enum را می‌توانید در Intentهای App Shortcuts برگردانید و ناوبری از Siri به‌صورت رایگان کار می‌کند.

سؤالات پرتکرار

آیا NavigationStack جایگزین کامل NavigationView است؟

برای پشته‌های خطی روی iPhone بله. برای رابط‌های چندستونه (iPad، Mac، visionOS) به NavigationSplitView نیاز دارید که در ستون جزئیات خود از NavigationStack استفاده می‌کند.

آیا می‌توانم انواع مختلف را در یک NavigationPath ذخیره کنم؟

بله. NavigationPath پاک‌کنندهٔ نوع است و هر مقدار Hashable را می‌پذیرد. اگر می‌خواهید بازیابی وضعیت هم کار کند، مقادیر باید Codable نیز باشند و باید navigationDestination(for:) را برای هر نوع جداگانه ثبت کنید.

چطور می‌توانم به ریشهٔ پشته برگردم؟

اگر از آرایه استفاده می‌کنید، path.removeAll() کافی است. برای NavigationPath از path.removeLast(path.count) استفاده کنید. هر دو با انیمیشن پیش‌فرض NavigationStack اجرا می‌شوند.

آیا NavigationStack با ماژول Combine کار می‌کند؟

بله، اما توصیه من در iOS 26 استفاده از فریم‌ورک Observation (@Observable) به‌جای ObservableObject است. کارایی بهتر و ادغام روان‌تر با NavigationPath دارد.

چرا Deep Link من گاهی صفحه را نشان نمی‌دهد؟

بیشتر اوقات دلیل این است که onOpenURL در سطح ریشه (روی نمای درون NavigationStack) نصب نشده است، یا مسیر پیش از ثبت navigationDestination تنظیم شده است. مطمئن شوید path را با یک NavigationPath کاملاً جدید جایگزین می‌کنید نه اینکه فقط append کنید.

Ava Thompson
درباره نویسنده Ava Thompson

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