أساسيات ودجات Flutter

Card و ListTile

40 دقيقة الدرس 8 من 18

ودجة Card

ودجة Card هي سطح Material Design يمثل قطعة من المحتوى. لديها زوايا مستديرة وظل ارتفاع افتراضياً، مما يجعلها مثالية لعرض المعلومات المجمعة مثل جهات الاتصال والمنتجات والإعدادات أو المقالات. تحت الغطاء، Card هي ببساطة ودجة Material مع بعض التنسيق المُعد مسبقاً.

على عكس بناء بطاقة يدوياً مع Container و BoxDecoration، ودجة Card تتبع تلقائياً إرشادات Material Design وتستجيب لتغييرات السمة.

بطاقة أساسية

Card(
  child: Padding(
    padding: const EdgeInsets.all(16),
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        const Text(
          'بطاقة بسيطة',
          style: TextStyle(
            fontSize: 20,
            fontWeight: FontWeight.bold,
          ),
        ),
        const SizedBox(height: 8),
        const Text(
          'هذه بطاقة أساسية مع بعض المحتوى بداخلها.',
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: () {},
          child: const Text('إجراء'),
        ),
      ],
    ),
  ),
)

خصائص Card الرئيسية:

  • elevation -- عمق ظل الإحداثي z. الافتراضي 1.0. اضبطه على 0 لبطاقة مسطحة.
  • color -- لون خلفية البطاقة. يأخذ افتراضياً لون بطاقة السمة.
  • shadowColor -- لون الظل أسفل البطاقة.
  • surfaceTintColor -- لون الصبغة المُطبق على سطح البطاقة (Material 3).
  • shape -- شكل البطاقة. الافتراضي RoundedRectangleBorder بنصف قطر 12 بكسل في Material 3.
  • margin -- مساحة فارغة حول البطاقة.
  • clipBehavior -- كيف تقص البطاقة محتواها. استخدم Clip.antiAlias للصور التي تمتد إلى الحواف.

أشكال البطاقة

يمكنك تخصيص شكل البطاقة باستخدام فئات ShapeBorder الفرعية:

أمثلة أشكال البطاقة

// مستطيل مستدير (الافتراضي)
Card(
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(16),
  ),
  child: const Padding(
    padding: EdgeInsets.all(16),
    child: Text('بطاقة مستديرة'),
  ),
)

// بطاقة بحدود
Card(
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(12),
    side: const BorderSide(color: Colors.blue, width: 2),
  ),
  child: const Padding(
    padding: EdgeInsets.all(16),
    child: Text('بطاقة بحدود'),
  ),
)

// شكل ملعب (شكل حبة)
Card(
  shape: const StadiumBorder(),
  child: const Padding(
    padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
    child: Text('بطاقة ملعب'),
  ),
)

// مستطيل مشطوف
Card(
  shape: BeveledRectangleBorder(
    borderRadius: BorderRadius.circular(12),
  ),
  child: const Padding(
    padding: EdgeInsets.all(16),
    child: Text('بطاقة مشطوفة'),
  ),
)

البطاقة مع ClipBehavior

عندما تحتوي البطاقة على صور تمتد إلى حوافها، تحتاج clipBehavior لضمان أن الصورة تحترم الزوايا المستديرة للبطاقة:

بطاقة مع صورة

Card(
  clipBehavior: Clip.antiAlias,
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(16),
  ),
  elevation: 4,
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Image.network(
        'https://picsum.photos/400/200',
        height: 200,
        width: double.infinity,
        fit: BoxFit.cover,
      ),
      const Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'منظر طبيعي جميل',
              style: TextStyle(
                fontSize: 20,
                fontWeight: FontWeight.bold,
              ),
            ),
            SizedBox(height: 8),
            Text(
              'مشهد مذهل التُقط أثناء نزهة صباحية.',
              style: TextStyle(color: Colors.grey),
            ),
          ],
        ),
      ),
    ],
  ),
)
تحذير: بدون clipBehavior: Clip.antiAlias، ستتجاوز الصور داخل Card الزوايا المستديرة، مما يخلق خللاً بصرياً قبيحاً. اضبط دائماً clipBehavior عند استخدام الصور التي تلمس حواف البطاقة.

ودجة ListTile

ListTile هي ودجة صف بارتفاع ثابت تتبع مواصفات قائمة Material Design. مصممة لعرض ما يصل إلى ثلاثة أسطر من النص مع ودجات leading و trailing اختيارية. ListTile هي وحدة البناء القياسية للقوائم وشاشات الإعدادات والقوائم المنسدلة.

ListTile أساسي

ListTile(
  leading: const CircleAvatar(
    child: Text('أ'),
  ),
  title: const Text('أحمد علي'),
  subtitle: const Text('مطور برمجيات'),
  trailing: const Icon(Icons.arrow_forward_ios),
  onTap: () {
    debugPrint('تم الضغط على أحمد');
  },
)

خصائص ListTile الرئيسية:

  • leading -- ودجة تُعرض في البداية (اليسار في LTR). عادةً أيقونة أو صورة رمزية أو صورة.
  • title -- ودجة النص الأساسي. عادةً ودجة Text.
  • subtitle -- نص ثانوي يُعرض أسفل العنوان.
  • trailing -- ودجة تُعرض في النهاية (اليمين في LTR). غالباً أيقونة أو مفتاح أو شارة.
  • onTap -- دالة رد الاتصال عند الضغط على البلاطة. يضيف تأثير التموج.
  • onLongPress -- دالة رد الاتصال للضغط المطول.
  • dense -- إذا كان true، يقلل ارتفاع البلاطة وأحجام النص.
  • selected -- إذا كان true، يستخدم لون السمة المحدد للنص والأيقونات.
  • contentPadding -- الحشو الداخلي للبلاطة.
  • tileColor -- لون خلفية البلاطة.
  • selectedTileColor -- لون الخلفية عندما يكون selected هو true.
  • isThreeLine -- إذا كان true، يسمح للعنوان الفرعي بشغل سطرين.

أنواع ListTile المختلفة

يوفر Flutter أنواعاً متخصصة من ListTile لحالات الاستخدام الشائعة:

أنواع ListTile

// ListTile قياسي بثلاثة أسطر
ListTile(
  isThreeLine: true,
  leading: const CircleAvatar(
    backgroundImage: NetworkImage(
      'https://picsum.photos/100/100',
    ),
  ),
  title: const Text('تحديث المشروع'),
  subtitle: const Text(
    'تم إكمال آخر سباق بنجاح. '
    'تم تسليم جميع المهام في الوقت المحدد.',
    maxLines: 2,
    overflow: TextOverflow.ellipsis,
  ),
  trailing: const Text('قبل ساعتين', style: TextStyle(color: Colors.grey)),
)

// ListTile مضغوط (مدمج)
const ListTile(
  dense: true,
  leading: Icon(Icons.wifi, size: 20),
  title: Text('واي فاي'),
  subtitle: Text('متصل'),
  trailing: Icon(Icons.check, color: Colors.green, size: 20),
)

// ListTile محدد
ListTile(
  selected: true,
  selectedTileColor: Colors.blue[50],
  selectedColor: Colors.blue,
  leading: const Icon(Icons.inbox),
  title: const Text('البريد الوارد'),
  trailing: const Text('12'),
  onTap: () {},
)
ملاحظة: عندما يكون isThreeLine هو false (الافتراضي)، يُحدَّد العنوان الفرعي بسطر واحد. عندما يكون true، يمكن للعنوان الفرعي أن يمتد على سطرين وتصبح البلاطة أطول لاستيعاب النص الإضافي.

ودجة Divider

ودجة Divider ترسم خطاً أفقياً رفيعاً، يُستخدم عادةً لفصل عناصر ListTile أو أقسام المحتوى:

استخدام Divider

