Logo

المعمل 06: إدارة الحالة، قوائم ListView، ومعمارية الواجهة

13 دقيقة قراءة
شرائح الدرس
1 / 13

المعمل 06: إدارة الحالة، وListView، ومعمارية الواجهة

جعل الواجهة تتفاعل مع البيانات، والتخطيط لتطبيقات متعددة الشاشات، وعرض القوائم الديناميكية

نظرة عامة

يغطي هذا المعمل أربعة مواضيع أساسية في Flutter تنقلك من تطبيق ثابت إلى تطبيق ديناميكي مدفوع بالبيانات. ستتعلم كيف تجعل عناصر الواجهة تستجيب لمدخلات المستخدم باستخدام إدارة الحالة (State Management)، وعرض مجموعات بيانات ديناميكية باستخدام ListView، وتخطيط مشروع واقعي باستخدام معمارية ثلاثية الطبقات، وتنظيم كود الواجهة بشكل صحيح باستخدام الدوال المساعدة مقابل أصناف عناصر الواجهة.


الأهداف

بنهاية هذا المعمل ستكون قادرًا على:

  • شرح معنى "الحالة" (State) في تطبيق Flutter
  • تحويل StatelessWidget إلى StatefulWidget واستدعاء setState()
  • تنسيق التواريخ باستخدام حزمة intl
  • تخطيط مشروع Flutter متعدد الشاشات قبل كتابة أي كود
  • عرض قوائم البيانات باستخدام ListView، وListView.builder، وListView.separated
  • تقرير متى تستخلص كود الواجهة إلى دالة مساعدة مقابل صنف عنصر واجهة منفصل
  • فهم كيف تؤثر دورات حياة بناء عناصر الواجهة (Build Lifecycle) على أداء إعادة البناء

المتطلبات الأساسية

  • إعداد مشروع Flutter ومعرفة أساسية بعناصر الواجهة (المعامل 01-05)
  • الإلمام بـ StatelessWidget، وColumn، وContainer، وText
  • صياغة أساسية لأصناف ومتغيرات Dart

خلفية نظرية

1. إدارة الحالة - جعل الواجهة تتفاعل مع البيانات

ما هي الحالة؟

الحالة (State) هي مجموعة المتغيرات القابلة للتغيير التي تعتمد عليها واجهتك. عندما تتغير هذه المتغيرات، يجب أن تعكس الواجهة القيم الجديدة.

الحالة = القيم الحالية للمتغيرات التي تقرأها عناصر واجهتك

فمثلاً، في تطبيق حاسبة العمر، تُعد السنوات والأشهر والأيام المحسوبة حالة - فهي تبدأ فارغة وتتغير عندما ينقر المستخدم على "احسب".

القواعد الثلاث لإعادة بناء عنصر واجهة

لكي يتحدث عنصر الواجهة على الشاشة عند تغيّر البيانات، يجب أن تتحقق ثلاثة شروط جميعها:

flowchart TD
    A[عنصر الواجهة يقرأ البيانات] --> B{هل البيانات مخزّنة\nفي متغيّر؟}
    B -->|لا - قيمة ثابتة| C[❌ لا يمكن للعنصر أن يتحدث]
    B -->|نعم - متغيّر| D{هل تغيّرت قيمة\nالمتغيّر؟}
    D -->|لا| E[❌ لا يوجد جديد لعرضه]
    D -->|نعم| F{هل تم استدعاء\nsetState؟}
    F -->|لا| G[❌ الواجهة لم تُعَد بناؤها]
    F -->|نعم| H[✅ يُعاد بناء العنصر\nبالقيمة الجديدة]

القاعدة 1 - يجب أن يقرأ عنصر الواجهة من متغيّر، لا من قيمة ثابتة مكتوبة يدويًا.

// ❌ Hard-coded — can never change
Text('0')
 
// ✅ Variable — can be updated
Text('$userAge.years')

القاعدة 2 - يجب أن تتغير قيمة المتغيّر فعليًا.

// Inside the button's onPressed:
userAge = calculateAge(birthDate, today);   // update the variable

القاعدة 3 - يجب استدعاء setState() لطلب إعادة البناء.

setState(() {
  userAge = calculateAge(birthDate, today);
});

StatelessWidget مقابل StatefulWidget

StatelessWidgetStatefulWidget
هل يمكنه إعادة بناء نفسه؟لانعم
هل يملك setState()؟لانعم
متى يُستخدمالواجهة لا تتغير أبدًا بعد البناء الأولالواجهة تتغير استجابة للمستخدم أو للبيانات

