Flutter: Material Design والمكوّنات الشائعة

Material Design هي اللغة الأصلية لـ Flutter — أتقنها وسيكون لتطبيقك "إحساس أصلي" متسق مع المنصة.

📋 المتطلبات السابقة: يجب أن تكون قد أكملت ما يلي أولًا

1. ما ستتعلمه


2. قصة من مواصفات التصميم إلى الكود

(1) المشكلة: بناء مكوّنات واجهة المستخدم من الصفر يستغرق وقتًا طويلًا

تلقى بوب مواصفات تصميم واجهة ShopApp ووجد أن 80% من المكوّنات هي مكوّنات Material Design قياسية: AppBar، Card، FAB، BottomSheet. إذا برمج كل واحد يدويًا، سيستغرق ذلك 3 أسابيع تقديرًا. والأخطر، أن مكوّناته المخصصة لم تتطابق مع النمط الأصلي للنظام، وشكا المستخدمون أن التطبيق "لا يبدو كتطبيق حقيقي".

(2) حل مكتبة مكوّنات Material

يحتوي Flutter على مكتبة مكوّنات Material Design كاملة — استخدم المكوّنات القياسية بسطر كود واحد، مع رسوم متحركة مدمجة وإمكانية الوصول والتكيف مع السمات.

DART
import 'package:flutter/material.dart';

