Flutter: التنقل والتوجيه

المسارات هي خريطة التطبيق — بدونها، المستخدمون كسائقين بدون GPS، لا يستطيعون الذهاب لأي مكان.

📋 المتطلبات السابقة: يجب أن تتقن ما يلي أولًا

1. ما ستتعلمه


2. قصة حقيقية عن صفحات مفقودة

(1) المشكلة: فوضى مكدس المسارات

تطبيق ShopApp لبوب فيه 10 صفحات، جميعها يُتنقل إليها عبر Navigator.push. يذهب المستخدم من الرئيسية ← البحث ← المنتج ← السلة ← الدفع، لكن الضغط على الرجوع يعيده إلى صفحة البحث بدلًا من الرئيسية. مكدس المسارات فيه 5 صفحات، يتطلب 4 ضغطات رجوع للوصول للرئيسية. والأخطر، أن URL في الويب لا يتغير، والتحديث يفقد كل الحالة.

(2) حل GoRouter

يستخدم GoRouter التوجيه التعريفي حيث تُربط عناوين URL والصفحات تلقائيًا، ويُحدد مكدس المسارات بالإعدادات بدلًا من ترتيب push.

⚙️ تثبيت التبعية: flutter pub add go_router

DART
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()),
  ],
);
TEXT
> الإخراج: شغّل محليًا باستخدام 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]

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: عمليات التنقل الأساسية

DART
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,
);
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

4. المسارات المسمّاة

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: إعداد المسارات المسمّاة

DART
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'});
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
النهج المزايا العيوب
push مباشر بسيط، بديهي، آمن الأنواع المسارات متفرقة، صعبة الصيانة
مسارات مسمّاة إدارة مركزية، تمرير معاملات المعاملات ليست آمنة الأنواع
GoRouter تعريفي، مناسب للويب، ربط عميق يحتاج تبعية إضافية

5. التوجيه التعريفي بـ GoRouter

100%
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
TEXT
> الإخراج: شغّل محليًا باستخدام 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

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: إعداد GoRouter لـ ShopApp

DART
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(),
    ),
  ],
);
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(2) ShellRoute التخطيط المتداخل

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: ShellRoute مع BottomNav

DART
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');
    }
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

6. مقارنة طرق التنقل

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: go مقابل push مقابل replace

DART
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'});
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
الطريقة عملية المكدس تحديث URL حالة الاستخدام
go يستبدل المكدس بالكامل نعم التنقل الرئيسي (تبديل التبويبات)
push يدفع مسارًا جديدًا نعم التنقل لصفحة التفاصيل
replace يستبدل المسار الحالي نعم تسجيل الدخول ← الرئيسية

7. مثال كامل: نظام توجيه ShopApp

⚙️ تثبيت التبعية: flutter pub add go_router

DART
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;
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

❓ أسئلة شائعة

س هل يمكن خلط GoRouter وNavigator 1.0؟
ج الخلط غير موصى به. GoRouter يدير Navigator داخليًا، والخلط يسبب تناقضات في مكدس المسارات.
س كيف أخفي BottomNav للمسارات الفرعية في ShellRoute؟
ج ضع المسارات التي لا تحتاج BottomNav خارج ShellRoute، مثل صفحات التفاصيل والدفع.
س كيف يُنفّذ GoRouter حراس المسارات (المصادقة)؟
ج استخدم استدعاء redirect: تحقق من حالة تسجيل الدخول، أعد التوجيه إلى /login إذا لم يكن مسجلًا.
س حالة صفحة الويب تُفقد عند التحديث؟
ج GoRouter يدعم استعادة حالة URL افتراضيًا. تأكد من استخدام MaterialApp.router بدلًا من MaterialApp.
س ما الفرق بين push وgo؟
ج push يكدّس مسارًا جديدًا فوق الحالي؛ go يستبدل المكدس بالكامل. استخدم go لتنقل التبويبات وpush لصفحة التفاصيل.
س كيف أعدّ الربط العميق على iOS؟
ج تحتاج لإعداد Associated Domains وملف apple-app-site-association. على Android، أعدّ Asset Links وintent-filter.

📖 ملخص


📝 تمارين

  1. أساسي (الصعوبة ⭐): استخدم GoRouter لإعداد 3 مسارات (الرئيسية/السلة/الملف الشخصي) مع تبديل التنقل السفلي.
  2. متوسط (الصعوبة ⭐⭐): أضف مسار /product/:id للتفاصيل، تنقل من بطاقات المنتجات في الصفحة الرئيسية، مع دعم تمرير معاملات المسار.
  3. متقدم (الصعوبة ⭐⭐⭐): نفّذ حارس مسارات كامل: أعد توجيه المستخدمين غير المصادق عليهم الذين يحاولون الوصول إلى /profile إلى /login، وأعد التوجيه تلقائيًا للخلف بعد تسجيل الدخول.

← السابق | التالي →

Web-Tutorial.com

فريق Web-Tutorial التقني

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

100%