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

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

الـ state والـ bindings و@Observable

19 دقراءة 4 داكتشف الخطأ

الدرس في لمحة

اجعل لكل قطعة بيانات مصدر حقيقة واحدًا، باستخدام @State للقيم التي يملكها الـ view، و@Binding لمشاركة صلاحية الكتابة، وclasses من نوع @Observable مع @Bindable والـ environment لبيانات التطبيق.

  1. 1@State: بيانات يملكها الـ view
  2. 2@Binding: استعارة state يملكها غيرك
  3. 3@Observable: بيانات التطبيق المشتركة
  4. 4@Bindable: bindings إلى داخل كائن مراقَب

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

الـ view في SwiftUI هو struct ينشئه SwiftUI ويقرؤه ويتخلّص منه متى شاء. فأين تعيش معلومة «فعّل المستخدم فلتر المصاريف الكبيرة فقط»؟ ليس في خاصية عادية: فالـ struct الذي يحملها قد يختفي بعد لحظة، ومن داخل body لا تستطيع حتى تغييرها. جواب SwiftUI مجموعة صغيرة من الـ property wrappers (أغلفة الخصائص)، كلٌّ منها يجيب عن سؤال واحد: من يملك هذه البيانات؟

@State: بيانات يملكها الـ view

ExpensesScreen.swift
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 يملكها الأب:

Swift
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 عندي لا يفعل شيئًا».

يملك ExpensesScreen القيمة showLargeOnly عبر State. ويمرّر binding إلى FilterBar، الذي يكتب الـ Toggle فيه عبر الـ binding رجوعًا إلى الـ state. أما المخزن، وهو class من نوع Observable يملكه التطبيق، فيقرأ منه ExpensesScreen وSettingsScreen كلاهما. ExpensesScreen @State showLargeOnly FilterBar @Binding showLargeOnly $binding يكتب رجوعًا @Observable BudgetStore يُملَك مرة واحدة عبر @State الـ views التي تقرأ خاصية تتحدّث حين تتغيّر
مصدر حقيقة واحد، يُشارَك عبر الـ bindings

@Observable: بيانات التطبيق المشتركة

مفتاح الفلتر ينتمي إلى شاشة واحدة. أما قائمة المصاريف فتنتمي إلى التطبيق كله: القائمة تعرضها، والملخّص يجمعها، ونموذج الإضافة يضيف إليها. هذه هوية مشتركة، وقد قال درس الـ structs إنها تستدعي class. علّمه بالماكرو @Observable من إطار Observation:

BudgetStore.swift
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:

PocketBudgetApp.swift
@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 يوفّره:

Swift
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، مثلًا لتعديلها في نموذج.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

يعلن view عن var count = 0 وينفّذ زرٌّ count += 1. ماذا يحدث؟

السؤال 2 من 4

يملك الأب @State private var showLargeOnly = false. ويجب على الابن FilterBar أن يبدّل قيمتها. ماذا يعلن الابن، وماذا يمرّر الأب؟

السؤال 3 من 4

أيّ الـ views يُعاد رسمها حين تتغيّر store.monthlyLimit في مخزن من نوع @Observable؟

السؤال 4 من 4

تحصل شاشة الإعدادات على المخزن عبر @Environment(BudgetStore.self) private var store وتحتاج إلى $store.monthlyLimit لحقل TextField. ماذا تضيف؟

تدريب

اكتشف الخطأ

البطاقة التي لا تتوسّع

يجب أن تعرض هذه البطاقة التاريخ والتصنيف حين ينقر المستخدم More. لكنها لا تُبنى: يُبلغ Xcode عن “cannot use mutating member on immutable value: ‘self’ is immutable” عند استدعاء toggle(). اختر السطر الذي يجب تغييره لإصلاح المشكلة.

حدّد السطر أو الأسطر التي تظنها خاطئة، ثم تحقّق.