// 5 lines = full Material page
Scaffold(
  appBar: AppBar(title: const Text('ShopApp')),
  body: const ProductList(),
  floatingActionButton: FloatingActionButton(onPressed: () {}, child: const Icon(Icons.add)),
  bottomNavigationBar: BottomNavigationBar(items: [...]),
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(3) النتيجة: تحسين سرعة التطوير بـ 3 أضعاف

بعد استخدام مكوّنات Material، انخفض وقت صفحة التفاصيل من 3 أيام إلى يوم واحد، مع تكيف تلقائي مع السمة الفاتحة/الداكنة ووضع إمكانية الوصول.


3. Scaffold

Scaffold هو الإطار الأساسي لصفحات Material Design، يوفر بنية صفحة معيارية.

100%
graph TD
    S[Scaffold] --> A[AppBar]
    S --> B[Body]
    S --> F[FAB]
    S --> BN[BottomNavigationBar]
    S --> D[Drawer]
    B --> C[Card]
    B --> LT[ListTile]
    B --> GV[GridView]
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(1) مكوّنات Scaffold

الخاصية النوع الوصف
appBar PreferredSizeWidget شريط التنقل العلوي
body Widget محتوى الصفحة
floatingActionButton Widget زر عائم في الأسفل يمينًا
bottomNavigationBar Widget شريط التنقل السفلي
drawer Widget قائمة الدرج اليسرى
bottomSheet Widget لوحة منبثقة سفلية
snackBar SnackBar شريط إشعار سفلي

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: Scaffold الصفحة الرئيسية لـ ShopApp

DART
import 'package:flutter/material.dart';

Scaffold(
  appBar: AppBar(
    title: const Text('ShopApp'),
    actions: [
      IconButton(icon: const Icon(Icons.search), onPressed: () {}),
      IconButton(icon: const Icon(Icons.shopping_cart), onPressed: () {}),
    ],
  ),
  body: const ProductGrid(),
  floatingActionButton: FloatingActionButton.extended(
    onPressed: () {},
    icon: const Icon(Icons.flash_on),
    label: const Text('Flash Sale'),
  ),
  bottomNavigationBar: NavigationBar(
    destinations: const [
      NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
      NavigationDestination(icon: Icon(Icons.category), label: 'Categories'),
      NavigationDestination(icon: Icon(Icons.favorite), label: 'Wishlist'),
      NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
    ],
  ),
  drawer: Drawer(
    child: ListView(
      children: [
        const DrawerHeader(
          decoration: BoxDecoration(color: Colors.blue),
          child: Text('ShopApp', style: TextStyle(color: Colors.white, fontSize: 24)),
        ),
        ListTile(leading: const Icon(Icons.home), title: const Text('Home'), onTap: () {}),
        ListTile(leading: const Icon(Icons.settings), title: const Text('Settings'), onTap: () {}),
      ],
    ),
  ),
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

4. مكوّنات البطاقة والقائمة

(1) Card وListTile

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: بطاقة المنتج (Card + ListTile)

DART
import 'package:flutter/material.dart';

// Simplified class definition
class Product {
  final String imageUrl;
  final String name;
  final double price;
  final String category;
  const Product({required this.imageUrl, required this.name, required this.price, required this.category});
}

Card(
  margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
  elevation: 2,
  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  child: ListTile(
    leading: ClipRRect(
      borderRadius: BorderRadius.circular(8),
      child: Image.network(product.imageUrl, width: 56, height: 56, fit: BoxFit.cover),
    ),
    title: Text(product.name, style: const TextStyle(fontWeight: FontWeight.w600)),
    subtitle: Text('$${product.price.toStringAsFixed(2)} - ${product.category}'),
    trailing: IconButton(
      icon: const Icon(Icons.add_shopping_cart),
      onPressed: () => addToCart(product),
    ),
    onTap: () => navigateToDetail(product),
  ),
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: قائمة الطلبات (ListTile + Divider)

DART
import 'package:flutter/material.dart';

// Simplified class definition
class Order {
  final int id;
  final double total;
  final String date;
  final String status;
  const Order({required this.id, required this.total, required this.date, required this.status});
}

ListView.separated(
  itemCount: orders.length,
  separatorBuilder: (_, __) => const Divider(height: 1, indent: 16),
  itemBuilder: (context, index) {
    final order = orders[index];
    return ListTile(
      leading: CircleAvatar(
        backgroundColor: order.status == 'Shipped' ? Colors.green : Colors.orange,
        child: Icon(order.status == 'Shipped' ? Icons.local_shipping : Icons.schedule,
          color: Colors.white),
      ),
      title: Text('Order #${order.id}'),
      subtitle: Text('$${order.total.toStringAsFixed(2)} - ${order.date}'),
      trailing: const Icon(Icons.chevron_right),
      onTap: () => navigateToOrderDetail(order),
    );
  },
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

5. مكوّنات الحواريات

(1) مقارنة أنواع الحواريات

المكوّن الموضع التفاعل حالة الاستخدام
AlertDialog منبثق في المنتصف تأكيد/إلغاء تأكيد إجراء مهم (حذف طلب)
BottomSheet ينزلق من الأسفل محتوى مخصص اختيار المقاس، التصفية
Snackbar إشعار مؤقت سفلي يمكن أن يتضمن إجراء تغذية راجعة خفيفة (أُضيف إلى السلة)
Dialog منبثق في المنتصف قابل للتخصيص بالكامل محتوى منبثق مخصص

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: تأكيد حذف AlertDialog

DART
import 'package:flutter/material.dart';

// Simplified class definition
class CartItem {
  final String name;
  const CartItem({required this.name});
}

void showDeleteConfirmation(BuildContext context, CartItem item) {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('Remove Item'),
      content: Text('Remove "${item.name}" from your cart?'),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('Cancel'),
        ),
        FilledButton(
          onPressed: () {
            removeItem(item);
            Navigator.pop(context);
          },
          child: const Text('Remove'),
        ),
      ],
    ),
  );
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: اختيار المقاس BottomSheet

DART
import 'package:flutter/material.dart';

// Product class definition shown above

void showSizeSelector(BuildContext context, Product product) {
  showModalBottomSheet(
    context: context,
    isScrollControlled: true,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
    ),
    builder: (context) => DraggableScrollableSheet(
      initialChildSize: 0.5,
      minChildSize: 0.3,
      maxChildSize: 0.8,
      expand: false,
      builder: (context, scrollController) => Column(
        children: [
          // Handle bar
          Center(child: Container(
            margin: const EdgeInsets.symmetric(vertical: 8),
            width: 40, height: 4,
            decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(2)),
          )),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text('Select Size', style: Theme.of(context).textTheme.titleLarge),
          ),
          Expanded(
            child: ListView(
              controller: scrollController,
              children: ['S', 'M', 'L', 'XL', 'XXL'].map((size) => ListTile(
                title: Text('Size $size'),
                trailing: size == 'M' ? const Icon(Icons.check, color: Colors.green) : null,
                onTap: () => Navigator.pop(context, size),
              )).toList(),
            ),
          ),
        ],
      ),
    ),
  );
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: تغذية راجعة للسلة عبر Snackbar

