NavigationStack في SwiftUI: الدليل الكامل مع NavigationPath والتنقل البرمجي في iOS 26

دليل عملي لبناء التنقل في SwiftUI باستخدام NavigationStack وNavigationPath ونمط الموجّه Router على iOS 26، مع أمثلة كاملة للتنقل البرمجي، الروابط العميقة، دعم VoiceOver، واختبار المسارات بـ Swift Testing.

NavigationStack SwiftUI iOS 26 دليل كامل

آخر تحديث: 14 يوليو 2026

NavigationStack في SwiftUI هو الحاوية القياسية للتنقل ذي العمود الواحد منذ iOS 16، وهو يستبدل NavigationView المهجور بواجهة برمجية مبنية على البيانات وآمنة على مستوى الأنواع. بدلاً من تشغيل الشاشات عبر منطق isActive الهش (والذي كلّفني ليلتين من التصحيح في مشروع سابق)، يعمل NavigationStack على تكديس القيم (Values) لا العروض، مما يجعل التنقل البرمجي، والروابط العميقة، واستعادة الحالة أموراً طبيعية في iOS 26. حسناً، لنبدأ. في هذا الدليل سنبني نمطاً كاملاً للتنقل باستخدام NavigationPath، وموجّه (Router) قائم على @Observable، ودعم كامل لـ VoiceOver.

  • يعتمد NavigationStack على تكديس القيم (Hashable) لا العروض، مما يمكّن التنقل البرمجي دون حيل isActive.
  • يجب استخدام NavigationLink(value:) مع .navigationDestination(for:) على أعلى نقطة ممكنة في شجرة العروض.
  • NavigationPath يدعم أنواعاً متعددة في نفس المكدس، ويتيح Push/Pop برمجياً وإعادة الجذر بسطر واحد.
  • في iOS 26 ومع Swift 6.2، الأسلوب المفضّل هو موجّه (Router) قائم على ماكرو @Observable بدل ObservableObject.
  • استخدم NavigationSplitView للتخطيطات متعددة الأعمدة على iPad وmacOS، وNavigationStack للعمود الواحد على iPhone.
  • الروابط العميقة (Deep Links) تُنفَّذ عبر .onOpenURL بتحديث مسار الموجّه مباشرة، بدون شاشات وسيطة.

الفرق الجوهري بين NavigationStack وNavigationView يقع في مصدر الحقيقة. كان NavigationView يعتمد على هرمية العروض نفسها: كل NavigationLink يحمل destination بشكل ثابت، والتحكم البرمجي يتم عبر isActive أو selection، وكلاهما يفقد الحالة بسهولة عند تغيير الشاشة الجذر أو تدوير الجهاز. النتيجة كانت رمزاً غير قابل للتنبؤ به، خصوصاً عند بناء تطبيقات فيها روابط عميقة أو استعادة حالة.

مع NavigationStack (الذي صدر مع iOS 16 وأصبح المعيار الوحيد المدعوم في iOS 26)، أصبحت الحالة عبارة عن قائمة من القيم (Values). كل قيمة تنتمي إلى نوع Hashable، وSwiftUI يستدعي المُغيِّر .navigationDestination(for:) ليقرر أي عرض يجب أن يُبنى لكل قيمة. النتيجة أن التنقل صار قابلاً للتسلسل (Serializable)، قابلاً للاختبار، ويمكن تركيبه ديناميكياً في وقت التشغيل.

الفائدة العملية؟ يمكنك حفظ المسار الحالي في SceneStorage، أو تلقّي رابط عميق من نظام التشغيل، أو إعادة توجيه المستخدم بعد استجابة شبكة، كل ذلك بتعديل مصفوفة القيم دون لمس شجرة العروض. من واقع تجربتي في ترحيل تطبيقات إنتاجية خلال العام الماضي، سطور الشيفرة المتعلقة بالتنقل تنخفض عادة بنسبة تتجاوز 40% بمجرد تبنّي هذا النموذج. صدقاً، الفرق مذهل.

لبدء استخدام NavigationStack، اعتمد على المُهيّئ البسيط أولاً، ثم انتقل تدريجياً إلى الإصدار المرتبط بمسار (Path-bound). في هذا المثال نعرض قائمة كتب، وعند الضغط على أي عنصر ينتقل المستخدم إلى شاشة التفاصيل مع تمرير القيمة بأمان تام:

import SwiftUI

struct Book: Identifiable, Hashable {
    let id: UUID
    let title: String
    let author: String
}

struct BooksListView: View {
    let books: [Book] = Book.sampleData

    var body: some View {
        NavigationStack {
            List(books) { book in
                NavigationLink(value: book) {
                    VStack(alignment: .leading) {
                        Text(book.title).font(.headline)
                        Text(book.author).font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                }
            }
            .navigationTitle("المكتبة")
            .navigationDestination(for: Book.self) { book in
                BookDetailView(book: book)
            }
        }
    }
}

لاحظ ثلاثة أمور مهمة هنا. أولاً، NavigationLink يمرّر قيمة (value:) لا وجهة (destination:). ثانياً، navigationDestination(for:) مُلحَق بـ List، وليس بكل رابط على حدة، وهذا هو المطلوب لأن SwiftUI يحتاج مسجّلاً واحداً لكل نوع في المكدس. ثالثاً، Book يطابق Hashable، وهو شرط إلزامي لكل قيمة تُدفع إلى المكدس.

عندما تحتاج إلى التحكم في المكدس برمجياً (مثل الرجوع إلى الجذر، أو دفع عدة شاشات دفعة واحدة، أو الاستجابة لحدث خارجي)، استخدم NavigationPath كمصدر حقيقة صريح. NavigationPath هو حاوية تمحو النوع (Type-erased) ويمكنها استيعاب قيم من أنواع مختلفة في نفس المكدس، طالما أن كل نوع لديه مُسجَّل navigationDestination خاص به.

الأسلوب المُوصى به في 2026 هو تعريف enum واحد للمسارات، مما يمنع التناقض ويجعل جميع الوجهات مرئية في مكان واحد. المثال التالي يستخدم موجّهاً بسيطاً (سنطوّره في القسم التالي):

enum AppRoute: Hashable {
    case bookDetail(id: UUID)
    case authorProfile(name: String)
    case settings
}

struct RootView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            HomeView(path: $path)
                .navigationDestination(for: AppRoute.self) { route in
                    switch route {
                    case .bookDetail(let id):
                        BookDetailView(id: id)
                    case .authorProfile(let name):
                        AuthorProfileView(name: name)
                    case .settings:
                        SettingsView()
                    }
                }
        }
    }
}

struct HomeView: View {
    @Binding var path: NavigationPath

    var body: some View {
        VStack(spacing: 16) {
            Button("افتح الإعدادات") {
                path.append(AppRoute.settings)
            }
            Button("ادفع ثلاث شاشات دفعة واحدة") {
                path.append(AppRoute.bookDetail(id: UUID()))
                path.append(AppRoute.authorProfile(name: "غسان كنفاني"))
                path.append(AppRoute.settings)
            }
            Button("عد إلى الجذر") {
                path.removeLast(path.count)
            }
        }
    }
}

ثلاث عمليات ستستخدمها يومياً: append لدفع قيمة جديدة، removeLast(_:) لإخراج n شاشة، وremoveLast(path.count) للعودة إلى الجذر بضربة واحدة. لاحظ أن NavigationPath.count يعطيك عمق المكدس الحالي، وهو مفيد لمنطق «الرجوع أو الإغلاق» في العروض القابلة للتقديم.

نمط الموجّه (Router) مع @Observable في iOS 26

الحل الأمثل لتطبيق حقيقي هو فصل منطق التنقل عن العروض عبر كائن موجّه (Router). في iOS 26 ومع Swift 6.2، الأسلوب المُفضّل هو استخدام ماكرو @Observable بدل ObservableObject، فهو أسرع في التتبّع (لأنه يتتبّع الخصائص المستخدمة فعلاً في العرض)، ويعمل بسلاسة مع الأنماط الجديدة كـ SwiftData في iOS 26.

import SwiftUI
import Observation

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

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

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

    func popToRoot() {
        path = NavigationPath()
    }

    func replaceStack(with routes: [AppRoute]) {
        var newPath = NavigationPath()
        routes.forEach { newPath.append($0) }
        path = newPath
    }
}

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

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

    @ViewBuilder
    private func destination(for route: AppRoute) -> some View {
        switch route {
        case .bookDetail(let id): BookDetailView(id: id)
        case .authorProfile(let name): AuthorProfileView(name: name)
        case .settings: SettingsView()
        }
    }
}

