Routing in Flutter

Sunday 14 June 2020 ~7 min read


الـ Route في تطبيقات Flutter هو ببساطة أي صفحة أو شاشة نعرضها للمستخدم في التطبيق، مثلاً، صفحة الإعدادات، التعليقات، الملف الشخصي، ... إلخ. سنتحدث في هذا المقال عن أساسياته وبضع نصائح تساعدك في استخدامه بشكل أفضل.

عنصر Navigator

حينَ نبدأ بمشروع Flutter، أول عنصر سيكون حاوياً لكل التطبيق هو MaterialApp، وكل صفحة وشاشة بداخله يمكن الوصول إليها عن طريق استخدام Navigator، بكلمات بسيطة، هذا العنصر يقومُ مقام المدير لجميع الـ Routes في تطبيقنا.

فإن أردت الذهاب لصفحة ما بالضغط على زر ما، فسأستخدم الـNavigator بهذا الشكل:

Navigator.of(context).push( /* HERE GOES YOUR PAGE WIDGET */ )

قبل أن نسهب في شرح كيفية التنقل داخل التطبيق، يجب أن نعرف أنّ Navigator يتبع نظاماً يسمّى Stack.

Stack

القاعدة الأساسية لهذا النظام هي First-in, Last-out. وتعني أن أول عنصر يدخل في الحاوية هو أول عنصر سيخرج، مهما كان عدد العناصر في الداخل، دوماً العنصر الأخير هو من سيستطيع الخروج أولاً لأنه مازال الأقرب للمخرج. في تطبيقنا سنتخيل أنّ هذه الحاوية هي MaterialApp وسيساعدنا المدير Navigator على إدخال وإخراج الصفحات داخل التطبيق، وحين لا يكون هناك أي صفحة أو Widget، هذا يعني أنّ التطبيق ليس موجوداً على الشاشة، وتم إغلاقه. إذاً ماذا ستمثل لنا الصفحة الظاهرة على شاشتنا في التطبيق؟ ستكون هي آخر عنصر قام صديقنا Navigator بدفعه للحاوية.

كفانا حديثاً ولنرى بعض الأمثلة! سنبدأ من الصفر.

import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp(
      home: HomePage(),
    ),
  );
}

class HomePage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text(
          'Hello World! Build some widgets!',
          style: Theme.of(context).textTheme.headline4,
        ),
      ),
    );
  }
}

سنضيف صفحة جديدة لتطبيقنا، وزر RaisedButton، وحين الضغط على الزر سننتقل للصفحة الجديدة.

class HomePage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Hello World! Build some widgets!',
              style: Theme.of(context).textTheme.headline4,
            ),
            RaisedButton(              child: Text("Go to second page"),              onPressed: () {                Navigator.of(context).push(                  MaterialPageRoute(                    builder: (context) => SecondPage(),                  ),                );              },            )          ],
        ),
      ),
    );
  }
}

class SecondPage extends StatelessWidget {    Widget build(BuildContext context) {    return Scaffold(      body: Center(        child: Text("Welcome from second page"),      ),    );  }}

استعملنا Navigator لنذهب للصفحة الثانية، ماذا إن أردنا العودة؟

class SecondPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: RaisedButton(          child: Text("Get back home"),          onPressed: () {            Navigator.of(context).pop();          },        ),      ),    );
  }
}

نلاحظ أنّ Navigator هو عبارة عن InheritedWidget، وهذا يفسر كونه متاحاً للاستخدام في جميع أرجاء التطبيق بدون أي تعريفات إضافية. لسنا بصدد الحديث عن InheritedWidget ، لكِن يمكنك الاطلاع على المزيد عنها من هنا.

الطريقة التي استخدمناها في المثال السابق تسمى Unnamed Routing، وتعني أني بنيتُ الـRoute باستخدام MaterialPageRoute (أو PageRoute أو CupertinoPageRoute في حالة كان تطبيقي مبنيا على CupertinoApp بدلاً من MaterialApp)، ثم زودته بالصفحة التي أريد الذهاب إليها، وهو قام ببنائها وتوجيهي إليها في حالة ضغطت على الزر.

وحين أريد العودة للخلف، سأستخدم

Navigator.of(context).pop();