DART
import 'package:flutter/material.dart';

// Product class definition shown above

void showCartSnackbar(BuildContext context, Product product) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text('${product.name} added to cart'),
      duration: const Duration(seconds: 2),
      action: SnackBarAction(
        label: 'View Cart',
        onPressed: () => navigateToCart(),
      ),
    ),
  );
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

6. مكوّنات التبديل

(1) مقارنة مكوّنات التبديل

المكوّن التفاعل نوع البيانات حالة الاستخدام
Switch تبديل بالانزلاق bool إعدادات تشغيل/إيقاف (الوضع الداكن)
Checkbox نقر للتحديد bool تأكيد متعدد الاختيارات (الموافقة على الشروط)
Radio نقر لاختيار واحد T اختيار واحد من مجموعة (طريقة الدفع)
Slider سحب شريط التمرير double اختيار نطاق (نطاق السعر)

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: اختيار طريقة الدفع بالـ Radio

DART
import 'package:flutter/material.dart';

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

  @override
  State<PaymentSelector> createState() => _PaymentSelectorState();
}

class _PaymentSelectorState extends State<PaymentSelector> {
  String _payment = 'credit_card';

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        RadioListTile<String>(
          title: const Text('Credit Card'),
          subtitle: const Text('Visa **** 4242'),
          secondary: const Icon(Icons.credit_card),
          value: 'credit_card',
          groupValue: _payment,
          onChanged: (v) => setState(() => _payment = v!),
        ),
        RadioListTile<String>(
          title: const Text('PayPal'),
          secondary: const Icon(Icons.account_balance_wallet),
          value: 'paypal',
          groupValue: _payment,
          onChanged: (v) => setState(() => _payment = v!),
        ),
        RadioListTile<String>(
          title: const Text('Apple Pay'),
          secondary: const Icon(Icons.phone_iphone),
          value: 'apple_pay',
          groupValue: _payment,
          onChanged: (v) => setState(() => _payment = v!),
        ),
      ],
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: شريط نطاق السعر Slider

DART
import 'package:flutter/material.dart';

RangeValues _priceRange = const RangeValues(0, 500);

