تطوير الويب

التنقّل بين الشاشات بـ NavigationStack وNavigationSplitView

4 min de lecture

📘 الدليل الرئيسي: تطوير تطبيق iOS بـ Swift وSwiftUI: بانوراما 2026. هذا الدليل يفترض إتقان أساسيات SwiftUI المُغطّاة في بناء أوّل view SwiftUI.

التنقّل أحد الجوانب التي تطوّر فيها SwiftUI أكثر منذ خروجه. أوّل واجهة NavigationView أظهرت حدودها (دعم سيّء للبرمجة، سلوك غير متّسق بين iPhone وiPad). منذ iOS 16، مكوّنان يتشاركان الدور: NavigationStack لأكوام كلاسيكية بعمود واحد، NavigationSplitView لتخطيطات متعدّدة الأعمدة مناسبة للشاشات الواسعة. يُفَصِّل هذا الدليل المكوّنَين خطوة بخطوة، مع مشروع كتالوج كتب.

المتطلّبات

  • Xcode 26 على macOS Tahoe.
  • هدف نشر iOS 16 كحدّ أدنى (NavigationStack)، iOS 17 موصى به للاستفادة من @Observable.
  • أساسيات SwiftUI: views، modifiers، state.
  • الوقت المتوقّع: 60 إلى 90 دقيقة.

الخطوة 1 — إنشاء كومة تنقّل بسيطة

المكوّن NavigationStack يُغَلِّف المحتوى ويُفَعِّل التنقّل الهرمي. داخله، نستعمل NavigationLink لتشغيل الانتقال نحو شاشة هدف.

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                NavigationLink("Détail simple") {
                    Text("Vous êtes sur l'écran de détail")
                        .navigationTitle("Détail")
                }

                NavigationLink {
                    DetailComplexe(message: "Bonjour")
                } label: {
                    Label("Détail avec paramètre", systemImage: "arrow.right")
                }
            }
            .padding()
            .navigationTitle("Accueil")
        }
    }
}

struct DetailComplexe: View {
    let message: String
    var body: some View {
        Text(message)
            .font(.title)
            .navigationTitle("Détail")
    }
}

عند التشغيل، simulator يعرض شاشة الترحيب برابطَين. النقر يدفع شاشة الهدف على الكومة مع رسم slide-in الكلاسيكي لـ iOS، وشريط التنقّل يعرض زرّ عودة. navigationTitle يجب استدعاؤه داخل شاشة الهدف، لا على NavigationStack — مصدر سوء فهم متكرّر للقادمين الجدد.

الخطوة 2 — التنقّل البرمجي بـ path binding

الجديد الكبير في NavigationStack هو التنقّل البرمجي النظيف. نُمَرِّر له binding نحو مصفوفة تُمَثِّل الكومة الحالية. دفع شاشة يعني إضافة عنصر للمصفوفة؛ العودة للجذر تعني إفراغها.

struct ContentView: View {
    @State private var chemin: [String] = []

    var body: some View {
        NavigationStack(path: $chemin) {
            VStack(spacing: 16) {
                Button("Aller en page 1") {
                    chemin.append("page-1")
                }
                Button("Aller en page 2 directement") {
                    chemin = ["page-1", "page-2"]
                }
                Button("Tout réinitialiser") {
                    chemin.removeAll()
                }
            }
            .navigationTitle("Accueil")
            .navigationDestination(for: String.self) { id in
                Text("Vous êtes sur \(id)")
                    .navigationTitle(id)
            }
        }
    }
}

الـ modifier .navigationDestination(for:) يُعلن view الهدف لنوع معطى. المصفوفة chemin تحوي القيم المدفوعة على الكومة؛ NavigationStack يستنبط هرمية الـ views من هذه المصفوفة. هذا النموذج يُتيح deep-link، استعادة حالة عند إعادة الإقلاع، وعودة للجذر بسطر واحد.

الخطوة 3 — التنقّل المكتَّب نحو نموذج

