النوافذ والرسائل في Flutter: AlertDialog و BottomSheet و SnackBar
«هل أنت متأكد من حذف الصورة؟»، «تم الحفظ بنجاح»، «اختر طريقة المشاركة». التطبيق الجيد يتحدث مع مستخدمه: يسأله قبل الأفعال الخطيرة، ويخبره بنتيجة ما فعل، ويعرض عليه الاختيارات عند الحاجة. في هذا الدرس ستتعلم ثلاث أدوات لذلك، ومتى تستخدم كل واحدة.
ثلاث أدوات، ثلاثة مواقف
Section titled “ثلاث أدوات، ثلاثة مواقف”| الأداة | الشكل | متى تستخدمها |
|---|---|---|
SnackBar |
شريط صغير في الأسفل يختفي وحده | خبر سريع لا يحتاج رداً: «تم الحفظ» |
AlertDialog |
نافذة في منتصف الشاشة توقف كل شيء | قرار مهم يجب أن يرد عليه المستخدم |
BottomSheet |
لوحة تصعد من أسفل الشاشة | قائمة اختيارات أو محتوى إضافي |
تخيّل الفرق هكذا: SnackBar مثل زميل يهمس لك بمعلومة وأنت تعمل، و AlertDialog مثل شخص يقف أمامك ولا يتحرك حتى تجيبه، و BottomSheet مثل درج تسحبه لتأخذ منه شيئاً ثم تغلقه.
AlertDialog: نافذة التأكيد
Section titled “AlertDialog: نافذة التأكيد”showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('حذف الصورة'), content: const Text('هل أنت متأكد؟ لا يمكن التراجع عن الحذف.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('إلغاء'), ), FilledButton( onPressed: () => Navigator.pop(context), child: const Text('حذف'), ), ], ); },);showDialog: الدالة التي تفتح النافذة وتعتّم ما خلفها.titleوcontent: العنوان والنص التوضيحي.actions: أزرار أسفل النافذة.Navigator.pop(context): تغلق النافذة. النافذة في الحقيقة شاشة صغيرة فوق الكومة، ولذلك تُغلق بنفس أمر الرجوع الذي تعلمته في درس التنقل.
معرفة اختيار المستخدم
Section titled “معرفة اختيار المستخدم”المثال السابق يغلق النافذة في الحالتين، لكن كيف تعرف أي زر ضُغط؟ مثل استقبال نتيجة من شاشة: مرّر القيمة إلى pop، وانتظرها بـ await.
Future<void> _confirmDelete() async { final confirmed = await showDialog<bool>( context: context, builder: (context) { return AlertDialog( title: const Text('حذف الصورة'), content: const Text('هل أنت متأكد؟'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('إلغاء'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('حذف'), ), ], ); }, );
if (confirmed == true) { debugPrint('تم الحذف'); }}showDialog<bool>: نخبر Dart أن النافذة ستُرجع قيمة منطقية.Navigator.pop(context, true): تغلق النافذة وتُرجعtrue.- النتيجة من نوع
bool?، لأن المستخدم قد يغلق النافذة بالضغط خارجها أو بزر الرجوع، فتكونnull. لذلك نكتبconfirmed == trueوليسif (confirmed).
BottomSheet: اللوحة السفلية
Section titled “BottomSheet: اللوحة السفلية”مريحة جداً على الهاتف، لأنها قريبة من الإبهام:
showModalBottomSheet( context: context, showDragHandle: true, builder: (context) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.share), title: const Text('مشاركة'), onTap: () => Navigator.pop(context, 'مشاركة'), ), ListTile( leading: const Icon(Icons.link), title: const Text('نسخ الرابط'), onTap: () => Navigator.pop(context, 'نسخ الرابط'), ), ], ), ); },);mainAxisSize: MainAxisSize.min: مهمة جداً. بدونها يتمدد العمود وتغطي اللوحة نصف الشاشة أو أكثر. معها تأخذ اللوحة ارتفاع محتواها فقط.showDragHandle: true: تُظهر مقبضاً صغيراً أعلى اللوحة يفهم منه المستخدم أنها قابلة للسحب.SafeArea: تحمي العناصر من شريط التنقل السفلي في الهاتف.- مثل النافذة تماماً: تُغلق بـ
Navigator.pop، وتُرجع قيمة تنتظرها بـawait.
وإذا كان محتوى اللوحة طويلاً أو فيه حقل كتابة، أضف isScrollControlled: true لتسمح لها بتجاوز نصف الشاشة.
SnackBar: الرسالة السريعة
Section titled “SnackBar: الرسالة السريعة”استخدمتها من قبل، وهذه خصائصها كاملة:
ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('نُقلت الرسالة إلى الأرشيف'), duration: const Duration(seconds: 3), behavior: SnackBarBehavior.floating, action: SnackBarAction( label: 'تراجع', onPressed: () { debugPrint('تم التراجع'); }, ), ),);duration: مدة الظهور. الافتراضي 4 ثوانٍ.behavior: SnackBarBehavior.floating: تجعل الشريط عائماً بزوايا دائرية بدلاً من الالتصاق بالحافة.action: زر واحد اختياري، وأشهر استخدام له «تراجع».
وإذا ضغط المستخدم الزر عدة مرات بسرعة تتراكم الرسائل في طابور. لإخفاء الرسالة الحالية قبل عرض الجديدة:
ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(const SnackBar(content: Text('تم الحفظ')));العلامة .. اسمها Cascade: تستدعي عدة دوال على نفس الكائن بدون تكرار اسمه.
context بعد await
Section titled “context بعد await”انظر إلى هذا الكود:
Future<void> _deleteWithMessage() async { final confirmed = await showDialog<bool>( context: context, builder: (context) => const AlertDialog(title: Text('حذف؟')), ); if (!mounted) return; if (confirmed == true) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('تم الحذف')), ); }}السطر if (!mounted) return; مهم. أثناء انتظار await قد يغادر المستخدم الشاشة، فيصبح context غير صالح، واستخدامه يسبب خطأ. الخاصية mounted تخبرك هل الشاشة ما زالت موجودة. اجعلها عادة: بعد كل await، افحص mounted قبل استخدام context.
المشروع: معرض صور صغير
Section titled “المشروع: معرض صور صغير”class PhotoScreen extends StatefulWidget { const PhotoScreen({super.key});
@override State<PhotoScreen> createState() => _PhotoScreenState();}
class _PhotoScreenState extends State<PhotoScreen> { bool _deleted = false;
Future<void> _confirmDelete() async { final confirmed = await showDialog<bool>( context: context, builder: (context) { return AlertDialog( title: const Text('حذف الصورة'), content: const Text('هل أنت متأكد؟'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('إلغاء'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('حذف'), ), ], ); }, ); if (!mounted || confirmed != true) return;
setState(() => _deleted = true); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('حُذفت الصورة'), action: SnackBarAction( label: 'تراجع', onPressed: () => setState(() => _deleted = false), ), ), ); }
Future<void> _showOptions() async { final choice = await showModalBottomSheet<String>( context: context, showDragHandle: true, builder: (context) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.share), title: const Text('مشاركة'), onTap: () => Navigator.pop(context, 'share'), ), ListTile( leading: const Icon(Icons.delete_outline), title: const Text('حذف'), onTap: () => Navigator.pop(context, 'delete'), ), ], ), ); }, ); if (choice == 'delete') { await _confirmDelete(); } }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('صورتي'), actions: [ IconButton( icon: const Icon(Icons.more_vert), tooltip: 'خيارات', onPressed: _showOptions, ), ], ), body: Center( child: _deleted ? const Text('لا توجد صورة') : const Icon(Icons.image, size: 160, color: Colors.indigo), ), ); }}تسلسل ما يحدث: زر الخيارات يفتح اللوحة السفلية، واختيار «حذف» يغلقها ويفتح نافذة التأكيد، والتأكيد يحذف الصورة ويعرض رسالة فيها «تراجع». ثلاث أدوات تعمل معاً، وكل واحدة في مكانها الصحيح.
جرّب بنفسك
Section titled “جرّب بنفسك”- شغّل المعرض وجرّب المسار كاملاً: خيارات، ثم حذف، ثم تراجع.
- أضف
barrierDismissible: falseلنافذة التأكيد وجرّب الضغط خارجها. - اجعل اختيار «مشاركة» يعرض
SnackBarعائماً مكتوباً فيه «جارٍ فتح المشاركة». - تحدٍّ: اصنع نافذة فيها
TextFieldتطلب اسماً جديداً للصورة، وتُرجع النص المكتوب ليظهر في عنوان الشاشة.
أخطاء شائعة
Section titled “أخطاء شائعة”- نسيان Navigator.pop في أزرار النافذة: تبقى النافذة مفتوحة ولا يعرف المستخدم كيف يخرج.
- كتابة if (confirmed) مع قيمة قد تكون null: خطأ في الترجمة. اكتب
confirmed == true. - استخدام AlertDialog لكل خبر صغير: نافذة توقف المستخدم ليقرأ «تم الحفظ» مزعجة. هذا مكان
SnackBar.
اختبر نفسك
Section titled “اختبر نفسك”سؤال
كيف تُرجع نافذة AlertDialog قيمة إلى الكود الذي فتحها؟
showDialog تُرجع Future، وقيمته هي ما يُمرَّر إلى Navigator.pop عند إغلاق النافذة.
سؤال
تريد إخبار المستخدم أن الملف حُفظ، بدون أن توقفه عن عمله. ما الأداة الأنسب؟
SnackBar رسالة قصيرة تظهر وتختفي وحدها، ولا تمنع المستخدم من متابعة عمله.
الخلاصة
Section titled “الخلاصة”SnackBarللأخبار السريعة، وAlertDialogللقرارات المهمة، وBottomSheetلقوائم الاختيارات.showDialogوshowModalBottomSheetتُرجعانFuture، فانتظر النتيجة بـawait.- أغلق النافذة أو اللوحة بـ
Navigator.pop، ومرّر معها القيمة. - النتيجة قد تكون
nullإذا أُغلقت النافذة بدون اختيار. - بعد كل
awaitافحصmountedقبل استخدامcontext.