RangeSlider(
  values: _priceRange,
  min: 0,
  max: 2000,
  divisions: 40,
  labels: RangeLabels(
    '$${_priceRange.start.toStringAsFixed(0)}',
    '$${_priceRange.end.toStringAsFixed(0)}',
  ),
  onChanged: (values) => setState(() => _priceRange = values),
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

7. مثال كامل: صفحة تفاصيل منتج ShopApp

DART
import 'package:flutter/material.dart';

// Simplified class definition
class Product {
  final String name;
  final double price;
  final String imageUrl;
  final double? originalPrice;
  final double rating;
  final int reviewCount;
  const Product({required this.name, required this.price, required this.imageUrl, this.originalPrice, this.rating = 0.0, this.reviewCount = 0});
}

class ProductDetailPage extends StatelessWidget {
  final Product product;
  const ProductDetailPage({super.key, required this.product});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          // Collapsible app bar with product image
          SliverAppBar(
            expandedHeight: 300,
            pinned: true,
            flexibleSpace: FlexibleSpaceBar(
              title: Text(product.name, style: const TextStyle(fontSize: 16)),
              background: Stack(
                fit: StackFit.expand,
                children: [
                  Image.network(product.imageUrl, fit: BoxFit.cover),
                  const DecoratedBox(
                    decoration: BoxDecoration(
                      gradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter,
                        colors: [Colors.black54, Colors.transparent]),
                    ),
                  ),
                ],
              ),
            ),
          ),
          // Product info
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(children: [
                    Text('$${product.price.toStringAsFixed(2)}',
                      style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.green)),
                    const SizedBox(width: 12),
                    if (product.originalPrice != null)
                      Text('$${product.originalPrice!.toStringAsFixed(2)}',
                        style: const TextStyle(decoration: TextDecoration.lineThrough, color: Colors.grey)),
                  ]),
                  const SizedBox(height: 8),
                  Row(children: [
                    Icon(Icons.star, color: Colors.amber[700], size: 18),
                    Text(' ${product.rating} (${product.reviewCount} reviews)'),
                  ]),
                  const SizedBox(height: 16),
                  const Text('Description', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
                  const SizedBox(height: 8),
                  const Text('Premium quality product with fast shipping worldwide. '
                      'Free returns within 30 days. USD settlement supported.'),
                ],
              ),
            ),
          ),
        ],
      ),
      // Bottom action bar
      bottomNavigationBar: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Row(children: [
            IconButton.outlined(onPressed: () {}, icon: const Icon(Icons.favorite_border)),
            const SizedBox(width: 8),
            Expanded(
              child: FilledButton.icon(
                onPressed: () => _showSizeSelector(context),
                icon: const Icon(Icons.shopping_cart),
                label: const Text('Add to Cart'),
              ),
            ),
          ]),
        ),
      ),
    );
  }

  void _showSizeSelector(BuildContext context) {
    showModalBottomSheet(
      context: context,
      builder: (ctx) => const SizeSelectorSheet(),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

❓ أسئلة شائعة

س هل يمكن استخدام body وbottomNavigationBar في Scaffold معًا؟
ج نعم، هذا هو الاستخدام القياسي. body يأخذ المساحة الرئيسية، bottomNavigationBar ثابت في الأسفل.
س Snackbar لا يظهر؟
ج يجب استدعاء ScaffoldMessenger.of(context).showSnackBar() باستخدام سياق Scaffold. إذا استخدمت Builder أو Scaffold متداخل، فقد يكون السياق خاطئًا.
س كيف أضع زوايا مستديرة على BottomSheet؟
ج استخدم خاصية shape: shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))).
س ما هو groupValue في Radio؟
ج هو القيمة المحددة حاليًا، يتشاركه كل أزرار Radio في المجموعة. عندما تساوي قيمة Radio قيمة groupValue، يُظهر حالة التحديد.
س ما الفرق بين Card وContainer؟
ج Card له ارتفاع (ظل) وزوايا مستديرة افتراضية، يتوافق مع مواصفات Material Design؛ Container أكثر مرونة لكن بدون زخرفة افتراضية. استخدم Card عندما تحتاج مظهر البطاقة.
س ما الفرق بين pinned وfloating في SliverAppBar؟
ج pinned=true يبقي AppBar ثابتًا في الأعلى أثناء التمرير؛ floating=true يُظهر AppBar فورًا عند التمرير للأسفل. يمكن استخدام كليهما معًا.

📖 ملخص


📝 تمارين

  1. أساسي (الصعوبة ⭐): أنشئ Scaffold مع Drawer يحتوي على 5 عناصر قائمة (الرئيسية/السلة/الطلبات/الإعدادات/حول).
  2. متوسط (الصعوبة ⭐⭐): نفّذ لوحة تصفية BottomSheet مع Slider للسعر وCheckboxes للفئات، مع عرض نتائج التصفية عبر Snackbar بعد التأكيد.
  3. متقدم (الصعوبة ⭐⭐⭐): نفّذ صفحة تفاصيل منتج كاملة: SliverAppBar بصورة قابلة للطي + معلومات المنتج + BottomSheet لاختيار المقاس + تغذية راجعة Snackbar للإضافة إلى السلة.

← السابق | التالي →

Web-Tutorial.com

فريق Web-Tutorial التقني

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

100%