النمط السابق يستعمل strings كقيم كومة. في app حقيقي، نُفَضِّل دفع نموذج عمل (كتاب، منتج، مستخدم) وإحالة العرض إلى view مخصَّصة. NavigationLink(value:) و.navigationDestination(for:) يجعلان هذا النمط طبيعيًّا.

struct Livre: Identifiable, Hashable {
    let id = UUID()
    let titre: String
    let auteur: String
    let pages: Int
}

struct CatalogueView: View {
    let livres: [Livre] = [
        Livre(titre: "Le Soleil des indépendances", auteur: "Ahmadou Kourouma", pages: 195),
        Livre(titre: "L'Aventure ambiguë",          auteur: "Cheikh Hamidou Kane",  pages: 191),
        Livre(titre: "Les Bouts de bois de Dieu",    auteur: "Ousmane Sembène",      pages: 379)
    ]

    var body: some View {
        NavigationStack {
            List(livres) { livre in
                NavigationLink(value: livre) {
                    VStack(alignment: .leading) {
                        Text(livre.titre).font(.headline)
                        Text(livre.auteur).font(.subheadline)
                    }
                }
            }
            .navigationTitle("Catalogue")
            .navigationDestination(for: Livre.self) { livre in
                DetailLivre(livre: livre)
            }
        }
    }
}

struct DetailLivre: View {
    let livre: Livre
    var body: some View {
        List {
            LabeledContent("Titre",  value: livre.titre)
            LabeledContent("Auteur", value: livre.auteur)
            LabeledContent("Pages",  value: "\(livre.pages)")
        }
        .navigationTitle(livre.titre)
    }
}

الـ struct Livre تطابق Hashable لأنّ NavigationStack يجب أن يستطيع مقارنة القيم المدفوعة. مطابقة Hashable لـ struct لا تتطلّب كودًا إن كانت كلّ خصائصها Hashable — المُجَمِّع يستنبط التنفيذ. LabeledContent (iOS 16+) يُنتج التخطيط القياسي label/value لـ Réglages iOS.

الخطوة 4 — Toolbar وأزرار التنقّل

شريط التنقّل يستضيف أزرار فعل عبر modifier .toolbar. ToolbarItem(placement:) يتحكّم في الموضع.

.toolbar {
    ToolbarItem(placement: .topBarLeading) {
        Button("Trier") { /* action */ }
    }
    ToolbarItem(placement: .topBarTrailing) {
        Menu {
            Button("Par titre")  { /* action */ }
            Button("Par auteur") { /* action */ }
        } label: {
            Image(systemName: "ellipsis.circle")
        }
    }
}

Menu SwiftUI يُنتج قائمة سياقية أصلية iOS مع رسم متحرّك pop-out. هذه الآلية مستعملة بكثافة في تطبيقات Apple (Mail، Notes، Réglages) وتُوَفِّر عشرات الأسطر مقارنة بـ UIMenu يدوي في UIKit.

الخطوة 5 — NavigationSplitView لـ iPad وMac

على شاشة واسعة، التخطيط أحادي العمود يفقد كفاءته. NavigationSplitView يعرض تخطيطًا متعدّد الأعمدة: sidebar يسارًا، المحتوى وسطًا، التفصيل يمينًا. على iPhone، المكوّن يتراجع طبيعيًّا إلى كومة بأعمدة متتابعة، دون كود مشروط.

struct ContentView: View {
    @State private var categorieSelectionnee: String? = nil
    @State private var livreSelectionne: Livre? = nil

    let categories = ["Roman", "Essai", "Poésie"]
    let livres: [Livre] = [/* … */]

    var body: some View {
        NavigationSplitView {
            List(categories, id: \.self, selection: $categorieSelectionnee) { cat in
                Text(cat)
            }
            .navigationTitle("Catégories")
        } content: {
            if let cat = categorieSelectionnee {
                List(livres.filter { _ in true }, selection: $livreSelectionne) { livre in
                    NavigationLink(value: livre) {
                        Text(livre.titre)
                    }
                }
                .navigationTitle(cat)
            } else {
                Text("Choisissez une catégorie")
            }
        } detail: {
            if let livre = livreSelectionne {
                DetailLivre(livre: livre)
            } else {
                Text("Choisissez un livre")
            }
        }
    }
}

