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

التنقل الرئيسي في Flutter: NavigationBar و TabBar و Drawer

الدرس 21 من 33 · Flutter

في درس التنقل تعلمت فتح شاشة فوق شاشة بـ Navigator.push. لكن أغلب التطبيقات لها أيضاً أقسام رئيسية تتنقل بينها دون أن تتراكم فوق بعضها: «الرئيسية» و «البحث» و «حسابي» في شريط سفلي، أو «المحادثات» و «الحالات» و «المكالمات» في تبويبات علوية. في هذا الدرس ستتعلم الأنماط الثلاثة للتنقل الرئيسي.

النمط متى تستخدمه
الشريط السفلي NavigationBar من 3 إلى 5 أقسام رئيسية متساوية الأهمية
التبويبات العلوية TabBar تقسيم محتوى شاشة واحدة إلى فئات متقاربة
القائمة الجانبية Drawer أقسام كثيرة، أو روابط ثانوية مثل الإعدادات والمساعدة

تخيّل الشريط السفلي مثل أبواب الغرف الرئيسية في البيت: قليلة وظاهرة دائماً. والتبويبات مثل أدراج المكتب داخل غرفة واحدة. والقائمة الجانبية مثل المخزن: فيه أشياء كثيرة لا تحتاجها كل يوم.

الفكرة بسيطة: متغير يحمل رقم القسم الحالي، وقائمة فيها صفحات الأقسام، ونعرض الصفحة التي رقمها يساوي المتغير.

class HomeShell extends StatefulWidget {
const HomeShell({super.key});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
int _index = 0;
static const _pages = [
Center(child: Text('الصفحة الرئيسية')),
Center(child: Text('البحث')),
Center(child: Text('حسابي')),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _pages[_index],
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (value) {
setState(() {
_index = value;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'الرئيسية',
),
NavigationDestination(icon: Icon(Icons.search), label: 'البحث'),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'حسابي',
),
],
),
);
}
}
  • selectedIndex: رقم القسم المحدد حالياً، ويبدأ من صفر.
  • onDestinationSelected: تُستدعى عند الضغط على قسم، وتستقبل رقمه.
  • destinations: الأقسام. لكل قسم icon و label، و selectedIcon اختيارية تظهر عندما يكون القسم محدداً.

لاحظ أن ترتيب _pages يجب أن يطابق ترتيب destinations: الصفحة الأولى للقسم الأول، وهكذا.

حفظ حالة الصفحات: IndexedStack

Section titled “حفظ حالة الصفحات: IndexedStack”

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

الحل IndexedStack: يبني الصفحات كلها ويحتفظ بها، لكنه يعرض واحدة فقط:

IndexedStack(
index: _index,
children: _pages,
)

ضعه في body بدلاً من _pages[_index]. الآن يعود المستخدم إلى كل قسم فيجده كما تركه. والثمن أن الصفحات كلها تُبنى من البداية، وهذا مقبول مع ثلاثة أو خمسة أقسام.

التبويبات تحتاج ثلاثة أجزاء تعمل معاً:

  1. DefaultTabController: يلف الشاشة، ويدير رقم التبويب الحالي بنفسه.
  2. TabBar: شريط العناوين، ومكانه bottom في AppBar.
  3. TabBarView: محتوى التبويبات، ومكانه body.
class ChatsScreen extends StatelessWidget {
const ChatsScreen({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('تواصل'),
bottom: const TabBar(
tabs: [
Tab(text: 'المحادثات'),
Tab(text: 'الحالات'),
Tab(text: 'المكالمات'),
],
),
),
body: const TabBarView(
children: [
Center(child: Text('قائمة المحادثات')),
Center(child: Text('قائمة الحالات')),
Center(child: Text('سجل المكالمات')),
],
),
),
);
}
}

بدون أي setState: الضغط على عنوان ينقلك إلى تبويبه، والسحب يميناً ويساراً يتنقل بين التبويبات بحركة ناعمة.

  • length يجب أن يساوي عدد عناصر tabs وعدد عناصر children. إذا اختلفت الأعداد يتوقف التطبيق بخطأ.
  • Tab يقبل text أو icon أو الاثنين معاً.
  • إذا كانت التبويبات كثيرة ولا يسعها العرض، أضف isScrollable: true إلى TabBar.
Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
const DrawerHeader(
decoration: BoxDecoration(color: Colors.indigo),
child: Text(
'مدرسة البرمجة',
style: TextStyle(color: Colors.white, fontSize: 22),
),
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('الإعدادات'),
onTap: () {
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.help_outline),
title: const Text('المساعدة'),
onTap: () {
Navigator.pop(context);
},
),
],
),
)
  • DrawerHeader: رأس القائمة، وفيه عادة اسم التطبيق أو صورة المستخدم.
  • padding: EdgeInsets.zero: تجعل الرأس يمتد حتى أعلى الشاشة خلف شريط الحالة.
  • Navigator.pop(context): تغلق القائمة قبل تنفيذ الإجراء. القائمة الجانبية، مثل النوافذ، تُعامل كشاشة فوق الكومة.

