
Flutter набирает популярность среди разработчиков. Большенство подходов в построении приложений уже устоялись и применяются ежедневно в разработке E-commerce приложений. Тема навигации опускают на второй или третий план. Какой API навигации предоставляет Фреймворк? Какие подходы выработаны? Как использовать эти подходы и на что они годятся?
Введение
Начнём с того, что такое навигация? Навигация — это метод который позволяет перемещаться между пользовательским интерфейсом с заданными параметрами.
К примеру в IOS мире организовывает навигацию UIViewController, а в Android — Navigation component. А что предоставляет Flutter?
Navigator
Экраны в Flutter называются route. Для перемещениями между route существует класс Navigator который имеющий обширный API для реализации различных видов навигации.
Навигации на новый route и возвращение с него
Начнём с простого. Навигация на новый экран(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 State
Состояние виджета Navigator, который вызван внутри одного из видов MaterialApp/CupertinoApp/WidgetsApp. State отвечает за хранение истории навигации и предоставляет API для управления историей.
Базовые методы навигации повторяют структуру данных Stack. В диаграмме можно наблюдать методы и "call flow" NavigatorState.

Императивный 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.

router.routeTo(RouteBundle(route: '/my_page', containerType: ContainerType.window));
Теперь мы имеем единый подход к навигации, позволяющий решить вышеперечисленные проблемы:
- Декларативный вызов навигации
- Отказ от BuildContext по средствам GlobalKey
- Модульность достигнута возможностью конфигурирования route относительно имени пути и контейнера для View
Итог
В Фреймворке Flutter существуют различные методы для навигации, которые дают преимущества и недостатки.
Ну и конечно полезные ссылки:
Мой телеграм канал
Мои друзья Flutter Dev Podcast
Вакансии Flutter разработчиков
ссылка на оригинал статьи https://habr.com/ru/post/512072/
Добавить комментарий