Flutter & La navigation

Introduction

En utilisant le framework Flutter on se rend vite compte que la navigation entre les pages est quelque chose de très puissant, mais peut devenir très complexe dans certains cas d’usage.

D’après la documentation de Flutter, le système de navigation par défaut porté par la classe Navigator apporte des limitations sur des projets complexes qui demandent à ce que la navigation soit maîtrisée, comme pour le deeplinking ou les navigations imbriquées par exemple. Dans la plupart des projets, Mobiapps utilise des packages tels que AutoRoute ou GoRouter permettant de simplifier l’utilisation de la navigation et d’avoir une sur couche syntaxique qui nous apporte une généralité sur l’ensemble de nos projets.

Une rumeur raconte que certains développeurs utiliseraient la navigation Flutter par défaut (sans aucun package) sur des projets assez complexe. Nous avons voulu vérifier la véracité de cette rumeur qui prône une utilisation simplifiée lorsqu’elle est correctement réfléchie.

Le système de navigation par défaut de Flutter est constitué de deux approches différentes, représentées parla version 1.0 & 2.0 du Navigator. Pour une première approche du navigator, nous nous sommes focalisés sur la première version du navigator.

Une première approche

La première version du navigator utilise des classes de type Routes pour naviguer entre les pages. À l’aide de la fonction Navigator.push, nous pouvons naviguer vers de nouvelles pages et la fonction pop permet de fermer la page courante.

Bien que déconseillé par Flutter, nous avons utilisé la gestion de route par la fonction pushNamed, une gestion de route qui apporte des limitations, mais qui semble pouvoir se structurer assez facilement.

Dans la déclaration du MaterialApp, un paramètre onGeneratedRoute est disponible. Il permet de définir une callback appelée lorsqu’une navigation est effectuée à l’aide de la fonction pushNamed. Nous avons organisé cette fonction de la manière suivante :

  class Routes {
    static Route onGenerate(RouteSettings settings) {
      final name = settings.name;
      final args = settings.arguments;

      MaterialPageRoute route(Widget widget) {
        return MaterialPageRoute(
          builder: (context) => widget,
          settings: settings,
        );
      }

      if (name == HomePage.routeName) {
        return route(const HomePage());
      }

      if (name == ItemDetailPage.routeName) {
        return route(ItemDetailPage(id: args as int));
      }

      return route(ErrorPage(settings: settings));
    }
  }

Pour essayer de structurer notre implémentation, une constante routeName a été créée dans nos classes de page. Elle nous permet de vérifier si elle correspond au nom de la nouvelle route vers laquelle nous souhaitons naviguer, il nous suffit par la suite de retourner une nouvelle instance de la classe MaterialPageRoute qui, quant à elle, crée une instance de notre page.

Transmettre des arguments entre les différentes pages par la navigation s’effectue de manière plutôt simple, le paramètre arguments de type Object?, nous permet de définir n’importe quel type et de pouvoir le traiter dans la callback onGeneratedRoute afin de la transmettre à notre prochaine page.

Lorsque nous souhaitons naviguer vers un nouvel écran, nous utilisation la syntaxe suivante :

  ButtonWidget(
    label: 'Go to item',
    onPressed: () => Navigator.of(context).pushNamed(
      ItemDetailPage.routeName,
      arguments: {id:1},
    ),
  )

Gestion d’une BottomNavigation

Adepte du package AutoRoute,nous avons l’habitude d’utiliser le widget AutoTabsRouter.tabBar(), un widget très pratique pour gérer des routes imbriquées et afficher de multiples onglets.

En abordant ce point, nous avons voulu essayer de recréer la syntaxe du widget tabBar(), la classe Navigator permettrait de gérer la page à afficher en fonction d’une liste de pages prédéfinies. De la même manière que le widget MaterialApp, il a un paramètre onGeneratedRoute qui sera appelé lors d’une navigation. La liste des pages est définie par le paramètre suivant :

  pages: const [
    MaterialPage(
      name: DashboardPage.routeName,
      child: DashboardPage(),
    ),
    MaterialPage(
      name: ListPage.routeName,
      child: ListPage(),
    ),
    MaterialPage(
      name: SettingsPage.routeName,
      child: SettingsPage(),
    ),
  ]