تحويل عنصر واجهة

في VS Code / Android Studio: انقر بزر الفأرة الأيمن على اسم صنف عنصر الواجهة ← Convert to StatefulWidget.

يلف هذا عنصر الواجهة الخاص بك داخل صنف State<T> يمتلك طريقة build() الخاصة به وطريقة setState():

class AgeResultWidget extends StatefulWidget {
  const AgeResultWidget({super.key});
  @override
  State<AgeResultWidget> createState() => _AgeResultWidgetState();
}
 
class _AgeResultWidgetState extends State<AgeResultWidget> {
  UserAge? userAge;      // ← the state variable
 
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Years:  ${userAge?.years  ?? 0}'),
        Text('Months: ${userAge?.months ?? 0}'),
        Text('Days:   ${userAge?.days   ?? 0}'),
      ],
    );
  }
}

عندما ينقر المستخدم على زر الحساب:

onPressed: () {
  setState(() {
    userAge = AgeCalculatorLogic.calculate(birthDate!, todayDate!);
  });
},

تُخبر setState() تطبيق Flutter: "أعد تشغيل build() لهذا العنصر بالحالة الجديدة." ولأن كل عناصر النص الثلاثة تقرأ من userAge، فإنها كلها تتحدث تلقائيًا.

لماذا تُحدّث استدعاء واحد لـ setState() السنوات والأشهر والأيام معًا؟ تعتمد عناصر Text الثلاثة جميعها على نفس الكائن userAge. عندما تُطلِق setState() عملية إعادة بناء، يعيد كل عنصر داخل صنف State قراءة أحدث قيمة لـ userAge.


2. تنسيق التواريخ باستخدام حزمة intl

يتضمن ناتج DateTime.toString() الافتراضي الطابع الزمني الكامل، وهو غير سهل القراءة للمستخدم. استخدم حزمة intl لتنسيق التواريخ.

الخطوة 1 - أضف التبعية:

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  intl: ^0.19.0

شغّل flutter pub get بعد الحفظ.

الخطوة 2 - استورد واستخدم DateFormat:

import 'package:intl/intl.dart';
 
// Format a DateTime object:
String formatted = DateFormat('d/M/yyyy').format(myDate);
// Example output: "5/3/2025"

أنماط التنسيق الشائعة:

النمطمثال على الناتج
d/M/yyyy5/3/2025
dd/MM/yyyy05/03/2025
MMMM d, yyyyMarch 5, 2025
yyyy-MM-dd2025-03-05

الخطوة 3 - نظّم التنسيق داخل صنف أدوات مساعدة (كود نظيف):

لا تضع منطق التنسيق متداخلًا مباشرة داخل دالة استدعاء الزر (Callback). استخلصه:

// date_formatter.dart
import 'package:intl/intl.dart';
 
class DateFormatter {
  static String format(DateTime date) {
    return DateFormat('d/M/yyyy').format(date);
  }
}

الاستخدام في أي مكان بالتطبيق:

controller.text = DateFormatter.format(selectedDate);

تطبيق التنسيق داخل TextEditingController عند اختيار تاريخ:

void _onDateSelected(DateTime? picked) {
  if (picked == null) return;
  setState(() {
    _selectedDate = picked;
    _dateController.text = DateFormatter.format(picked);
  });
}

3. تخطيط مشروع واقعي - المعمارية ثلاثية الطبقات

قبل كتابة سطر كود واحد، أجب عن أسئلة التخطيط التالية:

السؤاللماذا يهم
ما مصدر البيانات؟ (API / قاعدة بيانات / ملف)يحدد طبقة البيانات
هل تُحتاج مكتبة خاصة؟ (فيديو، صوت، تعلم آلي)يجب اختبارها قبل الالتزام بها
ما نمط التنقل (Navigation) المُستخدَم؟يؤثر على بنية الكود العامة

مثال: تطبيق دورات تعليمية عبر الإنترنت

ملخص المتطلبات:

  • تصفح قائمة دورات ← فتح دورة ← رؤية وحداتها ← فتح وحدة ← رؤية دروسها ← عرض صفحات الدرس
  • يمكن أن تحتوي كل صفحة على: نص، صورة، فيديو، نص + صورة، صورة + صوت
  • تأتي البيانات من واجهة API عبر الويب
  • متعدد المنصات: Android و iOS (يُختار Flutter بدلًا من قاعدتي كود أصليتين منفصلتين)

الشاشات:

