EduFlow
أساسيات Swift لتطبيقات iOS
⁦0%⁩

الوحدة 3 · بناء الشاشات بـ SwiftUI

القوائم والتنقّل والنماذج

19 دقراءة 3 داملأ الفراغات

الدرس في لمحة

اعرض المصاريف في List، وانتقل إلى شاشة تفاصيل باستخدام NavigationStack والروابط المبنية على القيم، واجمع المصاريف الجديدة في Form يتحقّق من المدخلات ويُعرض كـ sheet.

  1. 1قائمة من صفوف قابلة للتمييز
  2. 2التنقّل بالقيم
  3. 3نموذج داخل sheet

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

صار لدى Pocket Budget مخزن، وview للصف، وstate تقود الشاشة. الآن يجب أن يصبح تطبيقًا يستطيع الناس استخدامه: قائمة بمصاريف هذا الشهر، ونقرة تفتح التفاصيل، وزر + يفتح نموذجًا. هذه الأنماط الثلاثة (القائمة، والتنقّل بالدفع، والنموذج المنبثق) تشكّل هيكل معظم تطبيقات iPhone التي استخدمتها.

قائمة من صفوف قابلة للتمييز

ExpensesScreen.swift
import SwiftUI

struct ExpensesScreen: View {
    let store: BudgetStore
    @State private var isAdding = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(store.expenses) { expense in
                    NavigationLink(value: expense) {
                        ExpenseRow(expense: expense)
                    }
                }
                .onDelete { offsets in
                    store.delete(at: offsets)
                }
            }
            .overlay {
                if store.expenses.isEmpty {
                    ContentUnavailableView("No expenses yet", systemImage: "tray",
                                           description: Text("Tap + to add your first one."))
                }
            }
            .navigationTitle("Expenses")
            .navigationDestination(for: Expense.self) { expense in
                ExpenseDetail(expense: expense)
            }
            .toolbar {
                Button("Add expense", systemImage: "plus") {
                    isAdding = true
                }
            }
            .sheet(isPresented: $isAdding) {
                AddExpenseView(store: store)
            }
        }
    }
}

هذه هي الشاشة الرئيسية كاملة؛ خذها قطعة قطعة. ينتج ForEach(store.expenses) صفًا لكل مصروف. ويحتاج إلى هوية ثابتة لكل عنصر حتى يحرّك SwiftUI الصف الصحيح عند إدراج مصروف أو حذفه بدل إعادة رسم كل شيء، وهذه الهوية هي الـ id الذي يتطلّبه Identifiable. ويضيف .onDelete الحذف بالسحب ويسلّمك مواضع العناصر المراد حذفها؛ وdelete(at:) في المخزن سطر واحد: expenses.remove(atOffsets: offsets). هذه الـ method معرّفة في SwiftUI، لذلك يحتاج الملف BudgetStore.swift إلى import SwiftUI إلى جانب Observation.

ContentUnavailableView هو الحالة الفارغة القياسية في النظام. القائمة الفارغة بلا تفسير تبدو معطّلة؛ أما رسالة قصيرة وتلميح إلى الخطوة التالية فيجعلانها تبدو مكتملة.

التنقّل بالقيم

يدير NavigationStack مكدّسًا من الشاشات: القائمة في الأسفل، والتفاصيل تُدفع فوقها، مع زر الرجوع وإيماءة السحب من النظام دون أي جهد.

ينقسم التنقّل إلى نصفين. NavigationLink(value: expense) يقول ما الذي نُقر عليه. و.navigationDestination(for: Expense.self) يقول أيّ شاشة تعرض Expense. يجب أن تكون القيمة Hashable، وExpense كذلك أصلًا. إبقاء النصفين منفصلين يعني أن الوجهة تُعلَن مرة واحدة مهما كثرت الأماكن التي تربط إلى مصروف، ويجعل التنقّل البرمجي ممكنًا:

Swift
@State private var path: [Expense] = []

// In body:
NavigationStack(path: $path) {
    // …the same List and modifiers as above
}

// Anywhere in this view, for example after saving:
path.append(newExpense)    // pushes ExpenseDetail
path.removeAll()           // pops back to the list

المسار state عادية، فيصبح التنقّل بيانات تستطيع فحصها واختبارها وتغييرها من الكود. هكذا تفتح مصروفًا محدّدًا من إشعار، أو تقفز عائدًا إلى الجذر بعد مسار من عدة خطوات. وللشاشات التي فيها أنواع وجهات مختلطة، يؤدّي NavigationPath الدور نفسه بقيم ممحوّة النوع.

داخل NavigationStack، تدفع قائمة المصاريف شاشة تفاصيل المصروف عند النقر على صف، والرجوع يُخرجها. وبشكل منفصل، يعرض زر + نموذج إضافة مصروف كـ sheet، تُغلق بـ Save أو Cancel. NavigationStack المصاريف List التفاصيل عبر push نقرة رجوع إضافة مصروف sheet زر +
التنقّل بالدفع وsheet منبثقة

يمكن أن تكون شاشة التفاصيل Form يُستخدم للعرض، بصفوف LabeledContent تنسّق القيم لك:

Swift
struct ExpenseDetail: View {
    let expense: Expense

    var body: some View {
        Form {
            LabeledContent("Amount", value: expense.amount, format: .currency(code: "USD"))
            LabeledContent("Category", value: expense.category.title)
            LabeledContent("Date", value: expense.date, format: .dateTime.day().month().year())
        }
        .navigationTitle(expense.title)
    }
}

