الوحدة 3 · بناء الشاشات بـ SwiftUI
الـ state والـ bindings و@Observable
19 دقراءة 4 داكتشف الخطأ
الدرس في لمحة
اجعل لكل قطعة بيانات مصدر حقيقة واحدًا، باستخدام @State للقيم التي يملكها الـ view، و@Binding لمشاركة صلاحية الكتابة، وclasses من نوع @Observable مع @Bindable والـ environment لبيانات التطبيق.
- 1@State: بيانات يملكها الـ view
- 2@Binding: استعارة state يملكها غيرك
- 3@Observable: بيانات التطبيق المشتركة
- 4@Bindable: bindings إلى داخل كائن مراقَب
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
الـ view في SwiftUI هو struct ينشئه SwiftUI ويقرؤه ويتخلّص منه متى شاء. فأين تعيش معلومة «فعّل المستخدم فلتر المصاريف الكبيرة فقط»؟ ليس في خاصية عادية: فالـ struct الذي يحملها قد يختفي بعد لحظة، ومن داخل body لا تستطيع حتى تغييرها. جواب SwiftUI مجموعة صغيرة من الـ property wrappers (أغلفة الخصائص)، كلٌّ منها يجيب عن سؤال واحد: من يملك هذه البيانات؟
@State: بيانات يملكها الـ view
import SwiftUI
struct ExpensesScreen: View {
@State private var showLargeOnly = false
var body: some View {
Toggle("Large expenses only", isOn: $showLargeOnly)
}
}يخبر @State إطار SwiftUI أن يحتفظ بالقيمة في تخزين يديره هو، مرتبط بموقع هذا الـ view في الهرمية. يمكن إعادة إنشاء الـ struct مئة مرة وتبقى القيمة. وحين تتغيّر القيمة، يعيد SwiftUI قراءة body ويحدّث الشاشة.
العلامة $ في $showLargeOnly تطلب binding (ربطًا): اتصالًا في الاتجاهين مع الـ state. الـ Toggle لا يملك قيمة التشغيل والإيقاف؛ بل يستقبل binding ليقرأ القيمة الحالية ويكتب القيمة الجديدة عند النقر. وكل عنصر إدخال (TextField وToggle وPicker وDatePicker وSlider) يعمل بهذه الطريقة.
@Binding: استعارة state يملكها غيرك
استخرج الـ toggle إلى view مستقل، وسيحتاج الابن إلى تغيير state يملكها الأب:
struct FilterBar: View {
@Binding var showLargeOnly: Bool
var body: some View {
Toggle("Large expenses only", isOn: $showLargeOnly)
}
}
// In ExpensesScreen's body:
FilterBar(showLargeOnly: $showLargeOnly)يبقى الأب مصدر الحقيقة الوحيد (single source of truth)؛ ويحصل الابن على binding إليه. لو أعلن الابن @State بدلًا من ذلك، لحمل نسخة منفصلة تبدأ بقيمة الأب ثم تبتعد عنها. هذا هو الخطأ الكامن وراء معظم أسئلة «الـ toggle عندي لا يفعل شيئًا».
@Observable: بيانات التطبيق المشتركة
مفتاح الفلتر ينتمي إلى شاشة واحدة. أما قائمة المصاريف فتنتمي إلى التطبيق كله: القائمة تعرضها، والملخّص يجمعها، ونموذج الإضافة يضيف إليها. هذه هوية مشتركة، وقد قال درس الـ structs إنها تستدعي class. علّمه بالماكرو @Observable من إطار Observation:
import Observation
import Foundation
@Observable
final class BudgetStore {
var expenses: [Expense] = []
var monthlyLimit: Decimal = 1500
var totalSpent: Decimal {
expenses.reduce(0) { $0 + $1.amount }
}
var remaining: Decimal {
monthlyLimit - totalSpent
}
func add(_ expense: Expense) {
expenses.append(expense)
}
}لا property wrappers على الخصائص، ولا publishers. يعيد الماكرو كتابة كل خاصية مخزّنة حتى يستطيع SwiftUI تسجيل أيّها يقرأ الـ body. الـ view الذي يعرض store.remaining يقرأ monthlyLimit وexpenses من خلالها، فيتحدّث حين تتغيّر أيٌّ منهما؛ أما الـ view الذي يعرض الحدّ فقط فيتجاهل المصاريف الجديدة.
لاحظ أن totalSpent وremaining محسوبتان لا مخزّنتان. من المغري أن تحتفظ بـ var total وتحدّثها في add، لكن عندئذٍ يجب على كل method مستقبلية تمسّ expenses (الحذف، التعديل، الاستيراد) أن تتذكّر تحديثها أيضًا، وذات يوم ستنسى إحداها. خزّن الحدّ الأدنى من الحقائق واشتقّ كل ما عداها. يتتبّع Observation الخصائص المحسوبة عبر الخصائص المخزّنة التي تقرؤها، فتبقى الشاشة صحيحة دون أي جهد.
أنشئ المخزن مرة واحدة، قرب قمّة التطبيق، وامتلكه عبر @State:
@main
struct PocketBudgetApp: App {
@State private var store = BudgetStore()
var body: some Scene {
WindowGroup {
ExpensesScreen(store: store) // ExpensesScreen now has `let store: BudgetStore`
.environment(store)
}
}
}تستطيع الـ views الأبناء أن تأخذ المخزن كخاصية عادية let store: BudgetStore وتستدعي store.add(…)؛ لا حاجة إلى أي wrapper للقراءة أو لاستدعاء الـ methods. وللشاشات العميقة في الهرمية، يجعله .environment(store) متاحًا دون تمريره عبر كل مُهيِّئ، ويلتقطه الـ view عبر @Environment(BudgetStore.self) private var store.
@Bindable: bindings إلى داخل كائن مراقَب
حقل TextField الذي يعدّل الحدّ الشهري يحتاج إلى binding إلى store.monthlyLimit. و@Bindable يوفّره:
struct SettingsScreen: View {
@Environment(BudgetStore.self) private var store
var body: some View {
@Bindable var store = store
Form {
TextField("Monthly limit", value: $store.monthlyLimit, format: .currency(code: "USD"))
.keyboardType(.decimalPad)
}
}
}حين يصل المخزن كخاصية، اكتب @Bindable var store: BudgetStore بدلًا من ذلك؛ أما الصيغة المحلية المعروضة هنا فهي للكائنات القادمة من الـ environment.
لتقرّر أيّ wrapper تستخدم، اسأل من يملك البيانات. هل يملك هذا الـ view قيمة بسيطة؟ @State. هل يحتاج إلى تغيير قيمة يملكها غيره؟ @Binding. هل هي بيانات تطبيق مشتركة؟ class من نوع @Observable، يُملَك مرة واحدة عبر @State ويُمرَّر إلى الأسفل. الدرس التالي يشغّل المخزن في قائمة حقيقية مع تنقّل ونموذج لإضافة مصروف.
أهم النقاط
@Stateيخزّن القيمة خارج الـ struct الخاص بالـ view فتبقى بعد كل re-render؛ علّمها بـprivate، لأن الـ view هو من يملكها.- البادئة
$تعطيكBinding: صلاحية قراءة وكتابة على مصدر حقيقة يملكه غيرك، وهذا ما تحتاجه عناصر التحكّم مثلToggleوTextField. - علّم الـ class الخاص بالنموذج المشترك بـ
@Observable، فيعيد SwiftUI رسم الـ views التي قرأ الـbodyالخاص بها خاصية تغيّرت فقط. - امتلك كائن
@Observableعبر@Stateفي الأعلى، ثم مرّره إلى الأسفل مباشرةً أو عبر.environment(_:). - استخدم
@Bindableحين تحتاج bindings إلى خصائص كائن@Observable، مثلًا لتعديلها في نموذج.