Как стать автором
Обновить

Детальный разбор навигации в Flutter

Время на прочтение9 мин
Количество просмотров37K

image


Flutter набирает популярность среди разработчиков. Большенство подходов в построении приложений уже устоялись и применяются ежедневно в разработке E-commerce приложений. Тема навигации опускают на второй или третий план. Какой API навигации предоставляет Фреймворк? Какие подходы выработаны? Как использовать эти подходы и на что они годятся?


Введение


Начнём с того, что такое навигация? Навигация — это метод который позволяет перемещаться между пользовательским интерфейсом с заданными параметрами.
К примеру в IOS мире организовывает навигацию UIViewController, а в Android — Navigation component. А что предоставляет Flutter?



Экраны в Flutter называются route. Для перемещениями между route существует класс Navigator который имеющий обширный API для реализации различных видов навигации.



Начнём с простого. Навигация на новый экран(route) вызывается методом push() который принимает в себя один аргумент — это Route.


Navigator.push(MaterialPageRoute(builder: (BuildContext context) => MyPage()));

Давайте детальнее разберёмся в вызове метода push:


Navigator — Виджет, который управляет навигацией.
Navigator.push() — метод который добавляет новый route в иерархию виджетов.
MaterialPageRoute() — Модальный route, который заменяет весь экран адаптивным к платформе переходом анимации.
builder — обязательный аргумент конструктора MaterialPageRoute, который возвращает пользовательский интерфейс Фреймворк для отрисовки.
MyPage — пользовательский интерфейс реализованный при помощи Stateful/Stateless Widget


Возвращение на предыдущий route


Для возвращения с экрана на предыдущий необходимо использовать метод pop().


Navigator.pop();

Передача данных между экранами


Данные на новый экран передаются через конструктор при создании экрана в builder методе. Этот принцип работает в методах Navigator, где нужно реализовать метод build.


Navigator.push(context, MaterialPageRoute(builder: (context) => MyPage(someData: data)));

В примере продемонстрирована передача данных в класс MyPage (в этом классе хранится пользовательский интерфейс).


Для того чтобы передать данные на предыдущий экран нужно вызвать метод pop() и передать опциональным аргументом туда данные.


Navigator.pop(data);


Состояние виджета Navigator, который вызван внутри одного из видов MaterialApp/CupertinoApp/WidgetsApp. State отвечает за хранение истории навигации и предоставляет API для управления историей.
Базовые методы навигации повторяют структуру данных Stack. В диаграмме можно наблюдать методы и "call flow" NavigatorState.


https://habrastorage.org/webt/5w/dg/nb/5wdgnb-tjlngub4c8y4rlpqkeqi.png


Императивный vs Декларативный подход в навигации


Во всех примерах которые были приведены выше использовался императивный подход в навигации. Разница между императивным и декларативным подходом в том как будет выглядеть вызов нового route, и кто будет хранить реализацию перехода.


Давайте на простом примере:


Императивный подход , отвечает на вопрос — как?
Пример: Я вижу, что тот угловой столик свободен. Мы пойдём туда и сядем там.


Декларативный подход, отвечает на вопрос — что?
Пример: Столик для двоих, пожалуйста.


Для более глубокого понимания разницы советую прочитать эту статью Imperative vs Declarative Programming


Императивная навигация


Вернёмся к реализации навигации. В императивном подходе описывается детали работы в вызывающем коде. В нашем случае это поля Route. В Flutter много типов route, например MaterialPageRoute и CupertinoPageRoute. Например в CupertinoPageRoute задаётся title, или settings.


Пример:


Navigator.push(
    context,
    CupertinoPageRoute<T>(
        title: "Setting",
        builder: (BuildContext context) => MyPage(),
        settings: RouteSettings(name:"my_page"),
    ),
);

Этот код и знания о новом route будут храниться в ViewModel/Controller/BLoC/… У этот подхода существует недостаток.


Представим что потребовалось внести изменения в конструкторе в MyPage или в CupertinoPageRoute. Нужно искать каждый вызов метода push в проекте и изменять кодовую базу.


Вывод:


Этот подход не имеет единообразный подход к навигации, и знание о реализации route проникает в бизнес логику.

Декларативная навигация


Принцип декларативной навигации заключается в использовании декларативного подхода в программировании. Давайте разберём на примере.


Пример:


Navigator.pushNamed(context, '/my_page');

Принцип императивной навигации выглядит куда проще. Говорите "Отправь пользователя на экран настроек" передавая путь одним из аргументов навигации.
Для хранении реализации роста в самом Фреймворке предусмотрен механизм у MaterialApp/CupertinoApp/WidgetsApp. Это 2 колбэка onGenerateRoute и onUnknownRoute отвеспющие за хранение деталей реализации.


Пример:


MaterialApp(
    onUnknownRoute: (settings) => CupertinoPageRoute(
      builder: (context) {
                return UndefinedView(name: settings.name);
            }
    ),
  onGenerateRoute: (settings) {
    if (settings.name == '\my_page') {
      return CupertinoPageRoute(
                title: "MyPage",
                settings: settings,
        builder: (context) => MyPage(),
      );
    }
        // Тут будут описание других роутов
  },
);

Разберёмся подробнее в реализации:
Метод onGenerateRoute — данный метод срабатывает когда был вызван Navigator.pushNamed(). Метод должен вернуть route.
Метод onUnknownRoute — срабатывает когда метод onGenerateRoute вернул null. должен вернуть дефолтный route, по аналогии с web сайтами — 404 page.


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

Диалоговые и модальные окна


Для того чтобы вызвать диалоговое окна разного типа в Фреймворке предусмотрены глобальные методы. Давайте разберёмся в типах диалоговых окон.


Методы для вызова диалоговых и модальных окон:


  • showAboutDialog
  • showBottomSheet
  • showDatePicker
  • showGeneralDialog
  • showMenu
  • showModalBottomSheet
  • showSearch
  • showTimePicker
  • showCupertinoDialog
  • showDialog
  • showLicensePage
  • showCupertinoModalPopup

Эти методы покрывают базовые потребности разработчиков которые хотят работать с окнами. Если не хватает этого API, тогда стоит разобраться как эти методы работают.


Как работает это под капотом?


Давайте рассмотрим исходный код одного из методов, например showGeneralDialog.


Исходный код:


Future<T> showGeneralDialog<T>({
  @required BuildContext context,
  @required RoutePageBuilder pageBuilder,
  bool barrierDismissible,
  String barrierLabel,
  Color barrierColor,
  Duration transitionDuration,
  RouteTransitionsBuilder transitionBuilder,
  bool useRootNavigator = true,
  RouteSettings routeSettings,
}) {
  assert(pageBuilder != null);
  assert(useRootNavigator != null);
  assert(!barrierDismissible || barrierLabel != null);
  return Navigator.of(context, rootNavigator: useRootNavigator).push<T>(_DialogRoute<T>(
    pageBuilder: pageBuilder,
    barrierDismissible: barrierDismissible,
    barrierLabel: barrierLabel,
    barrierColor: barrierColor,
    transitionDuration: transitionDuration,
    transitionBuilder: transitionBuilder,
    settings: routeSettings,
  ));
}

Давайте детальнее разберёмся в устройстве этого метода. showGeneralDialog вызывает метод push у NavigatorState с _DialogRoute(). Нижнее подчёркивание обозначает что этот класс приватный и используется только в пределах области видимости в которой сам описан, то есть в пределах этого файла.


Диалоговые и модальные окна которые отображаются при помощи глобальных методов — это кастомные route которые реализованы разработчиками Фреймворка.

Типы route в фреймворке


Теперь понятно что "every thins is a route", то есть что связанное с навигацией. Давайте взглянем на то, какие route уже реализованы в Фреймворке.


Два основных route в Flutter — это PageRoute и PopupRoute.


PageRoute — Модальный route, который заменяет весь экран.
PopupRoute — Модальный route, который накладывает виджет поверх текущего route.


Реализации PageRoute:


  • MaterialPageRoute
  • CupertinoPageRoute
  • _SearchPageRoute
  • PageRouteBuilder

Реализации PopupRoute:


  • _ContexMenuRoute
  • _DialogRoute
  • _ModalBottomSheetRoute
  • _CupertinoModalPopupRoute
  • _PopupMenuRoute

Реализация PopupRoute приватна и скрыты для внешних потребителей, но роуты используют глобальные методы для показа диалоговых и модальных окон.


Вывод:


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

Best practices


В этой части статьи можно задаться вопросом, "а насколько мне всё это нужно?". Ответить на этот вопрос можно легко, если у есть желание сделать масштабируемый API навигации которая в любой момент будет модифицирована под нужды проекта, то это один из вариантов. Если приложение будет в будущем расширяться со сложной логикой.


Начнём с того что мы сделаем некий сервис который будет будет соблюдать следующим аспектам:


  • Декларативный вызов навигации.
  • Отказ от использования BuildContext для навигации (Это критично если сервис навигации будет вызываться в компонентах, в которых нет возможности получить BuildContext).
  • Модульность. Можно вызвать любой route, CupertinoPageRoute, BottomSheetRoute, DialogRoute и т.д.

Для нашего сервиса навигации нам понадобится интерфейс:


abstract class IRouter {
  Future<T> routeTo<T extends Object>(RouteBundle bundle);
  Future<bool> back<T extends Object>({T data, bool rootNavigator});
  GlobalKey<NavigatorState> rootNavigatorKey;
}

Разберём методы:
routeTo - выполняет навигацию на новый экран.
back — возвращает на предыдущий экран.
rootNavigatorKey — GlobalKey умеющий вызывать методы NavigatorState.

После того как мы сделали интерфейс навигации, давайте сделаем реализацию этого интерфейса.