Column(
  children: [
    const ListTile(
      leading: Icon(Icons.email),
      title: Text('البريد الإلكتروني'),
      subtitle: Text('ahmed@example.com'),
    ),
    const Divider(height: 1),
    const ListTile(
      leading: Icon(Icons.phone),
      title: Text('الهاتف'),
      subtitle: Text('+966 50 123 4567'),
    ),
    const Divider(height: 1),
    const ListTile(
      leading: Icon(Icons.location_on),
      title: Text('العنوان'),
      subtitle: Text('شارع الملك فهد 123'),
    ),
  ],
)

// خصائص Divider
const Divider(
  height: 20,        // المساحة الكلية (الخط + مسافة فوق + تحت)
  thickness: 2,      // سمك الخط
  indent: 16,        // مسافة بادئة يسرى
  endIndent: 16,     // مسافة بادئة يمنى
  color: Colors.grey,
)

دمج Card و ListTile

البطاقات و ListTile تعمل معاً بشكل جميل. النمط الشائع هو تغليف ListTiles داخل Card:

بطاقة مع ListTiles

Card(
  margin: const EdgeInsets.all(16),
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(12),
  ),
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      ListTile(
        leading: const CircleAvatar(
          backgroundColor: Colors.blue,
          child: Icon(Icons.person, color: Colors.white),
        ),
        title: const Text('أحمد محمد'),
        subtitle: const Text('عضو مميز'),
        trailing: IconButton(
          icon: const Icon(Icons.edit),
          onPressed: () {},
        ),
      ),
      const Divider(height: 1),
      ListTile(
        leading: const Icon(Icons.email_outlined),
        title: const Text('ahmed@email.com'),
        onTap: () {},
      ),
      ListTile(
        leading: const Icon(Icons.phone_outlined),
        title: const Text('+966 (55) 123-4567'),
        onTap: () {},
      ),
      ListTile(
        leading: const Icon(Icons.location_on_outlined),
        title: const Text('الرياض، السعودية'),
        onTap: () {},
      ),
    ],
  ),
)

مثال عملي: قائمة جهات الاتصال

شاشة قائمة جهات الاتصال

class ContactListScreen extends StatelessWidget {
  const ContactListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final contacts = [
      {'name': 'أحمد', 'role': 'مصمم', 'initial': 'أ'},
      {'name': 'سارة', 'role': 'مطورة', 'initial': 'س'},
      {'name': 'خالد', 'role': 'مدير', 'initial': 'خ'},
      {'name': 'فاطمة', 'role': 'قائدة ضمان الجودة', 'initial': 'ف'},
    ];

    return Scaffold(
      appBar: AppBar(title: const Text('جهات الاتصال')),
      body: ListView.separated(
        padding: const EdgeInsets.all(8),
        itemCount: contacts.length,
        separatorBuilder: (context, index) =>
            const Divider(height: 1),
        itemBuilder: (context, index) {
          final contact = contacts[index];
          return ListTile(
            leading: CircleAvatar(
              backgroundColor: Colors.primaries[
                  index % Colors.primaries.length],
              child: Text(
                contact['initial']!,
                style: const TextStyle(color: Colors.white),
              ),
            ),
            title: Text(contact['name']!),
            subtitle: Text(contact['role']!),
            trailing: const Icon(Icons.chevron_right),
            onTap: () {
              debugPrint('تم الضغط على \${contact["name"]}');
            },
          );
        },
      ),
    );
  }
}

مثال عملي: شاشة الإعدادات

شاشة إعدادات مع بطاقات

class SettingsScreen extends StatefulWidget {
  const SettingsScreen({super.key});

  @override
  State<SettingsScreen> createState() => _SettingsScreenState();
}