نموذج داخل sheet

الدفع مخصّص للتعمّق في المحتوى. أما الـ sheet (الورقة المنبثقة) فلمهمّة مستقلّة يُنهيها المستخدم أو يتخلّى عنها، مثل إضافة مصروف. يعرض .sheet(isPresented: $isAdding) النموذج كلما أصبحت isAdding صحيحة. إليك النموذج:

AddExpenseView.swift
struct AddExpenseView: View {
    let store: BudgetStore
    @Environment(\.dismiss) private var dismiss

    @State private var title = ""
    @State private var amount: Decimal?
    @State private var category: Category = .food
    @State private var date = Date.now

    private var canSave: Bool {
        !title.trimmingCharacters(in: .whitespaces).isEmpty && (amount ?? 0) > 0
    }

    var body: some View {
        NavigationStack {
            Form {
                Section("Details") {
                    TextField("Title", text: $title)
                    TextField("Amount", value: $amount, format: .currency(code: "USD"))
                        .keyboardType(.decimalPad)
                }
                Section {
                    Picker("Category", selection: $category) {
                        ForEach(Category.allCases, id: \.self) { category in
                            Label(category.title, systemImage: category.symbolName)
                                .tag(category)
                        }
                    }
                    DatePicker("Date", selection: $date, in: ...Date.now, displayedComponents: .date)
                }
            }
            .navigationTitle("New Expense")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        guard let amount else { return }
                        store.add(Expense(title: title, amount: amount, category: category, date: date))
                        dismiss()
                    }
                    .disabled(!canSave)
                }
            }
        }
    }
}

تغلّف الـ sheet نموذجها بـ NavigationStack خاص بها. فالـ sheet عرض منفصل لا يرث مكدّس القائمة، وبدونه لا يوجد شريط يحمل العنوان أو زرَّي Cancel وSave. ووضعهما عبر .cancellationAction و.confirmationAction يضعهما حيث يتوقّعهما مستخدمو iOS.

كل حقل مربوط بـ @State خاصة به، فيكون النموذج مسوّدة: لا شيء يمسّ المخزن حتى النقر على Save. وCancel يرمي المسوّدة. هذا سلوك القيمة يؤتي ثماره مرة أخرى.

حقل المبلغ هو الأهم. يحلّل TextField(value:format:) ما يكتبه المستخدم حسب إعداداته المحلية، فتصبح “12,50” في ألمانيا 12.5، ولا تحمل amount إلا رقمًا حُلّل بنجاح، أو nil ما دام لا يوجد رقم. هذا يستبدل التحليل المتساهل بـ Decimal(string:) من درس الـ optionals. ويفكّها guard let amount في إجراء Save، ويعطّل canSave الزر حتى يكون العنوان والمبلغ منطقيين، فلا يمكن حفظ مدخلات غير صالحة أصلًا. والنطاق in: ...Date.now يمنع التواريخ المستقبلية. أما dismiss فيأتي من الـ environment ويغلق كل ما عرض هذا الـ view.

صار Pocket Budget الآن تطبيقًا يعمل بمسار حقيقي من القائمة إلى التفاصيل إلى النموذج. لكنه ينسى كل شيء حين تغلقه، ولا يعرف شيئًا خارج الهاتف. القسم الأخير يصلح الأمرين، بدءًا بكيفية تنفيذ Swift للعمل الذي يستغرق وقتًا.

أهم النقاط

  • List وForEach يحتاجان إلى التمييز بين الصفوف، ولهذا تتوافق نماذجك مع Identifiable.
  • NavigationLink(value:) مع .navigationDestination(for:) يفصلان بين ما نُقر عليه وبين الشاشة التي تعرضه.
  • استخدم sheet لمهمة مستقلّة مثل إضافة مصروف، وأغلقها عبر قيمة dismiss من الـ environment.
  • حقل TextField مع format: يحوّل الكتابة إلى قيمة محدّدة النوع حسب الإعدادات المحلية للمستخدم، بدل تحليل النصوص يدويًا.
  • قيم .tag في Picker يجب أن تكون من نوع selection نفسه تمامًا، وإلا يتوقّف المنتقي عن العمل بصمت.

قراءات إضافية

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

كتبت NavigationLink(value: expense) لكن النقر على الصف لا يفعل شيئًا. ما السبب الأرجح؟

السؤال 2 من 3

يستخدم منتقي التصنيفات selection: $category حيث category من النوع Category، وصفوفه معلّمة بـ .tag(category.rawValue). ماذا يحدث؟

السؤال 3 من 3

لماذا تربط حقل المبلغ بقيمة Decimal? مع format: .currency(code: "USD") بدل String؟

تدريب

املأ الفراغات

اربط الشاشة الرئيسية

أكمل الشاشة الرئيسية في Pocket Budget. يجب أن يدفع النقر على صف الشاشة ExpenseDetail لذلك المصروف باستخدام التنقّل المبني على القيم، ويجب أن يعرض زر + الشاشة AddExpenseView بشكل منبثق ما دامت isAdding صحيحة.

NavigationStack { List(store.expenses) { expense in NavigationLink(: expense) { ExpenseRow(expense: expense) } } .navigationTitle("Expenses") .(for: Expense.self) { expense in ExpenseDetail(expense: expense) } .toolbar { Button("Add expense", systemImage: "plus") { isAdding = true } } .(isPresented: $isAdding) { AddExpenseView(store: store) } }