Навигация в приложениях Flutter: разбираем Navigator, Router и лучшие библиотеки
Привет! Меня зовут Павел Шалимов, я flutter-разработчик в InstaDev. Делаем мобильные приложения, которые помогают бизнесу расти.
В этой статье подробно рассмотрим навигацию во Flutter, отличия и специфичные моменты под разные платформы, а также обсудим возможности Router.
Навигация в Flutter предоставляет разработчикам возможность создавать сложные и гибкие пользовательские интерфейсы, а также легко адаптировать приложения под разные платформы и устройства.
Основной идеей навигации в Flutter является стековая организация экранов, где каждый экран представлен своим виджетом. Переходы между экранами осуществляются путем добавления и удаления маршрутов из стека.
Основные компоненты навигации
Navigator: центральный компонент для управления навигацией в приложении. Он поддерживает стек маршрутов, где каждый маршрут представляет собой экран или виджет. Navigator позволяет добавлять, удалять и заменять маршруты, а также управлять анимациями перехода между ними.
Route: класс, который представляет собой один экран приложения. Реализация Route может быть стандартной (например, MaterialPageRoute) или пользовательской, в зависимости от требований приложения.
MaterialPageRoute: реализация Route, специфичная для мобильных приложений, основанная на материальном дизайне. Она обеспечивает стандартные анимации перехода между экранами и интегрируется с другими элементами Material Design.
PageRoute: базовый класс для пользовательских маршрутов. Разработчики могут создавать собственные реализации маршрутов для достижения специфического поведения навигации.
При написании приложения с навигацией, можно придерживаться одного из двух вариантов реализации навигации: императивный способ (Navigator Api) или декларативный (Pages Api, он же Navigator 2.0, он же Router, далее мы будем называть его Router).
Обычно разработчики используют Navigator Api из-за его простоты и удобства, но если необходима бóльшая гибкость и глубокая поддержка веб приложений — то Router будет лучшим выбором.
Navigator API базируется на стеке маршрутов, где каждый маршрут представляет собой экран приложения. Navigator предоставляет методы для добавления, удаления и замены маршрутов в стеке, а также управляет анимациями перехода между экранами. При использовании Navigator API разработчики определяют маршруты напрямую внутри MaterialApp или Navigator-а, и навигация осуществляется путем вызова методов Navigator.push (), Navigator.pop () и других.
Router — новый способ навигации, представленный командой Flutter относительно недавно, он предоставляет разработчикам свободу самим реализовывать навигацию. Router основан на классах RouteInformationParser, RouterDelegate и RouteInformationProvider, которые позволяют приложению работать с маршрутами на более абстрактном уровне и интегрироваться с механизмами маршрутизации URL в веб-приложениях.
Далее я приведу пример простого приложения с реализацией навигации с обоими api.
Пример приложения, использующего Navigator Api
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Navigation Example',
// Начальный маршрут
initialRoute: '/',
// Вместо initialRoute можно указать home и тогда этот виджет становится '/'
home: HomeScreen(),
// Маршруты приложения
routes: {
// Главный экран
'/': (context) => const HomeScreen(),
// Экран подробностей
'/details': (context) => const DetailsScreen(),
},
);
}
}
// Главный экран
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Home'),
),
body: Center(
child: ElevatedButton(
onPressed: () {
// Переход на экран с подробностями
// Переход по имени роута (имя обязательно должно быть в routes в MaterialApp)
Navigator.pushNamed(context, '/details');
// Вместо перехода по имени можно переходить к конкретному экрану,
// используя конструктор MaterialPageRoute, который создаст новый модальный роут
Navigator.push(context, MaterialPageRoute(builder: (context) => DetailsScreen())),
},
child: const Text('Go to Details'),
),
),
);
}
}
// Экран с подробностями
class DetailsScreen extends StatelessWidget {
const DetailsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Details'),
),
body: Center(
child: ElevatedButton(
onPressed: () {
// Возврат на предыдущий экран
Navigator.pop(context);
},
child: const Text('Go Back'),
),
),
);
}
}
Здесь мы в виджете MaterialApp в поле routes определяем роуты приложения, по которым впоследствии сможем переходить, используя конструкции Navigator.pushNamed(context, ‘/something’)
, Navigator.pop(context)
, Navigator.pushReplacementNamed(context, ‘/something’)
и другие.
Пример приложения, использующего Router
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Pages API Example',
routerDelegate: AppRouterDelegate(),
routeInformationParser: AppRouteInformationParser(),
);
}
}
// Класс для парсинга информации о маршруте
class AppRouteInformationParser extends RouteInformationParser {
@override
Future parseRouteInformation(
RouteInformation routeInformation) async {
final uri = routeInformation.uri;
// Определение маршрута на основе URL
if (uri.pathSegments.isEmpty) {
return MyRoutePath.home();
} else if (uri.pathSegments.length == 1 &&
uri.pathSegments[0] == 'details') {
return MyRoutePath.details();
} else {
// Возвращаем маршрут по умолчанию
return MyRoutePath.unknown();
}
}
@override
RouteInformation restoreRouteInformation(MyRoutePath configuration) {
if (configuration.isUnknown) {
return RouteInformation(uri: Uri.parse('/404'));
}
return RouteInformation(uri: Uri.parse(configuration.location));
}
}
// Класс для определения маршрутов
class AppRouterDelegate extends RouterDelegate
with ChangeNotifier, PopNavigatorRouterDelegateMixin {
final GlobalKey _navigatorKey;
AppRouterDelegate() : _navigatorKey = GlobalKey();
@override
GlobalKey get navigatorKey => _navigatorKey;
MyRoutePath _routePath = MyRoutePath.home();
@override
MyRoutePath get currentConfiguration => _routePath;
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
const MaterialPage(child: HomeScreen(), key: ValueKey('Home')),
if (_routePath.isDetails)
const MaterialPage(child: DetailsScreen(), key: ValueKey('Details')),
],
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
_routePath = MyRoutePath.home();
notifyListeners();
return true;
},
);
}
@override
Future setNewRoutePath(MyRoutePath path) async {
_routePath = path;
}
}
// Класс для определения структуры маршрутов
class MyRoutePath {
final String location;
MyRoutePath.home() : location = '/';
MyRoutePath.details() : location = '/details';
MyRoutePath.unknown() : location = '/404';
bool get isHome => location == '/';
bool get isDetails => location == '/details';
bool get isUnknown => location == '/404';
}
// Главный экран
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Home'),
),
body: Center(
child: ElevatedButton(
onPressed: () {
// Здесь нам необходимо изменит стейт приложения,
},
child: const Text('Go to Details'),
),
),
);
}
}
// Экран с подробностями
class DetailsScreen extends StatelessWidget {
const DetailsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Details'),
),
body: Center(
child: ElevatedButton(
onPressed: () {
// Возврат на предыдущий экран
},
child: const Text('Go Back'),
),
),
);
}
}
Здесь нам необходимо создать 3 класса, благодаря которым и будут осуществляться навигация:
RouteInformationParser: класс, который преобразует информацию о маршруте (например, URL) в конфигурацию состояния навигации.
RouterDelegate: класс, который управляет состоянием навигации приложения. Он отслеживает текущий маршрут и уведомляет Router о необходимости перестроения навигации. Когда возникает необходимость изменить роут приложения, информация о маршруте парсится в RouteInformationParser в тип конфигурации навигации, RouterDelegate получает её и строит новый виджет (экран). Здесь мы можем реализовывать настолько сложную и кастомную навигацию, какую только захотим.
RouteInformationProvider: этот класс предоставляет информацию о текущем маршруте приложения. Он отвечает за передачу информации о маршруте и уведомляет слушателей (виджет Router), когда доступна новая информация о маршруте.
Как вы, наверное, заметили Router гораздо более многословен и труден в освоении, как и сложен в поддержке, когда в приложении количество экранов переваливает за 10–15 штук, тогда конфигурация навигации становится чересчур громоздкой и поддерживать декларативность становится затруднительно.
Поэтому разработчики используют либо проверенный и простой (но, к сожалению, слабо конфигурируемый) Navigator, либо какую-нибудь библиотеку, основанную на Router.
Библиотеки и решения
Библиотеки обычно объединяют всё лучшее из обоих миров: простоту и доступность Navigator, и гибкость и, пусть и не полную, но очень ощутимую кастомизируемость Router. Здесь я расскажу про наиболее популярные и интересные библиотеки.
go_router одна из наиболее популярных библиотек для навигации, она интересна тем, что для всех переходов по страницам используется код context.go('/user/765/post/1')
, которая сама заменит текущий стек навигации на стек, ведущий к этому роуту. И для работы диплинков нет необходимости что-то конфигурировать отдельно, кроме базовой конфигурации, необходимой для любого приложения, использующего диплинки.
Начальная конфигурация go_router крайне простая: необходимо создать экземпляр объекта GoRouter, в котором указаны все роуты приложения:
final _router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => HomeScreen(),
routes: [
GoRoute(
path: 'profile',
builder: (context, state) => ProfileScreen(),
),
],
),
],
);
И добавить в виджет App routerConfig:
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
);
}
}
auto_route - ещё одна популярная библиотека, которая предоставляет довольно много полезных «фишек» для разработчиков. Тут и диплинки из коробки, и кодогенерация для кучи бойлерплейт-кода, и guarded роуты.
Начальная конфигурация ничем не отличается от таковой в go_router, кроме того, что нужно пометить аннотацией @RoutePage () нужные страницы приложения:
part 'router.gr.dart';
@AutoRouterConfig()
class AppRouter extends _$AppRouter {
@override
final List routes = [
AutoRoute(
initial: true,
page: LaunchRoute.page,
path: '/launch',
),
AutoRoute(
initial: true,
page: AuthRoute.page,
path: '/auth',
),
AutoRoute(
page: HomeRoute.page,
path: 'home',
guards: [AuthGuard],
children: [
AutoRoute(
page: ProfileRoute.page,
path: 'profile',
)
],
),
];
}
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: AppRouter().config(),
);
}
}
А «защита» роутов с помощью auto_route становится довольно тривиальной задачей:
// в конфигурации выше я уже добавил AuthGuard для одного из роутов
class AuthGuard extends AutoRouteGuard {
@override
void onNavigation(NavigationResolver resolver, StackRouter router) {
// Здесь будет логика проверки авторизации
if (!isAuthenticated) {
router.push(AuthRoute());
} else {
resolver.next(true);
}
}
}
beamer — библиотека, ключевой концепт которой заключается в упрощении работы с Router. Она предоставляет опыт, похожий на работу с Router, но вместо того, чтобы ответственность за генерацию всего стека роутов лежала на одном RouterDelegate, Beamer предлагает создать группу классов BeamLocation, которые будут ответственны за свою часть приложения.
Здесь для начальной конфигурации нужно будет вместо routerConfig явно указать routeInformationParser и routerDelegate, которые предоставляет библиотека:
class App extends StatelessWidget {
final routerDelegate = BeamerDelegate(
locationBuilder: RoutesLocationBuilder(
routes: {
// Указываем тут роуты и соответсвующие им экраны
'/': (context, state, data) => HomeScreen(),
'/films': (context, state, data) => FilmsScreen(),
// Можно передавать данные в параметрах пути
'/films/:filmId': (context, state, data) {
// Берём из state парметры пути
final filmId = state.pathParameters['filmId']!;
// Используем BeamPage для дополнительных возможностей
return BeamPage(
key: ValueKey('film-$filmId'),
popToNamed: '/',
child: FilmDetailsScreen(filmId),
);
}
},
),
);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routeInformationParser: BeamerParser(),
routerDelegate: routerDelegate,
);
}
}
Или же лучше воспользоваться ключевым концептом всего пакета и создать BeamLocation для каждой части приложения:
class FilmsLocation extends BeamLocation {
@override Listget pathPatterns => ['/films/:filmsId'];
@override List buildPages(BuildContext context, BeamState state) {
final pages = [
const BeamPage(
key: ValueKey('home'),
child: HomeScreen(),
),
if (state.uri.pathSegments.contains('films'))
const BeamPage(
key: ValueKey('films'),
child: FilmsScreen(),
),
];
final String? filmsIdParameter = state.pathParameters['filmsId'];
if (filmsIdParameter !=null) {
final filmsId = int.tryParse(filmsIdParameter);
pages.add(
BeamPage(
key: ValueKey('films-$filmsIdParameter'),
child: FilmsDetailsScreen(filmsId: filmsId),
),
);
}
return pages;
}
}
Тогда RouterDelegate будет выглядеть так:
final routerDelegate = BeamerDelegate(
locationBuilder: BeamerLocationBuilder(
beamLocations: [
HomeLocation(),
FilmsLocation(),
//...etc
],
),
);
И это ещё далеко не все библиотеки для навигации.
Существует огромное множество совершенно разных плагинов, с различными дополнительными возможностями и способами взаимодействия с ними. Ещё рекомендую обратить внимание на Fluro, Octopus, Qlevar_router, мне они показались интересными, но, к сожалению, в статью уже не помещаются.
Резюме
Навигация в Flutter не самая тривиальная вещь и подступиться к ней можно по-разному.
Можно выбрать простой путь и использовать Navigator, это по-прежнему не самый плохой выбор, он просто выполняет свою работу и не требует большого количества кода, не ставит высокий порог для входа, но и не делает ничего дополнительного;, а можно выбрать любую доступную библиотеку, они все предоставляют бóльшую функциональность и гибкость в обмен на изучение и дополнительную зависимость.
Я бы только не рекомендовал использовать Router в его текущей версии, так как библиотеки выполняют его работу лучше.