Navigator представляет собой виджет, который управляет стеком объектов Route: push кладёт новый экран наверх, pop снимает верхний и снова показывает экран под ним.
Route: одна запись в стеке; MaterialPageRoute или CupertinoPageRoute строит страницу и даёт ей платформенный переход
Откуда он берётся: корневой Navigator создаёт MaterialApp; Navigator.of(context) находит ближайший над вашим виджетом
Назад: системная кнопка «назад» и стрелка в app bar вызывают maybePop, который не снимает последний маршрут (дальше система закрывает приложение); свайп в iOS снимает маршрут напрямую
**ModalRoute.of(context)**: маршрут, в котором живёт текущий виджет: его settings, isCurrent и animation перехода
Результат: push возвращает Future, который завершается значением, переданным в pop
FlutterJunior
Навигация и маршрутизация
Navigator, именованные маршруты, go_router, передача данных между экранами
Закрыто вопросов
0 / 24
- 0Повторить
- 6Часто спрашивают
Вопросы
push кладёт маршрут наверх, pushReplacement заменяет верхний, pushAndRemoveUntil кладёт новый и снимает маршруты под ним, пока предикат не скажет «хватит», а popUntil возвращается к маршруту, который уже есть в стеке. После логина нужен pushAndRemoveUntil(home, (r) => false).
**pushReplacement: старого верхнего маршрута больше нет, назад к нему не вернуться; хватает, если логин был единственным экраном
pushAndRemoveUntil**: предикат (r) => false снимает всё, (r) => r.isFirst оставляет первый маршрут
**popUntil(ModalRoute.withName('/cart'))**: сворачивает многошаговый флоу; нужны маршруты с именами, поэтому передавайте settings при их создании
**С go_router**: context.go('/home') строит стек заново из локации, так что логина в нём уже нет; ещё лучше переключить состояние авторизации и дать redirect перевести пользователя
Выход из аккаунта: сессию очищайте до навигации, иначе redirect, который ещё видит валидный токен, вернёт пользователя обратно
Вперёд данные передаются через конструктор нового экрана, а результат возвращается через pop со значением: push возвращает Future, который завершается тем, что второй экран передал в pop.
Аргументы конструктора: проверяются компилятором, поэтому это вариант по умолчанию
Именованные маршруты: arguments приходят как Object? в RouteSettings, каждое чтение требует каста, и опечатка падает только в рантайме
**go_router**: path- и query-параметры для id, extra для объекта, которому не место в URL (в вебе он теряется при перезагрузке)
**Результат может быть null: считайте это отменой
Общие данные**: если они нужны нескольким несвязанным экранам, держите их в стейт-холдере, а не передавайте через маршруты
Выведет picked: null. Future от push всегда завершается, когда маршрут снят, и если значение никто не передал (системное «назад», свайп в iOS, стрелка в app bar), он завершается с null.
**Настоящий тип равен String?**: push<String> возвращает Future<String?>, и анализатор заставит обработать null
**Считайте null отменой**: if (color == null) return; перед тем как использовать значение
Нужно отличать «отменил» от «ничего не выбрал»? Возвращайте небольшой тип результата, например sealed-класс или enum, а не нагружайте смыслом null
MaterialApp.routes сопоставляет точные имена с экранами; любое имя, которого там нет, уходит в onGenerateRoute, где вы сами разбираете имя и возвращаете Route. Всё, что осталось, ловит onUnknownRoute.
**Разбирайте через Uri.parse(settings.name!)**: тогда работают pathSegments и queryParameters, и query-строка не ломает сопоставление
**Передавайте settings дальше**: в возвращаемый MaterialPageRoute, иначе popUntil, ModalRoute.withName и аналитика теряют имя маршрута
**settings.arguments имеет тип Object?: проверяйте тип и уходите на unknown route, а не давайте кривому deep link упасть
Статус именованных маршрутов сегодня**: документация Flutter больше не рекомендует их для большинства приложений: они не дают настроить обработку deep links и не поддерживают кнопку «вперёд» в браузере; go_router делает этот разбор за вас
context принадлежит App, который находится выше MaterialApp, а MaterialApp создаёт Navigator внутри себя. Navigator.of ищет только вверх по дереву, поэтому из этого контекста Navigator не найти.
Вынесите экран в отдельный виджет: home: const HomePage(); контекст страницы находится ниже Navigator, поэтому в реальных приложениях это редко встречается
**Или оберните кнопку в Builder**: он даёт контекст на уровень глубже, уже внутри MaterialApp
**Или используйте GlobalKey<NavigatorState>**: переданный в MaterialApp.navigatorKey, для кода, у которого вообще нет контекста виджета
Вложенный близнец: внутри вкладки или ShellRoute Navigator.of находит тот Navigator; чтобы добраться до внешнего, используйте rootNavigator: true (или useRootNavigator: true у showDialog)
Все три кладут модальный маршрут в Navigator, так что то, что выглядит как оверлей, оказывается настоящей записью в стеке: «назад» сначала закрывает его, а возвращает он то, что вы передали в Navigator.pop.
**showDialog**: кладёт DialogRoute; barrierDismissible: false запрещает закрытие тапом снаружи
**showModalBottomSheet**: ModalBottomSheetRoute; isScrollControlled: true разрешает шторке быть выше половины экрана, что нужно шторке с текстовым полем
**showMenu**: popup-маршрут, привязанный к позиции; PopupMenuButton вызывает его за вас
**useRootNavigator: диалоги по умолчанию идут в корневой Navigator, а шторки не идут; во вкладке нижней навигации со своим Navigator шторка иначе откроется под панелью вкладок
Частая ошибка**: использовать context после await; сначала проверьте context.mounted, потому что страницы к этому моменту может уже не быть
Оберните экран в PopScope с canPop: false, пока есть несохранённые изменения; тогда pop отклоняется, onPopInvokedWithResult вызывается с didPop == false, и вы спрашиваете пользователя и закрываете экран сами, если он подтвердил.
Почему так разделено: canPop задаётся обычным bool, известным заранее, поэтому предиктивный жест «назад» в Android может показать правильное превью; старый WillPopScope (deprecated с Flutter 3.12) отвечал через Future, а этот жест ждать не умеет
**onPopInvokedWithResult**: заменил onPopInvoked во Flutter 3.24; вызывается после решения, поэтому didPop == true значит, что маршрута уже нет
Что он ловит: «назад» в Android (включая предиктивный жест), кнопку «назад» в браузере и maybePop, который вызывает стрелка в app bar; в iOS при canPop: false свайп от края просто отключается и колбэк не вызывается, поэтому нужна и видимая кнопка «Сохранить»/«Отменить». Ваш собственный Navigator.pop или context.go он не ловит
Частая ошибка: оставить canPop: true и показывать диалог в колбэке: к моменту появления диалога экран уже закрыт
Другие применения: закрыть поле поиска вместо экрана, сохранить черновик при уходе
Navigator 1.0 императивный: вы вызываете push и pop. Navigator 2.0 декларативный: вы отдаёте Navigator список pages, построенный из состояния приложения, а Router синхронизирует его с URL. Он появился, потому что deep links, web-URL и пересборка стека императивно давались тяжело.
Страницы, а не вызовы: меняете состояние, пересобираете, и Navigator сравнивает новый список pages со старым, сам добавляя и убирая маршруты
Ключи важны: страницы сопоставляются по типу и key; дайте каждой стабильный ValueKey, иначе при вставке страницы стейт пойдёт за позицией
Pop возвращается к вам: система всё равно может снять страницу, поэтому обрабатывайте onDidRemovePage (он заменил onPopPage во Flutter 3.24) и убирайте эту страницу из состояния, иначе следующая пересборка вернёт её обратно
Части Router: RouteInformationParser превращает URL в ваше состояние, RouterDelegate строит из него Navigator; писать их руками многословно, поэтому в приложениях используют go_router
Оба живут вместе: даже с go_router вы всё равно вызываете Navigator.push для разового диалога или пикера
go заменяет весь стек тем, который ваша таблица маршрутов задаёт для этой локации; push кладёт одну страницу поверх того, что уже есть.
**go**: локация меняется, и стек строится заново из дерева маршрутов, поэтому «назад» идёт вверх по дереву, а не туда, откуда пришёл пользователь. Используйте для верхнеуровневых разделов, переключения вкладок и deep links
**push**: добавляет страницу и возвращает Future, который завершается через context.pop(result), поэтому «назад» возвращает ровно туда, где был пользователь. Используйте для деталей, редакторов и пикеров
Частая ошибка: go на экран деталей внутри вкладки нижней навигации выбрасывает историю этой вкладки; push для переключения вкладок складывает вкладки друг на друга
Рядом: pushReplacement заменяет верхнюю страницу, replace заменяет её, сохраняя ключ страницы; extra переносит объекты, которые не влезают в URL, и теряется при перезагрузке в вебе
Дайте каждой вкладке свой Navigator и держите все вкладки живыми: в go_router это StatefulShellRoute.indexedStack, который держит по ветке на вкладку и переключается между ними, ничего не уничтожая.
**IndexedStack: ответ на чистом Flutter: все вкладки остаются в дереве, отрисовывается только выбранная; стейт вкладок сохраняется, но своей истории у вкладки нет
История на вкладку**: вложенный Navigator на каждую вкладку, чтобы экран деталей в Поиске не перекрывал Главную; StatefulShellRoute создаёт эти Navigator за вас, и deep links всё равно попадают в нужную вкладку
**goBranch(i, initialLocation: ...)**: переключает вкладки; true при повторном тапе по активной вкладке сворачивает её к корню, как и ждут пользователи
Частая ошибка: у обычного ShellRoute один Navigator, поэтому переключение вкладок через go выбрасывает стек предыдущей вкладки
Полноэкранные страницы поверх панели: укажите маршруту parentNavigatorKey: rootNavigatorKey
Кнопки делают pop с context страницы, а не диалога. showDialog кладёт диалог в корневой Navigator, но внутри вкладки ближайшим к странице оказывается собственный Navigator вкладки, поэтому pop снимает OrderPage, а диалог остаётся на экране.
Почему раньше работало: при одном Navigator оба контекста находят один и тот же, и верхним маршрутом в нём оказывается диалог, так что неправильный контекст случайно закрывал правильный маршрут
Теперь Navigator два: showDialog по умолчанию использует useRootNavigator: true, а Navigator.pop(context) ищет вверх от страницы и находит Navigator вкладки, где на вершине стоит OrderPage
Побочные эффекты: OrderPage снимается с результатом false, а Future из showDialog не завершится, пока диалог не закроют как-то иначе, так что await у вызывающего кода зависает
Исправление: дать параметру билдера имя (dialogContext) и делать pop через него: контекст изнутри маршрута всегда находит Navigator, в котором этот маршрут лежит; для нижних панелей (bottom sheet) и меню правило то же: делать pop через контекст билдера (нижняя панель по умолчанию использует ближайший Navigator, поэтому баг проявляется только с useRootNavigator: true)
Как поймать: не выбрасывать контекст билдера как _ в билдерах диалогов и нижних панелей; виджет-тест с вложенным Navigator нажимает No и проверяет, что диалог закрыт, а страница на месте
Через redirect: go_router вызывает его перед показом любой локации, и вы возвращаете другой путь, чтобы отправить пользователя туда, или null, чтобы пропустить. Передайте состояние авторизации как refreshListenable, чтобы redirect запускался заново при каждом входе и выходе.
Верхний уровень для общего правила: не вошёл, значит /login; redirect на отдельном GoRoute закрывает более тонкие правила, например админку
Проверяйте, где пользователь уже находится: возвращайте null, если он уже на /login; этот redirect дописывает ?from=..., поэтому без проверки каждый проход строит новую, более длинную локацию, а старые версии go_router упираются в redirectLimit
Сохраняйте цель: положите исходную локацию в query-параметр, чтобы deep link на /cart продолжился после входа
Пока неизвестно: пока токен грузится из хранилища, держите всех на splash-маршруте, а не мигайте экраном логина
Частая ошибка: проверять авторизацию в build страницы: защищённый экран уже построен и лежит в стеке
Новый GoRouter создаётся внутри build, поэтому каждая перестройка App (здесь смена темы) передаёт в MaterialApp.router свежий роутер, который стартует со своей начальной локации, и весь стек навигации теряется.
Почему так происходит: Router видит новый routerConfig, читает информацию о маршруте из нового роутера и строит стек для /; открытая страница /settings принадлежала старому роутеру
Срабатывает на любую перестройку выше: тема, локаль, notifier авторизации, провайдер над MaterialApp; баг проявляется только при смене такого значения, поэтому беглая ручная проверка его пропускает
Заодно это утечка: у старого роутера никто не вызывает dispose, поэтому его слушатели, включая подписку на refreshListenable, остаются висеть
Исправление: создавать роутер один раз: top-level final, поле State (создаётся в initState, освобождается в dispose) или синглтон в DI; изменения авторизации доходят до него через refreshListenable, а не через новый роутер
Как поймать: виджет-тест, который открывает маршрут, меняет тему и проверяет, что страница всё ещё на экране; на код-ревью GoRouter( внутри метода build служит тревожным сигналом
Стек будет [Home, A, B], сверху B: pushReplacement заменил C на D, поэтому последний pop снял D и открыл B. Следующее «назад» покажет A.
Пошагово: [Home] → [Home, A] → [Home, A, B] → [Home, A, B, C] → [Home, A, B, D] → [Home, A, B]
**pushReplacement**: кладёт новый маршрут и убирает тот, что под ним, так что стек не растёт; C пропадает насовсем
**pop снимает верхний**: здесь это D, а не C; часто неверно отвечают [Home, A, B, C]
Родственные вызовы: pushAndRemoveUntil очищает стек до условия (флоу логина), popUntil откатывается до маршрута без push
Проверка: NavigatorObserver.didPush, didReplace и didPop залогируют каждый шаг, если сомневаетесь в результате
Используйте проверяемые https-ссылки: разместите assetlinks.json и apple-app-site-association в /.well-known/ на своём домене, объявите домен в приложении и дайте MaterialApp.router получить путь; с версии 3.27 Flutter по умолчанию передаёт deep links в роутер.
**Кастомная схема (myapp://product/42): просто, но любое приложение может заявить ту же схему, а в браузере она ничего не значит
App Links / Universal Links: https-URL открывает приложение, если оно установлено, и сайт, если нет; ОС проверяет ваш файл на домене, чтобы убедиться, что домен ваш, поэтому перехватить такие ссылки нельзя
Android**: intent filter с android:autoVerify="true" и SHA-256 релизного ключа подписи в assetlinks.json; проверить можно через adb shell pm get-app-links <package>
iOS: entitlement Associated Domains applinks:example.com; Apple забирает файл через свой CDN, поэтому изменения доходят до устройств часами
Свой обработчик: с app_links или SDK вендора выключите обработку во Flutter (flutter_deeplinking_enabled / FlutterDeepLinkingEnabled = false), чтобы ссылка не обрабатывалась дважды
Проверка: adb shell am start -a android.intent.action.VIEW -d <url> и xcrun simctl openurl booted <url>, и при холодном, и при тёплом старте
Навигируйте через объект, который живёт вне дерева виджетов: GlobalKey<NavigatorState>, переданный в MaterialApp.navigatorKey, или (с go_router) сам экземпляр GoRouter, лежащий в DI-контейнере.
Ключ: navigatorKey.currentState?.push(...) работает из сервиса; currentContext даёт контекст для диалога
**go_router**: роутер и есть API: router.go('/orders/42') или router.push(...) из обработчика уведомления
Ломает тайминг: тап может прийти до первого кадра; currentState ещё null, и ?. молча проглатывает вызов. Сохраните отложенную локацию и используйте её, когда приложение поднимется
Два случая в Firebase Messaging: getInitialMessage() для тапа, который запустил приложение (используйте как initialLocation), onMessageOpenedApp для тапа, когда приложение было в фоне
Чище для большинства событий: отправьте событие в слой состояния, и пусть на него отреагирует виджет, у которого есть контекст
По умолчанию Flutter web использует hash-стратегию: всё после # на сервер не отправляется, поэтому подходит любой статический хостинг. Чистые пути (usePathUrlStrategy()) отправляют на сервер полный путь, и сервер должен отвечать на неизвестные пути файлом index.html.
Как включить: вызовите usePathUrlStrategy() из package:flutter_web_plugins/url_strategy.dart до runApp; нужен flutter_web_plugins из SDK в pubspec.yaml, а <base href="/"> должен остаться в web/index.html; на мобильных он ничего не делает
Потом настройте хостинг: rewrite ** на /index.html в Firebase Hosting, try_files $uri $uri/ /index.html в nginx; хостинг без rewrite служит причиной остаться на hash-URL
**extra нет в URL: при обновлении или по ссылке он теряется, поэтому всё, что нужно странице для восстановления, кладите в path- или query-параметры
Проверка**: вставьте URL в новую вкладку: вы должны попасть на тот же экран с разумным стеком под ним
Используйте PageRouteBuilder (или CustomTransitionPage в go_router) и оберните страницу в свой переход внутри transitionsBuilder; animation ведёт этот маршрут при появлении и уходе, а secondaryAnimation ведёт его, пока поверх него кладут другой маршрут.
**pageBuilder**: возвращает целевой виджет; transitionsBuilder(context, animation, secondaryAnimation, child) оборачивает его
**animation: от 0 до 1 при push, от 1 до 0 при pop
secondaryAnimation: от 0 до 1, когда следующий маршрут перекрывает этот; через неё сдвигают или затемняют старый экран
Кривые и тайминг**: пропустите значения 0..1 через animation.drive(CurveTween(curve: ...)); transitionDuration и reverseTransitionDuration задаются отдельно
На всё приложение, а не на маршрут: задайте pageTransitionsTheme в ThemeData; стандартный MaterialPageRoute и так использует платформенный вид
Оберните виджет в Hero с **одинаковым tag на обоих экранах; во время перехода Flutter поднимает его в оверлей и анимирует из старой позиции и размера в новые.
Теги уникальны в пределах экрана**: два Hero с одним тегом на маршруте падают с «There are multiple heroes that share the same tag within a subtree», поэтому в списках используют 'product-${product.id}'
Только между страницами: Hero летают при переходах PageRoute; диалог или bottom sheet их не запускают
Текст в полёте становится красным с жёлтым подчёркиванием: у летящего виджета нет предка Material; оберните его в Material(type: MaterialType.transparency)
Разная форма: flightShuttleBuilder или createRectTween сглаживают смену пропорций или переход от круглой картинки к квадратной
Вложенные Navigator: у Navigator, который вы создали сами, нет HeroController, поэтому оберните его в HeroControllerScope
Используйте RouteObserver и подмешайте RouteAware в State экрана: didPushNext вызывается, когда маршрут перекрывают, а didPopNext вызывается, когда он снова виден. Перекрытый экран не уничтожается, поэтому без этого его видео продолжит играть.
Экран под низом остаётся живым: сохраняет State, таймеры и стримы; Flutter лишь перестаёт его отрисовывать и выключает его тикеры, поэтому AnimationController встают на паузу, а видеоплеер не встаёт; страницу скрывает только непрозрачный маршрут
Считаются только полноэкранные страницы: используйте RouteObserver<PageRoute<void>>; с ModalRoute диалог или шторка тоже вызовут didPushNext, хотя страница остаётся видимой и видео должно играть
Настройка: добавьте observer в MaterialApp.navigatorObservers (или GoRouter(observers: ...); вложенному Navigator от ShellRoute нужен свой) и подписывайтесь в didChangeDependencies
Частая ошибка: ждать didChangeAppLifecycleState: приложение всё ещё resumed, поменялся только маршрут
**maintainState: false**: у MaterialPageRoute полностью выбрасывает перекрытую страницу; её локальный стейт при возврате теряется
Защитите навигацию сами: Navigator никогда не убирает дубликаты, потому что два маршрута из одного виджета действительно остаются двумя маршрутами. Достаточно флага, который блокирует второй тап, пока первая навигация не закончилась.
Почему так происходит: оба тапа приходят до начала первого перехода, а await перед push расширяет окно до целого сетевого запроса
Флаг в State: _navigating = true перед работой, сброс в finally, чтобы ошибка не заблокировала список
**isCurrent недостаточно**: пока первый маршрут не положен, он всё ещё true, так что два тапа до начала перехода он не остановит; оставьте флаг
Отключите контрол: onPressed: _busy ? null : _open даёт ту же защиту, и пользователь видит, почему ничего не происходит
В масштабе: вынесите защиту в один хелпер навигации, а не в каждый виджет, чтобы экраны, добавленные позже, тоже её получили
Сделайте так, чтобы URL описывал иерархию, и пусть роутер построит все страницы над целевой: в go_router вложите маршрут товара в маршрут каталога, тогда /catalog/product/42 даст [Home, Catalog, Product], и «назад» пойдёт вверх, а не наружу.
Как строится стек
**go_router**: go строит по странице на каждый совпавший маршрут от корня до листа; верхнеуровневый /product/:id даёт стек из одной страницы
Navigator 1.0: initialRoute: '/catalog/product/42' заставляет стандартный onGenerateInitialRoutes положить /, /catalog и полный путь, если все они есть в routes
Вкладки: со StatefulShellRoute кладите маршрут в ту ветку, к которой он относится, тогда ссылка ещё и выберет нужную вкладку
Когда родителя нет в URL
Детали, доступные из разных мест: выберите одного канонического родителя для ссылок; внутри приложения их по-прежнему можно открывать через push откуда угодно
Родителю нужны данные: пусть он грузит их по своим path-параметрам, тогда холодная ссылка построит его без стейта предыдущего экрана
Холодный и тёплый старт
Холодный старт: ссылка становится начальной локацией, и стек строится из неё
Приложение уже открыто: go заменяет всё, что было открыто у пользователя; для тапа по уведомлению часто нужен push поверх текущего стека. Решайте для каждого типа ссылки и проверяйте оба случая
Включите state restoration во Flutter: задайте приложению restorationScopeId и открывайте экраны через restorable*-методы Navigator, тогда фреймворк сохранит стек маршрутов в ОС и пересоберёт его после убийства процесса.
**MaterialApp(restorationScopeId: 'app'): без корневого restoration scope ничего не восстанавливается, и именно этой строки чаще всего не хватает
Navigator.restorablePush(context, _orderRoute, arguments: id)**: _orderRoute должен быть статической или top-level функцией с аннотацией @pragma('vm:entry-point'): сохраняется ссылка на неё и аргументы, поэтому замыкание восстановить нельзя, а аргументы должны быть простыми значениями (то, что умеет кодировать StandardMessageCodec)
Стейт виджетов: RestorationMixin со значениями Restorable*; ListView с restorationId восстанавливает позицию прокрутки
**С go_router**: состоянием служит локация: MaterialApp.router(restorationScopeId: ...) плюс GoRouter(restorationScopeId: ...); extra не восстанавливается
Держите данные маленькими: у сохраняемых данных есть лимит размера, поэтому храните id, а остальное загружайте заново
Проверка: включите Don't keep activities в параметрах разработчика или уведите приложение в фон и выполните adb shell am kill <package>