Nous avons ainsi répété l’implémentation de la callback onGeneratedRoute en ajoutant une gestion d’index afin de naviguer vers la bonne page :

  onTap: (int index) {
    setState(() => _currentIndex = index);
    if (index == 0) {
      _navigatorKey.currentState!.pushReplacementNamed(DashboardPage.routeName);
    } else if (index == 1) {
      _navigatorKey.currentState!.pushReplacementNamed(ListPage.routeName);
    } else if (index == 2) {
      _navigatorKey.currentState!.pushReplacementNamed(SettingsPage.routeName);
    }
  },

Le widget tabBar() offre la possibilité de garder “active” la page déjà affichée, elle n’est pas reconstruite si elle a déjà été affichée. Un avantage considérable qui permet de maintenir les données et l'état de la page. Nous avons imaginé que la classe Navigator permettait la même implémentation, cependant, lorsque nous effectuons une nouvelle navigation à l’aide de la fonction pushReplacementNamed, une nouvelle instance de la page est créée, ce qui nous empêche de garder la page “active”.

De fait, après des recherches dans le code source du package AutoRoute, nous avons pu voir que les classes AutoTabsRouter et autres solutions n'étaient que du sucre syntaxique autour de systèmes existants dans flutter (IndexedStack, PageView…), les onglets étant automatiquement contenus dans des widgets permettant de préserver leur état quand ils n'étaient pas affichés à l'écran. Il n’est donc pas possible d’utiliser un système de navigation pour permettre de gérer les différents affichages d’une barre de navigation.

Navigation après l’ouverture d’une notification

De nombreux commentaires, nous ont informés que la navigation après réception d’une notification était plus difficile à gérer sans package de navigation. Nous avons créé un cas d’usage pour vérifier ce point, il consiste à naviguer vers une page de détail d’un élément en récupérant son identifiant depuis la notification. Le schéma de navigation classique est le suivant :

  HomePage -> ItemsPage -> ItemDetails

Notre objectif est d’atteindre directement la page ItemDetails à la réception de la notification et de créer toutes les routes précédentes pour correspondre au parcours utilisateur. Firebase Cloud Messaging et sa gestion de notification, nous a facilement permis de recevoir une notification et de la traiter dans notre application. L’action de navigation quant à elle a été plus compliquée: le contexte dans lequel se trouvaient notre widget n’avait pas connaissance de l’instance du navigator utilisée, ce qui nous retournait l'erreur suivante :

  Navigator operation requested with a context that does not include a Navigator

Pour ne plus obtenir cette erreur, nous avons dû définir une clé de navigation, instanciée dans le widget MaterialApp, cette clé nous a permis d’accéder au contexte contenant l’instance de notre navigator et de naviguer vers notre page.

  final GlobalKey<NavigatorState> kNavigationKey = GlobalKey<NavigatorState>();

  class Root extends StatelessWidget {
    const Root({Key? key}) : super(key: key);

    @override
    Widget build(BuildContext context) {
      return MaterialApp(
        navigatorKey: kNavigationKey,
      );
    }

La navigation vers notre page de détail s’est effectuée très simplement par la suite, nous pensons que le cas d’usage que nous avons testé est peut-être trop simple ou que l’utilisation de la clé de navigation a évité tous les problèmes d’instance de navigator ou autre problème de stack. Mais nous n’avons pas rencontré de difficulté importante pour remplir notre objectif.

Conclusion

Le système de navigation de base a la réputation d'être compliqué à utiliser et très verbeux. C’est en effet le constat que nous nous sommes fait, les ressources (fonctions) disponibles sur la classe Navigator sont beaucoup plus importants que certains packages que nous avons l’habitude d’utiliser. C’est aussi l’objectif des packages tel que AutoRoute ou GoRouter, ils masquent la complexité par rapport à l’existant et apportent une opinion forte sur la manière d’effectuer et d’architecturer la navigation, aux dépens parfois de la compréhension de ce qui se produit.

L’avis est partagé dans l'équipe entre l’utilisation du navigator de base pour une utilisation difficile, mais une liberté d’architecture ou alors l’utilisation d’un package simplifiant l’utilisation, mais qui apporte encore une dépendance externe et une manière d’opérer bien précise.

Nous pensons que la navigation de base peut être utilisée sur des projets, mais il est pour nous important de réfléchir aux besoins et de garantir la capacité du projet à évoluer pour éviter tout développement nécessitant une modification ultérieure.

Et vous ? Que pensez-vous de la navigation Flutter ?

Vous souhaitez en savoir plus ? Contactez-nous !

Clément MARCHAIS – Développeur Mobile

Vincent LEBRAS – Développeur Mobile

Guillaume CAZE FRANCOIS – Développeur Mobile

Maxime BIZERAY – Développeur Mobile

Sources : Navigation and routing