В проектах под web на Flutter изначально казалось, что достаточно будет использовать onGenerateRoute и все у нас будет нормально. А routerDelegate, который был написан для мобильных проектов, что-то плохо подружился в вебом. Количество проектов росло, проекты развивались, навигация становилась сложнее… В какой-то момент я принял решение, скрестить таки routerDelegate (вроде как, концепция Navigator 2.0) с вэбом и написать свой, относительно простой, навигатор. Главное требование — он должен хорошо работать как в мобильной версии, так и в вебе. Тем более, есть идеи сделать некоторые новые проекты так, чтобы из них собрать мобильное приложение и можно было выложить «на сайт».
Вопрос адаптивной верстки здесь оставим за скобками, а вот эксперименты, выводы и рассуждения «по ходу» создания своего нового роутерделегата постараюсь изложить здесь. И для поддержки этого творения коллегами будет потом полезно и мысли поможет структурировать. Ну и кому-то, вероятно, поможет погрузиться в тему декларативного подхода к навигации в Flutter.
«Учебный» проект на гите https://github.com/dumptyhumpty2014gmail/navigator2web
Дальнейшая проработка темы в другой статье Navigator 2.0 — реальный пример
Вводная
Задача: разработать свой навигатор со следующими свойствами:
- должен прекрасно сочетаться с работой в браузере, то есть, ожидаемо реагировать на рефреш страницы, движения по истории (стрелки вперед, назад) и ввод ссылок (так называемых «диплинков») непосредственно в строку браузера
- так же поддерживать диплинки в мобильной версии, переданные, к примеру, из пуша или из другого приложения через intent
- учитывать состояние приложения, к примеру, авторизован пользователь или нет. И перенаправлять на другой экран, при необходимости
- ссылки мы должны нормально обрабатывать разной сложности. К примеру, books/book/author?book=100&author=20 и в тоже время authors/author/book?author=20&book=100. Где books — экран со списком книг; book/12 — экран о книге с айдишником 12; authors — экран со списком авторов; author/100 — экран с данными об акторе с айдишником 100.
В рамках данной статьи я сделал основу для декларативного именно навигатора, который потом разовью уже в конкретных проектах. Потом он станет, скорее всего, гибридным, а не чисто декларативным, но это уже, как говорится, другая история… которая, может быть, будет описана в другой статье.