flowchart LR
    A[شاشة البداية Splash] --> B[قائمة الدورات]
    B --> C[قائمة الوحدات]
    C --> D[قائمة الدروس]
    D --> E[صفحات الدرس\nPageView يمين/يسار]
    E --> F{محتوى الصفحة}
    F --> G[نص]
    F --> H[صورة]
    F --> I[فيديو]
    F --> J[صوت]

المعمارية ثلاثية الطبقات

يجب فصل كل شاشة في تطبيق Flutter إنتاجي إلى ثلاث طبقات:

graph TD
    subgraph Screen
        UI[طبقة واجهة المستخدم\nعناصر الواجهة - تعرض البيانات]
        BL[طبقة منطق الأعمال\nتصفّي، تتحقق، تحوّل]
        DL[طبقة البيانات / Repository\nتستدعي API أو قاعدة بيانات]
    end
    API[(واجهة API عبر الويب)] --> DL
    DL --> BL
    BL --> UI
الطبقةالمسؤوليةمثال على اسم الصنف
الواجهةبناء عناصر الواجهة وعرضهاHomeScreen
منطق الأعمالمعالجة البيانات وتصفيتهاHomeBusiness
البيانات / Repositoryجلب البيانات من API أو قاعدة بياناتHomeData

قاعدة: لا تُقلِّص أبدًا عن ثلاث طبقات. تحتوي معظم التطبيقات الحقيقية على الثلاث جميعًا - الواجهة، والمنطق، ومصدر بيانات.

قارن هذا بتطبيق حاسبة العمر من المعمل 05:

الطبقةحاسبة العمرتطبيق الدورات عبر الإنترنت
الواجهة
منطق الأعمال
مصدر البيانات❌ (لا حاجة لـ API/قاعدة بيانات)✅ (واجهة API عبر الويب)

4. ListView - عرض قوائم العناصر

ListView هو عنصر Flutter القياسي لعرض قائمة قابلة للتمرير من العناصر. يتعامل مع التمرير تلقائيًا.

متى تستخدم ListView مقابل Column

ColumnListView
التمرير❌ لا يوجد تمرير مدمج✅ يمرر تلقائيًا
mainAxisAlignment❌ غير متاحة
عدد كبير أو ديناميكي من العناصر❌ خطأ تجاوز (Overflow)✅ يتعامل مع أي حجم

استخدم Column عندما تكون العناصر تلائم الشاشة دائمًا. استخدم ListView عندما قد تتجاوز العناصر الشاشة أو تأتي من بيانات ديناميكية.

الباني 1: ListView مع قائمة children

الأفضل لعدد صغير وثابت من العناصر:

ListView(
  padding: const EdgeInsets.all(8),
  children: [
    Container(height: 60, color: Colors.blue[100], child: const Text('Item 1')),
    Container(height: 60, color: Colors.blue[200], child: const Text('Item 2')),
    Container(height: 60, color: Colors.blue[300], child: const Text('Item 3')),
  ],
)

يمكنك استخلاص القائمة إلى طريقة (Method) للحفاظ على نظافة build():

List<Widget> _buildItems() {
  return [
    Container(height: 60, color: Colors.blue[100], child: const Text('Item 1')),
    Container(height: 60, color: Colors.blue[200], child: const Text('Item 2')),
  ];
}
 
// In build():
ListView(children: _buildItems())

الباني 2: ListView.builder - القوائم الديناميكية / الكبيرة

الأفضل للقوائم المدفوعة بالبيانات. يبني فقط عناصر الواجهة المرئية حاليًا على الشاشة (تحميل كسول Lazy Loading - أكثر كفاءة):

ListView.builder(
  itemCount: courses.length,
  itemBuilder: (BuildContext context, int index) {
    return CourseCard(course: courses[index]);
  },
)
المعاملالوصف
itemCountالعدد الإجمالي للعناصر
itemBuilderدالة تُستدعى مرة واحدة لكل عنصر مرئي؛ تستقبل context وindex

الباني 3: ListView.separated - قائمة مع فواصل

ListView.separated(
  itemCount: courses.length,
  itemBuilder: (context, index) => CourseCard(course: courses[index]),
  separatorBuilder: (context, index) => const Divider(),
)

التمرير الأفقي

ListView(
  scrollDirection: Axis.horizontal,
  children: [...],
)

ملخص اتجاهات التمرير

flowchart LR
    A[ListView] --> B{scrollDirection}
    B -->|Axis.vertical الافتراضي| C[يمرر لأعلى/أسفل]
    B -->|Axis.horizontal| D[يمرر يمينًا/يسارًا]

5. الدوال المساعدة مقابل أصناف عناصر الواجهة

عندما تصبح طريقة build() كبيرة جدًا، استخلص أجزاء من الواجهة. لديك خياران.

الخيار أ - دالة مساعدة (Method)

class CoursesScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          _buildHeader(),    // extracted method
          _buildCourseList(),
        ],
      ),
    );
  }
 
  Widget _buildHeader() {
    return Container(
      padding: const EdgeInsets.all(16),
      child: const Text('All Courses', style: TextStyle(fontSize: 20)),
    );
  }
 
  Widget _buildCourseList() {
    return Expanded(
      child: ListView.builder(
        itemCount: 10,
        itemBuilder: (context, index) => ListTile(title: Text('Course $index')),
      ),
    );
  }
}

استخدم البادئة _ للإشارة إلى أن الطريقة خاصة (Private) بالصنف.

الخيار ب - صنف عنصر واجهة منفصل

// courses_header.dart
class CoursesHeader extends StatelessWidget {
  const CoursesHeader({super.key});
 
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      child: const Text('All Courses', style: TextStyle(fontSize: 20)),
    );
  }
}
 
// courses_list.dart
class CoursesList extends StatelessWidget {
  const CoursesList({super.key});
 
  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: ListView.builder(
        itemCount: 10,
        itemBuilder: (context, index) => ListTile(title: Text('Course $index')),
      ),
    );
  }
}
 
// courses_screen.dart — clean and minimal
class CoursesScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          const CoursesHeader(),
          const CoursesList(),
        ],
      ),
    );
  }
}

المقارنة

الجانبالدالة المساعدةصنف عنصر الواجهة
تنظيم الكودأفضل من الكتابة المباشرةالأفضل
قابلية الاختبارلا يمكن اختبارها بمعزل عن غيرهايمكن اختبارها كوحدة مستقلة
دورة حياة البناءتشارك دورة حياة الأبدورة حياة مستقلة خاصة بها
نطاق إعادة البناءيعيد بناء الشاشة الأب بأكملهايعيد بناء نفسه فقط
قابلية إعادة الاستخدام عبر الشاشات❌ لا يمكن إعادة استخدامها✅ استوردها وأعد استخدامها في أي مكان

الفرق الحاسم في إعادة البناء

هذا هو أهم سبب عملي لتفضيل أصناف عناصر الواجهة:

flowchart TD
    subgraph نهج الدالة المساعدة
        A[استدعاء setState للأب] --> B[تُعاد بناء الشاشة الأب بأكملها]
        B --> C[الرأس يُعاد بناؤه ♻️]
        B --> D[القائمة تُعاد بناؤها ♻️]
        B --> E[التذييل يُعاد بناؤه ♻️]
    end
 
    subgraph نهج صنف عنصر الواجهة
        F[استدعاء setState الخاص بالرأس فقط] --> G[CoursesHeader يُعاد بناؤه ♻️]
        H[الشاشة الأب - بدون إعادة بناء ✅]
        I[CoursesList - بدون إعادة بناء ✅]
    end

عندما تستدعي setState() داخل دالة مساعدة، يعيد Flutter بناء شجرة عناصر الواجهة الأب بأكملها. أما عندما يعيش نفس المنطق داخل صنف StatefulWidget منفصل، فإن ذلك العنصر فقط هو الذي يُعاد بناؤه، تاركًا بقية الشاشة دون تغيير.

متى تستخدم أيًا منهما

استخدم صنف عنصر واجهة عندما:

  • يمتلك المكون (أو قد يمتلك) حالته الخاصة
  • يُعاد استخدام المكون في أكثر من شاشة واحدة
  • يمثّل المكون قسمًا مستقلًا منطقيًا من الشاشة (رأس، بطاقة، قائمة، تذييل)

استخدم دالة مساعدة عندما:

  • يكون الكود بسيطًا جدًا ومرتبطًا بشدة بالأب
  • لا توجد حالة مستقلة ولا إعادة استخدام
  • سينتج عن الاستخلاص عنصر واجهة من سطر واحد لا يضيف أي وضوح

قاعدة عامة: فكّر في الشاشة كأقسام منطقية. كل قسم قائم بذاته - استخلصه كصنف عنصر واجهة (Widget class). لا تستخلص كل Text أو Icon على حدة.


مهام المعمل