class _SettingsScreenState extends State<SettingsScreen> {
  bool _darkMode = false;
  bool _notifications = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('الإعدادات')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // قسم الحساب
          const Text(
            'الحساب',
            style: TextStyle(
              fontSize: 14,
              fontWeight: FontWeight.bold,
              color: Colors.grey,
            ),
          ),
          const SizedBox(height: 8),
          Card(
            child: Column(
              children: [
                ListTile(
                  leading: const CircleAvatar(
                    child: Text('أ'),
                  ),
                  title: const Text('أحمد محمد'),
                  subtitle: const Text('ahmed@email.com'),
                  trailing: const Icon(Icons.chevron_right),
                  onTap: () {},
                ),
                const Divider(height: 1),
                ListTile(
                  leading: const Icon(Icons.security),
                  title: const Text('الخصوصية'),
                  trailing: const Icon(Icons.chevron_right),
                  onTap: () {},
                ),
              ],
            ),
          ),

          const SizedBox(height: 24),

          // قسم التفضيلات
          const Text(
            'التفضيلات',
            style: TextStyle(
              fontSize: 14,
              fontWeight: FontWeight.bold,
              color: Colors.grey,
            ),
          ),
          const SizedBox(height: 8),
          Card(
            child: Column(
              children: [
                SwitchListTile(
                  secondary: const Icon(Icons.dark_mode),
                  title: const Text('الوضع الداكن'),
                  value: _darkMode,
                  onChanged: (value) {
                    setState(() => _darkMode = value);
                  },
                ),
                const Divider(height: 1),
                SwitchListTile(
                  secondary: const Icon(Icons.notifications),
                  title: const Text('الإشعارات'),
                  value: _notifications,
                  onChanged: (value) {
                    setState(() => _notifications = value);
                  },
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}
نصيحة: جمّع الإعدادات ذات الصلة داخل بطاقة واحدة مع فواصل بين العناصر. أضف عناوين أقسام فوق كل بطاقة لتنظيم الإعدادات حسب الفئة. هذا نمط مُستخدم في كل من إعدادات iOS وإعدادات نظام Android.

مثال عملي: بطاقة المنتج

بطاقة منتج تجارة إلكترونية

Card(
  clipBehavior: Clip.antiAlias,
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(16),
  ),
  elevation: 2,
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    mainAxisSize: MainAxisSize.min,
    children: [
      // صورة المنتج
      Stack(
        children: [
          Image.network(
            'https://picsum.photos/400/250',
            height: 180,
            width: double.infinity,
            fit: BoxFit.cover,
          ),
          Positioned(
            top: 8,
            right: 8,
            child: Container(
              padding: const EdgeInsets.symmetric(
                horizontal: 8,
                vertical: 4,
              ),
              decoration: BoxDecoration(
                color: Colors.red,
                borderRadius: BorderRadius.circular(8),
              ),
              child: const Text(
                '-20%',
                style: TextStyle(
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
          ),
        ],
      ),
      // معلومات المنتج
      Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              'سماعات لاسلكية',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 4),
            const Text(
              'جودة صوت ممتازة مع إلغاء الضوضاء',
              style: TextStyle(color: Colors.grey),
            ),
            const SizedBox(height: 12),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                const Text(
                  '\$79.99',
                  style: TextStyle(
                    fontSize: 20,
                    fontWeight: FontWeight.bold,
                    color: Colors.blue,
                  ),
                ),
                ElevatedButton.icon(
                  onPressed: () {},
                  icon: const Icon(Icons.add_shopping_cart),
                  label: const Text('إضافة'),
                ),
              ],
            ),
          ],
        ),
      ),
    ],
  ),
)

تمرين عملي

ابنِ شاشة كاملة بثلاثة أقسام: (1) بطاقة إعدادات بأربعة عناصر ListTile على الأقل، كل منها بأيقونة وعنوان وعنوان فرعي وودجة trailing (مزيج من المفاتيح وأيقونات السهم والشارات). (2) صف قابل للتمرير أفقياً من بطاقات المنتجات باستخدام ListView.builder مع scrollDirection: Axis.horizontal. يجب أن تحتوي كل بطاقة على صورة وعنوان وسعر وزر إضافة للسلة. (3) بطاقة جهات اتصال مع عدة ListTiles وفواصل وودجات leading مختلفة (أيقونات وصور رمزية). التحدي: أضف معالج ضغط يعرض SnackBar باسم العنصر المحدد.