التنقل المتداخل باستخدام Navigator
التنقل المتداخل باستخدام Navigator
في Flutter، يمتلك كل تطبيق Navigator جذري — وهو مكدس التنقل الأعلى مستوىً الذي يدير المسارات التي تغطي الشاشة بالكامل. غير أن كثيراً من التطبيقات الحقيقية تحتوي على أقسام (تبويبات، أدراج، أو تدفقات فرعية مخصصة) تحتاج إلى مكدس تاريخ مستقل خاص بها. يحل التنقل المتداخل هذه المشكلة بتضمين ودجت Navigator ثانٍ داخل جزء من واجهة المستخدم بحيث تستطيع الشاشات الداخلية الدفع والسحب باستقلالية دون إزعاج المتصفح الجذري.
المثال الكلاسيكي هو تطبيق يستخدم BottomNavigationBar حيث يحتفظ كل تبويب بمكدسه الخلفي المستقل. عندما يتنقل المستخدم في عمق التبويب أ ثم ينتقل إلى التبويب ب، يظل مكدس التبويب أ محفوظاً تماماً كما تركه. الضغط على زر الرجوع في الجهاز يُسحب فقط داخل متصفح التبويب الحالي ولا يمس المستوى الجذري.
Navigator هو مجرد ودجت — يمكن وضعه في أي مكان في الشجرة. كل نسخة Navigator تحتفظ بمكدس تاريخها المستقل بمعزل عن أي Navigator آخر في شجرة الودجات.لماذا نستخدم المتصفحات المتداخلة؟
- حفظ حالة التبويب: كل تبويب يتذكر مكدس شاشاته الخاص عند تبديل التبويبات.
- سلوك رجوع معزول: التنقل للخلف داخل تدفق متداخل لا يُزيل الشاشة الجذرية بشكل غير مقصود.
- تدفقات فرعية مدارة: يمكن للإعداد والدفع أو التدفقات الإرشادية أن تعيش داخل
Navigatorمتداخل وتُزال كوحدة واحدة عند اكتمالها. - بنية أنظف: كل قسم في التطبيق يمتلك منطق التوجيه الخاص به دون تلويث جدول المسارات العالمي.
التسلسل الهرمي لودجت Navigator
عندما تعالج Flutter عملية التنقل، تتصاعد في شجرة الودجات بحثاً عن أقرب سلف Navigator. إن ضمّنت متصفحاً ثانياً، فإن استدعاءات Navigator.of(context) من داخل تلك الشجرة الفرعية ستحل إلى المتصفح الداخلي ما لم تمرر rootNavigator: true صراحةً.
إعداد Navigator متداخل أساسي (تنقل بالتبويبات)
import 'package:flutter/material.dart';
class MainShell extends StatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _currentIndex = 0;
// مفتاح GlobalKey لكل تبويب لإبقاء كل Navigator حياً
final List<GlobalKey<NavigatorState>> _navigatorKeys = [
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
];
Widget _buildTabNavigator(int index) {
return Navigator(
key: _navigatorKeys[index],
onGenerateRoute: (settings) {
// كل تبويب يبدأ في شاشته الرئيسية الخاصة
return MaterialPageRoute(
builder: (_) => _tabHome(index),
settings: settings,
);
},
);
}
Widget _tabHome(int index) {
const titles = ['الرئيسية', 'البحث', 'الملف الشخصي'];
return Scaffold(
appBar: AppBar(title: Text(titles[index])),
body: Center(
child: ElevatedButton(
onPressed: () {
// يدفع على Navigator هذا التبويب المتداخل وليس الجذر
_navigatorKeys[index].currentState!.push(
MaterialPageRoute(builder: (_) => const DetailScreen()),
);
},
child: const Text('الذهاب إلى التفاصيل'),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: List.generate(3, _buildTabNavigator),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (i) => setState(() => _currentIndex = i),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'الرئيسية'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'البحث'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'الملف'),
],
),
);
}
}
class DetailScreen extends StatelessWidget {
const DetailScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('التفاصيل')),
body: const Center(child: Text('شاشة التفاصيل')),
);
}
}
IndexedStack بدلاً من Stack عادي أو Offstage للإبقاء على جميع متصفحات التبويب حية في آنٍ واحد. يحفظ IndexedStack حالة كل ابن بينما يعرض الفهرس النشط فقط، وهو بالضبط ما تحتاجه لمكدسات تبويب دائمة.التعامل مع زر الرجوع على Android
افتراضياً، يعالج زر الرجوع في النظام (الرجوع الصلب أو إيماءة الرجوع التنبؤية على Android) المتصفح الجذري. لاعتراضه وسحب متصفح التبويب النشط أولاً، لُفّ Scaffold في PopScope (Flutter 3.12+) أو الأقدم WillPopScope:
اعتراض زر الرجوع للمتصفحات المتداخلة
@override
Widget build(BuildContext context) {
return PopScope(
// canPop: false يمنع السحب على المستوى الجذري بينما نتعامل معه
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
final innerNav = _navigatorKeys[_currentIndex].currentState;
if (innerNav != null && innerNav.canPop()) {
// اسحب المتصفح الداخلي (التبويب) أولاً
innerNav.pop();
} else {
// لم يتبق شيء في هذا التبويب — اسمح للنظام بالتعامل
// (مثل الخروج من التطبيق إن كنت في المسار الجذري)
Navigator.of(context).pop();
}
},
child: Scaffold(
body: IndexedStack(
index: _currentIndex,
children: List.generate(3, _buildTabNavigator),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (i) => setState(() => _currentIndex = i),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'الرئيسية'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'البحث'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'الملف'),
],
),
),
);
}
Navigator.of(context, rootNavigator: true) لدفع المسارات داخل تبويب. سيتجاوز ذلك المتصفح المتداخل ويدفع على المكدس الجذري، مما يجعل الشاشة الجديدة تغطي الغلاف بالكامل (يختفي شريط التبويبات السفلي) ويُفسد سلوك المكدس الخلفي المعزول الذي أعددته.الوصول إلى المتصفح الصحيح
داخل ودجت يعيش ضمن شجرة متصفح متداخل، يحل Navigator.of(context) إلى أقرب سلف — المتصفح الداخلي. هذا عادةً ما تريده. استخدم rootNavigator: true فقط عندما تريد عمداً إظهار نافذة مشروطة أو شاشة فوق الغلاف بالكامل (مثل تدفق دفع كامل الشاشة يغطي كل شيء).
Navigator.of(context)— أقرب متصفح (المتصفح المتداخل للتبويب داخل التبويبات)Navigator.of(context, rootNavigator: true)— المتصفح الجذري (يغطي غلاف التطبيق بالكامل)navigatorKey.currentState!— وصول مباشر عبرGlobalKeyمن أي مكان
الخلاصة
التنقل المتداخل نمط أساسي لأي تطبيق Flutter يمتلك أقساماً تستلزم مكدسات تاريخ مستقلة. النقاط الرئيسية هي: ضمّن ودجت Navigator بـ GlobalKey لكل قسم مستقل، استخدم IndexedStack لإبقاء جميع الأقسام حية، اعترض زر الرجوع في النظام بـ PopScope لسحب المتصفح الداخلي أولاً، وكن واعياً بشأن ما إذا كنت تدفع على المتصفح الداخلي أو الجذري باختيارك بين Navigator.of(context) وNavigator.of(context, rootNavigator: true).