Заметки на полях
Немного дополнительных вводных.
Постулируется, что Navigator 2.0 — это чисто декларативный подход, но везде, в том числе и в пакете go_router от команды флаттера, создаются методы типа «go», «push» и тому подобные. Что, на мой взгляд, нарушает декларативность (( Для себя я декларативность определяю так, что в чистом виде мы не должны пушить страницы и удалять, а передавать навигатору состояние страниц, которое нам нужно на данный момент. Поэтому параметр, передаваемый в setNewRoutePath — это не новая страница, которую нужно добавить, а «состояние» (конфигурация) навигации на данный момент. то есть, то, что мы желаем получить!
Но такой подход, хотя и коррелирует с общей парадигмой Flutter, как утверждается в статьях, посвященных Navigator 2.0, все таки сопряжен с бОльшим объемом кода и, в целом, сложнее. Данный проект я и попытался сделать именно декларативным. Но в целом для меня RouterDelegate — просто более гибкий и контролируемый инструмент, чем стандартный onGenerateRoute и Observers.
к содержанию ↑Основы Navigator 2.0
Пусть это прозвучит пафосно, но здесь я в «паре абзацев» (на самом деле побольше, конечно) изложу свое виденье основ работы с концепцией Navigator 2.0.
к содержанию ↑RouterDelegate
Чтобы убежденно говорить коллегам что-то типа «я использую Navigator 2.0», достаточно написать свой простой RouterDelegate, в котором переопределить пару методов, написать немного логики работы со страницами и подключить его или через Router или через MaterialApp.router. Наш AppRouterDelegate должен наследоваться от RouterDelegate, а так же примешиваем ему ChangeNotifier и PopNavigatorRouterDelegateMixin.
Главная задача нашего делегейта — управлять состоянием(!) навигации приложения и уведомлять Router, если состояние изменилось.
Обязательно нужно переопределить:
- геттер navigatorKey
@override
final GlobalKey<NavigatorState> navigatorKey;
- метод setNewRoutePath, который на вход получает желаемую конфигурацию стейта и производит различные манипуляции со стеком страниц, в общем случае). Код ниже не правильный (только для демонстрации метода), потому что работает не декларативно, но для случая «я использую Navigator 2.0», в простом навигаторе, можно и что-то подобное сделать ))
@override
Future<void> setNewRoutePath(BookRoutePath configuration) async {
if (kDebugMode) {
print('setNewRoutePath ${configuration.path.path}');
}
final page = switch (configuration.path) {
AppRoutePaths.start => StartPage(),
AppRoutePaths.booksList => BooksPage(books, _handleBookTapped),
AppRoutePaths.bookDetails => BookDetailsPage(book: _selectedBook),
};
_currentConfiguration = configuration;
pages.add(page);
notifyListeners();
}
- ну и главный метод build, который должен вернуть Navigator, в котором выводятся наши страницы, что-то типа такого
@override
Widget build(BuildContext context) {
if (kDebugMode) {
print('pages ${pages.length}');
}
return Navigator(
key: navigatorKey,
pages: pages.isEmpty ? [StartPage()] : List.of(pages.map((e) => e)),
onDidRemovePage: (Page route) {
if (kDebugMode) {
print('Route removed: ${route.toString()}');
}
pages.remove(route);
notifyListeners();
},
);
}
для работы с вебом нужно будет еще переопределить геттер currentConfiguration, но об этом позже
к содержанию ↑RouteInformationParser
Это класс для работы непосредственно с вебовскими урлами. И здесь мы должны переопределить пару методов.
parseRouteInformation — метод получает из строки браузера RouteInformation, который по факту на данный момент состоит только из Uri. Мы должны написать, как этот url «превращается» в состояние приложения (навигации). Дальше это состояние (конфигурация) передается в роутер-делегейт в метод setNewRoutePath.
restoreRouteInformation — обратный, по сути, метод, который из конфигурации (состояния) навигации формирует строку браузера. Если возвращает null, строка браузера не меняется, а значит и «ломается» история.
к содержанию ↑RootBackButtonDispatcher
Класс, который «отлавливает», по сути, нажатие кнопки «назад» в андроид-приложении (не вебовское). В рамках данной статьи не подключаем.
к содержанию ↑RouteInformationProvider
Класс, который призван объединять работу вышеописанных классов, но… и в документации и в других статья описан мутно. В пакете go_router используется, но тоже не совсем прозрачно. Эксперименты показали, что геттер value «перебивает» RouteInformation для парсера, а если в routerReportsNewRouteInformation менять _value на новый, то входим в рекурсию. В общем, на момент написания этой статьи, не разобрался и, вроде, без этого провайдера получилось показать все «прелести» Navigator 2.0 Так что, пишите комментарии, если кто понял, как «это» применить.
к содержанию ↑Навигация
«Соединяя» парсер и сам делегейт мы получим следующую концепцию. Если мы получаем из строки браузера Uri, то мы должны определить на его основе конфигурацию и передать ее в setNewRoutePath. А так же, непосредственно из UI (нажатием кнопки) или из слоя бизнес-логики мы запускаем setNewRoutePath с конфигурацией, которую хотим получить на данный момент. К примеру, открыт экран списка книг и открыт экран одной книги.
В setNewRoutePath из полученной конфигурации мы составляем, самое главное, List страниц и изменяем currentConfiguration, чтобы изменилась строка браузера (если мы в веб-приложении).
Если мы не хотим заморачиваться этими состояниями в UI, то можем реализовать «гибридный» вариант (не чистый декларативный). Добавим в роутер методы addPage и removePage, которые будут формировать, условно, свободное состояние.
к содержанию ↑Реализация Navigator 2.0
Состояния
Для начала нам придется написать все возможные в нашем приложении варианты навигации. Пусть базово это будет класс IAppConfiguration, у которого мы определим метод getPages, в котором для каждого состояния будем прописывать набор нужных нам страниц в стеке. И у нас должны, для начала, быть следующие состояния:
«/» — стартовая страница, которая выводится, пока мы инициализируем приложение
«/login» — страница для логина. В учебном приложении, мы будем авторизовываться просто помещая в «хранилище» признак true.
«/books» — если мы авторизованы, то должны попадать на эту «главную» страницу нашего приложения. Здесь же мы можем сделать разавторизацию.
«/books/book?book=2» — страница конкретной книги, поверх экрана со списком книг
к содержанию ↑Страницы
Для страниц мы сделаем класс IAppPage, у которого определим три геттера:
- page, который возвращает непосредственно Page
- pagePath, это строка для одного сегмента uri. Для парсера.
- queryParameters, набор параметров для данного экрана. Тоже для парсера.
И экранов, как и конфигураций, у нас будет четыре для простоты.
Важное замечание: у Page необходимо обязательно прописывать valueKey (см. в коде), чтобы при переопределении списка страниц они не билдились с нуля каждый раз.
к содержанию ↑AppRouteInformationParser
В методе parseRouteInformation мы будем именно парсить полученный линк и «превращать» его в состояние:
if (uri.pathSegments.length == 2) {
if (uri.pathSegments[1] == PagePath.book.path && uri.pathSegments[0] == PagePath.booksList.path && uri.queryParameters.containsKey('book')) {
return BooksBookAppConfiguration(id: int.parse(uri.queryParameters['book']!));
}
} else if (uri.pathSegments.length == 1) {
final segment0 = uri.pathSegments[0];
if (segment0 == PagePath.booksList.path) {
return BooksListAppConfiguration();
} else if (segment0 == PagePath.login.path) {
return LoginAppConfiguration();
}
}
return StartAppConfiguration();
}
Здесь мы четко фиксируем, какие url легитимны для нас, а для остальных будем отправлять стартовую конфигурацию.
Метод restoreRouteInformation получился более универсальным — подойдет в дальнейшем и для гибридной реализации навигатора. Мы просто от конфигурации получаем страницы и параметры для строки браузера.
к содержанию ↑AppRouterDelegate
Здесь только «хардкор» — только метод setNewRoutePath, который принимает именно конфигурации, достает из них список страниц и заменяет его на новый. Путем замены currentConfiguration «дергает» restoreRouteInformation , чтобы изменилась строка бразуера.
Из UI переход на другую страницу будет выглядеть как декларация нового состояния. К примеру, так:
Expanded(
child: ListView(
children: [
...books.map(
(book) => ListTile(
title: Text(book.title),
subtitle: Text(book.author),
onTap: () => {context.read<AppRouterDelegate>().setNewRoutePath(BooksBookAppConfiguration(id: book.id))},
),
),
],
),
),
к содержанию ↑mainStateListener
Чтобы отслеживать, авторизованы ли мы, вешаем слушатель на mainState. В реальных проектах это будет сделано по иному, здесь же только для демонстрации.
к содержанию ↑Итог
По проекту раскиданы принты в ключевых местах, так что, запускайте, тестируйте различные кейсы и смотрите, как себя ведет наш навигатор.
Можно подключить AppRouteInformationProvider и поэкспериментировать с ним, в том числе.