المهمة 1: أضف حالة إلى حاسبة العمر

  1. افتح مشروع حاسبة العمر من المعمل 05.
  2. حدد عناصر Text التي تعرض السنوات والأشهر والأيام.
  3. تأكد أنها تقرأ من متغيّر UserAge (وليس قيمًا ثابتة).
  4. حوّل عنصر الواجهة الأب إلى StatefulWidget باستخدام إجراء IDE السريع.
  5. داخل onPressed الخاص بزر الحساب، حدّث userAge واستدعِ setState().
  6. شغّل التطبيق. انقر على "احسب" وتأكد من ظهور القيم الثلاث على الشاشة.

الناتج المتوقع: بعد اختيار تاريخين والنقر على "احسب"، تتحدث السنوات والأشهر والأيام فورًا على الشاشة.

المهمة 2: نسّق عرض التاريخ باستخدام intl

  1. أضف intl: ^0.19.0 إلى pubspec.yaml وشغّل flutter pub get.
  2. أنشئ صنف أدوات مساعدة DateFormatter بطريقة static String format(DateTime date) باستخدام DateFormat('d/M/yyyy').
  3. في دالة استدعاء منتقي التاريخ (Date Picker Callback)، استخدم DateFormatter.format(pickedDate) لضبط نص TextEditingController.
  4. تأكد أن حقل التاريخ يعرض تنسيق "5/3/2025" بدلًا من ناتج toString() الخام.

الناتج المتوقع: تعرض حقول التاريخ تواريخ نظيفة وسهلة القراءة مثل 5/3/2025.

المهمة 3: ابنِ شاشة قائمة دورات ثابتة

  1. أنشئ مشروع Flutter جديدًا (أو شاشة جديدة داخل المشروع الحالي).
  2. أنشئ صنف نموذج Course بالحقول: String title، String description، String imageUrl.
  3. أنشئ قائمة من 5 كائنات Course تجريبية مباشرة في كود Dart (بدون API بعد).
  4. ابنِ شاشة باستخدام ListView.builder تعرض كل دورة كـ Card تحتوي العنوان والوصف.
  5. اختبر سلوك التمرير مع أكثر من 5 عناصر.

الناتج المتوقع: قائمة قابلة للتمرير من بطاقات الدورات.

المهمة 4: أعد هيكلة الواجهة باستخدام أصناف عناصر الواجهة

  1. في شاشة قائمة الدورات، حدد قسمين منطقيين: الرأس (شريط العنوان) وجسم القائمة.
  2. استخلص الرأس إلى StatelessWidget باسم CoursesHeader في ملفه الخاص.
  3. استخلص كل عنصر قائمة (الـ Card) إلى StatelessWidget باسم CourseCard يستقبل كائن Course كمعامل.
  4. حدّث ListView.builder لاستخدام CourseCard(course: courses[index]).
  5. شغّل flutter analyze وأصلح أي تحذيرات.

الناتج المتوقع: كود نظيف ومعياري (Modular) حيث يملك كل ملف مسؤولية واحدة.

المهمة 5: معرض تمرير أفقي

  1. أضف ListView أفقيًا في أعلى شاشة الدورات يعرض رقاقات فئات (Category Chips) أو صور مصغرة.
  2. استخدم scrollDirection: Axis.horizontal.
  3. أعطِ كل عنصر عرضًا ثابتًا باستخدام SizedBox.

الناتج المتوقع: صف قابل للتمرير أفقيًا من العناصر أعلى قائمة الدورات الرأسية.


الملخص

المفهومالفكرة الأساسية
الحالة (State)متغيرات قابلة للتغيير تقرأها الواجهة - عندما تتغير، يجب إعادة بناء الواجهة
setState()يُشير لـ Flutter بإعادة بناء StatefulWidget بالحالة المُحدَّثة
intl / DateFormatينسّق DateTime إلى نصوص سهلة القراءة؛ استخلصه في صنف أدوات مساعدة
المعمارية ثلاثية الطبقاتتحتاج كل شاشة إلى طبقة واجهة، وطبقة منطق أعمال، وطبقة بيانات
ListViewعنصر قائمة قابل للتمرير؛ استخدم .builder لمجموعات البيانات الديناميكية/الكبيرة
الدالة المساعدةاستخلاص سريع لأجزاء واجهة بسيطة وغير قابلة لإعادة الاستخدام
صنف عنصر الواجهةالأفضل لمكونات واجهة قابلة لإعادة الاستخدام، ذات حالة مستقلة، ولها دورة حياة إعادة بناء خاصة بها