Тема навигации в приложениях обсуждалась многократно, однако она остается актуальной, особенно в контексте конкретных проектов и задач разработчиков.
Что такое Navigator и Router?
Flutter предоставляет полноценную систему навигации между экранами, и для этой задачи отлично подходит виджет Navigator. Этот класс управляет набором дочерних виджетов с помощью стека. Проще говоря, когда пользователь переходит с одного экрана на другой, новый экран накладывается поверх предыдущего, формируя своеобразный «слоеный пирог» из экранов и модальных окон.
Аналогично, при закрытии экрана он просто удаляется из стека. Технически это реализуется добавлением OverlayEntry в OverlayState, что позволяет хранить историю маршрутов (роутов). Кроме того, система навигации позволяет передавать данные обратно при возврате на предыдущий экран.
Итого мы имеем следующую картину:
Что делать, если нам требуется более сложная навигация?
Например, если следующий переход должен не просто добавить новый экран в стек, а создать совершенно новый стек и бесшовно переключиться на него? В таких случаях используется динамическая маршрутизация (dynamic routing).
Чтобы реализовать такую логику, можно заранее определить маршруты (routes) для Navigator, задав их в параметре routes внутри MaterialApp. Однако если маршрут не определен заранее или нужно передавать параметры, используется onGenerateRoute. Этот параметр позволяет работать с RouteSettings, где хранятся переданные аргументы.
С появлением Dart 3.0 можно использовать обновленный switch...case, что делает код навигации более читаемым:
MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => const _FirstScreen(),
},
onGenerateRoute: (settings) => switch (settings.name) {
'/second_screen' => MaterialPageRoute(
builder: (context) => _SecondScreen(
inputArg: settings.arguments as String,
),
),
_ => MaterialPageRoute(
builder: (context) => Scaffold(
body: Center(
child: Text('Screen with ${settings.name} not found.'),
),
),
),
},
);
Этот способ позволяет динамически обрабатывать маршруты, управлять аргументами и задавать fallback-страницу для неизвестных путей.
Хотя статические маршруты (routes в MaterialApp) могут покрыть большую часть сценариев, они не всегда подходят для сложных приложений. Например, если пользователю нужно глубоко перейти по ссылке (deep linking) или полностью изменить стек экранов в зависимости от контекста, статическая маршрутизация становится ограничением.
Чтобы решить эту проблему, в MaterialApp и CupertinoApp добавили именованный конструктор .router, который позволяет управлять маршрутами динамически. Для этого используются:
- routeInformationParser — разбирает входящий маршрут;
- routerDelegate — отвечает за логику маршрутизации.
Эта реализация сложнее и чаще относится к категории advanced, но она полезна в крупных проектах.
Альтернативные решения: AutoRoute и go_router
Чтобы упростить управление навигацией, можно использовать AutoRoute. Этот пакет позволяет настроить навигацию декларативно, используя аннотации и кодогенерацию. Его основной конкурент — go_router, который официально поддерживается командой Flutter и лучше подходит для deep linking и веб-приложений.
Для работы с AutoRoute нам понадобятся следующие пакеты:
flutter pub add auto_route
flutter pub add auto_route_generator --dev
flutter pub add build_runner --dev
После установки необходимо организовать файлы, связанные с навигацией. Например:
lib/navigation/
├── domain/
│ ├── app_route_paths.dart
│ ├── app_route_names.dart
├── service/
│ ├── app_router.dart
│ ├── app_router.gr.dart
Настройка AutoRoute
Файл app_router.dart содержит основной класс AppRouter, который управляет маршрутами:
// Импорты
import 'package:auto_route/auto_route.dart';
import 'app_router.gr.dart';
/// Конфигурация маршрутов AutoRoute
@AutoRouterConfig(replaceInRouteName: 'Screen,Route')
class AppRouter extends RootStackRouter {
@override
RouteType get defaultRouteType => const RouteType.material();
@override
List get routes => [
AutoRoute(
path: '/',
page: TempRoute.page,
children: [
AutoRoute(page: HomeRoute.page),
AutoRoute(page: SearchRoute.page),
AutoRoute(page: BasketRoute.page),
AutoRoute(page: ProfileRoute.page),
],
),
];
}
Перед тем как сгенерировать маршруты, необходимо обозначить аннотацией @RoutePage() каждый экран. TempScreen представляет собой экран, который содержит BottomNavigationBar.
Аннотация для каждого из экранов:
@RoutePage()
class TempScreen extends StatelessWidget {
const TempScreen({super.key});
}
Завершим подготовку для первого запуска настройкой нашего MaterialApp/CupertinoApp
class _MyAppState extends State {
late final AppRouter _appRouter;
@override
void initState() {
super.initState();
_appRouter = AppRouter();
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
routerConfig: _appRouter.config(navigatorObservers: () => [AutoRouteObserver()]),
);
}
}
Если все сделано правильно, проект соберется без ошибок. Однако на экране пока ничего не появится, так как мы не добавили BottomNavigationBar.
Использование AutoTabsRouter для BottomNavigationBar
AutoRoute предоставляет AutoTabsRouter, который работает как lazy (добавляет потомка в стек только при первом обращении, не добавляя всех сразу) IndexedStack и управляет переключением вкладок.
Пример реализации BottomNavigationBar с AutoTabsRouter
import 'package:auto_route/auto_route.dart';
import 'package:auto_route_showcase/app_router.gr.dart';
import 'package:flutter/material.dart';
class TempScreen extends StatelessWidget {
const TempScreen({super.key});
/// Иконки для навигационного бара
final Map _icons = const {
'Home': Icons.home,
'Search': Icons.search,
'Basket': Icons.shopping_basket,
'Profile': Icons.person,
};
/// Маршруты для вкладок
final List _bottomNavBarRoutes = const [
HomeRoute(),
SearchRoute(),
BasketRoute(),
ProfileRoute(),
];
@override
Widget build(BuildContext context) {
return AutoTabsRouter(
routes: _bottomNavBarRoutes,
builder: (ctx, child) {
/// Получаем `tabsRouter` из контекста
final tabsRouter = AutoTabsRouter.of(ctx);
return Scaffold(
body: child,
bottomNavigationBar: BottomNavigationBar(
currentIndex: tabsRouter.activeIndex,
onTap: tabsRouter.setActiveIndex,
items: _icons.entries
.map((entry) => BottomNavigationBarItem(
label: entry.key,
icon: Icon(entry.value),
))
.toList(),
),
);
},
);
}
}
Укажем базовую информацию для вложенных экранов и посмотрим, что получилось:
Вложенная навигация с передачей параметров в AutoRoute
Теперь добавим вложенный маршрут для перехода в ItemScreen, передавая параметр index.
Как работает вложенная навигация?
Каждая вкладка (HomeTabScreen) представляет собой отдельный стек, который содержит свои дочерние экраны (HomeScreen, ItemScreen).
Чтобы реализовать такую структуру:
- Создадим HomeTabScreen
- Он будет родителем для HomeScreen и ItemScreen.
- Внутри используется AutoRouter(), который автоматически подставляет текущий дочерний экран.
- Можно добавить Provider/BlocProvider для передачи данных по всему стеку.
@RoutePage()
class HomeTabScreen extends StatelessWidget {
/// {@macro HomeTabScreen.class}
const HomeTabScreen({super.key});
@override
/// Мы также можем положить здесь что-то в дерево (Provider/BlocProvider/InheritedWidget...)
/// Данные будут доступны внутри всего стека навигации (в нашем случае в HomeScreen, ItemScreen)
Widget build(BuildContext context) => AutoRouter();
}
- Изменим AppRouter, добавив вложенные маршруты
AutoRoute(
path: '/',
page: TempRoute.page,
children: [
/// Таб-родитель.
AutoRoute(page: HomeTabRoute.page, path: 'home', children: [
/// Дочерние маршруты: HomeScreen
AutoRoute(page: HomeRoute.page, path: ''),
/// Дочерние маршруты: ItemScreen
AutoRoute(page: ItemRoute.page, path: 'items/:id'),
]),
AutoRoute(page: SearchRoute.page),
AutoRoute(page: BasketRoute.page),
AutoRoute(page: ProfileRoute.page),
],
),
Теперь ItemScreen будет доступен по path: ':id', и можно передавать index как параметр маршрута.
- Обработка параметров в ItemScreen
Чтобы получить id в ItemScreen, изменим его конструктор:
import 'package:auto_route/annotations.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
@RoutePage()
class ItemScreen extends StatelessWidget {
const ItemScreen(@PathParam() this.id, {super.key});
final String id;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text('Item Screen')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 10.0,
children: [
Text('Router path: ${context.router.currentPath}'),
Text('Provided item id -> $id'),
],
)),
);
}
Результат: теперь можно переходить на ItemScreen, передавая index:context.pushRoute(ItemRoute(id: '5'));