class Router implements IRouter {
    @override
  GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();

    @override
  Future<T> routeTo<T>(RouteBundle bundle) async {
   // Push logic here
  }

    @override
  Future<bool> back<T>({T data, bool rootNavigator = false}) async {
        // Back logic here
    }
}

 Супер, теперь нам нужно реализовать метод routeTo().


@override
  Future<T> routeTo<T>(RouteBundle bundle) async {
        assert(bundle != null, "The bundle [RouteBundle.bundle] is null");
    NavigatorState rootState = rootNavigatorKey.currentState;
    assert(rootState != null, 'rootState [NavigatorState] is null');

    switch (bundle.route) {
      case "/routeExample":
        return await rootState.push(
          _buildRoute<T>(
            bundle: bundle,
            child: RouteExample(),
          ),
        );

      case "/my_page":
        return await rootState.push(
          _buildRoute<T>(
            bundle: bundle,
            child: MyPage(),
          ),
        );
      default:
        throw Exception('Route is not found');
    }
  }

Данный метод вызывает у root NavigatorState (который описан в WidgetsApp) метод push и конфигурирует его относительно RouteBundle который приходит одним из аргументов в данный метод.


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


enum ContainerType {
  /// The parent type is [Scaffold].
  ///
  /// In IOS route with an iOS transition [CupertinoPageRoute].
  /// In Android route with an Android transition [MaterialPageRoute].
  ///
  scaffold,

  /// Used for show child in dialog.
  ///
  /// Route with [DialogRoute].
  dialog,

  /// Used for show child in [BottomSheet].
  ///
  /// Route with [ModalBottomSheetRoute].
  bottomSheet,

  /// Used for show child only.
  /// [AppBar] and other features is not implemented.
  window,
}
class RouteBundle {
  /// Creates a bundle that can be used for [Router].
  RouteBundle({
    this.route,
    this.containerType,
  });

  /// The route for current navigation.
  ///
  /// See [Routes] for details.
  final String route;

  /// The current status of this animation.
  final ContainerType containerType;
}

enum ContainerType — тип контейнера, котрый будет задаваться декларативно из вызываемого кода.
RouteBundle — класс-холдер данных отвечающих конфигурацию нового route.


Как вы могли заметить у я использовал метод _buildRoute. Именно он отвечает за то, кой тип route будет вызван.


Route<T> _buildRoute<T>({@required RouteBundle bundle, @required Widget child}) {
    assert(bundle.containerType != null, "The bundle.containerType [RouteBundle.containerType] is null");

    switch (bundle.containerType) {
      case ContainerType.scaffold:
        return CupertinoPageRoute<T>(
          title: bundle.title,
          builder: (BuildContext context) => child,
          settings: RouteSettings(name: bundle.route),
        );
      case ContainerType.dialog:
        return DialogRoute<T>(
          title: '123',
          settings: RouteSettings(name: bundle.route),
          pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
                        return child;
                    },
        );
      case ContainerType.bottomSheet:
        return ModalBottomSheetRoute<T>(
          settings: RouteSettings(name: bundle.route),
          isScrollControlled: true,
          builder: (BuildContext context) => child,
        );
      case ContainerType.window:
        return CupertinoPageRoute<T>(
          settings: RouteSettings(name: bundle.route),
          builder: (BuildContext context) => child,
        );
      default:
        throw Exception('ContainerType is not found');
    }
  }

Думаю что в этой функции стоит рассказать о ModalBottomSheetRoute и DialogRoute, которые использую. Исходный код этих route позаимствован из раздела Material исходного кода Flutter.


Осталось сделать метод back.


@override
Future<bool> back<T>({T data, bool rootNavigator = false}) async {
    NavigatorState rootState = rootNavigatorKey.currentState;
  return await (rootState).maybePop<T>(data);
}

Ну и конечно перед использованием сервиса необходимо передать rootNavigatorKey в App следующим образом:


MaterialApp(
    navigatorKey: widget.router.rootNavigatorKey,
    home: Home()
);

Кодовая база для нашего сервиса готова, давайте вызовем наш route. Для этого создадим инстанс нашего сервиса и каким-либо образом "прокинуть" в объект, который будет вызывать этот инстанс, например при помощи Dependency Injection.


router.routeTo(RouteBundle(route: '/my_page', containerType: ContainerType.window));

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


  • Декларативный вызов навигации
  • Отказ от BuildContext по средствам GlobalKey
  • Модульность достигнута возможностью конфигурирования route относительно имени пути и контейнера для View

Итог


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


Ну и конечно полезные ссылки:
Мой телеграм канал
Мои друзья Flutter Dev Podcast
Вакансии Flutter разработчиков

Теги:
Хабы:
Всего голосов 5: ↑4 и ↓1+3
Комментарии13

Публикации

Истории

Работа

iOS разработчик
24 вакансии
Swift разработчик
31 вакансия

Ближайшие события