Ранее вы могли познакомиться с базовым механизмом навигации во Flutter — Navigator. В этой статье мы разберём более сложный декларативный подход к навигации — Navigator 2.0, он же Router.
Он нужен для гибкого управления навигацией в приложении, особенно когда требуется поддержка диплинков, восстановления состояния, сложных навигационных сценариев (например, вложенные маршруты, веб-приложения).
Приступим!
Основы
Начнём с того, что в официальной документации Flutter нет ни одного упоминания термина „Navigator 2.0“. Однако он до сих пор встречается во многих статьях и видеоуроках. Его использовала даже команда Flutter — но затем переименовала в Router.
Поэтому, чтобы не путаться, в этой статье мы будем применять такую терминологию:
-
Navigator 2.0 — впервые представленный во Flutter 2.0 декларативный подход к навигации, позволяющий создавать более сложную навигацию по сравнению с Navigator 1.0.
-
Router
— виджет, основа для навигации в Navigator 2.0 (как виджетNavigator
для Navigator 1.0). -
Page
— иммутабельный объект, который встраивается в стек навигации (наша страница).
С терминами разобрались, можем продолжать. Поговорим о том, почему вообще возникла необходимость в Navigator 2.0.
Зачем это нужно
Вернемся к Navigator 1.0 и разберём основные недостатки данного подхода:
-
Во-первых, стандартный
Navigator
не расширяем. Он предоставляет фиксированный набор методов для навигации — мы не можем добавить свои или поменять существующие. -
Во-вторых, при построении навигации с
Navigator
мы используем императивный подход, прямо говоря, что ему добавить или убрать в стеке страниц. -
В-третьих, существуют проблемы при использовании диплинков (deeplinks) для мобильных приложений и образовании URL-ссылок поисковой строки для веб-приложений. Об этом мы уже рассказывали, ниже коротко напомним суть.
Пример проблемы
Вы захотели поделиться публикацией из профиля своего друга в какой-нибудь социальной сети. Диплинк на данную публикацию имеет следующий вид: /user/{my_friend_id}/post/{post_id}.
Переходя в приложение по подобной ссылке, мы ожидаем попасть на нужную публикацию. При этом, закрыв экран публикации, мы хотим попасть в профиль нашего друга, а не на домашнюю страницу приложения или главный экран устройства.
Реализация подобного поведения при помощи стандартного
Navigator
возможна, однако приводит к созданию множества костылей, так как базовый механизм под такое не заточен.
Router
же, в отличие от Navigator
:
-
расширяемый и конфигурируемый;
-
использует декларативный подход и меняется в зависимости от определенного состояния;
-
способен сразу создавать нужный стек навигации из URL, что полезно при навигации по диплинкам, и наоборот, позволяет собирать URL-ссылку из текущего стека навигации, что полезно для web-приложений.
С мотивацией разобрались. Теперь давайте взглянем на основные сущности Navigator 2.0 и рассмотрим каждую из них подробнее:
-
Router
-
BackButtonDispatcher
-
RouteInformationProvider
-
RouteInformationParser
-
RouterDelegate
-
RouterConfig
Router
Виджет, который отвечает за образование стека страниц Page
навигатора в зависимости от состояния приложения.
Чтобы воспользоваться Router
в проекте, мы можем вызвать соответствующий именованный конструктор MaterialApp.router
:
1void main() {
2 runApp(SomeApp());
3}
4
5class SomeApp extends StatelessWidget {
6 @override
7 Widget build(BuildContext context) {
8 /// Вместо MaterialApp вы также можете использовать
9 /// CupertinoApp и WidgetApp
10 return MaterialApp.router(
11 /// Об этих параметрах речь пойдёт далее
12 routerDelegate: ...,
13 routeInformationParser: ...,
14 routeInformationProvider: ...,
15 backButtonDispatcher: ...,
16 ...
17 );
18 }
19}
20
21
или виджет Router
, если хотим создать поддерево навигации:
1...
2SomeWidget(
3 child: Router(
4 routerDelegate: ...,
5 routeInformationParser: ...,
6 routeInformationProvider: ...,
7 backButtonDispatcher: ...,
8 ),
9),
10...
11
BackButtonDispatcher
Класс, сообщающий Router
через коллбэк, что пользователь нажал на системную кнопку «Назад» на платформах, поддерживающих данную функциональность (например, Android OS). При вызове коллбэка Router
должен передать сообщение в RouterDelegate
на обработку и вернуть в BackButtonDispatcher
результат этой обработки.
Сам параметр backButtonDispatcher
не является обязательным и может быть опущен. По умолчанию используется класс RootBackButtonDispatcher
, который прослушивает ивенты закрытия pop()
от платформы.
Если в приложении есть необходимость использовать несколько диспетчеров, можно использовать ChildBackButtonDispatcher
. Данный диспетчер слушает уведомления от родительского диспетчера и может:
-
перехватывать контроль над обработкой событий (
takePriority
); -
делегировать контроль на дочерний диспетчер (
deferTo
).
Наличие диспетчера не гарантирует доступность действия «назад». Должны также соблюдаться следующие условия:
-
для мобильных платформ — наличие в
pages
больше одной страницы; -
для веба — наличие в истории браузера предыдущих состояний.
RouteInformationProvider
Провайдер путей навигации для виджета Router
. Этот класс — наследник ValueListenable
, в value
которого лежит информация о поступившем пути. Router
использует value
для самого первого построения навигационного стека и далее при работе приложения, передавая информацию на обработку в RouteInformationParser
.
Параметр routeInformationProvider
не обязателен, и в случае с MaterialApp.router
по умолчанию будет использоваться PlatformRouteInformationProvider
. Данная реализация предоставляет в Router
информацию навигации от платформы (например, диплинки), а также информацию о новых путях от Router
обратно в Flutter Engine.
При попытке создать экземпляр PlatformRouteInformationProvider
вручную мы столкнёмся с его обязательным параметром — initialRouteInformation
. Сам параметр — это объект класса RouteInformation
, то есть информация о пути.
Она состоит из строки местоположения приложения (location) и объекта состояния, который конфигурирует приложение в этом месте. RouterInformationProvider
и Router
передают данный объект друг другу при взаимодействии. Также класс может использоваться для сохранения состояния навигации при закрытии приложения.
RouteInformationParser
Компонент, позволяющий Router
парсить поступающую от RouteInformationProvider
информацию о пути в состояние (конфигурацию) навигации. Вся информация, поступающая от RouteInformationProvider
в Router
, предоставляется парсером через метод parseRouteInformation()
.
И наоборот, из Router
в RouteInformationProvider
через метод restoreRouteInformation()
. Оба метода решают проблемы с навигацией по диплинкам и отображением корректного URL в веб-приложении.
Параметр routeInformationParser
не является обязательным и может быть опущен.
Примечание
Более подробный разбор RouteInformationParser
и RouteInformationProvider
, про их взаимодействие друг с другом и с платформой вы можете почитать по ссылке.
RouterDelegate
Класс отвечает за то, как именно Router
узнаёт об изменениях состояния приложения и реагирует на них. Для этого он прослушивает RouteInformationParser
и состояние навигации, а затем встраивает в дерево виджет Navigator
с готовым стеком страниц.
Рассмотрим стандартную реализацию данного класса:
1class SomeAppRouterDelegate extends RouterDelegate<SomeAppRouteConfiguration>{
2 @override
3 void addListener(VoidCallback listener) {
4 // TODO: implement addListener
5 }
6
7 @override
8 Widget build(BuildContext context)
9 // TODO: implement build
10 throw UnimplementedError();
11 }
12
13 @override
14 Future<bool> popRoute() {
15 // TODO: implement popRoute
16 throw UnimplementedError();
17 }
18
19 @override
20 void removeListener(VoidCallback listener) {
21 // TODO: implement removeListener
22 }
23
24 @override
25 Future<void> setNewRoutePath(SomeAppRouteConfiguration configuration) {
26 // TODO: implement removeListener
27 throw UnimplementedError();
28 }
29}
30
31
Обратите внимание
Класс необходимо типизировать. В качестве примера мы использовали придуманное название SomeAppRouteConfiguration. В реальном приложении этот тип должен описывать состояние навигации, с которым необходимо оперировать для построения стека страниц.
Далее мы приведём пример такого состояния.
Разберём методы, которые необходимо переопределить в данном классе:
-
Методы
addListener()
иremoveListener()
предназначены для того, чтобы регистрировать прослушивание уведомлений со стороныRouter
и в дальнейшем сообщать ему об изменении состояния навигации. В большинстве случаев для простоты достаточно подмешать в классChangeNotifier
— и необходимость в ручной реализации этих методов отпадёт.1class SomeAppRouterDelegate extends RouterDelegate<SomeAppRouteConfiguration> with ChangeNotifier { 2 ... 3} 4 5
-
Метод
setNewRoutePath()
вызывается самимRouter
, когда в него поступает новая конфигурация отRouteInformationParser
после обработки полученной изRouteInformationProvider
информации. Метод нужен для реакции самого делегата на такое событие. -
build()
— возвращает виджетNavigator
с готовым списком страниц в зависимости от текущего состояния. При сообщениях от делегатаRouter
вызывает данный метод. Далее полученный в результате выполненияNavigator
встраивается в дерево виджетов. -
popRoute()
— вызывается самимRouter
в тот момент, когдаBackButtonDispatcher
сообщает, что операционная система запрашивает закрытие текущего пути. Сам метод должен возвращатьFuture<bool>
значение и указывать, обработал ли делегат запрос. Возвратfalse
приведёт к сворачиванию всего приложения. Мы также можем избавиться от необходимости имплементировать данный метод, подмешав в наш делегат миксинPopNavigatorRouterDelegateMixin<T>
. Данный миксин сам вызываетNavigator.maybePop()
, но для успешной работы мы обязаны переопределить параметрnavigatorKey
, который будет идентифицировать наш встраиваемый виджетNavigator
.1class SomeAppRouterDelegate extends RouterDelegate<SomeAppRouteConfiguration> with ChangeNotifier, PopNavigatorRouterDelegateMixin<SomeAppRouteConfiguration> { 2 @override 3 final GlobalKey<NavigatorState> navigatorKey; 4 5 @override 6 Widget build(BuildContext context) => Navigator( 7 key: navigatorKey, 8 ... 9 ); 10} 11 12
-
Также стоит упомянуть о необязательном для реализации методе
setInitialRoutePath()
. Этот метод вызывается виджетомRouter
после получения информации о первоначальном путиinitalRouteInformation
.
Таким образом, данный класс является основой навигации при использовании Router
. Переопределяя перечисленные методы, мы задаём управление навигацией в приложении.
Взаимодействие компонент
Разобрав основные компоненты представленного API Navigator 2.0, рассмотрим схему их взаимодействия.
Какие выводы тут можно сделать:
-
AppState
— состояние приложения (фичи/компоненты/сущности), которое определяет стек навигации. -
BackButtonDispatcher
,RouteInformationProvider
иRouteInformationParser
нужны для общения с платформой (в общем случае). -
Можно провести аналогию с виджетами во Flutter:
Router
— этоStatefulWidget
,RouterDelegate
—State
этого виджета.
RouterConfig
Это интерфейс, который позволяет инкапсулировать создание раннее перечисленных компонент в единую сущность и передавать их в Router
как один объект.
Вот пример из известной библиотеки навигации go_router
, где сам класс GoRouter
— это наследник данного интерфейса. По ссылке можно посмотреть пример реализации конфига.
Для использования конфига применяются именованные конструкторы Router.withConfig
или MaterialApp.router
:
1void main() {
2 runApp(SomeApp());
3}
4
5class SomeApp extends StatelessWidget {
6 @override
7 Widget build(BuildContext context) {
8 /// Вместо MaterialApp вы также можете использовать
9 /// CupertinoApp и WidgetApp
10 return MaterialApp.router(
11 routerConfig: SomeAppRouterConfig(
12 routerDelegate: ...,
13 routeInformationParser: ...,
14 routeInformationProvider: ...,
15 backButtonDispatcher: ...,
16 ),
17 ...,
18 );
19 }
20}
21
22
Отлично, с основами разобрались. Теперь давайте посмотрим, как использовать Navigator 2.0 на практике.
Пример реализации
В параграфе о Navigator 1.0 мы приводили пример простого приложения, состоящего из нескольких экранов. Давайте попробуем переписать навигацию такого приложения на Navigator 2.0.
Конфигурация страницы
В нашем примере у приложения есть три экрана. Для удобства заведём enum
, в котором будут храниться все возможные типы страниц приложения. В качестве параметра используем строковый вид пути, который сможем использовать и для сравнения страниц между собой, и для навигации по диплинкам.
1/// Enum-тип для экранов приложения.
2enum AppPage {
3 /// Неизвестный корневой путь. По дефолту в данном примере будем производить
4 /// навигацию на страницу А.
5 unknown('/'),
6
7 /// Тип для страницы A.
8 pageA('page_A'),
9
10 /// Тип для страницы B.
11 pageB('page_B'),
12
13 /// Тип для страницы C.
14 pageC('page_C');
15
16 /// Параметр, позволяющий хранить знание о названии пути определённой
17 /// страницы.
18 final String pathName;
19
20 const AppPage(this.pathName);
21
22 /// Геттер, создающий так называемый location, который используется в
23 /// [RouteInformation].
24 String get location => this == AppPage.unknown ? pathName : '/$pathName';
25}
26
В определённых случаях, помимо типа и пути, страницы могут хранить аргументы навигации. Также может понадобиться несколько способов создания модели страницы и удобный способ маппинга строковых путей обратно в enum
-значения.
Для подобных случаев удобно использовать самописную конфигурацию страницы:
1/// Конфигурация базового экрана. Содержит в себе enum-тип экрана и аргумент,
2/// опциональный при создании конфигурации.
3class PageConfiguration {
4 late final AppPage page;
5
6 /// Note: хорошим тоном будет использование Map<String, Object?> для
7 /// возможности прокидывать сразу несколько параметров и обращаться к ним
8 /// через ключи. Здесь используется Object? для облегчения примера.
9 final Object? argument;
10
11 /// Вспомогательный словарь, позволяющий получать enum-тип из строкового
12 /// названия пути.
13 static final Map<String, AppPage> _pagePathsMap = {
14 AppPage.unknown.pathName: AppPage.unknown,
15 AppPage.pageA.pathName: AppPage.pageA,
16 AppPage.pageB.pathName: AppPage.pageB,
17 AppPage.pageC.pathName: AppPage.pageC,
18 };
19
20 PageConfiguration({
21 required this.page,
22 this.argument,
23 });
24
25 /// Дополнительный именованный конструктор, позволяющий создать страницу
26 /// через строковый путь.
27 PageConfiguration.fromPath({
28 required String pathName,
29 this.argument,
30 }) {
31 page = _resolvePage(pathName);
32 }
33
34 /// Переопределяем [hashCode] и оператор сравнения для корректного сравнения
35 /// различных [PageConfiguration].
36 @override
37 int get hashCode => Object.hash(page, argument);
38
39 @override
40 bool operator ==(Object other) =>
41 other is PageConfiguration &&
42 runtimeType == other.runtimeType &&
43 page == other.page &&
44 argument == other.argument;
45
46 /// Вспомогательный метод, производящий маппинг строкового пути к enum-типу
47 /// страницы.
48 static AppPage _resolvePage(String? path) {
49 return _pagePathsMap[path] ?? AppPage.unknown;
50 }
51}
52
Состояние навигации
Далее займёмся состоянием навигации. В данном случае нужно хранить структуру, позволяющую быстро преобразовать её в список PageConfiguration
и удобно им оперировать. Используем собственную реализацию структуры данных «Стек» с обобщённым типом:
1/// Реализация стека на основе [List], которая и будет нашей конфигурацией
2/// навигации в приложении. Для своего приложения вы можете создать своё
3/// состояние.
4class RouterPagesStack<T> {
5 final List<T> _stack;
6
7 RouterPagesStack(this._stack);
8
9 List<T> get pages => _stack;
10
11 int get length => _stack.length;
12
13 T get last => _stack.last;
14
15 void pop() {
16 _stack.removeLast();
17 }
18
19 void push(T value) {
20 _stack.add(value);
21 }
22}
23
На основе RouterPagesStack
можно создать конфигурацию приложения. Пример — AppPagesConfig
, конфигурация навигации в приложении, которым ниже будет обобщён делегат навигации.
1/// Наша конфигурация, обобщённая под использование [PageConfiguration].
2class AppPagesConfig extends RouterPagesStack<PageConfiguration> {
3 AppPagesConfig(super.stack);
4}
5
Управление состоянием
Для хранения состояния навигации и управления им классу AppPagesConfig
нужен менеджер, инкапсулирующий само состояние и позволяющий подписаться на изменения состояния. Опишем интерфейс такого менеджера:
1/// Абстракция над менеджером для хранения конфигурации навигации
2/// [AppPagesConfig] и управления ею. Интерфейс имплементирует [Listenable]
3/// для возможности подписки на состояние.
4///
5/// Зададим только 3 метода, необходимых в примере. При необходимости
6/// можно дополнять данный интерфейс другими методами, такими как
7/// pushReplacement, popUntil и т. д.
8abstract class RouterPagesManager implements Listenable {
9 /// Текущие страницы стека навигации.
10 List<PageConfiguration> get pages;
11
12 /// Геттер, возвращающий значение о том, возможно ли произвести [pop].
13 bool get canPop;
14
15 /// Удаление последней страницы из стека навигации.
16 void pop();
17
18 /// Добавление нового роута в стек навигации.
19 void push(
20 AppPage page, {
21 Object? argument,
22 });
23}
24
Для имплементации будем использовать стандартные компоненты Flutter, а именно ChangeNotifier
. В своём примере вы можете использовать любой другой подход для управления состоянием.
1class RouterPagesManagerImpl extends ChangeNotifier implements RouterPagesManager {
2 late AppPagesConfig _pagesConfig;
3
4 RouterPagesManagerImpl() {
5 _pagesConfig = AppPagesConfig([PageConfiguration(page: AppPage.pageA)]);
6 }
7
8 @override
9 List<PageConfiguration> get pages => _pagesConfig.pages;
10
11 @override
12 bool get canPop => _pagesConfig.length > 1;
13
14 @override
15 void pop() {
16 // Убедимся, что мы можем безопасно произвести операцию.
17 if (!canPop) {
18 return;
19 }
20
21 _pagesConfig.pop();
22
23 // Сообщаем слушателю об удалении пути из стека.
24 notifyListeners();
25 }
26
27 @override
28 void push(
29 AppPage page, {
30 Object? argument,
31 }) {
32 // Убедимся, что не пытаемся встроить в стек тот же самый путь.
33 if (page == _pagesConfig.last.page) {
34 return;
35 }
36
37 final configuration = PageConfiguration(
38 page: page,
39 argument: argument,
40 );
41 _pagesConfig.push(configuration);
42
43 // Сообщаем слушателю о добавлении пути из стека.
44 notifyListeners();
45 }
46}
47
Инъекция зависимости
Для того чтобы будущий делегат навигации получил доступ к состоянию, хранимому в RouterPagesManager
, необходимо передать его в качестве зависимости. Как и в случае с самим менеджером, в данном примере предлагаем использовать стандартные компоненты Flutter, а именно InheritedWidget
.
1class _RouterScopeInherited extends InheritedWidget {
2 /// Используем интерфейс менеджера для соблюдения инверсии зависимостей.
3 final RouterPagesManager pagesManager;
4
5 const _RouterScopeInherited({
6 required this.pagesManager,
7 required super.child,
8 });
9
10 static RouterPagesManager of(
11 BuildContext context, {
12 bool listen = false,
13 }) {
14 final manager = maybeOf(context);
15 return ArgumentError.checkNotNull(manager);
16 }
17
18 static RouterPagesManager? maybeOf(
19 BuildContext context, {
20 bool listen = false,
21 }) {
22 if (listen) {
23 return context
24 .dependOnInheritedWidgetOfExactType<_RouterScopeInherited>()
25 ?.pagesManager;
26 } else {
27 return (context
28 .getElementForInheritedWidgetOfExactType<_RouterScopeInherited>()
29 ?.widget as _RouterScopeInherited?)
30 ?.pagesManager;
31 }
32 }
33
34 @override
35 bool updateShouldNotify(_RouterScopeInherited oldWidget) =>
36 oldWidget.pagesManager != pagesManager;
37}
38
Так как реализация менеджера навигации является наследником класса ChangeNotifier
, после использования стоит вызвать его метод dispose()
. Помним, что в основе Navigator 2.0 лежит декларативный подход, поэтому продолжим следовать ему и свяжем создание экземпляра менеджера с построением дерева виджетов.
Для этого реализуем скоуп зависимостей через StatefulWidget
, который будет управлять жизненным циклом менеджера навигации и позволит получить к нему доступ вниз по дереву.
1class RouterScope extends StatefulWidget {
2 final Widget child;
3
4 const RouterScope({
5 required this.child,
6 Key? key,
7 }) : super(key: key);
8
9 @override
10 State<RouterScope> createState() => _RouterScopeState();
11
12 /// Данный метод используется в деревьях, обёрнутых в сам скоуп.
13 ///
14 /// Даёт доступ к интерфейсу менеджера навигации.
15 static RouterPagesManager managerOf(
16 BuildContext context, {
17 bool listen = false,
18 }) =>
19 _RouterScopeInherited.of(context, listen: listen);
20}
21
22class _RouterScopeState extends State<RouterScope> {
23 late final RouterPagesManagerImpl manager;
24
25 @override
26 void initState() {
27 super.initState();
28 // Создаём экземпляр менеджера при встраивании в дерево.
29 manager = RouterPagesManagerImpl();
30 }
31
32 @override
33 void dispose() {
34 // Вызываем диспоуз менеджера при удалении из дерева.
35 manager.dispose();
36 super.dispose();
37 }
38
39 @override
40 Widget build(BuildContext context) => _RouterScopeInherited(
41 pagesManager: manager,
42 child: widget.child,
43 );
44}
45
Управление состоянием и доступ к нему готовы, теперь можно приступить к реализации самой навигации.
Делегат навигации
Воспользуемся полученными знаниями и замешаем в делегат ChangeNotifier
и PopNavigatorRouterDelegateMixin
. Сам делегат будет оперировать созданным раннее AppPagesConfig
.
1class AppRouterDelegate extends RouterDelegate<AppPagesConfig>
2 with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppPagesConfig> {
3 @override
4 final GlobalKey<NavigatorState> navigatorKey;
5
6 /// Прокидываем в качестве зависимости наш менеджер для доступа к текущему
7 /// состоянию конфигурации [AppPagesConfig] и методам менеджера.
8 final RouterPagesManager manager;
9
10 AppRouterDelegate({
11 required this.navigatorKey,
12 required this.manager,
13 }) {
14 // Подписываемся на изменения нашего хранителя состояния и производим
15 // перестроение навигации, если что-то поменялось.
16 manager.addListener(notifyListeners);
17 }
18
19 @override
20 void dispose() {
21 manager.removeListener(notifyListeners);
22 super.dispose();
23 }
24
25 @override
26 Widget build(BuildContext context) => Navigator(
27 key: navigatorKey,
28 pages: _resolvePages(manager.pages).toList(growable: false),
29 onPopPage: _onPopPage,
30 );
31
32 /// Генератор, предназначенный для маппинга текущего списка конфигурации в
33 /// соответствующие экраны. Используем стандартную обёртку [MaterialPage].
34 ///
35 /// Note: вы вольны создать свой/свои наследник(-и) [Page], чтобы
36 /// кастомизировать поведение экранов при встраивании в стек навигации,
37 /// например, [PageTransitionsBuilder].
38 Iterable<Page> _resolvePages(List<PageConfiguration> configs) sync* {
39 for (final config in configs) {
40 switch (config.page) {
41 case AppPage.unknown:
42 yield MaterialPage(child: const PageA(), arguments: config.argument);
43 break;
44 case AppPage.pageA:
45 yield MaterialPage(child: const PageA(), arguments: config.argument);
46 break;
47 case AppPage.pageB:
48 yield MaterialPage(child: const PageB(), arguments: config.argument);
49 break;
50 case AppPage.pageC:
51 yield MaterialPage(child: const PageC(), arguments: config.argument);
52 break;
53 }
54 }
55 }
56
57 bool _onPopPage(Route<Object?> route, Object? result) {
58 // Не даём закрыть единственную страницу в стеке навигации.
59 if (route.isFirst && route.isActive) {
60 return false;
61 }
62
63 // Для объяснения данного действия рекомендуем посмотреть этот комментарий
64 // на stackoverflow: <https://stackoverflow.com/a/65810416>
65 if (!route.didPop(result)) {
66 return false;
67 }
68
69 // Производим pop с помощью нашего [RouterPagesNotifier], если можем.
70 manager.pop();
71 return true;
72 }
73}
74
В таком виде делегат готов к использованию. Однако стоит учитывать следующее: начиная с версии Flutter v3.16.0-17.0.pre
параметр onPopPage
класса Navigator
помечен как deprecated. Вместо него рекомендуется использовать параметр onDidRemovePage
.
Данный параметр предоставляет коллбэк, когда страница убрана из стека страниц навигатора. Стоит брать во внимание, что подобное поведение может произойти не только при закрытии страницы, но и при её замене. В таком случае код Navigator
изменится на следующий вид:
1 @override
2 Widget build(BuildContext context) => Navigator(
3 key: navigatorKey,
4 pages: _resolvePages(manager.pages).toList(growable: false),
5 onDidRemovePage: (page) {
6 // В данном случае нам понадобится дополнительный метод,
7 // который уберёт из стека навигации соответствующую
8 // страницу.
9 //
10 // Если этого не сделать, при перестроении навигатора
11 // страница будет встроена заново.
12 manager.removePageByName(page.name);
13 },
14 );
15
Важно понимать, что:
-
Этот коллбэк не предоставляет доступ к результату закрытия страницы и не позволяет его переопределить, о чем подробнее можно прочитать тут.
-
Коллбэк вызывается внутри
build
уже при перестроении, то есть он сам не должен вызывать дополнительное изменение состояния виджета (setState
). Поэтому в реализации методаmanager.removePageByName()
не должно быть вызоваnotifyListeners()
, иначе страница будет перестроена дважды.
Чтобы переопределить поведение вызова Navigator.pop
, стоит:
-
установить
canPop: true
уPage
, которую мы добавляем в список страниц нашегоNavigator
; -
или обернуть страницу в
PopScope
и переопределить поведение через него.
Навигация по приложению
После создания делегата навигация для приложения почти готова. Остаётся только встроить Router
в дерево виджетов, обернуть его в скоуп RouterScope
с менеджером RouterPagesManager
и собрать экраны. Конечная архитектура взаимодействия компонент приложения получается следующая:
Для встраивания Router
в дерево используем именованный конструктор MaterialApp.router
. Файл main.dart
выглядит так:
1import 'package:flutter/material.dart';
2import 'package:navigation_example/src/navigation/router/route_information_parser.dart';
3
4import 'src/navigation/router/router_delegate.dart';
5import 'src/navigation/state/router_scope.dart';
6
7/// Глобальный ключ навигации, который прокинем в наш [AppRouterDelegate].
8final GlobalKey<NavigatorState> _navigatorKey = GlobalKey();
9
10void main() {
11 // Оборачиваем дерево в скоуп для доступа к менеджеру навигации ниже
12 // по дереву.
13 runApp(const RouterScope(child: MyApp()));
14}
15
16class MyApp extends StatelessWidget {
17 const MyApp({super.key});
18
19 @override
20 Widget build(BuildContext context) {
21 // Получаем доступ к менеджеру из созданного скоупа.
22 final pagesManager = RouterScope.managerOf(context, listen: true);
23
24 // Используем для создания именованный конструктор [MaterialApp].
25 return MaterialApp.router(
26 debugShowCheckedModeBanner: false,
27 // Задаём свой [RouterDelegate]
28 routerDelegate: AppRouterDelegate(
29 navigatorKey: _navigatorKey,
30 manager: pagesManager,
31 ),
32 );
33 }
34}
35
Далее используем те же страницы, которые вы могли видеть в статье про базовую навигацию.
1import 'package:flutter/material.dart';
2
3import '../navigation/config/page_config.dart';
4import '../navigation/state/router_scope.dart';
5
6abstract class YandexColors {
7 static const red = Color(0xFFFF2C00);
8 static const green = Color(0xFF00DA72);
9 static const blue = Color(0xFF4042EE);
10}
11
12class PageA extends StatelessWidget {
13 const PageA({super.key});
14
15 @override
16 Widget build(BuildContext context) {
17 return Scaffold(
18 appBar: AppBar(
19 title: const Text('Page A'),
20 backgroundColor: YandexColors.blue,
21 ),
22 body: Center(
23 child: TextButton(
24 child: const Text('Go to «Page B»'),
25
26 // Здесь обратим внимание, что через скоуп мы обращаемся к интерфейсу менеджера
27 // навигации и пушим новые страницы, тем самым меняя состояние навигации.
28 onPressed: () => RouterScope.managerOf(context).push(AppPage.pageB),
29 ),
30 ),
31 );
32 }
33}
34
35class PageB extends StatefulWidget {
36 const PageB({super.key});
37
38 @override
39 State<PageB> createState() => _PageBState();
40}
41
42class _PageBState extends State<PageB> {
43 final _textFieldController = TextEditingController(text: '');
44
45 @override
46 void dispose() {
47 _textFieldController.dispose();
48 super.dispose();
49 }
50
51 @override
52 Widget build(BuildContext context) {
53 return Scaffold(
54 appBar: AppBar(
55 title: const Text('Page B'),
56 backgroundColor: YandexColors.green,
57 ),
58 body: SizedBox.expand(
59 child: Column(
60 mainAxisAlignment: MainAxisAlignment.center,
61 crossAxisAlignment: CrossAxisAlignment.center,
62 children: [
63 Padding(
64 padding: const EdgeInsets.only(left: 32, right: 32, bottom: 16),
65 child: TextField(
66 controller: _textFieldController,
67 decoration: const InputDecoration(hintText: 'Enter your name'),
68 ),
69 ),
70 const SizedBox(height: 16),
71 TextButton(
72 child: const Text('Go back'),
73 onPressed: () => RouterScope.managerOf(context).pop(),
74 ),
75 const SizedBox(height: 16),
76 TextButton(
77 child: const Text('Go to «Page C»'),
78
79 // Здесь в качестве аргумента встраиваемой в стек страницы передаём
80 // значение из [TextEditingController], чтобы получить его на странице С.
81 onPressed: () => RouterScope.managerOf(context).push(
82 AppPage.pageC,
83 argument: _textFieldController.text,
84 ),
85 ),
86 ],
87 ),
88 ),
89 );
90 }
91}
92
93class PageC extends StatelessWidget {
94 const PageC({super.key});
95
96 @override
97 Widget build(BuildContext context) {
98 // Получаем аргумент из навигации, может быть nullable, поэтому будем
99 // внимательны при typecast.
100 final userName = ModalRoute.of(context)?.settings.arguments as String?;
101
102 return Scaffold(
103 appBar: AppBar(
104 title: const Text('Page C'),
105 backgroundColor: YandexColors.red,
106 ),
107 body: Center(
108 child: Text('Hello, $userName'),
109 ),
110 );
111 }
112}
113
Таким образом, мы готовы протестировать навигацию и получаем следующее поведение:

