Ранее вы могли познакомиться с базовым механизмом навигации во 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:

  1. расширяемый и конфигурируемый;

  2. использует декларативный подход и меняется в зависимости от определенного состояния;

  3. способен сразу создавать нужный стек навигации из 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, RouterDelegateState этого виджета.

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

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

gif0.gif

Дополнительные возможности 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

Императивный

Декларативный

Зависит от реализации в пакете

Многоуровневая вложенная навигация

Частично

Есть

Есть

Нативная поддержка
Deeplink и Web URL

Частично

Есть

Есть

Расширяемость

Нет

Есть

Есть

Порог входа

Низкий

Высокий

Средний

Назначение

Быстрая и простая реализация переходов между экранами

Полностью контролируемая навигация с наибольшей гибкостью

Декларативный подход с бо́льшими возможностями, чем у 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 для основных компонент и механизма:

Также обращаем внимание на инструменты, которые использовались в статье, но не являются частью самой темы:

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

Отмечайте параграфы как прочитанные, чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф5.6. IoC Provider и его использование во Flutter
Следующий параграф6.1. Channels