البطاقات في Flutter: Card و ListTile و ExpansionTile و Dismissible
في درس ListView استخدمت ListTile بشكله البسيط. لكن قوائم التطبيقات الحقيقية أغنى من ذلك: بطاقات مرتفعة بظل خفيف، وأسئلة تنفتح عند الضغط لتُظهر إجابتها، ورسائل تُحذف بسحبها جانباً. في هذا الدرس ستتعلم أربعة عناصر جاهزة تبني بها هذه القوائم بأقل كود.
Card: البطاقة
Section titled “Card: البطاقة”Card صندوق جاهز بزوايا دائرية وظل خفيف، يتبع تصميم Material. تخيّله مثل بطاقة ورقية موضوعة على المكتب: مرتفعة قليلاً عن السطح، فتبدو منفصلة عما حولها.
Card( elevation: 2, margin: const EdgeInsets.all(12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: const Padding( padding: EdgeInsets.all(16), child: Text('محتوى البطاقة'), ),)elevation: ارتفاع البطاقة عن الخلفية. كلما زاد الرقم زاد الظل. القيمة 0 تلغي الظل.margin: المسافة حول البطاقة.shape: شكل الحواف.RoundedRectangleBorderتسمح بتغيير نصف قطر الزوايا.color: لون خلفية البطاقة.
Card ليس له padding، لذلك نلف المحتوى بـ Padding بأنفسنا.
ListTile بكل خصائصه
Section titled “ListTile بكل خصائصه”ListTile صف جاهز مقسم إلى أماكن محددة:
ListTile( leading: const CircleAvatar(child: Text('م')), title: const Text('محمد علي'), subtitle: const Text('هل وصلت الشحنة؟'), trailing: const Text('10:30 ص'), onTap: () {}, onLongPress: () {},)| الخاصية | مكانها ووظيفتها |
|---|---|
leading |
بداية الصف: صورة أو أيقونة |
title |
السطر الرئيسي |
subtitle |
سطر ثانوي أصغر تحت العنوان |
trailing |
نهاية الصف: وقت أو سهم أو مفتاح |
onTap |
ما يحدث عند الضغط |
onLongPress |
ما يحدث عند الضغط المطوّل |
selected |
تلوين الصف كعنصر محدد |
enabled |
إذا كانت false يصبح الصف باهتاً لا يستجيب |
dense |
نسخة مضغوطة بارتفاع أقل |
وعندما تضع ListTile داخل Card تحصل على بطاقة أنيقة في سطرين من الكود:
Card( child: ListTile( leading: const Icon(Icons.local_shipping), title: const Text('طلب رقم 1042'), subtitle: const Text('في الطريق إليك'), trailing: const Icon(Icons.chevron_left), onTap: () {}, ),)الفواصل: Divider
Section titled “الفواصل: Divider”خط رفيع يفصل بين العناصر أو الأقسام:
const Divider(height: 24, thickness: 1, indent: 16, endIndent: 16)height: المساحة الرأسية الكلية التي يحتلها الفاصل، وليست سُمك الخط.thickness: سُمك الخط نفسه.indentوendIndent: مسافة فارغة قبل الخط وبعده.
ويوجد VerticalDivider للفواصل الرأسية داخل Row.
ExpansionTile: عنصر ينفتح وينطوي
Section titled “ExpansionTile: عنصر ينفتح وينطوي”مثالي لصفحات الأسئلة الشائعة والإعدادات المجمّعة:
const ExpansionTile( leading: Icon(Icons.help_outline), title: Text('كيف أسترجع المنتج؟'), children: [ Padding( padding: EdgeInsets.all(16), child: Text('يمكنك استرجاع المنتج خلال 14 يوماً من الاستلام.'), ), ],)يظهر العنوان فقط مع سهم صغير. عند الضغط ينفتح العنصر بحركة ناعمة ويُظهر ما في children، وعند الضغط مرة أخرى ينطوي. وكل هذا بدون أن تكتب setState، فالـ Widget يدير حالته بنفسه. وإذا أردته مفتوحاً من البداية أضف initiallyExpanded: true.
Dismissible: الحذف بالسحب
Section titled “Dismissible: الحذف بالسحب”الحركة المعروفة في تطبيقات البريد: تسحب الرسالة جانباً فتختفي.
Dismissible( key: const ValueKey('message-1'), direction: DismissDirection.endToStart, background: Container( color: Colors.red, alignment: AlignmentDirectional.centerEnd, padding: const EdgeInsets.symmetric(horizontal: 16), child: const Icon(Icons.delete, color: Colors.white), ), onDismissed: (direction) { debugPrint('تم الحذف'); }, child: const ListTile(title: Text('اسحبني لتحذفني')),)key: إجباري. معرّف فريد لكل عنصر، ليعرف Flutter أي عنصر بالضبط حُذف. استخدم شيئاً يميّز العنصر، مثل رقمه التعريفي، وليس ترتيبه في القائمة.direction: اتجاه السحب المسموح.endToStartتعني من نهاية السطر إلى بدايته.background: ما يظهر خلف العنصر أثناء السحب، وعادة يكون خلفية حمراء وأيقونة سلة.onDismissed: تُستدعى بعد اكتمال السحب، وفيها يجب أن تحذف العنصر من بياناتك.
المشروع: صندوق رسائل بالحذف والتراجع
Section titled “المشروع: صندوق رسائل بالحذف والتراجع”class MessagesScreen extends StatefulWidget { const MessagesScreen({super.key});
@override State<MessagesScreen> createState() => _MessagesScreenState();}
class _MessagesScreenState extends State<MessagesScreen> { final List<String> _messages = [ 'تم شحن طلبك', 'عرض خاص لك اليوم', 'تذكير بموعد الغد', 'رسالة ترحيب', ];
void _remove(int index) { final removed = _messages[index]; setState(() { _messages.removeAt(index); }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('حُذفت: $removed'), action: SnackBarAction( label: 'تراجع', onPressed: () { setState(() { final position = index <= _messages.length ? index : _messages.length; _messages.insert(position, removed); }); }, ), ), ); }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('الرسائل (${_messages.length})')), body: ListView.builder( padding: const EdgeInsets.all(8), itemCount: _messages.length, itemBuilder: (context, index) { final message = _messages[index]; return Dismissible( key: ValueKey(message), background: Container( color: Colors.red, alignment: AlignmentDirectional.centerStart, padding: const EdgeInsets.symmetric(horizontal: 16), child: const Icon(Icons.delete, color: Colors.white), ), onDismissed: (direction) => _remove(index), child: Card( child: ListTile( leading: const Icon(Icons.mail_outline), title: Text(message), subtitle: const Text('اسحب للحذف'), ), ), ); }, ), ); }}كيف يعمل:
- تظهر أربع بطاقات، والعنوان يعرض «الرسائل (4)».
- عند سحب بطاقة تظهر خلفها الخلفية الحمراء، ثم تختفي البطاقة ويصبح العنوان «الرسائل (3)».
- يظهر في الأسفل شريط «حُذفت: …» وفيه زر «تراجع».
- الضغط على «تراجع» يعيد الرسالة إلى مكانها الأصلي بـ
insert. والمتغيرpositionيحمينا من حالة نادرة: إذا حُذفت رسائل أخرى قبل الضغط على «تراجع» وصارت القائمة أقصر، نضيف الرسالة في آخرها بدلاً من حدوث خطأ.
زر التراجع عادة ممتازة: الحذف بالسحب سهل جداً، وقد يحدث بالخطأ.
جرّب بنفسك
Section titled “جرّب بنفسك”- شغّل صندوق الرسائل، واحذف رسالة ثم تراجع.
- غيّر
elevationوshapeللبطاقات. - أضف تحت القائمة قسماً للأسئلة الشائعة فيه ثلاثة عناصر
ExpansionTile. - تحدٍّ: اجعل السحب في اتجاه يحذف الرسالة، وفي الاتجاه الآخر يؤرشفها بخلفية خضراء. (تلميح:
secondaryBackgroundوقيمةdirectionداخلonDismissed.)
أخطاء شائعة
Section titled “أخطاء شائعة”- استخدام index كمفتاح في Dismissible: بعد الحذف تتغير الأرقام فيختلط الأمر على Flutter. استخدم قيمة ثابتة تخص العنصر نفسه.
- نسيان حذف العنصر من القائمة في onDismissed: يظهر خطأ أن العنصر ما زال في الشجرة.
- وضع padding داخل Card مباشرة:
Cardلا يملك هذه الخاصية. لف المحتوى بـPadding.
اختبر نفسك
Section titled “اختبر نفسك”سؤال
ما الخاصية الإجبارية في Dismissible التي تميّز كل عنصر عن غيره؟
key معرّف فريد يحتاجه Flutter ليعرف أي عنصر حُذف بالضبط، ويجب أن يكون مرتبطاً ببيانات العنصر.
سؤال
أي Widget يعرض عنواناً ينفتح عند الضغط ليُظهر محتوى إضافياً؟
ExpansionTile يدير حالة الفتح والطي بنفسه، ويعرض ما في children عند الفتح.
الخلاصة
Section titled “الخلاصة”Cardبطاقة جاهزة بزوايا وظل، وتتحكم فيها بـelevationوshape.ListTileصف له أماكن محددة:leadingوtitleوsubtitleوtrailing.Dividerللفواصل، وExpansionTileللعناصر القابلة للطي.Dismissibleللحذف بالسحب، ويحتاجkeyفريداً وحذفاً فورياً من البيانات.- أضف زر «تراجع» في
SnackBarبعد أي حذف سهل.