المعمل 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
StatelessWidget | StatefulWidget | |
|---|---|---|
| هل يمكنه إعادة بناء نفسه؟ | لا | نعم |
هل يملك 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/yyyy | 5/3/2025 |
dd/MM/yyyy | 05/03/2025 |
MMMM d, yyyy | March 5, 2025 |
yyyy-MM-dd | 2025-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
Column | ListView | |
|---|---|---|
| التمرير | ❌ لا يوجد تمرير مدمج | ✅ يمرر تلقائيًا |
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: أضف حالة إلى حاسبة العمر
- افتح مشروع حاسبة العمر من المعمل 05.
- حدد عناصر
Textالتي تعرض السنوات والأشهر والأيام. - تأكد أنها تقرأ من متغيّر
UserAge(وليس قيمًا ثابتة). - حوّل عنصر الواجهة الأب إلى
StatefulWidgetباستخدام إجراء IDE السريع. - داخل
onPressedالخاص بزر الحساب، حدّثuserAgeواستدعِsetState(). - شغّل التطبيق. انقر على "احسب" وتأكد من ظهور القيم الثلاث على الشاشة.
الناتج المتوقع: بعد اختيار تاريخين والنقر على "احسب"، تتحدث السنوات والأشهر والأيام فورًا على الشاشة.
المهمة 2: نسّق عرض التاريخ باستخدام intl
- أضف
intl: ^0.19.0إلىpubspec.yamlوشغّلflutter pub get. - أنشئ صنف أدوات مساعدة
DateFormatterبطريقةstatic String format(DateTime date)باستخدامDateFormat('d/M/yyyy'). - في دالة استدعاء منتقي التاريخ (Date Picker Callback)، استخدم
DateFormatter.format(pickedDate)لضبط نصTextEditingController. - تأكد أن حقل التاريخ يعرض تنسيق
"5/3/2025"بدلًا من ناتجtoString()الخام.
الناتج المتوقع: تعرض حقول التاريخ تواريخ نظيفة وسهلة القراءة مثل 5/3/2025.
المهمة 3: ابنِ شاشة قائمة دورات ثابتة
- أنشئ مشروع Flutter جديدًا (أو شاشة جديدة داخل المشروع الحالي).
- أنشئ صنف نموذج
Courseبالحقول:String title،String description،String imageUrl. - أنشئ قائمة من 5 كائنات
Courseتجريبية مباشرة في كود Dart (بدون API بعد). - ابنِ شاشة باستخدام
ListView.builderتعرض كل دورة كـCardتحتوي العنوان والوصف. - اختبر سلوك التمرير مع أكثر من 5 عناصر.
الناتج المتوقع: قائمة قابلة للتمرير من بطاقات الدورات.
المهمة 4: أعد هيكلة الواجهة باستخدام أصناف عناصر الواجهة
- في شاشة قائمة الدورات، حدد قسمين منطقيين: الرأس (شريط العنوان) وجسم القائمة.
- استخلص الرأس إلى
StatelessWidgetباسمCoursesHeaderفي ملفه الخاص. - استخلص كل عنصر قائمة (الـ
Card) إلىStatelessWidgetباسمCourseCardيستقبل كائنCourseكمعامل. - حدّث
ListView.builderلاستخدامCourseCard(course: courses[index]). - شغّل
flutter analyzeوأصلح أي تحذيرات.
الناتج المتوقع: كود نظيف ومعياري (Modular) حيث يملك كل ملف مسؤولية واحدة.
المهمة 5: معرض تمرير أفقي
- أضف
ListViewأفقيًا في أعلى شاشة الدورات يعرض رقاقات فئات (Category Chips) أو صور مصغرة. - استخدم
scrollDirection: Axis.horizontal. - أعطِ كل عنصر عرضًا ثابتًا باستخدام
SizedBox.
الناتج المتوقع: صف قابل للتمرير أفقيًا من العناصر أعلى قائمة الدورات الرأسية.
الملخص
| المفهوم | الفكرة الأساسية |
|---|---|
| الحالة (State) | متغيرات قابلة للتغيير تقرأها الواجهة - عندما تتغير، يجب إعادة بناء الواجهة |
setState() | يُشير لـ Flutter بإعادة بناء StatefulWidget بالحالة المُحدَّثة |
intl / DateFormat | ينسّق DateTime إلى نصوص سهلة القراءة؛ استخلصه في صنف أدوات مساعدة |
| المعمارية ثلاثية الطبقات | تحتاج كل شاشة إلى طبقة واجهة، وطبقة منطق أعمال، وطبقة بيانات |
ListView | عنصر قائمة قابل للتمرير؛ استخدم .builder لمجموعات البيانات الديناميكية/الكبيرة |
| الدالة المساعدة | استخلاص سريع لأجزاء واجهة بسيطة وغير قابلة لإعادة الاستخدام |
| صنف عنصر الواجهة | الأفضل لمكونات واجهة قابلة لإعادة الاستخدام، ذات حالة مستقلة، ولها دورة حياة إعادة بناء خاصة بها |