Навигация для SwiftUI
В стандартном NavigationStack есть ограничения: нельзя просто очистить стек экранов, неудобно обрабатывать logout, сложно управлять переходами между флоу.
Чтобы это решить, мы используем кастомный навигационный слой, построенный на базе:
- AppRouter<T: Hashable> — управляет навигационными событиями (
push,pop,replace,replaceStack,popUntilи др.) через Combine. - AppRouterHost<T: Hashable> — хост для
NavigationStack, слушает команды роутера и обновляет стек экранов. - AppRoute — перечисление маршрутов приложения (
signIn,main,detail).
Главное преимущество такого подхода — полный контроль над стеком навигации, анимациями и маршрутизацией.
AppRoute
AppRoute - это основа навигации. Каждый экран, на который вы хотите перейти, должен быть кейсом этого enum.
import SwiftUI
enum AppRoute: Hashable {
case signIn
case main
case detail(id: Int)
}
- Enum обязательно должен реализовывать
Hashable, чтобыNavigationStackмог работать с этим enum'ом. - Параметры экранов передаются через associated values
(.detail(id: Int)). - В сложных проектах можно заводить несколько Route, например AuthRoute для авторизации и AppRoute для основного функционала приложения.
AppRouter
AppRouter — это "пульт управления" навигацией. Он сам не переключает экраны, а только отправляет команды.
class AppRouter<T: Hashable>: ObservableObject {
// Приватные сабджекты для управления событиями
private let commandSubject = PassthroughSubject<RouterCommand<T>, Never>()
// Публичные паблишеры
var commandPublisher: AnyPublisher<RouterCommand<T>, Never> {
commandSubject.eraseToAnyPublisher()
}
/// Добавить экран следующим в стеке навигации
func push(_ route: T) {
commandSubject.send(.push(route: route))
}
/// Заменить весь стек навигации на новый роут
func replace(_ route: T) {
commandSubject.send(.replace(route: route))
}
/// Заменить весь стек навигации на другой стек
func replaceStack(_ stack: [T]) {
commandSubject.send(.replaceStack(stack: stack))
}
func popUntil(popIf: @escaping (T) -> Bool) {
commandSubject.send(.popUntil(popIf: popIf))
}
/// Убрать из стека навигации роуты удовлетворяющие условию и добавить новый
func popUntilAndPush(popIf: @escaping (T) -> Bool, pushRoute: T) {
commandSubject.send(.popUntilAndPush(popIf: popIf, pushRoutes: [pushRoute]))
}
/// Убрать из стека навигации роуты удовлетворяющие условию и добавить несколько новых
func popUntilAndPush(popIf: @escaping (T) -> Bool, pushRoutes: [T]) {
commandSubject.send(.popUntilAndPush(popIf: popIf, pushRoutes: pushRoutes))
}
/// Убрать из стека навигации один экран
func pop() {
commandSubject.send(.pop)
}
}
enum RouterCommand<T: Hashable> {
case push(route: T)
case popUntil(popIf: (T) -> Bool)
case popUntilAndPush(popIf: (T) -> Bool, pushRoutes: [T])
case pop
case replace(route: T)
case replaceStack(stack: [T])
}
Пример использования:
@EnvironmentObject var router: AppRouter<AppRoute>
router.push(.main) // перейти на главный экран
router.pop() // вернуться назад
router.replace(.signIn) // очистить стек и перейти на авторизацию
Как router передается между экранами:
Роутер удобно получать через @EnvironmentObject, так его не нужно вручную передавать во все экраны.
- AppRouterHost создаёт и хранит объект
AppRouter. - Все экраны внутри этого хоста получают роутер через
environmentObject(router). - SwiftUI автоматически вкладывает объект в иерархию view, так что любой экран, который находится внутри
AppRouterHost, может использовать его через @EnvironmentObject.
В AppRouterHost:
@ObservedObject private var router = AppRouter<T>()
var body: some View {
NavigationStack(path: $navigationPath) {
routeView(router, rootRoute)
.navigationDestination(
or: T.self,
destination: { routeView(router, $0) }
)
}.environmentObject(router) // роутер передается всем дочерним экранам
}
В любом дочернем экране:
struct AuthScreen: View {
@EnvironmentObject var router: AppRouter<AppRoute>
var body: some View {
Button("Войти") {
router.replace(.main)
}
}
}
AppRouterHost
AppRouterHost связывает AppRouter с NavigationStack. Он слушает команды роутера и обновляет, какие экраны должны быть показаны.
Основные свойства:
rootRoute— корневой экран (с которого начинается навигация).navigationPath— стек экранов, которые уже открыты.routeView- билдер конкретного экрана. В аргумент приходит роут. Когда используем enum для роута - делаем просто switch по вариантам этого enum и возвращаем нужные экраны.
Пример:
AppRouterHost<AppRoute>(initialRoute: .signIn) { router, route in
switch route {
case .signIn:
AuthScreen()
case .main:
MainScreen()
case let .detail(id):
DetailScreen(id: id)
}
}
- При старте открывается signIn.
- Если вызвать router.push(.main) → перейдем на экран main.
- Если вызвать router.pop() → вернемся обратно на signIn.
В итоге у нас получается такая последовательность:
Экран → AppRouter (отправил команду) → AppRouterHost (выполнил) → NavigationStack (обновился)
RootScreenView
Как видно из названия, это корневой экран приложения, именно он решает какой экран показать в данный момент. Если в приложении несколько роутов, то именно здесь будет происходить переключение между ними
struct RootScreenView: View {
@State private var root: RootScreen = .splash
var body: some View {
LogoutNavigationHookView(onLogout: {
root = .mainFlow(route: .signIn)
}) {
switch root {
case .splash:
SplashScreen(root: $root)
case let .mainFlow(route):
MainNavigationView(initialRoute: route)
}
}
}
}
При запуске приложения root = .splash, соответсвенно показываться будет SplashScreen.
После этого сплешскрин определяет, авторизован юзер или нет.
Если не авторизван то меняет значение root на .mainFlow(.signIn).
Если авторизован, то меняет значение root на .mainFlow(.main).
В этот момент RootScreenView переключает показ на MainNavigationView.
Если пользователь нажимает "Выйти", то срабатывает LogoutNavigationHookView, и всё сбрасывается на SignIn.
LogoutNavigationHookView
В приложениях часто нужно сбросить навигацию при выходе пользователя. Для этого используется LogoutNavigationHookView. Он оборачивает контент и отслеживает события логаута через logoutHandler. Когда происходит событие логаута, вызывается onLogout(), и можно, например, сбросить стек навигации на экран авторизации.
import Combine
import MultiPlatformLibrary
import SwiftUI
struct LogoutNavigationHookView<Content: View>: View {
// LogoutNavigationHookView слушает события логаута через logoutHandler.
private var logoutHandler: LogoutHandler = Koin.instance.getLogoutHandler()
let onLogout: () -> Void
let content: () -> Content
init(onLogout: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content) {
self.onLogout = onLogout
self.content = content
}
var body: some View {
content()
.onReceive(
logoutHandler.logoutEvents.toPublisher()
.catch { _ in Empty<KotlinUnit, Never>() }
.assertNoFailure()
) { _ in
// При логауте вызывается onLogout(), которое сбрасывает root.
onLogout()
}
}
}
В этом примере вызов onLogout приводит к сбросу значения root на .mainFlow(.signIn).
RootScreenView реагирует на изменение root и показывает экран авторизации.
struct RootScreenView: View {
@State private var root: RootScreen = .splash
var body: some View {
LogoutNavigationHookView(onLogout: {
// Сбрасываем стек навигации на экран авторизации
root = .mainFlow(route: .signIn)
}) {
switch root {
case .splash:
SplashScreen(root: $root)
case let .mainFlow(route):
MainNavigationView(initialRoute: route)
}
}
}
}
Если мы получаем событие logout на любом экране, весь стек навигации автоматически сбрасывается.