تخطَّ إلى المحتوى

البطاقات في Flutter: Card و ListTile و ExpansionTile و Dismissible

الدرس 17 من 33 · Flutter

في درس ListView استخدمت ListTile بشكله البسيط. لكن قوائم التطبيقات الحقيقية أغنى من ذلك: بطاقات مرتفعة بظل خفيف، وأسئلة تنفتح عند الضغط لتُظهر إجابتها، ورسائل تُحذف بسحبها جانباً. في هذا الدرس ستتعلم أربعة عناصر جاهزة تبني بها هذه القوائم بأقل كود.

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 صف جاهز مقسم إلى أماكن محددة:

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: () {},
),
)

خط رفيع يفصل بين العناصر أو الأقسام:

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(
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 “المشروع: صندوق رسائل بالحذف والتراجع”
lib/messages_screen.dart
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('اسحب للحذف'),
),
),
);
},
),
);
}
}

كيف يعمل:

  1. تظهر أربع بطاقات، والعنوان يعرض «الرسائل (4)».
  2. عند سحب بطاقة تظهر خلفها الخلفية الحمراء، ثم تختفي البطاقة ويصبح العنوان «الرسائل (3)».
  3. يظهر في الأسفل شريط «حُذفت: …» وفيه زر «تراجع».
  4. الضغط على «تراجع» يعيد الرسالة إلى مكانها الأصلي بـ insert. والمتغير position يحمينا من حالة نادرة: إذا حُذفت رسائل أخرى قبل الضغط على «تراجع» وصارت القائمة أقصر، نضيف الرسالة في آخرها بدلاً من حدوث خطأ.

زر التراجع عادة ممتازة: الحذف بالسحب سهل جداً، وقد يحدث بالخطأ.

  1. شغّل صندوق الرسائل، واحذف رسالة ثم تراجع.
  2. غيّر elevation و shape للبطاقات.
  3. أضف تحت القائمة قسماً للأسئلة الشائعة فيه ثلاثة عناصر ExpansionTile.
  4. تحدٍّ: اجعل السحب في اتجاه يحذف الرسالة، وفي الاتجاه الآخر يؤرشفها بخلفية خضراء. (تلميح: secondaryBackground وقيمة direction داخل onDismissed.)
  • استخدام index كمفتاح في Dismissible: بعد الحذف تتغير الأرقام فيختلط الأمر على Flutter. استخدم قيمة ثابتة تخص العنصر نفسه.
  • نسيان حذف العنصر من القائمة في onDismissed: يظهر خطأ أن العنصر ما زال في الشجرة.
  • وضع padding داخل Card مباشرة: Card لا يملك هذه الخاصية. لف المحتوى بـ Padding.

سؤال

ما الخاصية الإجبارية في Dismissible التي تميّز كل عنصر عن غيره؟

سؤال

أي Widget يعرض عنواناً ينفتح عند الضغط ليُظهر محتوى إضافياً؟

  • Card بطاقة جاهزة بزوايا وظل، وتتحكم فيها بـ elevation و shape.
  • ListTile صف له أماكن محددة: leading و title و subtitle و trailing.
  • Divider للفواصل، و ExpansionTile للعناصر القابلة للطي.
  • Dismissible للحذف بالسحب، ويحتاج key فريداً وحذفاً فورياً من البيانات.
  • أضف زر «تراجع» في SnackBar بعد أي حذف سهل.