Flutter: Material Design والمكوّنات الشائعة
Material Design هي اللغة الأصلية لـ Flutter — أتقنها وسيكون لتطبيقك "إحساس أصلي" متسق مع المنصة.
📋 المتطلبات السابقة: يجب أن تكون قد أكملت ما يلي أولًا
- الدرس 5: StatefulWidget والتفاعل
1. ما ستتعلمه
- الهيكل العام Scaffold: AppBar / BottomNavigationBar / FloatingActionButton / Drawer
- البطاقات والقوائم: Card، ListTile، Divider
- الحواريات: AlertDialog، BottomSheet، Snackbar
- مكوّنات التبديل: Switch، Checkbox، Radio، Slider
- صفحة تفاصيل منتج ShopApp: SliverAppBar + BottomSheet لاختيار المقاس
2. قصة من مواصفات التصميم إلى الكود
(1) المشكلة: بناء مكوّنات واجهة المستخدم من الصفر يستغرق وقتًا طويلًا
تلقى بوب مواصفات تصميم واجهة ShopApp ووجد أن 80% من المكوّنات هي مكوّنات Material Design قياسية: AppBar، Card، FAB، BottomSheet. إذا برمج كل واحد يدويًا، سيستغرق ذلك 3 أسابيع تقديرًا. والأخطر، أن مكوّناته المخصصة لم تتطابق مع النمط الأصلي للنظام، وشكا المستخدمون أن التطبيق "لا يبدو كتطبيق حقيقي".
(2) حل مكتبة مكوّنات Material
يحتوي Flutter على مكتبة مكوّنات Material Design كاملة — استخدم المكوّنات القياسية بسطر كود واحد، مع رسوم متحركة مدمجة وإمكانية الوصول والتكيف مع السمات.
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: [...]),
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(3) النتيجة: تحسين سرعة التطوير بـ 3 أضعاف
بعد استخدام مكوّنات Material، انخفض وقت صفحة التفاصيل من 3 أيام إلى يوم واحد، مع تكيف تلقائي مع السمة الفاتحة/الداكنة ووضع إمكانية الوصول.
3. Scaffold
Scaffold هو الإطار الأساسي لصفحات Material Design، يوفر بنية صفحة معيارية.
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]
> الإخراج: شغّل محليًا باستخدام 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 | شريط إشعار سفلي |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: Scaffold الصفحة الرئيسية لـ ShopApp
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: () {}),
],
),
),
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
4. مكوّنات البطاقة والقائمة
(1) Card وListTile
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: بطاقة المنتج (Card + ListTile)
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),
),
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: قائمة الطلبات (ListTile + Divider)
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),
);
},
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
5. مكوّنات الحواريات
(1) مقارنة أنواع الحواريات
| المكوّن | الموضع | التفاعل | حالة الاستخدام |
|---|---|---|---|
| AlertDialog | منبثق في المنتصف | تأكيد/إلغاء | تأكيد إجراء مهم (حذف طلب) |
| BottomSheet | ينزلق من الأسفل | محتوى مخصص | اختيار المقاس، التصفية |
| Snackbar | إشعار مؤقت سفلي | يمكن أن يتضمن إجراء | تغذية راجعة خفيفة (أُضيف إلى السلة) |
| Dialog | منبثق في المنتصف | قابل للتخصيص بالكامل | محتوى منبثق مخصص |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: تأكيد حذف AlertDialog
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'),
),
],
),
);
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: اختيار المقاس BottomSheet
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(),
),
),
],
),
),
);
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: تغذية راجعة للسلة عبر Snackbar
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(),
),
),
);
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
6. مكوّنات التبديل
(1) مقارنة مكوّنات التبديل
| المكوّن | التفاعل | نوع البيانات | حالة الاستخدام |
|---|---|---|---|
| Switch | تبديل بالانزلاق | bool |
إعدادات تشغيل/إيقاف (الوضع الداكن) |
| Checkbox | نقر للتحديد | bool |
تأكيد متعدد الاختيارات (الموافقة على الشروط) |
| Radio | نقر لاختيار واحد | T |
اختيار واحد من مجموعة (طريقة الدفع) |
| Slider | سحب شريط التمرير | double |
اختيار نطاق (نطاق السعر) |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: اختيار طريقة الدفع بالـ Radio
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!),
),
],
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: شريط نطاق السعر Slider
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),
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
7. مثال كامل: صفحة تفاصيل منتج ShopApp
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(),
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
❓ أسئلة شائعة
ScaffoldMessenger.of(context).showSnackBar() باستخدام سياق Scaffold. إذا استخدمت Builder أو Scaffold متداخل، فقد يكون السياق خاطئًا.shape: shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))).📖 ملخص
- Scaffold يوفر بنية صفحة Material Design القياسية: AppBar + Body + FAB + BottomNav + Drawer
- Card + ListTile يبني عناصر القائمة بسرعة، Divider يفصل بينها
- AlertDialog للإجراءات التأكيدية، BottomSheet للاختيارات المعقدة، Snackbar للتغذية الراجعة الخفيفة
- Switch/Checkbox/Radio/Slider يغطي جميع سيناريوهات التبديل
- SliverAppBar يُنشئ رؤوسًا قابلة للطي، مع CustomScrollView لصفحات تفاصيل غامرة
📝 تمارين
- أساسي (الصعوبة ⭐): أنشئ Scaffold مع Drawer يحتوي على 5 عناصر قائمة (الرئيسية/السلة/الطلبات/الإعدادات/حول).
- متوسط (الصعوبة ⭐⭐): نفّذ لوحة تصفية BottomSheet مع Slider للسعر وCheckboxes للفئات، مع عرض نتائج التصفية عبر Snackbar بعد التأكيد.
- متقدم (الصعوبة ⭐⭐⭐): نفّذ صفحة تفاصيل منتج كاملة: SliverAppBar بصورة قابلة للطي + معلومات المنتج + BottomSheet لاختيار المقاس + تغذية راجعة Snackbar للإضافة إلى السلة.