
Привет! Меня зовут Павел Шалимов, я 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<MyRoutePath> {
@override
Future<MyRoutePath> 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<MyRoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<MyRoutePath> {
final GlobalKey<NavigatorState> _navigatorKey;
AppRouterDelegate() : _navigatorKey = GlobalKey<NavigatorState>();
@override
GlobalKey<NavigatorState> 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<void> 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<AutoRoute> 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<BeamState> {
@override List<Pattern>get pathPatterns => ['/films/:filmsId'];
@override List<BeamPage> 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 в его текущей версии, так как библиотеки выполняют его работу лучше.