بدون تزويد الـNavigator بالجهة التي أريد العودة لها، لأن ما سيفعله هنا بسيط للغاية، سيقوم بإزالة الصفحة الحالية من الـStack وبالتالي تظهر لي الصفحة السابقة.

Unnamed vs Named Routes

في الأمثلة السابقة قمنا باستعمال Unnamed Routes، أي أنّي بنيته في كل مرة يدوياً، لكِن هناك طريقة أخرى أكثر اختصاراً، ستتطلب مني فقط بعض التعريفات المسبقة لكل الـRoutes في تطبيقي، وإعطاء كل Route اسماً خاصاً به، ومن هنا نطلق عليها Named Routes.

حتى أستعملها، أولاً سأعود لـMaterialApp وأعطي اسماً لمسار صفحتي الثانية، داخل خاصية routes.

MaterialApp(
  home: HomePage(),
  routes: {    '/secondPage': (context) => SecondPage()  })

ثم وبكل سهولة بإمكاني استخدام هذا الاسم لأذهب لهذه الصفحة من أي مكان بالتطبيق.

Navigator.of(context).pushNamed('/secondPage');

Passing Arguments

ماذا إن أردت نقل بعض البيانات لأني أحتاجها في الصفحة الثانية؟ كان هذا ممكناً باستخدام الـconstructor، هكذا مثلاً:

Navigator.of(context).push( 
  MaterialPageRoute(
      builder: (context) => SecondPage(name: "Mais)
  )
)

لكِن هذا سيتطلب مني تعريف متغيرات في الـSecondPage وإن حصل أي تغيير في قيمة هذه المتغيرات من الصفحة السابقة لأي سببٍ كان، لن أتلقى هذه التحديثات لأني استقبلت هذه البيانات مرة واحدة حين بناء الصفحة فقط. لحسن الحظ لدينا خيار آخر.

//In a Named Route
Navigator.of(context).pushNamed('/secondPage', arguments: "Mais");

//In an Unnamed Route
final _settings = RouteSettings(arguments: "Mais");Navigator.of(context).push( 
  MaterialPageRoute(
      builder: (context) => SecondPage(),
      settings: _settings  )
)

نلاحظ هنا استخدامنا لـRouteSettings في الكود الثاني، لكِن فعلياً هو أيضاً موجود في الكود الأول، الاختلاف فقط أننا قمنا ببناء الـRoute مسبقاً لذلك الـRouteSettings تم تحديدها مسبقاً لهذه الصفحة.

ماهي RouteSettings؟ هي بعض الإعدادات الخاصة بالـRoute، ومن ضمنها المتغيرات التي يمكننا تمريرها عبرها، واسم هذا الـRoute.

تمكنني RouteSettings من إعطاء اسمٍ للـRoute حتى لو لم أكن أستخدم Named Routes.

final _settings = RouteSettings(arguments: "Mais", name: '/secondPage');

هذا الأمر مهم إن كنت أستخدم Flutter للويب! فبدون إعطاء اسمٍ للـRoute، هذا يعني أنّ الـURL في الأعلى لن يتغير حين أذهب للصفحة الثانية. بهذا الصدد، وفي تاريخ كتابة هذا المقال، يجب أن نذكر أنّ هذه من ضمن الأشياء التي مازالت قيد التطوير في Flutter للويب، ومن الصعب قليلاً التحكم في الـURL خصوصاً حين تكون مخصصة، مثلاً على حسب المعرف الفريد لكل مستخدم.

Reading arguments in SecondPage

كيف أقرأ البيانات التي قمت بإرسالها عن طريق خاصية arguments؟ هذه البيانات الآن متواجدة في الـRoute، ولأستخرجها، علي اولاً تحويل الصفحة الثانية إلى StatefulWidget، لأني أريد استعمال إحدى الدوال من دورة حياتها، وهي didChangeDependencies.

class SecondPage extends StatefulWidget {
  const SecondPage({Key key}) : super(key: key);

  
  _SecondPageState createState() => _SecondPageState();
}

class _SecondPageState extends State<SecondPage> {

  String data;
    void didChangeDependencies() {    final data = ModalRoute.of(context).settings.arguments;    if (this.data != data) {      this.data = data;    }    super.didChangeDependencies();  }
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: Text('Hello, $data'),
      ),
    );
  }
}