كما عرفت في درس Scaffold، يكفي أن تضعها في خاصية drawer ليظهر زر القائمة في AppBar تلقائياً.

المشروع: هيكل تطبيق كامل

Section titled “المشروع: هيكل تطبيق كامل”

لنجمع الشريط السفلي مع IndexedStack والقائمة الجانبية، ونثبت بعدّاد أن الحالة محفوظة فعلاً:

lib/app_shell.dart
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Center(
child: ElevatedButton(
onPressed: () => setState(() => _count++),
child: Text('ضغطات: $_count'),
),
);
}
}
class AppShell extends StatefulWidget {
const AppShell({super.key});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
int _index = 0;
static const _titles = ['الرئيسية', 'المفضلة', 'حسابي'];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(_titles[_index])),
drawer: Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
const DrawerHeader(
decoration: BoxDecoration(color: Colors.indigo),
child: Text(
'تطبيقي',
style: TextStyle(color: Colors.white, fontSize: 22),
),
),
ListTile(
leading: const Icon(Icons.person),
title: const Text('اذهب إلى حسابي'),
onTap: () {
Navigator.pop(context);
setState(() => _index = 2);
},
),
],
),
),
body: IndexedStack(
index: _index,
children: const [
CounterPage(),
Center(child: Text('لا توجد عناصر مفضلة')),
Center(child: Text('صفحة حسابي')),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (value) => setState(() => _index = value),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'الرئيسية'),
NavigationDestination(icon: Icon(Icons.favorite), label: 'المفضلة'),
NavigationDestination(icon: Icon(Icons.person), label: 'حسابي'),
],
),
);
}
}

جرّب هذا: اضغط الزر في «الرئيسية» خمس مرات، ثم انتقل إلى «المفضلة»، ثم ارجع. ستجد العدّاد ما زال على 5، لأن IndexedStack احتفظ بالصفحة. وعنوان AppBar يتغير مع كل قسم لأنه يقرأ من _titles[_index]. وعنصر القائمة الجانبية يغلق القائمة ثم ينقلك إلى القسم الثالث بتغيير نفس المتغير.

  1. شغّل هيكل التطبيق وتأكد أن العدّاد يحتفظ بقيمته.
  2. استبدل IndexedStack مؤقتاً بقائمة عادية وصفحة برقمها، وشاهد العدّاد وهو يعود إلى الصفر.
  3. أضف قسماً رابعاً «الإشعارات» بأيقونته وصفحته وعنوانه.
  4. تحدٍّ: اجعل قسم «المفضلة» شاشة فيها TabBar بتبويبين: «منتجات» و «متاجر».
  • عدد الصفحات لا يساوي عدد الأقسام: الضغط على القسم الأخير يسبب خطأ RangeError. حافظ على نفس العدد والترتيب.
  • قيمة length لا تطابق عدد التبويبات: يتوقف التطبيق عند فتح الشاشة.
  • استخدام Navigator.push للتنقل بين الأقسام الرئيسية: تتراكم الشاشات، ويضطر المستخدم إلى ضغط «رجوع» مرات كثيرة. الأقسام الرئيسية تتبدل بتغيير الرقم فقط.

سؤال

ما فائدة IndexedStack مع الشريط السفلي؟

سؤال

ما الأجزاء الثلاثة اللازمة لعمل التبويبات العلوية؟

  • الشريط السفلي لـ 3 إلى 5 أقسام رئيسية، والتبويبات لتقسيم شاشة واحدة، والقائمة الجانبية للروابط الثانوية.
  • NavigationBar يعمل بمتغير selectedIndex مع setState.
  • IndexedStack يحفظ حالة الصفحات عند التنقل.
  • DefaultTabController و TabBar و TabBarView تعمل معاً، ويجب أن تتطابق أعدادها.
  • عناصر Drawer تستدعي Navigator.pop أولاً لإغلاق القائمة.