Дополнительные возможности Navigator 2.0
Есть ещё пара вещей, на которых хотим заострить внимание.
Так как Router
позволяет встраивать в дерево виджетов свой Navigator
и свои Page
, то вы можете настроить их параметры самостоятельно. Например, создать свой TransitionDelegate
. Это делегат, определяющий поведение перехода между страницами в моменты открытия и закрытия.
По умолчанию в Navigator
установлен DefaultTransitionDelegate
, который следует двум правилам:
-
Все входящие страницы размещаются поверх уже существующих.
-
Самая верхняя страница всегда анимированно встраивается/выходит из стека страниц. Данное поведение можно изменить, реализуя свой делегат и предоставляя его в
Navigator
. Пример можно посмотреть в документации.
Так же вы можете реализовать свои классы страниц Page
и PageRoute
, что, в свою очередь позволяет создавать свою анимацию для страниц, её продолжительность, кривую и многое другое. Возможности ограничены только фреймворком.
Диплинки
Как мы сказали выше, одно из преимуществ Navigator 2.0 — это удобная работа с диплинками. Подробнее об этом мы рассказали в отдельном параграфе. Здесь же просто оставим ссылку на полную версию кода приложения с реализованной обработкой диплинков.
Важно: это не лучшие практики с рынка, а учебный пример для иллюстрации.
Достоинства и недостатки Navigator 2.0
Как вы могли убедиться, Navigator 2.0 — это довольно мощный инструмент для навигации. И, как у любого инструмента, у него есть и плюсы, и минусы, определяющие ограничения для его области применения.
Начнём с плюсов:
-
Навигация становится гибкой, мы сами определяем, как и когда она меняется.
-
Состояние навигации можно сохранять и восстанавливать, что полезно для веб-приложения.
-
Декларативный подход к навигации возвращает нас к Flutter-way, код становится более консистентным и понятным.
-
Явная работа с диплинками.
-
API позволяет применить любые доступные решения в управлении состоянием (state managment) и инъекции зависимостей (dependency injection). Мы можем использовать стандартные подходы Flutter или интегрировать готовые решения с pub.dev.
Помимо плюсов, у механизма также есть свои минусы, которые стоит учитывать перед началом разработки:
-
много кода, что для простых и базовых сценариев навигации может быть избыточным;
-
довольно нетривиальный API, с которым явно сложнее разобраться, чем с Navigator 1.0;
-
мало хороших примеров использования, так как механизм сложный и редко применяется в чистом низкоуровневом виде.
Navigator 2.0 относительно редко применяется в чистом виде. Зато он лежит в основе многих популярных пакетов для навигации (примеры будут ниже). Чтобы сравнить разные подходы к навигации, мы подготовили таблицу-шпаргалку:
Navigator 1.0 |
Navigator 2.0 |
Пакеты с Navigator 2.0 |
|
API |
Императивный |
Декларативный |
Зависит от реализации в пакете |
Многоуровневая вложенная навигация |
Частично |
Есть |
Есть |
Нативная поддержка |
Частично |
Есть |
Есть |
Расширяемость |
Нет |
Есть |
Есть |
Порог входа |
Низкий |
Высокий |
Средний |
Назначение |
Быстрая и простая реализация переходов между экранами |
Полностью контролируемая навигация с наибольшей гибкостью |
Декларативный подход с бо́льшими возможностями, чем у Navigator 1.0, но меньшим количеством кода и более высокоуровневым API, чем у чистого Navigator 2.0 |
Пакеты с pub.dev
Если вы не хотите писать чистую низкоуровневую навигацию, но при этом хотите использовать декларативный подход, существует множество пакетов с pub.dev, которые могут помочь в этом.
Советуем обратить внимание на следующие:
-
go_router. Официально поддерживаемая библиотека для декларативной навигации в Flutter от Google. Как было отмечено ранее, основой навигации в библиотеке является наследник класса
RouterConfig
- классGoRouter
, и его компонентыGoRoute
- классы, хранящие в себе путь к страницеpath
иbuilder
функцию для создания виджета самой страницы. Также библиотека поддерживает вложенную навигацию, которая представляется какShellRoute
— специальная реализацияGoRoute
, которая включает собственное дерево виджетов с навигацией. -
auto_route. Библиотека, позволяющая реализовать навигацию при помощи кодогенерации. Это позволяет создать строго типизированные классы страниц, что гарантирует безопасность во время компиляции приложения. Библиотека также предоставляет возможности для реализации сложной вложенной навигации.
-
Beamer. Предоставляет разработчику низкоуровневый контроль над состоянием навигации, что делает его отличным выбором для сложных приложений, требующих кастомизированных решений и детализированных настроек. Поддерживает паттерн „Navigator-to-page“, где каждый экран соответствует навигатору, что помогает реализовывать многослойную вложенную маршрутизацию.
-
routemaster. Предлагает простое и гибкое решение для навигации с возможностью глубоких ссылок и анимации переходов. Библиотека позволяет описывать маршруты прямо в коде без необходимости в дополнительных файлах конфигурации или генерации. Она хорошо подходит для структурированной маршрутизации и активно поддерживается сообществом.
Полезные ссылки
Если вы хотите ещё глубже погрузиться в тему, то вот документация Flutter для основных компонент и механизма:
Также обращаем внимание на инструменты, которые использовались в статье, но не являются частью самой темы: