Hola, Amigos! Меня зовут Тимур Моисеев, я — руководитель мобильной разработки в Amiga. В разработке я уже более 20 лет, а последние 4 года плотно занимаюсь мобильной разработкой на фреймворке Flutter. Сегодня хочу поднять тему вложенной навигации во Flutter.

Тут документация, как Google понимает особенности навигации между страницами приложения.

Если рассматривать сложность данного вопроса, то, скорее всего, это задача со звездочкой.

Данная задача относится к теме декларативного роутинга в приложении. Если кратко, то декларативный роутинг дает возможность заменить весь стек навигатора в одно действие. 

Например, у вас был путь:

/homePage/catalog/itemsPage

И вам нужно перейти в ветку:

/homePage/pfile/payHistory

Но для начала давайте немного определимся с понятиями. 

Почему мы говорим Routing, а не Navigator? 

Все дело в том, что во Flutter это два разных понятия. Navigator представляет собой старый добрый механизм навигации (PUSH / POP и т.п.), то есть вы говорите навигатору «добавь страницу такую-то», «вернись назад» и т.п. Если в вашем приложении достаточно этого функционала, то посмотрите статью как возможность использовать в новом проекте. 

Но мы все чаще сталкиваемся с ситуациями, когда даже в небольших проектах обычного навигатора недостаточно. 

Так вот Routing — это заход команды Flutter на территорию декларативной навигации. Когда вы прописываете все возможные пути заранее, а в run time просто указываете роутеру (router) какую страницу вам требуется показать именно сейчас.

Для тех кому интересна подноготная как все устроено, то вот вам ссылка на техническую документацию команды Flutter разработки по данной теме.

Не буду вдаваться в подробности, как это все устроено под капотом. Есть статья на эту тему.

Декларативный Router (навигация) позволяет решить главную задачу — управление стеком навигации. Поднималась эта задача много раз, копья ломались чуть ли не с первого стабильного релиза Flutter. Есть много различных решений, которые позволяют реализовать данный функционал. Вы даже можете самостоятельно написать свой пакет, который позволит вам досконально контролировать работу роутера. 

В частности, есть пакет auto_route. Главная особенность его работы: он генерирует навигационные файлы, которые используются в последствии. 

Мы в своей работе остановились на разработке flutter.dev — это пакет go_router. У данного пакета было две главных проблемы:

  1. Отсутствие возможности выполнить var res = await context.push('/homePage'). Приходилось изобретать велосипед, чтобы передавать результаты выполнения страницы или использовать классический навигатор Navigator.of(context). И данный функционал уже реализован!

  2. И не менее важно то, что go_router не умел сохранять стек навигатора, когда вам требовалось использовать вложенную навигацию.

Лично мое мнение, что вложенная навигация это не очень хорошо. Пользователь путается, интерфейс перегружается, а на слабых устройствах могут быть подтормаживания, т.к. это всё висит в памяти устройства.

Go_router реализовал подобный функционал буквально недавно. И так как проект с открытым исходным кодом, реализован он сторонним разработчиком. Огромное спасибо ему за напор и веру :) 

Для интересующихся можно посмотреть, как шел процесс принятия данного PR.

Ради справедливости стоит сказать, что вложенная навигация появилась в пакете go_router начиная с версии пакета 4.5.0, где-то в ноябре 2022г., но он не поддерживал сохранение состояний при переключении между вкладками. Такой «чемодан без ручки». Формально, вложенная навигация есть, но без костылей ей пользоваться не захочешь.

Как используется GoRouter? 

Наверное, хватит вводной части, давайте попробуем разобраться, как же стало просто и легко использовать пакет для реализации вложенной навигации в своем проекте.

Первое что стоит сделать — это указать в проекте конструктор MaterialApp.router() вместо обычного MaterialApp().

class MyApp extends StatelessWidget {
  MyApp({super.key});

  final GoRouter _router = GoRouter(
    initialLocation: '/main',
    routes: [],
    redirect: (BuildContext context, state) {
      return null;
    },
  );

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      routerConfig: _router,
    );
  }
}

Новый конструктор представляет возможность подключения конфигуратора роутера. Данный конфигуратор и управляет роутингом в приложении. 

const RouterConfig({
    this.routeInformationProvider,
    this.routeInformationParser,
    required this.routerDelegate,
    this.backButtonDispatcher,
}) : assert((routeInformationProvider == null) == (routeInformationParser == null));

Ранее была ссылка на всю подноготную этого подхода. Рекомендую к ознакомлению.

Теперь нам требуется определить сам объект GoRouter, который и будет содержать в себе все возможные пути навигации. Не лезем в то, как он устроен, просто попробуем указать нужные параметры этого «магического» объекта.

В конструкторе GoRouter есть обязательный параметр «routes» – это список роутов, его-то и нужно заполнить.

Корневым элементом списка должен быть объект: StatefulShellRoute.indexedStack

Вся логика хранения состояний открытых страниц реализована в StatefulShellRoute.indexedStack. Благодаря ему, при переключении между вкладками, не происходит сбрасывание состояния страниц.

Вот так это выглядит в сборе:

final GoRouter _router = GoRouter(
    initialLocation: '/main',
    routes: [
      StatefulShellRoute.indexedStack(
        builder:
            (BuildContext context, GoRouterState state, StatefulNavigationShell navigationShell) {
          return ScaffoldNavBar(navigationShell: navigationShell);
        },
        branches: const <StatefulShellBranch>[],
      )
    ],
);

Вы видите, что StatefulShellRoute.indexedStack в себя принимает два параметра:

  • required List<StatefulShellBranch> branches

  • Widget Function(BuildContext, GoRouterState, StatefulNavigationShell)? builder

Конструктор принимает больше параметров, но для нашей задачи ограничимся этими двумя.

С параметром branches все понятно, он содержит в себе список веток, внутри которых и между которыми происходит навигация.

Что такое builder и для чего он используется?

Вы видите, что builder — функция, которая должна возвращать виджет. При этом кроме context в ней есть еще GoRouterState, которая содержит информацию о текущем состоянии роутера, текущий URI. А в StatefulNavigationShell передается уже страница, которую необходимо отобразить внутри стека вложенной навигации.

И builder должен вернуть это всё в том виде, как вы заходите. В нашем примере это страница с названием ScaffoldNavBar, на ней и расположен нижний бар навигации.

class ScaffoldNavBar extends StatelessWidget {
  const ScaffoldNavBar({
    required this.navigationShell,
    Key? key,
  }) : super(key: key ?? const ValueKey<String>('ScaffoldNavBar'));

  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Main'),
          BottomNavigationBarItem(icon: Icon(Icons.work), label: 'Tab b'),
        ],
        currentIndex: navigationShell.currentIndex,
        onTap: (int index) => _onTap(context, index),
      ),
    );
  }

  void _onTap(BuildContext context, int index) {
    navigationShell.goBranch(
      index,
      initialLocation: index == navigationShell.currentIndex,
    );
  }
}

Надеюсь, я вас окончательно запутал :)

Схематично можно представить это в таком виде:

Осталось разобраться со свойством branches — это список возможных путей навигации для нашего приложения. Особенностью является то, что мы можем задать древовидную структуру навигации.

Так это выглядит в коде:

final GoRouter _router = GoRouter(
    initialLocation: '/main',
    routes: [
        StatefulShellRoute.indexedStack(
        builder:
            (BuildContext context, GoRouterState state, StatefulNavigationShell navigationShell) {
            return ScaffoldNavBar(navigationShell: navigationShell);
        },
        branches: <StatefulShellBranch>[
            StatefulShellBranch(
            routes: <RouteBase>[
                GoRoute(
                path: '/main',
                builder: (BuildContext context, GoRouterState state) => const MyHomePage(),
                routes: <RouteBase>[
                    GoRoute(
                    path: 'details',
                    builder: (BuildContext context, GoRouterState state) =>
                        const DetailPage('from Main Page'),
                    ),
                ],
                ),
            ],
            ),
            StatefulShellBranch(
            routes: <RouteBase>[
                GoRoute(
                path: '/tabB',
                builder: (BuildContext context, GoRouterState state) => const TabB(),
                routes: <RouteBase>[
                    GoRoute(
                    path: 'details',
                    builder: (BuildContext context, GoRouterState state) =>
                        const DetailPage('from Tab B'),
                    ),
                ],
                ),
            ],
            ),
        ],
        )
    ],
);

В итоге мы создали две вкладки со страницами детализации. Но основной принцип создания путей в приложении выглядит примерно так.

Вот так это выглядит в приложении:

Как выполнять команды для навигации между страницами

Тут подход go_router немного ломает мозг :) Разработчики пакета рекомендуют использовать команду go().

В коде это вызов выглядит следующим образом:

InkWell(
        onTap: () => context.go('/tabB/details'),
        child: Padding(
        padding: const EdgeInsets.all(20),
        child: Text('item: $index'),
    ),
);

Вам достаточно вызвать функцию go() и в параметрах указать путь навигации. Также у данной функции есть возможность передать аргументы, если необходимо.

Разработчики пакета утверждают, что функция достаточно «умная», чтобы понимать, что требуется сделать без вызова классических PUSH, POP и т.п. Хотя эти функции навигации в пакете тоже представлены. В любом случае рекомендую вам поэкспериментировать с данной функцией. 

На этом всё, надеюсь, что мой труд не прошел даром, и данная статья позволит вам сэкономить время при реализации ваших проектов.

Еще в репозитории библиотеки есть рабочий пример приложения, где есть несколько разных вариантов реализации роутинга. А также можете посмотреть итоговый код приложения, которое описано в статье.

Комментарии (6)


  1. PackRuble
    30.08.2023 09:52
    +1

    Спасибо за статью! Стоит отметить, что у go_router очень изменчивое api от версии к версии, которое пока что не стабилизировалось. Но пакет сильно значимый. Хочется верить, что получится учесть прошлый костыльный опыт по навигации и сделать favorite-пакет (я не про плашку в pub.dev)


    1. ivTimur Автор
      30.08.2023 09:52

      Последние изменения версий практически не меняют АПИ, прошлось несколько приложений обновлять. Но в целом, да, до идеала еще есть что поделать)


      1. Krushiler
        30.08.2023 09:52
        +1

        Ну там различные params deprecated стали. Теперь через Uri. Не так давно вроде было.


  1. Krushiler
    30.08.2023 09:52

    StatefulShellRoute на самом деле кривоватый. Пример:

    • Есть экран "1" с TextField, на котором висит onTapOutside

    • Есть экран "2" в другой branch, на котором также находится TextField

    • Мы переходим с экрана 1 на экран 2 через context.go() (т.е. в backstack экрана 1 нет)

    • Нажатия по экрану 2 вызывают onTapOutside с экрана 1

    Вопрос: А как так? StatefulShellBranch просто держит состояние активным?

    Из-за данной проблемы вернулся на обычный ShellRoute.

    Идея StatefulShellRoute хорошая, но его допиливать надо. А если я хочу, чтобы не все экраны были в StatefulShellBranch. Иногда хочется что-то вроде StatelessShellBranch.

    Вообще, goRouter - очень крутая либа с классными фичами (refreshListanable, redirect и т.д.), всегда с её помощью навигацию строю. Но StatefullShellRoute, вышедший несколько месяцев назад, ещё сырой. Я бы рекомендовал воздержаться от его использования в больших проектах, мало ли что.


    1. ivTimur Автор
      30.08.2023 09:52

      Хорошее замечание, мне такой кейс не встречался
      А можете по подробнее расписать, для какой логики вы используете onTapOutside?


      1. Krushiler
        30.08.2023 09:52

        TextField находится в DraggableScrollableSheet, и нужно вызывать unfocus() в onTapOutside, чтобы при взаимодействии с DraggableScrollableSheet клавиатура убиралась.

        А когда я использовал StatefullShellRoute, при нажатии на TextField (и всё остальное) на экране 2 сразу происходил unfocus :-)

        Добавил debugPrint() в onTapOutside и увидел, что в он вызывается, хотя экран даже не в backstack.

        Как-то так.