الوحدة 3 · بناء الشاشات بـ SwiftUI
القوائم والتنقّل والنماذج
19 دقراءة 3 داملأ الفراغات
الدرس في لمحة
اعرض المصاريف في List، وانتقل إلى شاشة تفاصيل باستخدام NavigationStack والروابط المبنية على القيم، واجمع المصاريف الجديدة في Form يتحقّق من المدخلات ويُعرض كـ sheet.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
صار لدى Pocket Budget مخزن، وview للصف، وstate تقود الشاشة. الآن يجب أن يصبح تطبيقًا يستطيع الناس استخدامه: قائمة بمصاريف هذا الشهر، ونقرة تفتح التفاصيل، وزر + يفتح نموذجًا. هذه الأنماط الثلاثة (القائمة، والتنقّل بالدفع، والنموذج المنبثق) تشكّل هيكل معظم تطبيقات iPhone التي استخدمتها.
قائمة من صفوف قابلة للتمييز
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 كذلك أصلًا. إبقاء النصفين منفصلين يعني أن الوجهة تُعلَن مرة واحدة مهما كثرت الأماكن التي تربط إلى مصروف، ويجعل التنقّل البرمجي ممكنًا:
@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 الدور نفسه بقيم ممحوّة النوع.
يمكن أن تكون شاشة التفاصيل Form يُستخدم للعرض، بصفوف LabeledContent تنسّق القيم لك:
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 صحيحة. إليك النموذج:
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نفسه تمامًا، وإلا يتوقّف المنتقي عن العمل بصمت.