هذه الدالة تبدأ العمل بعد دالة initState، لماذا لم نستعمل initState بدلاً منها؟ إن حاولت ذلك سيظهر لي خطأ وفي شرحه نجد أنه من الأفضل إن كنا نقرأ قيمة تعتمد على InheritedWidget في بداية حياة العنصر، فيفضل فعل ذلك في دالة didChangeDependencies. لن نسهب في ذلك أكثر لأن هذا ليس موضوعنا، لكِن يجب أن نعرف أن المعلومات التي تم تمريرها من الـRoute السابق ستكون متاحة عن طريق استخدام ModalRoute لاستخراجها من الـRouteSettings.

Returning Data

لنقل أنّي أريد أني أريد أن أعرف حين يقوم المستخدم بالعودة للصفحة السابقة، وأظهر له SnackBar في اللحظة التي يعود فيها. منطقياً بإمكاني القول أن هذا ممكن إن كان هناك قيمة bool تعود لي من الصفحة وأقوم بتفقدها، وإن كانت true فأطلب من التطبيق إظهار SnackBar. بالمثال التالي سيمكنك رؤية النتيجة، فقط اضغط على Run Pen. إن لم تستطع رؤية أي شيء، توجه للمثال من هذا الرابط.

لنشرح ما حصل، أولاً سنأخذ نظرة أخرى على دالة _navigateToSecondPage ونلاحظ أنه تم تعريفها على أنها دالة غير متزامنة async، ثم تم تعريف متغير قيمته حالاً غير متاحة، إنما هي بانتظار await ما سيعود لها من الصفحة الثانية. كيف استطعنا فعل ذلك؟ إن عدنا للـDocumentation الخاص بدالة push سنلاحظ أنها Future، أي أني أستطيع انتظارها وأضع القيمة التي ستعيدها لي في متغير، إن لم أقم بإرجاع أي قيمة فإن المتغير سيكون null. ثم أستطيع فحص قيمة المتغير، وإن كان true أطلب من التطبيق إظهار الـSnackBar.

onGenerateRoute

أخيراً سنتحدث عن هذه الخاصية الرائعة، وكيف بإمكاننا استخدامها لجعل الكود أقصر وأكثر كفاءة، بجمع كل منطق الـRouting في مكان واحد. حتى الآن كنت أعمل على ملف واحد فقط main.dart، الآن سننشئ ملفاً جديداً ونعطيه اسم router.dart، وسيحتوي على الكود التالي:

class Router {
  static Route<dynamic> generateRoute(RouteSettings settings) {
    switch (settings.name) {
      case '/':
        return MaterialPageRoute(builder: (_) => MyHomePage());
      case '/secondPage':
        return MaterialPageRoute(
          builder: (_) => SecondPage(),
          settings: settings,
        );
      default:
        return MaterialPageRoute(
          builder: (_) => Scaffold(
            body: Center(child: Text('No route defined for ${settings.name}')),
          ),
        );
    }
  }
}

ثم سنستخدم هذه الدالة في MaterialApp.

MaterialApp(
  home: HomePage(),
  onGenerateRoute: Router.onGenerateRoute,)

وفي كل مرة نستخدم Navigator ونعطيه اسماً لـRoute، ستقوم هذه الدالة بتمرير الاسم وترى إن كان يطابق أياً من الصفحات الموجودة في التطبيق، وإن كان لا يطابق أياً منها فستظهر صفحة افتراضية تخبرنا أن الـRoute المطلوب غير موجود. ما فائدة هذا؟ سنلاحظ أن هذه الطريقة قد جمعت لي اختصار الـNamed Routes كوني سأستطيع التوجه لأي صفحة بسطر واحد فقط، وأيضاً أستطيع استخدام قوة التخصيص الموجودة في الـUnnamed Routes، حيث بإمكاني التحكم في الكثير من الأمور كتغيير نوع الـTransition وهي الطريقة التي سيتم فيها الانتقال بين الصفحات. وأهم فائدة هي أنّ كودي يبدو أكثر أناقة وأسهل في القراءة والتعديل.

مصادر

Navigation Cookbook from Flutter Official Docs
Navigator Class API