Flutter: StatefulWidget والتفاعل

الحالة هي ذاكرة الويدجت — بدون الحالة، الواجهة كمريض فقد الذاكرة لا يتعرف عليك كل مرة.

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

1. ما ستتعلمه


2. قصة حقيقية عن معضلة تفاعل

(1) المشكلة: النقرات لا تستجيب

واجه بوب خطأً غريبًا في صفحة سلة ShopApp: نقر المستخدمون زر "−"، انخفضت الكمية من 1 إلى 0، لكن واجهة المستخدم لا تزال تعرض 1. عدّل المتغير مباشرة _count = _count - 1 دون استدعاء setState — الويدجت لم يعرف أن الحالة تغيرت، لذا طبيعي ألا يُعيد بناء واجهة المستخدم.

(2) حل setState

المبدأ الأساسي لـ StatefulWidget: تغيير الحالة لا يعني تغيير واجهة المستخدم. يجب إخطار إطار العمل عبر setState بأن "ني تغيرت، يُرجى إعادة البناء".

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

(3) النتيجة: واجهة المستخدم والحالة متزامنة دائمًا

بعد إضافة setState، حدُثت كمية سلة التسوق في الوقت الفعلي وأُعيد حساب السعر الإجمالي تلقائيًا. منذ ذلك الحين، تذكر بوب: عند تغيير البيانات، يجب استدعاء setState.


3. دورة حياة StatefulWidget

100%
stateDiagram-v2
    [*] --> createState
    createState --> initState
    initState --> didChangeDependencies
    didChangeDependencies --> build
    build --> didUpdateWidget: setState / parent rebuild
    didUpdateWidget --> build
    build --> deactivate: removed from tree
    deactivate --> dispose
    dispose --> [*]
TEXT
> الإخراج: شغّل محليًا باستخدام 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 يُدمر بشكل دائم تحرير الموارد، إلغاء الاشتراكات

▶ مثال

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

: تتبع دورة الحياة

DART
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();
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام 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

▶ مثال

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

: محدد كمية سلة التسوق

DART
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),
        ],
      ),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

5. معالجة الأحداث

(1) GestureDetector مقابل InkWell

الويدجت تأثير النقر تموج حالة الاستخدام
GestureDetector لا تغذية راجعة بصرية لا إيماءات مخصصة (ضغط طويل/سحب/نقر مزدوج)
InkWell تموج Material نعم نقرات البطاقة/عنصر القائمة
IconButton أيقونة + تموج نعم إجراءات شريط الأدوات
ElevatedButton نمط زر نعم الإجراءات الرئيسية

▶ مثال

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

: التعرف على إيماءات GestureDetector

DART
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),
)
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` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: نقر بطاقة المنتج InkWell

DART
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)}'),
        ],
      ),
    ),
  ),
)
TEXT
> الإخراج: شغّل محليًا باستخدام 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()

▶ مثال

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

: تنفيذ شريط البحث

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

7. مثال كامل: صفحة سلة ShopApp

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

❓ أسئلة شائعة

س هل يمكن استدعاء setState داخل initState؟
ج لا. عند تنفيذ initState، لم يكتمل بناء الويدجت بعد. إذا كنت بحاجة للتحديث بعد التهيئة، استخدم WidgetsBinding.instance.addPostFrameCallback.
س لماذا أستخدم إغلاقًا في setState حتى مع إسناد متغير واحد؟
ج الإغلاق يحدد بوضوح أي حالة تغيرت، مما يساعد إطار العمل في التحسين. كما يسهّل تتبع تغييرات الحالة أثناء التصحيح.
س onTap في GestureDetector لا يعمل؟
ج تحقق مما إذا كان الابن لديه منافس إيماءة يمتص الأحداث (مثل تمرير ListView). استخدم behavior: HitTestBehavior.opaque لضمان قلبية المنطقة بأكملها.
س متى يجب التخلص من TextEditingController؟
ج استدعِ controller.dispose() في طريقة dispose الخاصة بـ State، وإلا فسيسبب تسربًا في الذاكرة.
س لماذا لا تتغير واجهة المستخدم بعد setState؟
ج تحقق مما إذا عدّلت المتغير خارج إغلاق setState. التغييرات داخل الإغلاق فقط هي التي تحرّك إعادة البناء.
س هل تسبب استدعاءات setState المتعددة إعادة بناء متعددة؟
ج لا. Flutter يدمج استدعاءات setState المتعددة داخل إطار واحد ويُعيد البناء مرة واحدة فقط.

📖 ملخص


📝 تمارين

  1. أساسي (الصعوبة ⭐): أنشئ صفحة عداد بأزرار "إضافة/طرح/إعادة تعيين" وعرض رقم في المنتصف.
  2. متوسط (الصعوبة ⭐⭐): نفّذ شريط بحث يفلتر قائمة في الوقت الفعلي أثناء الكتابة (باستخدام setState + TextEditingController).
  3. متقدم (الصعوبة ⭐⭐⭐): نفّذ سلة تسوق كاملة: دعم تعديل الكمية، إزالة العناصر، حساب تلقائي للإجمالي، وعرض "السلة فارغة" عند الحالة الفارغة.

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

Web-Tutorial.com

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

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

100%