[Dart, Flutter, Разработка под Android, Разработка под iOS] Детальный разбор навигации в Flutter

Автор Сообщение
news_bot ®

Стаж: 6 лет 3 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
24-Июл-2020 16:30

Навигация во Flutter

Flutter набирает популярность среди разработчиков. Большенство подходов в построении приложений уже устоялись и применяются ежедневно в разработке E-commerce приложений. Тема навигации опускают на второй или третий план. Какой API навигации предоставляет Фреймворк? Какие подходы выработаны? Как использовать эти подходы и на что они годятся?
Введение
Начнём с того, что такое навигация? Навигация — это метод который позволяет перемещаться между пользовательским интерфейсом с заданными параметрами.
К примеру в IOS мире организовывает навигацию UIViewController, а в Android — Navigation component. А что предоставляет Flutter?
Navigator
Экраны в Flutter называются route. Для перемещениями между route существует класс Navigator который имеющий обширный API для реализации различных видов навигации.
Навигации на новый route и возвращение с него
Начнём с простого. Навигация на новый экран(route) вызывается методом push() который принимает в себя один аргумент — это Route.
Navigator.push(MaterialPageRoute(builder: (BuildContext context) => MyPage()));

Давайте детальнее разберёмся в вызове метода push:
Navigator — Виджет, который управляет навигацией.
Navigator.push() — метод который добавляет новый route в иерархию виджетов.
MaterialPageRoute() — Модальный route, который заменяет весь экран адаптивным к платформе переходом анимации.
builder — обязательный аргумент конструктора MaterialPageRoute, который возвращает пользовательский интерфейс Фреймворк для отрисовки.
[MyPage](https://miro.medium.com/max/664/1Xm96KtLeIAAMtAYWcr1-MA.png)* — пользовательский интерфейс реализованный при помощи 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 State
Состояние виджета Navigator, который вызван внутри одного из видов MaterialApp/CupertinoApp/WidgetsApp. State отвечает за хранение истории навигации и предоставляет API для управления историей.
Базовые методы навигации повторяют структуру данных Stack. В диаграмме можно наблюдать методы и "call flow" NavigatorState.

Императивный 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 разработчиков
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_dart, #_flutter, #_razrabotka_pod_android (Разработка под Android), #_razrabotka_pod_ios (Разработка под iOS), #_flutter, #_dart, #_dart_2.0, #_android, #_ios, #_android_dev, #_android_development, #_ios_development, #_ios_razrabotka (iOS разработка), #_ios_dev, #_react, #_reactnative, #_crossplatform, #_crossplatform, #_navigation, #_ui, #_multiplatform, #_mobile, #_mobile_development, #_web, #_web_development, #_programming, #_dart, #_flutter, #_razrabotka_pod_android (
Разработка под Android
)
, #_razrabotka_pod_ios (
Разработка под iOS
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 15-Май 10:12
Часовой пояс: UTC + 5