struct SomeChildView: View {
    @Environment(AppRouter.self) private var router

    var body: some View {
        Button("افتح ملف المؤلف") {
            router.push(.authorProfile(name: "محمود درويش"))
        }
    }
}

لماذا هذا النمط ينتصر؟ لأنه يجعل التنقل خدمة يمكن حقنها، ومحاكاتها في الاختبارات، ومشاركتها بين شاشات متعددة. بدلاً من تمرير @Binding var path عبر خمس طبقات، يستدعي أي عرض router.push(...) مباشرة. وإذا احتجت لاحقاً إلى تسجيل أحداث التحليلات لكل انتقال، فلديك مكان واحد لفعل ذلك.

كيف تنفّذ الروابط العميقة (Deep Linking) في SwiftUI؟

الروابط العميقة كانت السبب الرئيسي لمعاناة المطورين مع NavigationView. مع NavigationStack + موجّه، أصبحت المسألة عبارة عن ترجمة URL إلى قائمة AppRoute ثم تعيينها للمسار. لا حاجة لشاشات وسيطة، ولا لتأخيرات مصطنعة:

extension AppRouter {
    func handle(url: URL) {
        guard url.scheme == "bookapp",
              let host = url.host else { return }

        switch host {
        case "book":
            if let idString = url.pathComponents.dropFirst().first,
               let id = UUID(uuidString: idString) {
                replaceStack(with: [.bookDetail(id: id)])
            }
        case "author":
            let name = url.pathComponents.dropFirst().first ?? ""
            replaceStack(with: [.authorProfile(name: name)])
        default:
            popToRoot()
        }
    }
}

// في العرض الجذر:
.onOpenURL { url in
    router.handle(url: url)
}

لأن replaceStack يستبدل المسار كاملاً، يمكنك بناء تدفقات معقدة مثل «افتح المستخدم X ثم أظهر منشوره Y» بمصفوفة من عنصرين. وإذا أردت الحفاظ على شاشات المستخدم الحالية والإضافة فوقها، استخدم path.append(...) ببساطة. الميزة أن كل شيء يبقى قابلاً للاختبار: أعطِ الموجّه URL، وتحقّق من محتوى path بعدها.

للتعمق في التكامل مع النظام، راجع دليلنا حول إطار App Intents في iOS 26، الذي يُظهر كيف يمكن لطلبات Siri وShortcuts أن تدفع مسارات مباشرة إلى الموجّه بنفس الآلية.

يقدّم SwiftUI حاويتين مختلفتين للتنقل، وكلٌّ منهما مصمم لغرض محدد. Apple توصي رسمياً باستخدام NavigationStack للتخطيطات ذات العمود الواحد (شائعة على iPhone)، وNavigationSplitView للتخطيطات متعددة الأعمدة (شائعة على iPad وmacOS). الاختيار الخاطئ يعني إما تصميماً غير مُلائم للجهاز، أو معاناة مع API غير مناسب لحالة الاستخدام.

الميزةNavigationStackNavigationSplitView
الأنسب لـiPhone وواجهة عمود واحدiPad، Mac، واجهات متعددة الأعمدة
نموذج البياناتمكدس قيم (Stack of values)تحديد قيم لكل عمود (Selection per column)
التنقل البرمجيNavigationPath مرن جداً@State منفصلة لكل عمود
التكيّف على iPhoneيعمل بشكل طبيعييطوي الأعمدة تلقائياً إلى مكدس
Deep Linkingممتاز عبر NavigationPathمدعوم لكن يتطلب تنسيق حالات متعددة
الانتقالات (Transitions)Push/Pop قياسيةتبديل أعمدة متزامن
مثال حقيقيتطبيق تصفح المهامMail، Notes، الإعدادات

القاعدة العملية: إذا كان تطبيقك «قائمة → تفاصيل → تفاصيل أعمق»، استخدم NavigationStack. إذا كان «شريط جانبي + محتوى + تفاصيل»، استخدم NavigationSplitView. ولاستهداف iPhone وiPad معاً بأناقة، يمكنك تضمين NavigationStack داخل عمود التفاصيل في NavigationSplitView، وهو نمط شائع في تطبيقات الأخبار والمكتبات.

