Детальный разбор навигации в Flutter

от автора

image

Flutter набирает популярность среди разработчиков. Большенство подходов в построении приложений уже устоялись и применяются ежедневно в разработке E-commerce приложений. Тема навигации опускают на второй или третий план. Какой API навигации предоставляет Фреймворк? Какие подходы выработаны? Как использовать эти подходы и на что они годятся?

Введение

Начнём с того, что такое навигация? Навигация — это метод который позволяет перемещаться между пользовательским интерфейсом с заданными параметрами.
К примеру в IOS мире организовывает навигацию UIViewController, а в Android — Navigation component. А что предоставляет Flutter?

Экраны в Flutter называются route. Для перемещениями между route существует класс Navigator который имеющий обширный API для реализации различных видов навигации.

Начнём с простого. Навигация на новый экран(route) вызывается методом push() который принимает в себя один аргумент — это Route.

Navigator.push(MaterialPageRoute(builder: (BuildContext context) => MyPage()));

Давайте детальнее разберёмся в вызове метода push:

Navigator — Виджет, который управляет навигацией.
Navigator.push() — метод который добавляет новый route в иерархию виджетов.
MaterialPageRoute() — Модальный route, который заменяет весь экран адаптивным к платформе переходом анимации.
builder — обязательный аргумент конструктора MaterialPageRoute, который возвращает пользовательский интерфейс Фреймворк для отрисовки.
MyPage — пользовательский интерфейс реализованный при помощи Stateful/Stateless Widget

Возвращение на предыдущий route

Для возвращения с экрана на предыдущий необходимо использовать метод pop().

Navigator.pop();

Переда данных между экранами

Данные на новый экран передаются через конструктор при создании экрана в builder методе. Этот принцип работает в методах Navigator, где нужно реализовать метод build.

Navigator.push(context, MaterialPageRoute(builder: (context) => MyPage(someData: data)));

В примере продемонстрирована передача данных в класс MyPage (в этом классе хранится пользовательский интерфейс).

Для того чтобы передать данные на предыдущий экран нужно вызвать метод pop() и передать опциональным аргументом туда данные.

Navigator.pop(data);

Состояние виджета Navigator, который вызван внутри одного из видов MaterialApp/CupertinoApp/WidgetsApp. State отвечает за хранение истории навигации и предоставляет API для управления историей.
Базовые методы навигации повторяют структуру данных Stack. В диаграмме можно наблюдать методы и "call flow" NavigatorState.

https://habrastorage.org/webt/5w/dg/nb/5wdgnb-tjlngub4c8y4rlpqkeqi.png

Императивный vs Декларативный подход в навигации

Во всех примерах которые были приведены выше использовался императивный подход в навигации. Разница между императивным и декларативным подходом в том как будет выглядеть вызов нового route, и кто будет хранить реализацию перехода.

Давайте на простом примере:

Императивный подход , отвечает на вопрос — как?
Пример: Я вижу, что тот угловой столик свободен. Мы пойдём туда и сядем там.

Декларативный подход, отвечает на вопрос — что?
Пример: Столик для двоих, пожалуйста.

Для более глубокого понимания разницы советую прочитать эту статью Imperative vs Declarative Programming

Императивная навигация

Вернёмся к реализации навигации. В императивном подходе описывается детали работы в вызывающем коде. В нашем случае это поля Route. В Flutter много типов route, например MaterialPageRoute и CupertinoPageRoute. Например в CupertinoPageRoute задаётся title, или settings.

Пример:

Navigator.push(     context,     CupertinoPageRoute<T>(         title: "Setting",         builder: (BuildContext context) => MyPage(),         settings: RouteSettings(name:"my_page"),     ), );

Этот код и знания о новом route будут храниться в ViewModel/Controller/BLoC/… У этот подхода существует недостаток.

Представим что потребовалось внести изменения в конструкторе в MyPage или в CupertinoPageRoute. Нужно искать каждый вызов метода push в проекте и изменять кодовую базу.

Вывод:

Этот подход не имеет единообразный подход к навигации, и знание о реализации route проникает в бизнес логику.

Декларативная навигация

Принцип декларативной навигации заключается в использовании декларативного подхода в программировании. Давайте разберём на примере.

Пример:

Navigator.pushNamed(context, '/my_page');

Принцип императивной навигации выглядит куда проще. Говорите "Отправь пользователя на экран настроек" передавая путь одним из аргументов навигации.
Для хранении реализации роста в самом Фреймворке предусмотрен механизм у MaterialApp/CupertinoApp/WidgetsApp. Это 2 колбэка onGenerateRoute и onUnknownRoute отвеспющие за хранение деталей реализации.

Пример:

MaterialApp(     onUnknownRoute: (settings) => CupertinoPageRoute(       builder: (context) {                 return UndefinedView(name: settings.name);             }     ),   onGenerateRoute: (settings) {     if (settings.name == '\my_page') {       return CupertinoPageRoute(                 title: "MyPage",                 settings: settings,         builder: (context) => MyPage(),       );     }         // Тут будут описание других роутов   }, );

Разберёмся подробнее в реализации:
Метод onGenerateRoute — данный метод срабатывает когда был вызван Navigator.pushNamed(). Метод должен вернуть route.
Метод onUnknownRoute — срабатывает когда метод onGenerateRoute вернул null. должен вернуть дефолтный route, по аналогии с web сайтами — 404 page.

Этот принцип упрощает вызывающий код и хранить логику переходов в одном едином месте с возможностью переиспользования кодовой базы внутри. Но так же существует недостаток, это универсальность для разных типов раутов.

Диалоговые и модальные окна

Для того чтобы вызвать диалоговое окна разного типа в Фреймворке предусмотрены глобальные методы. Давайте разберёмся в типах диалоговых окон.

Методы для вызова диалоговых и модальных окон:

  • showAboutDialog
  • showBottomSheet
  • showDatePicker
  • showGeneralDialog
  • showMenu
  • showModalBottomSheet
  • showSearch
  • showTimePicker
  • showCupertinoDialog
  • showDialog
  • showLicensePage
  • showCupertinoModalPopup

Эти методы покрывают базовые потребности разработчиков которые хотят работать с окнами. Если не хватает этого API, тогда стоит разобраться как эти методы работают.

Как работает это под капотом?

Давайте рассмотрим исходный код одного из методов, например showGeneralDialog.

Исходный код:

Future<T> showGeneralDialog<T>({   @required BuildContext context,   @required RoutePageBuilder pageBuilder,   bool barrierDismissible,   String barrierLabel,   Color barrierColor,   Duration transitionDuration,   RouteTransitionsBuilder transitionBuilder,   bool useRootNavigator = true,   RouteSettings routeSettings, }) {   assert(pageBuilder != null);   assert(useRootNavigator != null);   assert(!barrierDismissible || barrierLabel != null);   return Navigator.of(context, rootNavigator: useRootNavigator).push<T>(_DialogRoute<T>(     pageBuilder: pageBuilder,     barrierDismissible: barrierDismissible,     barrierLabel: barrierLabel,     barrierColor: barrierColor,     transitionDuration: transitionDuration,     transitionBuilder: transitionBuilder,     settings: routeSettings,   )); }

Давайте детальнее разберёмся в устройстве этого метода. showGeneralDialog вызывает метод push у NavigatorState с _DialogRoute(). Нижнее подчёркивание обозначает что этот класс приватный и используется только в пределах области видимости в которой сам описан, то есть в пределах этого файла.

Диалоговые и модальные окна которые отображаются при помощи глобальных методов — это кастомные route которые реализованы разработчиками Фреймворка.

Типы route в фреймворке

Теперь понятно что "every thins is a route", то есть что связанное с навигацией. Давайте взглянем на то, какие route уже реализованы в Фреймворке.

Два основных route в Flutter — это PageRoute и PopupRoute.

PageRoute — Модальный route, который заменяет весь экран.
PopupRoute — Модальный route, который накладывает виджет поверх текущего route.

Реализации PageRoute:

  • MaterialPageRoute
  • CupertinoPageRoute
  • _SearchPageRoute
  • PageRouteBuilder

Реализации PopupRoute:

  • _ContexMenuRoute
  • _DialogRoute
  • _ModalBottomSheetRoute
  • _CupertinoModalPopupRoute
  • _PopupMenuRoute

Реализация PopupRoute приватна и скрыты для внешних потребителей, но роуты используют глобальные методы для показа диалоговых и модальных окон.

Вывод:

Универсальный метод для декларативной навигации из капота реализовать невозможно, так как нужно учесть навигацию не только между экранами.

Best practices

В этой части статьи можно задаться вопросом, "а насколько мне всё это нужно?". Ответить на этот вопрос можно легко, если у есть желание сделать масштабируемый API навигации которая в любой момент будет модифицирована под нужды проекта, то это один из вариантов. Если приложение будет в будущем расширяться со сложной логикой.

Начнём с того что мы сделаем некий сервис который будет будет соблюдать следующим аспектам:

  • Декларативный вызов навигации.
  • Отказ от использования BuildContext для навигации (Это критично если сервис навигации будет вызываться в компонентах, в которых нет возможности получить BuildContext).
  • Модульность. Можно вызвать любой route, CupertinoPageRoute, BottomSheetRoute, DialogRoute и т.д.

Для нашего сервиса навигации нам понадобится интерфейс:

abstract class IRouter {   Future<T> routeTo<T extends Object>(RouteBundle bundle);   Future<bool> back<T extends Object>({T data, bool rootNavigator});   GlobalKey<NavigatorState> rootNavigatorKey; }

Разберём методы:
routeTo — выполняет навигацию на новый экран.
back — возвращает на предыдущий экран.
rootNavigatorKey — GlobalKey умеющий вызывать методы NavigatorState.

После того как мы сделали интерфейс навигации, давайте сделаем реализацию этого интерфейса.

class Router implements IRouter {     @override   GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();      @override   Future<T> routeTo<T>(RouteBundle bundle) async {    // Push logic here   }      @override   Future<bool> back<T>({T data, bool rootNavigator = false}) async {         // Back logic here     } }

 Супер, теперь нам нужно реализовать метод routeTo().

@override   Future<T> routeTo<T>(RouteBundle bundle) async {         assert(bundle != null, "The bundle [RouteBundle.bundle] is null");     NavigatorState rootState = rootNavigatorKey.currentState;     assert(rootState != null, 'rootState [NavigatorState] is null');      switch (bundle.route) {       case "/routeExample":         return await rootState.push(           _buildRoute<T>(             bundle: bundle,             child: RouteExample(),           ),         );        case "/my_page":         return await rootState.push(           _buildRoute<T>(             bundle: bundle,             child: MyPage(),           ),         );       default:         throw Exception('Route is not found');     }   }

Данный метод вызывает у root NavigatorState (который описан в WidgetsApp) метод push и конфигурирует его относительно RouteBundle который приходит одним из аргументов в данный метод.

Теперь нужно реализовать класс RouteBundle. Это просто модель, которая хранит в себе набор полей для конфигурации.

enum ContainerType {   /// The parent type is [Scaffold].   ///   /// In IOS route with an iOS transition [CupertinoPageRoute].   /// In Android route with an Android transition [MaterialPageRoute].   ///   scaffold,    /// Used for show child in dialog.   ///   /// Route with [DialogRoute].   dialog,    /// Used for show child in [BottomSheet].   ///   /// Route with [ModalBottomSheetRoute].   bottomSheet,    /// Used for show child only.   /// [AppBar] and other features is not implemented.   window, } class RouteBundle {   /// Creates a bundle that can be used for [Router].   RouteBundle({     this.route,     this.containerType,   });    /// The route for current navigation.   ///   /// See [Routes] for details.   final String route;    /// The current status of this animation.   final ContainerType containerType; }

enum ContainerType — тип контейнера, котрый будет задаваться декларативно из вызываемого кода.
RouteBundle — класс-холдер данных отвечающих конфигурацию нового route.

Как вы могли заметить у я использовал метод _buildRoute. Именно он отвечает за то, кой тип route будет вызван.

Route<T> _buildRoute<T>({@required RouteBundle bundle, @required Widget child}) {     assert(bundle.containerType != null, "The bundle.containerType [RouteBundle.containerType] is null");      switch (bundle.containerType) {       case ContainerType.scaffold:         return CupertinoPageRoute<T>(           title: bundle.title,           builder: (BuildContext context) => child,           settings: RouteSettings(name: bundle.route),         );       case ContainerType.dialog:         return DialogRoute<T>(           title: '123',           settings: RouteSettings(name: bundle.route),           pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {                         return child;                     },         );       case ContainerType.bottomSheet:         return ModalBottomSheetRoute<T>(           settings: RouteSettings(name: bundle.route),           isScrollControlled: true,           builder: (BuildContext context) => child,         );       case ContainerType.window:         return CupertinoPageRoute<T>(           settings: RouteSettings(name: bundle.route),           builder: (BuildContext context) => child,         );       default:         throw Exception('ContainerType is not found');     }   }

Думаю что в этой функции стоит рассказать о ModalBottomSheetRoute и DialogRoute, которые использую. Исходный код этих route позаимствован из раздела Material исходного кода Flutter.

Осталось сделать метод back.

@override Future<bool> back<T>({T data, bool rootNavigator = false}) async {     NavigatorState rootState = rootNavigatorKey.currentState;   return await (rootState).maybePop<T>(data); }

Ну и конечно перед использованием сервиса необходимо передать rootNavigatorKey в App следующим образом:

MaterialApp(     navigatorKey: widget.router.rootNavigatorKey,     home: Home() );

Кодовая база для нашего сервиса готова, давайте вызовем наш route. Для этого создадим инстанс нашего сервиса и каким-либо образом "прокинуть" в объект, который будет вызывать этот инстанс, например при помощи Dependency Injection.

image

router.routeTo(RouteBundle(route: '/my_page', containerType: ContainerType.window));

Теперь мы имеем единый подход к навигации, позволяющий решить вышеперечисленные проблемы:

  • Декларативный вызов навигации
  • Отказ от BuildContext по средствам GlobalKey
  • Модульность достигнута возможностью конфигурирования route относительно имени пути и контейнера для View

Итог

В Фреймворке Flutter существуют различные методы для навигации, которые дают преимущества и недостатки.

Ну и конечно полезные ссылки:
Мой телеграм канал
Мои друзья Flutter Dev Podcast
Вакансии Flutter разработчиков

ссылка на оригинал статьи https://habr.com/ru/post/512072/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *