Flutter: التنقل والتوجيه
المسارات هي خريطة التطبيق — بدونها، المستخدمون كسائقين بدون GPS، لا يستطيعون الذهاب لأي مكان.
📋 المتطلبات السابقة: يجب أن تتقن ما يلي أولًا
- الدرس 6: Material Design والمكوّنات الشائعة
- الدرس 7: ممارسة المرحلة 1 — ShopApp نسخة البداية
1. ما ستتعلمه
- Navigator 1.0: push/pop/pushReplacement وإدارة مكدس المسارات
- المسارات المسمّاة: جدول routes / onGenerateRoute / تمرير المعاملات
- GoRouter (Navigator 2.0): التوجيه التعريفي، ShellRoute المتداخل، إعادة التوجيه
- الربط العميق واستراتيجية URL
- نظام توجيه ShopApp بـ GoRouter: /home /product/:id /cart /checkout
2. قصة حقيقية عن صفحات مفقودة
(1) المشكلة: فوضى مكدس المسارات
تطبيق ShopApp لبوب فيه 10 صفحات، جميعها يُتنقل إليها عبر Navigator.push. يذهب المستخدم من الرئيسية ← البحث ← المنتج ← السلة ← الدفع، لكن الضغط على الرجوع يعيده إلى صفحة البحث بدلًا من الرئيسية. مكدس المسارات فيه 5 صفحات، يتطلب 4 ضغطات رجوع للوصول للرئيسية. والأخطر، أن URL في الويب لا يتغير، والتحديث يفقد كل الحالة.
(2) حل GoRouter
يستخدم GoRouter التوجيه التعريفي حيث تُربط عناوين URL والصفحات تلقائيًا، ويُحدد مكدس المسارات بالإعدادات بدلًا من ترتيب push.
⚙️ تثبيت التبعية:
flutter pub add go_router
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// Declarative routing: URL ↔ Page mapping
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(path: '/product/:id', builder: (_, state) => DetailPage(id: state.pathParameters['id'])),
GoRoute(path: '/cart', builder: (_, __) => const CartPage()),
],
);
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(3) الفائدة: تنقل مدفوع بـ URL + منطق رجوع تلقائي
بعد استخدام GoRouter، يُحدَّث URL في الويب مع تغير الصفحات والتحديث يحافظ على الحالة؛ زر الرجوع في Android يتبع مكدس المسارات تلقائيًا، وبعد الدفع، go('/') يعود مباشرة إلى الرئيسية.
3. أساسيات Navigator 1.0
(1) عمليات مكدس المسارات
| الطريقة | التأثير | تغيير المكدس |
|---|---|---|
push |
دفع صفحة جديدة | [A] → [A, B] |
pop |
إزالة الصفحة الحالية | [A, B] → [A] |
pushReplacement |
استبدال الصفحة الحالية | [A, B] → [A, C] |
pushAndRemoveUntil |
دفع ومسح حتى صفحة هدف | [A,B,C] → [A, D] |
popUntil |
إزالة حتى صفحة هدف | [A,B,C] → [A] |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: عمليات التنقل الأساسية
import 'package:flutter/material.dart';
// Simplified class definitions
class Product {
const Product();
}
class DetailPage extends StatelessWidget {
final Product product;
const DetailPage({super.key, required this.product});
@override
Widget build(BuildContext context) => const Scaffold();
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) => const Scaffold();
}
// Push: navigate to detail page
Navigator.push(
context,
MaterialPageRoute(builder: (_) => DetailPage(product: product)),
);
// Pop: go back with result
Navigator.pop(context, result);
// Push replacement: login → home (no back to login)
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const HomePage()),
);
// Push and remove until: checkout → home (clear all intermediate)
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => const HomePage()),
(route) => route.isFirst,
);
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
4. المسارات المسمّاة
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: إعداد المسارات المسمّاة
import 'package:flutter/material.dart';
// Simplified page class definitions
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class CartPage extends StatelessWidget {
const CartPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class CheckoutPage extends StatelessWidget {
const CheckoutPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class DetailPage extends StatelessWidget {
final int id;
const DetailPage({super.key, required this.id});
@override Widget build(BuildContext context) => const Scaffold();
}
MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => const HomePage(),
'/cart': (context) => const CartPage(),
'/checkout': (context) => const CheckoutPage(),
},
onGenerateRoute: (settings) {
// Handle dynamic routes like /product/123
final uri = Uri.parse(settings.name!);
if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'product') {
final id = int.parse(uri.pathSegments[1]);
return MaterialPageRoute(builder: (_) => DetailPage(id: id));
}
return null;
},
)
// Navigate with named route
Navigator.pushNamed(context, '/product/42');
Navigator.pushNamed(context, '/cart', arguments: {'source': 'detail'});
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
| النهج | المزايا | العيوب |
|---|---|---|
| push مباشر | بسيط، بديهي، آمن الأنواع | المسارات متفرقة، صعبة الصيانة |
| مسارات مسمّاة | إدارة مركزية، تمرير معاملات | المعاملات ليست آمنة الأنواع |
| GoRouter | تعريفي، مناسب للويب، ربط عميق | يحتاج تبعية إضافية |
5. التوجيه التعريفي بـ GoRouter
graph TD
GR[GoRouter] --> R1["/ → HomePage"]
GR --> R2["/product/:id → DetailPage"]
GR --> R3["/cart → CartPage"]
GR --> R4["/checkout → CheckoutPage"]
R1 --> |DeepLink| R2
R2 --> |addToCart| R3
R3 --> |checkout| R4
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(1) المفاهيم الأساسية لـ GoRouter
| المفهوم | الوصف | مثال |
|---|---|---|
GoRoute |
تعريف المسار | GoRoute(path: '/cart') |
pathParameters |
معاملات المسار | /product/:id → {'id': '42'} |
queryParams |
معاملات الاستعلام | ?sort=price → {'sort': 'price'} |
redirect |
منطق إعادة التوجيه | غير مسجل ← /login |
ShellRoute |
تخطيط متداخل | مسارات فرعية تتشارك BottomNav |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: إعداد GoRouter لـ ShopApp
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// Simplified class definitions
class AuthService {
static bool isLoggedIn = false;
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class DetailPage extends StatelessWidget {
final int productId;
const DetailPage({super.key, required this.productId});
@override Widget build(BuildContext context) => const Scaffold();
}
class CartPage extends StatelessWidget {
const CartPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class CheckoutPage extends StatelessWidget {
const CheckoutPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
final router = GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isLoggedIn = AuthService.isLoggedIn;
final isLoginRoute = state.matchedLocation == '/login';
if (!isLoggedIn && !isLoginRoute) return '/login';
if (isLoggedIn && isLoginRoute) return '/';
return null;
},
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/product/:id',
name: 'product',
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return DetailPage(productId: id);
},
),
GoRoute(
path: '/cart',
name: 'cart',
builder: (context, state) => const CartPage(),
),
GoRoute(
path: '/checkout',
name: 'checkout',
builder: (context, state) => const CheckoutPage(),
),
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
],
);
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(2) ShellRoute التخطيط المتداخل
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: ShellRoute مع BottomNav
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// Simplified page class definitions
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class CategoriesPage extends StatelessWidget {
const CategoriesPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class CartPage extends StatelessWidget {
const CartPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class DetailPage extends StatelessWidget {
final int id;
const DetailPage({super.key, required this.id});
@override Widget build(BuildContext context) => const Scaffold();
}
class CheckoutPage extends StatelessWidget {
const CheckoutPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
final router = GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) => ScaffoldWithNavBar(child: child),
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(path: '/categories', builder: (_, __) => const CategoriesPage()),
GoRoute(path: '/cart', builder: (_, __) => const CartPage()),
GoRoute(path: '/profile', builder: (_, __) => const ProfilePage()),
],
),
GoRoute(path: '/product/:id', builder: (_, state) => DetailPage(
id: int.parse(state.pathParameters['id']!)),
),
GoRoute(path: '/checkout', builder: (_, __) => const CheckoutPage()),
],
);
class ScaffoldWithNavBar extends StatelessWidget {
final Widget child;
const ScaffoldWithNavBar({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex(context),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.category), label: 'Categories'),
NavigationDestination(icon: Icon(Icons.shopping_cart), label: 'Cart'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
onDestinationSelected: (index) => _onItemTapped(index, context),
),
);
}
int _selectedIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith('/categories')) return 1;
if (location.startsWith('/cart')) return 2;
if (location.startsWith('/profile')) return 3;
return 0;
}
void _onItemTapped(int index, BuildContext context) {
switch (index) {
case 0: context.go('/');
case 1: context.go('/categories');
case 2: context.go('/cart');
case 3: context.go('/profile');
}
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
6. مقارنة طرق التنقل
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: go مقابل push مقابل replace
import 'package:go_router/go_router.dart';
// go: replace entire stack (Web: URL changes)
context.go('/cart'); // Stack becomes [/cart]
// push: add to stack (Web: URL changes)
context.push('/product/42'); // Stack becomes [/, /product/42]
// replace: replace current route
context.replace('/checkout'); // Stack becomes [/, /checkout]
// go with named route
context.goNamed('product', pathParameters: {'id': '42'});
// go with query parameters
context.go('/products', queryParams: {'sort': 'price', 'order': 'desc'});
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
| الطريقة | عملية المكدس | تحديث URL | حالة الاستخدام |
|---|---|---|---|
go |
يستبدل المكدس بالكامل | نعم | التنقل الرئيسي (تبديل التبويبات) |
push |
يدفع مسارًا جديدًا | نعم | التنقل لصفحة التفاصيل |
replace |
يستبدل المسار الحالي | نعم | تسجيل الدخول ← الرئيسية |
7. مثال كامل: نظام توجيه ShopApp
⚙️ تثبيت التبعية:
flutter pub add go_router
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() => runApp(ShopApp(router: router));
final router = GoRouter(
initialLocation: '/',
routes: [
ShellRoute(
builder: (context, state, child) => MainScaffold(child: child),
routes: [
GoRoute(path: '/', name: 'home', builder: (_, __) => const HomePage()),
GoRoute(path: '/categories', builder: (_, __) => const CategoriesPage()),
GoRoute(path: '/cart', name: 'cart', builder: (_, __) => const CartPage()),
GoRoute(path: '/profile', builder: (_, __) => const ProfilePage()),
],
),
GoRoute(
path: '/product/:id',
name: 'product',
builder: (_, state) => DetailPage(id: int.parse(state.pathParameters['id']!)),
),
GoRoute(
path: '/checkout',
name: 'checkout',
builder: (_, __) => const CheckoutPage(),
),
],
errorBuilder: (_, state) => Scaffold(
body: Center(child: Text('Page not found: ${state.error}')),
),
);
class ShopApp extends StatelessWidget {
final GoRouter router;
const ShopApp({super.key, required this.router});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'ShopApp',
routerConfig: router,
theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true),
);
}
}
class MainScaffold extends StatelessWidget {
final Widget child;
const MainScaffold({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: _calcIndex(context),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.category), label: 'Categories'),
NavigationDestination(icon: Icon(Icons.shopping_cart), label: 'Cart'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
onDestinationSelected: (i) {
final paths = ['/', '/categories', '/cart', '/profile'];
context.go(paths[i]);
},
),
);
}
int _calcIndex(BuildContext context) {
final loc = GoRouterState.of(context).matchedLocation;
if (loc.startsWith('/categories')) return 1;
if (loc.startsWith('/cart')) return 2;
if (loc.startsWith('/profile')) return 3;
return 0;
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
❓ أسئلة شائعة
redirect: تحقق من حالة تسجيل الدخول، أعد التوجيه إلى /login إذا لم يكن مسجلًا.MaterialApp.router بدلًا من MaterialApp.apple-app-site-association. على Android، أعدّ Asset Links وintent-filter.📖 ملخص
- Navigator 1.0 يدير مكدس المسارات بـ push/pop، مناسب للسيناريوهات البسيطة
- المسارات المسمّاة تركز إدارة المسارات، لكن المعاملات ليست آمنة الأنواع
- التوجيه التعريفي GoRouter: تعيين تلقائي URL ↔ صفحة، مناسب للويب
- ShellRoute يُنفذ تخطيطًا مشتركًا (BottomNav) مع مسارات فرعية متداخلة
- redirect يُنفذ حراس المسارات؛ go/push/replace يتحكم في عمليات المكدس
📝 تمارين
- أساسي (الصعوبة ⭐): استخدم GoRouter لإعداد 3 مسارات (الرئيسية/السلة/الملف الشخصي) مع تبديل التنقل السفلي.
- متوسط (الصعوبة ⭐⭐): أضف مسار
/product/:idللتفاصيل، تنقل من بطاقات المنتجات في الصفحة الرئيسية، مع دعم تمرير معاملات المسار. - متقدم (الصعوبة ⭐⭐⭐): نفّذ حارس مسارات كامل: أعد توجيه المستخدمين غير المصادق عليهم الذين يحاولون الوصول إلى
/profileإلى/login، وأعد التوجيه تلقائيًا للخلف بعد تسجيل الدخول.