تخطَّ إلى المحتوى

النوافذ والرسائل في Flutter: AlertDialog و BottomSheet و SnackBar

الدرس 20 من 33 · Flutter

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

ثلاث أدوات، ثلاثة مواقف

Section titled “ثلاث أدوات، ثلاثة مواقف”
الأداة الشكل متى تستخدمها
SnackBar شريط صغير في الأسفل يختفي وحده خبر سريع لا يحتاج رداً: «تم الحفظ»
AlertDialog نافذة في منتصف الشاشة توقف كل شيء قرار مهم يجب أن يرد عليه المستخدم
BottomSheet لوحة تصعد من أسفل الشاشة قائمة اختيارات أو محتوى إضافي

تخيّل الفرق هكذا: SnackBar مثل زميل يهمس لك بمعلومة وأنت تعمل، و AlertDialog مثل شخص يقف أمامك ولا يتحرك حتى تجيبه، و BottomSheet مثل درج تسحبه لتأخذ منه شيئاً ثم تغلقه.

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): تغلق النافذة. النافذة في الحقيقة شاشة صغيرة فوق الكومة، ولذلك تُغلق بنفس أمر الرجوع الذي تعلمته في درس التنقل.

المثال السابق يغلق النافذة في الحالتين، لكن كيف تعرف أي زر ضُغط؟ مثل استقبال نتيجة من شاشة: مرّر القيمة إلى 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).

مريحة جداً على الهاتف، لأنها قريبة من الإبهام:

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 لتسمح لها بتجاوز نصف الشاشة.

استخدمتها من قبل، وهذه خصائصها كاملة:

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: تستدعي عدة دوال على نفس الكائن بدون تكرار اسمه.

انظر إلى هذا الكود:

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.

lib/photo_screen.dart
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),
),
);
}
}

تسلسل ما يحدث: زر الخيارات يفتح اللوحة السفلية، واختيار «حذف» يغلقها ويفتح نافذة التأكيد، والتأكيد يحذف الصورة ويعرض رسالة فيها «تراجع». ثلاث أدوات تعمل معاً، وكل واحدة في مكانها الصحيح.

  1. شغّل المعرض وجرّب المسار كاملاً: خيارات، ثم حذف، ثم تراجع.
  2. أضف barrierDismissible: false لنافذة التأكيد وجرّب الضغط خارجها.
  3. اجعل اختيار «مشاركة» يعرض SnackBar عائماً مكتوباً فيه «جارٍ فتح المشاركة».
  4. تحدٍّ: اصنع نافذة فيها TextField تطلب اسماً جديداً للصورة، وتُرجع النص المكتوب ليظهر في عنوان الشاشة.
  • نسيان Navigator.pop في أزرار النافذة: تبقى النافذة مفتوحة ولا يعرف المستخدم كيف يخرج.
  • كتابة if (confirmed) مع قيمة قد تكون null: خطأ في الترجمة. اكتب confirmed == true.
  • استخدام AlertDialog لكل خبر صغير: نافذة توقف المستخدم ليقرأ «تم الحفظ» مزعجة. هذا مكان SnackBar.

سؤال

كيف تُرجع نافذة AlertDialog قيمة إلى الكود الذي فتحها؟

سؤال

تريد إخبار المستخدم أن الملف حُفظ، بدون أن توقفه عن عمله. ما الأداة الأنسب؟

  • SnackBar للأخبار السريعة، و AlertDialog للقرارات المهمة، و BottomSheet لقوائم الاختيارات.
  • showDialog و showModalBottomSheet تُرجعان Future، فانتظر النتيجة بـ await.
  • أغلق النافذة أو اللوحة بـ Navigator.pop، ومرّر معها القيمة.
  • النتيجة قد تكون null إذا أُغلقت النافذة بدون اختيار.
  • بعد كل await افحص mounted قبل استخدام context.