على iPad في الوضع landscape، الأعمدة الثلاثة تظهر جنبًا إلى جنب. على iPhone، المستخدم يتنقّل من sidebar إلى المحتوى ثم التفصيل تتابعًا. على Mac، التخطيط يأخذ شكل Finder/Notes مع أعمدة قابلة لتغيير الحجم. نفس الكود يخدم المنصّات الثلاث.

الخطوة 6 — تبويبات بـ TabView

لتطبيقات بعدّة أقسام مستوى أوّل (Twitter بـ Timeline / Explore / Messages مثلًا)، TabView يُنتج شريط التبويبات القياسي iOS.

struct ContentView: View {
    var body: some View {
        TabView {
            CatalogueView()
                .tabItem {
                    Label("Catalogue", systemImage: "books.vertical")
                }

            FavorisView()
                .tabItem {
                    Label("Favoris", systemImage: "heart.fill")
                }

            ProfilView()
                .tabItem {
                    Label("Profil", systemImage: "person.circle")
                }
        }
    }
}

كلّ tabItem يُحَدِّد الأيقونة واللصاقة في الشريط. iOS 18 قدّم واجهة Tab جديدة تُحَدِّث الصياغة، لكنّ القديمة تبقى صالحة للتوافق الخلفي. للاستفادة من الجديد البصري، انتقل للواجهة الجديدة حين يسمح هدف النشر.

الخطوة 7 — Sheets وalerts وconfirmation dialogs

التنقّل الهرمي لا يُغطّي كلّ الحالات. لعرض view modal مؤقّتة، نستعمل .sheet. لسؤال نعم/لا، .alert. لخيار متعدّد، .confirmationDialog.

@State private var afficherEdition = false
@State private var afficherSupprimer = false

Button("Modifier") { afficherEdition = true }
    .sheet(isPresented: $afficherEdition) {
        FormulaireEdition()
    }

Button("Supprimer", role: .destructive) { afficherSupprimer = true }
    .confirmationDialog(
        "Supprimer ce livre ?",
        isPresented: $afficherSupprimer,
        titleVisibility: .visible
    ) {
        Button("Supprimer", role: .destructive) {
            // منطق الحذف
        }
        Button("Annuler", role: .cancel) { }
    }

الدور .destructive على زرّ يُعطيه تلقائيًّا اللون الأحمر للنظام ويُعَرِّفه لـ VoiceOver كفعل تدميري. هذا الانضباط يُحسِّن الوصول دون جهد.

الخطوة 8 — استعادة حالة التنقّل

app جيّد يستعيد حالته عند إعادة الإقلاع: إن كان المستخدم يتصفّح شاشة تفصيل كتاب، يجب أن يعود إليها عند الإقلاع التالي. الـ binding path لـ NavigationStack يُسَهِّل هذا الاستمرار.

@main
struct MonAppApp: App {
    @AppStorage("nav-path") private var pathData: Data = Data()
    @State private var chemin: [Livre] = []

    var body: some Scene {
        WindowGroup {
            CatalogueView(chemin: $chemin)
                .onAppear { restaurer() }
                .onChange(of: chemin) { _, nouveau in sauvegarder(nouveau) }
        }
    }

    private func restaurer() {
        if let livres = try? JSONDecoder().decode([Livre].self, from: pathData) {
            chemin = livres
        }
    }

    private func sauvegarder(_ livres: [Livre]) {
        if let data = try? JSONEncoder().encode(livres) {
            pathData = data
        }
    }
}

ليكون Livre قابلًا للترميز، يجب أن يطابق Codable (غالبًا مولَّد تلقائيًّا). @AppStorage يُداوم في UserDefaults، كافٍ لبضع كيلوبايت. لبيانات أكبر، استعمل ملفًّا في مجلّد Documents أو SwiftData.

هيكلة مشروع بعدّة شاشات: نصائح التقسيم

