Навигация во Flutter
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](https://miro.medium.com/max/664/1Xm96KtLeIAAMtAYWcr1-MA.png)*
пользовательский интерфейс реализованный при помощи
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.
@overrideFuture<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 разработчиков