سأقولها بصراحة: كثير من التطبيقات تُطلق نسخاً «جميلة» لكنها كارثة عند تشغيل VoiceOver. إمكانية الوصول ليست ميزة تُضاف لاحقاً، بل قرار يُتَّخذ منذ اللحظة الأولى لبناء التنقل. لحسن الحظ، NavigationStack يقوم بالجزء الصعب نيابة عنك، بشرط أن تحترم بضع قواعد.

أولاً، احرص على أن يكون لكل شاشة navigationTitle واضح، لأن VoiceOver ينطقه فور دفع الشاشة الجديدة، وهو مصدر التوجيه الأساسي للمستخدم الكفيف. ثانياً، إذا كان NavigationLink يحتوي على أيقونة ونص، لُف المحتوى الفعلي في Label لتصبح قراءة VoiceOver متسلسلة ومنطقية:

NavigationLink(value: AppRoute.settings) {
    Label {
        Text("الإعدادات")
    } icon: {
        Image(systemName: "gearshape")
    }
}
.accessibilityHint("افتح الإعدادات العامة للتطبيق")

ثالثاً، عند التنقل البرمجي (مثلاً بعد تسجيل الدخول)، أعلن التغيير يدوياً باستخدام AccessibilityNotification.ScreenChanged ليخبر VoiceOver أن السياق تغيّر. رابعاً، اختبر تطبيقك مع أكبر إعداد لـ Dynamic Type (AX5). إذا انكسر شريط التنقل، فعادةً السبب هو عنوان طويل مثبّت في وضع inline بدل large. استخدم .navigationBarTitleDisplayMode(.large) لإعطاء العنوان مساحة للتنفس، أو قصّره بذوق.

الأخطاء الشائعة عند العمل مع NavigationStack

بعد ترحيل أكثر من عشرة تطبيقات إنتاجية من NavigationView، رأيت نفس المشكلات تتكرر. إليك أهمها والحلول المُختبرة:

1. تسجيل navigationDestination مرتين لنفس النوع

إذا سجّلت نوعاً في List، ثم سجّلته مجدداً في عرض ابن، سيستخدم SwiftUI الأخير الذي يجده لكن مع تحذير في السجل. الحل: سجّل كل نوع مرة واحدة فقط على أعلى مستوى ممكن، ويفضّل مباشرة داخل NavigationStack.

2. تمرير Binding بدلاً من استخدام Environment

تمرير @Binding var path عبر خمس أو ست طبقات هو رائحة تصميم سيئة، ويجعل إعادة الهيكلة مؤلمة. حقن الموجّه عبر .environment(router) يُنظّف الشيفرة ويقلل الأعطال.

3. الاعتماد على NavigationLink الابتدائي مع الوجهة الثابتة

الشكل القديم NavigationLink { DestinationView() } label: { ... } يعمل، لكنه يُنشئ الوجهة عاجلاً وليس عند الحاجة، ويضخّم شجرة العروض. استخدم دائماً الشكل القائم على القيمة: NavigationLink(value: route) { label }.

4. عدم التعامل مع dismiss داخل الشاشة المدفوعة

عندما تريد إغلاق شاشة برمجياً من داخلها (مثلاً بعد حفظ ناجح)، استخدم @Environment(\.dismiss) بدلاً من مناداة الموجّه. هذا يبقي منطق العرض داخل العرض، ويعمل بغض النظر عن كيفية تقديم الشاشة (Sheet أم Push).

5. فقدان حالة المكدس عند تدوير الجهاز

إذا كنت تستخدم @State عادي لـ NavigationPath، فإنه يُعاد إنشاؤه في بعض حالات إعادة التركيب. الحل: خزّنه في الموجّه (كائن @Observable يبقى حياً)، أو استخدم @SceneStorage إذا كنت تريد استعادة الحالة عبر تشغيلات التطبيق.

لأنماط إعادة تركيب العروض والحالة، ستجد أن مبادئ Liquid Glass في SwiftUI تتكامل بشكل ممتاز مع نمط الموجّه، إذ يبقى المحتوى مستقراً بينما تتحرك طبقات الزجاج.

اختبار التنقل: SwiftUI Previews وSwift Testing

الميزة الأهم لنمط الموجّه أن التنقل أصبح قابلاً للاختبار بدون رفع أي عرض على الشاشة. تحتاج فقط إلى إنشاء موجّه، استدعاء الأفعال، ثم فحص path. مع إطار Swift Testing في Xcode 26، تصبح كتابة هذه الاختبارات أسرع وأنظف من XCTest:

import Testing
@testable import BookApp

@Suite("سلوك الموجّه")
struct AppRouterTests {
    @Test("push يزيد عمق المكدس")
    func pushIncrementsDepth() {
        let router = AppRouter()
        #expect(router.path.count == 0)

        router.push(.settings)

        #expect(router.path.count == 1)
    }

    @Test("popToRoot يفرّغ المسار")
    func popToRootClearsPath() {
        let router = AppRouter()
        router.push(.settings)
        router.push(.bookDetail(id: UUID()))

        router.popToRoot()

        #expect(router.path.isEmpty)
    }

    @Test("handle(url:) يفسّر رابط الكتاب")
    func deepLinkParsesBookURL() {
        let router = AppRouter()
        let id = UUID()
        let url = URL(string: "bookapp://book/\(id.uuidString)")!

        router.handle(url: url)

        #expect(router.path.count == 1)
    }
}

للاختبار البصري، استخدم #Preview مع سيناريوهات مختلفة عبر تمرير موجّه معبأ مسبقاً. هذا يسمح لك بمعاينة تطبيقك في «حالة عميقة» دون النقر يدوياً عبر ثلاث شاشات في كل مرة.

للتعمق أكثر في واجهة برمجة التطبيقات الرسمية، راجع توثيق Apple الرسمي لـ NavigationStack ومرجع NavigationPath، بالإضافة إلى دليل Apple لبناء بنية تنقل قوية الذي يغطي حالات الاستخدام الأكثر تقدماً.

الأسئلة الشائعة

ما الفرق بين NavigationStack و NavigationView في SwiftUI؟

NavigationView كان يعتمد على هرمية العروض ويتحكم في التنقل عبر isActive، بينما NavigationStack يعتمد على مكدس قيم (Hashable) ومُغيّر navigationDestination(for:). النتيجة: تنقل برمجي موثوق، دعم أفضل للروابط العميقة، وحالة قابلة للتسلسل والاختبار. NavigationView أصبح مهجوراً في iOS 16، ولا يُنصح باستخدامه في أي مشروع جديد يستهدف iOS 16 فما فوق.

كيف أعود إلى الشاشة الجذر من أي شاشة داخل NavigationStack؟

إذا كنت تدير المسار عبر @State private var path = NavigationPath()، استدعِ path.removeLast(path.count) أو ببساطة path = NavigationPath() لإعادة تعيينه. مع موجّه قائم على @Observable، أضف طريقة popToRoot() تفعل ذلك، ونادِها من أي عرض عبر router.popToRoot().

هل يمكنني وضع أنواع مختلفة من القيم في نفس NavigationPath؟

نعم، وهذه إحدى نقاط القوة الأساسية لـ NavigationPath، فهو حاوية تمحو النوع (Type-erased). يكفي أن يكون كل نوع مطابقاً لـ Hashable، وأن تسجّل مُغيّر navigationDestination(for:) منفصل لكل نوع على مستوى الجذر. هذا يفيد عندما تريد دفع كتاب ثم مؤلف ثم إعدادات في نفس المكدس.

متى يجب استخدام NavigationSplitView بدلاً من NavigationStack؟

استخدم NavigationSplitView عندما يحتاج تطبيقك إلى تخطيط متعدد الأعمدة، مثل شريط جانبي + قائمة + تفاصيل، وهو التخطيط النموذجي لتطبيقات iPad وmacOS. على iPhone سيطوى تلقائياً إلى عرض عمود واحد. أما إذا كان تدفقك «قائمة تؤدي إلى تفاصيل»، فـ NavigationStack هو الاختيار الأبسط والأنسب.

كيف أنفّذ Deep Linking في تطبيق SwiftUI يستخدم NavigationStack؟

أضف .onOpenURL { url in router.handle(url: url) } على العرض الجذر. داخل handle(url:) افحص المُخطّط (Scheme) والمضيف (Host)، ثم استبدل مسار الموجّه بمصفوفة [AppRoute] تصف الوجهة النهائية. هذه الطريقة تُلغي الحاجة إلى شاشات وسيطة وتبقي المنطق قابلاً للاختبار.

Ava Thompson
عن الكاتب Ava Thompson

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