ما وراء ثلاث أو أربع شاشات، تنظيم الملفّات يصنع الفرق. النمط الأكثر ممارسة إنشاء مجلّد لكلّ قسم وظيفي (Catalogue، Profil، Réglages)، يحوي الشاشات والمكوّنات الخاصّة بهذا القسم. المكوّنات العامّة القابلة لإعادة الاستعمال (أزرار، بطاقات، شارات) في مجلّد Components/ منفصل.

للتنقّل بين الأقسام، مدرستان تتعايشان. الأولى تستعمل TabView في أعلى المستوى مع NavigationStack لكلّ تبويب؛ كلّ قسم له كومته الخاصّة، مستقلّة عن الأخرى. الثانية تُغَلِّف المجموع في NavigationSplitView، أنسب لتطبيقات iPad وMac التي تكشف sidebar دائمًا. الاختيار حسب الهدف.

أخطاء شائعة في التنقّل

الخطأ السبب الحلّ
عنوان التنقّل لا يظهر .navigationTitle على NavigationStack بدل الـ view الداخلية انقل modifier إلى الـ view داخل الكومة
« Type ‘X’ does not conform to protocol ‘Hashable' » نوع مدفوع عبر NavigationLink(value:) غير Hashable طابق Hashable؛ تافه إن كانت كلّ الخصائص Hashable
شاشة التفصيل لا تُحَمَّل مع navigationDestination .navigationDestination(for:) خارج NavigationStack ضع modifier داخل NavigationStack، على الـ view الجذرية
Sheet فارغ أو شاشة سوداء .sheet مُطَبَّق على view مدمَّرة بعد رسم متحرّك طَبِّق modifier على container الأبّ المستقرّ
NavigationSplitView يعرض سيّئًا على iPhone لا مشكلة — سلوك متوقّع اختبر في وضع landscape iPad للتحقّق من التخطيط متعدّد الأعمدة
زرّ عودة غائب عرض modal بدل push تحقّق من استعمال NavigationLink لا .sheet

أسئلة شائعة

هل نتخلّى عن NavigationView؟ نعم للمشاريع الجديدة. NavigationView مُسْتَبعَد منذ iOS 16. للمشاريع القائمة، هاجِر شاشة شاشة نحو NavigationStack.

كيف ننتقل بعد حدث غير متزامن؟ عدِّل binding path في كتلة .task أو في دالّة async. NavigationStack يكشف التغيير ويُحرِّك الانتقال. مثال: chemin.append(livre) بعد fetch شبكي ناجح.

NavigationSplitView أم NavigationStack على iPad؟ NavigationSplitView هو الافتراضي لـ iPad — يتكيّف مع الشكل ويستغلّ مساحة الشاشة. على iPhone، يتراجع إلى كومة طبيعية. استعمل NavigationStack على iPad فقط حين لا يكون للمحتوى بنية هرمية واضحة.

كيف نُمَرِّر binding عبر NavigationLink؟ غَلِّف الـ binding في struct وسيطة أو استعمل نموذج @Observable مشتركًا عبر @Environment. NavigationLink(value:) لا يقبل سوى أنواع Hashable.

أيمكن تخصيص رسم الانتقال؟ ليس مباشرة على NavigationStack — Apple تفرض رسم النظام للاتّساق. لانتقالات مخصَّصة، استعمل .fullScreenCover مع modifier .transition، أو نَفِّذ تنقّلًا يدويًّا بـ ZStack.

أيّ مكوّن لـ wizard أو نفق onboarding؟ لنفق متسلسل (خطوة 1 ← 2 ← 3)، TabView بنمط .page أنسب من NavigationStack. يُوَفِّر swipe أفقيًّا أصليًّا ومؤشّرات صفحة. استهدف iOS 16 أو أحدث للدعم الكامل.

الأدلّة الموصى بها بعد هذا

مصادر رسمية

  • توثيق NavigationStack.
  • توثيق NavigationSplitView.
  • WWDC22 — The SwiftUI cookbook for navigation — فيديو رسمي للعرض.
  • Human Interface Guidelines — Navigation Bars — قواعد استعمال Apple.

🔝 العودة إلى الدليل الرئيسي.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité