Flutter: StatefulWidget والتفاعل
الحالة هي ذاكرة الويدجت — بدون الحالة، الواجهة كمريض فقد الذاكرة لا يتعرف عليك كل مرة.
📋 المتطلبات السابقة: يجب أن تكون قد أكملت ما يلي أولًا
- الدرس 3: أساسيات الويدجت
1. ما ستتعلمه
- دورة حياة StatefulWidget الكاملة: createState → initState → build → dispose
- آلية إعادة البناء عبر
setStateونطاق إعادة البناء الأدنى - معالجة الأحداث: GestureDetector، InkWell، استدعاءات onPressed
- تفاعل الإدخال: TextField، استماع TextEditingController للنص
- تنفيذ محدد كمية سلة تسوق ShopApp
2. قصة حقيقية عن معضلة تفاعل
(1) المشكلة: النقرات لا تستجيب
واجه بوب خطأً غريبًا في صفحة سلة ShopApp: نقر المستخدمون زر "−"، انخفضت الكمية من 1 إلى 0، لكن واجهة المستخدم لا تزال تعرض 1. عدّل المتغير مباشرة _count = _count - 1 دون استدعاء setState — الويدجت لم يعرف أن الحالة تغيرت، لذا طبيعي ألا يُعيد بناء واجهة المستخدم.
(2) حل setState
المبدأ الأساسي لـ StatefulWidget: تغيير الحالة لا يعني تغيير واجهة المستخدم. يجب إخطار إطار العمل عبر setState بأن "ني تغيرت، يُرجى إعادة البناء".
import 'package:flutter/material.dart';
// WRONG: Direct mutation, UI not updated
void _decrement() {
_count = _count - 1; // State changed, but UI doesn't know
}
// RIGHT: setState triggers rebuild
void _decrement() {
setState(() {
_count = _count - 1; // Framework rebuilds this widget
});
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(3) النتيجة: واجهة المستخدم والحالة متزامنة دائمًا
بعد إضافة setState، حدُثت كمية سلة التسوق في الوقت الفعلي وأُعيد حساب السعر الإجمالي تلقائيًا. منذ ذلك الحين، تذكر بوب: عند تغيير البيانات، يجب استدعاء setState.
3. دورة حياة StatefulWidget
stateDiagram-v2
[*] --> createState
createState --> initState
initState --> didChangeDependencies
didChangeDependencies --> build
build --> didUpdateWidget: setState / parent rebuild
didUpdateWidget --> build
build --> deactivate: removed from tree
deactivate --> dispose
dispose --> [*]
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(1) استدعاءات دورة الحياة
| الاستدعاء | وقت التشغيل | الغرض |
|---|---|---|
createState |
إطار العمل ينشئ State | تهيئة كائن State |
initState |
State يُدرج في الشجرة | تهيئة لمرة واحدة (اشتراكات، متحكمات) |
didChangeDependencies |
InheritedWidget المعتمد يتغير | قراءة السمة، اللغة، إلخ |
build |
كلما احتاجت واجهة المستخدم للعرض | بناء شجرة الويدجت |
didUpdateWidget |
إعادة بناء الويدجت الأب | مقارنة الويدجت القديم/الجديد والاستجابة |
setState |
المطور يستدعي صراحةً | تعليم كمتسخ، تحريك إعادة البناء |
deactivate |
State يُزال من الشجرة | إزالة مؤقتة (يمكن إعادة إدراجه) |
dispose |
State يُدمر بشكل دائم | تحرير الموارد، إلغاء الاشتراكات |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: تتبع دورة الحياة
import 'package:flutter/material.dart';
class LifecycleTracker extends StatefulWidget {
const LifecycleTracker({super.key});
@override
State<LifecycleTracker> createState() => _LifecycleTrackerState();
}
class _LifecycleTrackerState extends State<LifecycleTracker> {
int _buildCount = 0;
@override
void initState() {
super.initState();
debugPrint('initState called');
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
debugPrint('didChangeDependencies called');
}
@override
void didUpdateWidget(covariant LifecycleTracker oldWidget) {
super.didUpdateWidget(oldWidget);
debugPrint('didUpdateWidget called');
}
@override
Widget build(BuildContext context) {
_buildCount++;
debugPrint('build #$_buildCount');
return Text('Build count: $_buildCount');
}
@override
void dispose() {
debugPrint('dispose called');
super.dispose();
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
4. آلية setState بعمق
(1) سير عمل setState
| الخطوة | الإجراء |
|---|---|
| 1 | استدعاء setState(fn) |
| 2 | تنفيذ fn، تحديث متغيرات الحالة |
| 3 | تعليم العنصر كمتسخ |
| 4 | الإطار التالي، إطار العمل يستدعي build |
| 5 | فرق أشجار الويدجت القديمة/الجديدة، تحديث RenderObject |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: محدد كمية سلة التسوق
import 'package:flutter/material.dart';
class QuantitySelector extends StatefulWidget {
final int initialValue;
final ValueChanged<int> onChanged;
const QuantitySelector({
super.key,
this.initialValue = 1,
required this.onChanged,
});
@override
State<QuantitySelector> createState() => _QuantitySelectorState();
}
class _QuantitySelectorState extends State<QuantitySelector> {
late int _count;
@override
void initState() {
super.initState();
_count = widget.initialValue;
}
@override
void didUpdateWidget(covariant QuantitySelector oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialValue != widget.initialValue) {
_count = widget.initialValue;
}
}
void _increment() {
setState(() {
_count = (_count + 1).clamp(1, 99);
});
widget.onChanged(_count);
}
void _decrement() {
setState(() {
_count = (_count - 1).clamp(1, 99);
});
widget.onChanged(_count);
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.remove), onPressed: _decrement),
Text('$_count', style: const TextStyle(fontSize: 18)),
IconButton(icon: const Icon(Icons.add), onPressed: _increment),
],
),
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
5. معالجة الأحداث
(1) GestureDetector مقابل InkWell
| الويدجت | تأثير النقر | تموج | حالة الاستخدام |
|---|---|---|---|
GestureDetector |
لا تغذية راجعة بصرية | لا | إيماءات مخصصة (ضغط طويل/سحب/نقر مزدوج) |
InkWell |
تموج Material | نعم | نقرات البطاقة/عنصر القائمة |
IconButton |
أيقونة + تموج | نعم | إجراءات شريط الأدوات |
ElevatedButton |
نمط زر | نعم | الإجراءات الرئيسية |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: التعرف على إيماءات GestureDetector
import 'package:flutter/material.dart';
// Simplified class definition
class Product {
final String name;
final double price;
final String imageUrl;
final int id;
const Product({required this.name, required this.price, required this.imageUrl, required this.id});
}
GestureDetector(
onTap: () => debugPrint('Tapped'),
onDoubleTap: () => debugPrint('Double tapped'),
onLongPress: () => debugPrint('Long pressed'),
onHorizontalDragEnd: (details) {
// Swipe to add/remove from cart
if (details.primaryVelocity! < 0) {
addToCart(product); // Swipe left
} else {
removeFromCart(product); // Swipe right
}
},
child: ProductCard(product: 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` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: نقر بطاقة المنتج InkWell
import 'package:flutter/material.dart';
// Product class definition shown above
InkWell(
onTap: () => Navigator.pushNamed(context, '/product/${product.id}'),
borderRadius: BorderRadius.circular(12),
child: Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Image.network(product.imageUrl, width: 60, height: 60),
const SizedBox(width: 12),
Expanded(child: Text(product.name)),
Text('$${product.price.toStringAsFixed(2)}'),
],
),
),
),
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
6. تفاعل الإدخال: TextField
(1) TextEditingController
| الميزة | الطريقة |
|---|---|
| الحصول على النص | controller.text |
| تعيين النص | controller.text = 'new' |
| الاستماع للتغييرات | controller.addListener(fn) |
| تحديد النص | controller.selection |
| مسح الإدخال | controller.clear() |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: تنفيذ شريط البحث
import 'package:flutter/material.dart';
class SearchBar extends StatefulWidget {
final ValueChanged<String> onSearch;
const SearchBar({super.key, required this.onSearch});
@override
State<SearchBar> createState() => _SearchBarState();
}
class _SearchBarState extends State<SearchBar> {
final _controller = TextEditingController();
@override
void initState() {
super.initState();
_controller.addListener(() {
widget.onSearch(_controller.text);
});
}
@override
void dispose() {
_controller.dispose(); // Important: prevent memory leak
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Search products...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
widget.onSearch('');
},
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)),
),
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
7. مثال كامل: صفحة سلة ShopApp
import 'package:flutter/material.dart';
class CartPage extends StatefulWidget {
const CartPage({super.key});
@override
State<CartPage> createState() => _CartPageState();
}
class _CartPageState extends State<CartPage> {
final List<CartItem> _items = [
CartItem(name: 'Pro Laptop', price: 1299.99, quantity: 1),
CartItem(name: 'Wireless Mouse', price: 29.99, quantity: 2),
CartItem(name: 'USB-C Hub', price: 49.99, quantity: 1),
];
double get _total => _items.fold(0.0, (sum, item) => sum + item.price * item.quantity);
int get _itemCount => _items.fold(0, (sum, item) => sum + item.quantity);
void _updateQuantity(int index, int newQty) {
setState(() {
if (newQty <= 0) {
_items.removeAt(index);
} else {
_items[index] = _items[index].copyWith(quantity: newQty);
}
});
}
void _removeItem(int index) {
setState(() => _items.removeAt(index));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Cart ($_itemCount items)')),
body: _items.isEmpty
? const Center(child: Text('Your cart is empty'))
: Column(
children: [
Expanded(
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, index) {
final item = _items[index];
return ListTile(
leading: const Icon(Icons.shopping_bag),
title: Text(item.name),
subtitle: Text('$${item.price.toStringAsFixed(2)} each'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.remove_circle_outline),
onPressed: () => _updateQuantity(index, item.quantity - 1)),
Text('${item.quantity}', style: const TextStyle(fontSize: 16)),
IconButton(icon: const Icon(Icons.add_circle_outline),
onPressed: () => _updateQuantity(index, item.quantity + 1)),
IconButton(icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _removeItem(index)),
],
),
);
},
),
),
// Total bar
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.grey[100],
border: Border(top: BorderSide(color: Colors.grey[300]!))),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [
const Text('Total', style: TextStyle(fontSize: 12)),
Text('$${_total.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
]),
ElevatedButton(
onPressed: _items.isEmpty ? null : () {},
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16)),
child: const Text('Checkout'),
),
],
),
),
],
),
);
}
}
class CartItem {
final String name;
final double price;
final int quantity;
const CartItem({required this.name, required this.price, required this.quantity});
CartItem copyWith({String? name, double? price, int? quantity}) =>
CartItem(name: name ?? this.name, price: price ?? this.price, quantity: quantity ?? this.quantity);
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
❓ أسئلة شائعة
WidgetsBinding.instance.addPostFrameCallback.behavior: HitTestBehavior.opaque لضمان قلبية المنطقة بأكملها.controller.dispose() في طريقة dispose الخاصة بـ State، وإلا فسيسبب تسربًا في الذاكرة.📖 ملخص
- StatelessWidget له دورة حياة كاملة: initState → build → dispose
- setState هي الطريقة الوحيدة لتحريك تحديثات واجهة المستخدم: تغيير الحالة دون استدعاء setState لن يحدّث واجهة المستخدم
- GestureDetector يدعم إيماءات غنية؛ InkWell يوفر تغذية راجعة بتموج Material
- TextEditingController يدير حالة حقل الإدخال ويجب التخلص منه في طريقة dispose
- محدد كمية سلة التسوق هو تطبيق كلاسيكي لـ setState + تمرير الاستدعاءات
📝 تمارين
- أساسي (الصعوبة ⭐): أنشئ صفحة عداد بأزرار "إضافة/طرح/إعادة تعيين" وعرض رقم في المنتصف.
- متوسط (الصعوبة ⭐⭐): نفّذ شريط بحث يفلتر قائمة في الوقت الفعلي أثناء الكتابة (باستخدام setState + TextEditingController).
- متقدم (الصعوبة ⭐⭐⭐): نفّذ سلة تسوق كاملة: دعم تعديل الكمية، إزالة العناصر، حساب تلقائي للإجمالي، وعرض "السلة فارغة" عند الحالة الفارغة.