Навигация в SwiftUI: NavigationStack, Router-паттерн, deep linking и восстановление состояния

От основ NavigationStack до Router-паттерна, deep linking и восстановления состояния навигации. Рабочие примеры на Swift 6 для iOS 17+ с @Observable и типизированными маршрутами.

Навигация в SwiftUI: NavigationStack, Router-паттерн, deep linking и восстановление состояния

Если вы работали со SwiftUI с первых версий, то наверняка помните боль от NavigationView. Программное управление стеком? Нет. Pop to root? Только через костыли. Deep linking? Ну, удачи. Честно говоря, навигация была одним из самых слабых мест фреймворка вплоть до iOS 16, когда Apple наконец представила NavigationStack — и это реально всё изменило.

В этом руководстве мы пройдём путь от базового использования NavigationStack до серьёзных архитектурных паттернов: Router с макросом @Observable, обработка deep link-ов, навигация в TabView и восстановление состояния между запусками. Все примеры написаны под Swift 6.x и iOS 17+, с использованием @Observable вместо устаревшего ObservableObject.

Введение: почему NavigationStack заменил NavigationView

NavigationView был с нами с самого первого релиза SwiftUI в 2019 году. Для простых сценариев он работал нормально, но по мере роста приложений начинались проблемы:

  • Отсутствие программной навигации — единственный способ перехода — NavigationLink с привязкой к isActive или selection. Отправить пользователя на нужный экран из бизнес-логики было крайне неудобно.
  • Невозможность pop to root — чтобы вернуться к корневому экрану из глубины стека, приходилось городить хаки с @Environment(\.dismiss) или пробрасывать binding-и через всю иерархию. Мягко говоря, не элегантно.
  • Проблемы с deep linking — без программного управления стеком полноценный deep linking реализовать было практически невозможно.
  • Неконсистентное поведениеNavigationView по-разному работал на iPhone и iPad, что добавляло головной боли при разработке универсальных приложений.
  • Отсутствие восстановления состояния — встроенного механизма сохранения навигационного стека между запусками просто не было.

NavigationStack из iOS 16 решает все эти проблемы. Принцип простой: весь навигационный стек — это массив данных. Добавляем элемент — push. Удаляем — pop. Очищаем массив — pop to root. Просто, предсказуемо и, что важно, тестируемо.

Основы NavigationStack

Начнём с базового примера. В простейшем виде NavigationStack работает похоже на старый NavigationView:

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Профиль", value: "profile")
                NavigationLink("Настройки", value: "settings")
            }
            .navigationTitle("Главная")
            .navigationDestination(for: String.self) { value in
                switch value {
                case "profile":
                    ProfileView()
                case "settings":
                    SettingsView()
                default:
                    Text("Неизвестный экран")
                }
            }
        }
    }
}

Заметили ключевое отличие? NavigationLink теперь принимает значение (value), а не представление. Само представление задаётся в .navigationDestination(for:). Это разделение — фундаментальный принцип нового API, и именно оно делает возможным всё остальное.

Value-based подход с пользовательскими типами

Использование строк для маршрутов — плохая идея в реальных проектах. Опечатка — и привет, runtime-ошибка. Вместо этого определите enum маршрутов с конформностью к Hashable:

enum Route: Hashable {
    case profile(userId: Int)
    case settings
    case articleDetail(articleId: Int)
}

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Мой профиль", value: Route.profile(userId: 1))
                NavigationLink("Настройки", value: Route.settings)
                NavigationLink("Статья #42", value: Route.articleDetail(articleId: 42))
            }
            .navigationTitle("Главная")
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .profile(let userId):
                    ProfileView(userId: userId)
                case .settings:
                    SettingsView()
                case .articleDetail(let articleId):
                    ArticleDetailView(articleId: articleId)
                }
            }
        }
    }
}

Теперь компилятор сам проверит, что все маршруты обработаны. Добавили новый case в Route — Swift заставит вас добавить обработку в switch. Никаких сюрпризов в рантайме.

NavigationPath: навигация с разными типами данных

В реальных приложениях стек часто содержит экраны с разными типами данных. Из каталога товаров идём к карточке товара (Product), оттуда — к профилю продавца (User), потом — к отзывам (Review). Для таких сценариев есть NavigationPath — type-erased обёртка над навигационным путём.

struct Product: Hashable {
    let id: Int
    let name: String
}

struct Seller: Hashable {
    let id: Int
    let name: String
}

struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List {
                ForEach(products) { product in
                    NavigationLink(product.name, value: product)
                }
            }
            .navigationTitle("Каталог")
            .navigationDestination(for: Product.self) { product in
                ProductDetailView(product: product, path: $path)
            }
            .navigationDestination(for: Seller.self) { seller in
                SellerProfileView(seller: seller)
            }
        }
    }
}

struct ProductDetailView: View {
    let product: Product
    @Binding var path: NavigationPath

    var body: some View {
        VStack(spacing: 20) {
            Text(product.name)
                .font(.largeTitle)

            Button("Перейти к продавцу") {
                path.append(Seller(id: 1, name: "Иван Петров"))
            }
        }
        .navigationTitle("Товар")
    }
}

NavigationPath хранит значения любых типов, конформящих Hashable. Каждый .navigationDestination(for:) регистрирует обработчик для конкретного типа, а SwiftUI подбирает нужное представление автоматически.

Но если все маршруты описаны одним enum (и это, на мой взгляд, предпочтительный вариант), используйте обычный массив:

@State private var path: [Route] = []

NavigationStack(path: $path) {
    // ...
    .navigationDestination(for: Route.self) { route in
        // обработка маршрутов
    }
}

Типизированный [Route] даёт лучшую безопасность типов, а NavigationPath — гибкость при работе с разнородными типами. Выбирайте исходя из структуры вашего приложения.

Программная навигация

Вот где NavigationStack по-настоящему раскрывается. Возможность полностью программно управлять стеком — это его главная суперсила.

Push — переход на новый экран

Просто добавляем элемент в path:

// Push одного экрана
path.append(Route.profile(userId: 42))

// Push нескольких экранов сразу (например, после deep link)
path.append(Route.settings)
path.append(Route.profile(userId: 42))

Pop — возврат на предыдущий экран

// Удаляем последний элемент из стека
if !path.isEmpty {
    path.removeLast()
}

// Pop на два уровня назад
if path.count >= 2 {
    path.removeLast(2)
}

Pop to root — возврат к корневому экрану

// Очищаем весь стек — моментальный возврат к корню
path.removeAll()

Одна строка. Серьёзно. После всех тех мучений с NavigationView это ощущается как глоток свежего воздуха.

Давайте посмотрим на полный пример со всеми операциями:

struct NavigationDemoView: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            VStack(spacing: 16) {
                Button("Открыть профиль") {
                    path.append(.profile(userId: 1))
                }

                Button("Глубокая навигация") {
                    // Создаём стек из трёх экранов сразу
                    path = [
                        .settings,
                        .profile(userId: 1),
                        .articleDetail(articleId: 42)
                    ]
                }
            }
            .navigationTitle("Главная")
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .profile(let userId):
                    ProfileScreen(userId: userId, path: $path)
                case .settings:
                    SettingsScreen()
                case .articleDetail(let articleId):
                    ArticleScreen(articleId: articleId, path: $path)
                }
            }
        }
    }
}

struct ArticleScreen: View {
    let articleId: Int
    @Binding var path: [Route]

    var body: some View {
        VStack(spacing: 16) {
            Text("Статья #\(articleId)")
                .font(.title)

            Button("Назад") {
                if !path.isEmpty {
                    path.removeLast()
                }
            }

            Button("На главную") {
                path.removeAll()
            }
        }
        .navigationTitle("Статья")
    }
}

Программная навигация открывает путь к более сложным вещам: навигации из вью-моделей, реакции на push-уведомления, deep linking. Но чтобы всё это не превратилось в хаос, нужна архитектура. И тут на сцену выходит Router-паттерн.

Router-паттерн: централизованная навигация

Когда приложение растёт, передача @Binding var path через десятки экранов быстро становится неуправляемой. Логика навигации разбросана по всему проекту, тестировать это — одно мучение. Router-паттерн решает проблему, собирая всю навигационную логику в одном месте.

Определение маршрутов

Первый шаг — описать все маршруты приложения в enum. Каждый case — отдельный экран с нужными параметрами:

enum Route: Hashable {
    case home
    case profile(userId: Int)
    case settings
    case settingsDetail(section: SettingsSection)
    case articleList(categoryId: Int)
    case articleDetail(articleId: Int)
    case comments(articleId: Int)
    case userSearch
}

enum SettingsSection: String, Hashable {
    case general
    case notifications
    case privacy
    case about
}

Создание Router с @Observable

Используем макрос @Observable из iOS 17. В отличие от ObservableObject, он автоматически отслеживает обращения к свойствам и обновляет только те представления, которые реально зависят от изменившихся данных. Никаких @Published — просто объявляете свойства и всё работает:

import SwiftUI

@Observable
final class AppRouter {
    var path: [Route] = []

    // MARK: - Навигация

    func navigate(to route: Route) {
        path.append(route)
    }

    func navigateBack() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

    func navigateBack(steps: Int) {
        let stepsToRemove = min(steps, path.count)
        path.removeLast(stepsToRemove)
    }

    func navigateToRoot() {
        path.removeAll()
    }

    func replaceCurrentRoute(with route: Route) {
        guard !path.isEmpty else {
            path.append(route)
            return
        }
        path[path.count - 1] = route
    }

    // MARK: - Утилиты

    var currentRoute: Route? {
        path.last
    }

    var depth: Int {
        path.count
    }

    var isAtRoot: Bool {
        path.isEmpty
    }
}

Получился чистый, лаконичный API. Хочешь перейти куда-то — navigate(to:). Вернуться — navigateBack(). На корень — navigateToRoot(). Всё очевидно.

Фабрика представлений

Чтобы не загромождать корневое представление огромным switch, вынесем создание экранов в расширение роутера:

extension AppRouter {
    @ViewBuilder
    func destination(for route: Route) -> some View {
        switch route {
        case .home:
            HomeView()
        case .profile(let userId):
            ProfileView(userId: userId)
        case .settings:
            SettingsView()
        case .settingsDetail(let section):
            SettingsDetailView(section: section)
        case .articleList(let categoryId):
            ArticleListView(categoryId: categoryId)
        case .articleDetail(let articleId):
            ArticleDetailView(articleId: articleId)
        case .comments(let articleId):
            CommentsView(articleId: articleId)
        case .userSearch:
            UserSearchView()
        }
    }
}

Интеграция с Environment

Чтобы любое представление могло обращаться к роутеру, пробросим его через @Environment:

// Регистрация в Environment
extension EnvironmentValues {
    @Entry var router = AppRouter()
}

// Корневое представление приложения
@main
struct MyApp: App {
    @State private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(\.router, router)
        }
    }
}

// Корневое представление с NavigationStack
struct RootView: View {
    @Environment(\.router) private var router

    var body: some View {
        NavigationStack(path: Bindable(router).path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    router.destination(for: route)
                }
        }
    }
}

Обратите внимание на Bindable(router).path. Это способ создать Binding для свойства @Observable-объекта, полученного из @Environment. Нужен потому, что NavigationStack требует Binding к path. Поначалу конструкция выглядит непривычно, но быстро становится рутиной.

Использование роутера в представлениях

Теперь любой экран может навигировать, вообще не зная о существовании других экранов:

struct HomeView: View {
    @Environment(\.router) private var router

    var body: some View {
        List {
            Section("Профиль") {
                Button("Мой профиль") {
                    router.navigate(to: .profile(userId: 1))
                }
            }

            Section("Контент") {
                Button("Статьи по Swift") {
                    router.navigate(to: .articleList(categoryId: 1))
                }
                Button("Статьи по SwiftUI") {
                    router.navigate(to: .articleList(categoryId: 2))
                }
            }

            Section("Приложение") {
                Button("Настройки") {
                    router.navigate(to: .settings)
                }
            }
        }
        .navigationTitle("Главная")
    }
}

struct ArticleListView: View {
    let categoryId: Int
    @Environment(\.router) private var router

    var body: some View {
        List(0..<10, id: \.self) { index in
            Button("Статья \(index + 1)") {
                router.navigate(to: .articleDetail(articleId: index))
            }
        }
        .navigationTitle("Статьи")
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                Button("На главную") {
                    router.navigateToRoot()
                }
            }
        }
    }
}

struct ArticleDetailView: View {
    let articleId: Int
    @Environment(\.router) private var router

    var body: some View {
        VStack(spacing: 20) {
            Text("Статья #\(articleId)")
                .font(.largeTitle)

            Text("Здесь будет содержимое статьи...")
                .foregroundStyle(.secondary)

            Button("Комментарии") {
                router.navigate(to: .comments(articleId: articleId))
            }
            .buttonStyle(.borderedProminent)
        }
        .navigationTitle("Детали")
    }
}

Преимущества такого подхода ощутимы сразу. ArticleListView понятия не имеет о существовании ArticleDetailView — он просто говорит роутеру «открой статью с таким-то id». Вся навигационная логика в одном месте. Роутер легко покрывается unit-тестами. И если завтра понадобится добавить аналитику переходов — это одна точка изменения, а не двадцать представлений.

Deep linking: обработка внешних ссылок

Deep linking позволяет внешним ссылкам (из push-уведомлений, писем, других приложений) открывать конкретный экран вашего приложения. С Router-паттерном реализация получается на удивление элегантной.

Настройка URL-схемы

Для начала зарегистрируйте URL-схему в настройках проекта: Target → Info → URL Types. Добавьте схему, например, swiftcrafted. После этого приложение сможет обрабатывать ссылки вида swiftcrafted://profile/42.

Парсер deep link-ов

Создадим компонент, который превращает URL в типизированные маршруты:

enum DeepLinkParser {
    static func parse(url: URL) -> [Route]? {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
            return nil
        }

        let pathComponents = components.path
            .split(separator: "/")
            .map(String.init)

        guard let host = components.host ?? pathComponents.first else {
            return nil
        }

        switch host {
        case "profile":
            // swiftcrafted://profile/42
            if let idString = pathComponents.last,
               let userId = Int(idString) {
                return [.profile(userId: userId)]
            }

        case "article":
            // swiftcrafted://article/15
            if let idString = pathComponents.last,
               let articleId = Int(idString) {
                return [
                    .articleList(categoryId: 0),
                    .articleDetail(articleId: articleId)
                ]
            }

        case "settings":
            // swiftcrafted://settings/notifications
            if let sectionName = pathComponents.last,
               let section = SettingsSection(rawValue: sectionName) {
                return [.settings, .settingsDetail(section: section)]
            }
            return [.settings]

        case "search":
            // swiftcrafted://search?query=swift
            return [.userSearch]

        default:
            return nil
        }

        return nil
    }
}

Обратите внимание: парсер возвращает массив маршрутов, а не один маршрут. Это важный момент — мы формируем полный навигационный стек, чтобы пользователь мог нажать «назад» и оказаться в логичном месте иерархии.

Расширение роутера для deep linking

extension AppRouter {
    func handleDeepLink(url: URL) {
        guard let routes = DeepLinkParser.parse(url: url) else {
            print("Не удалось распознать deep link: \(url)")
            return
        }

        // Сбрасываем текущий стек и устанавливаем новый
        navigateToRoot()

        // Небольшая задержка, чтобы NavigationStack успел обработать сброс
        Task { @MainActor in
            try? await Task.sleep(for: .milliseconds(100))
            path = routes
        }
    }
}

Подключение в приложении

Для перехвата ссылок используем модификатор .onOpenURL:

@main
struct MyApp: App {
    @State private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(\.router, router)
                .onOpenURL { url in
                    router.handleDeepLink(url: url)
                }
        }
    }
}

Теперь при нажатии на ссылку swiftcrafted://article/42 приложение откроет карточку статьи с id 42, а свайп назад приведёт в список статей. Навигация ощущается естественно — как будто пользователь сам прошёл весь путь.

Для тестирования в симуляторе пригодится терминал:

xcrun simctl openurl booted "swiftcrafted://article/42"

TabView с несколькими навигационными стеками

Большинство iOS-приложений используют TabView как корневой элемент навигации, и каждая вкладка живёт со своим независимым стеком. Переключаешься между вкладками — каждая помнит, где ты остановился. Для этого каждой вкладке нужен свой path.

enum AppTab: Int, Hashable, CaseIterable {
    case home
    case explore
    case settings

    var title: String {
        switch self {
        case .home: "Главная"
        case .explore: "Обзор"
        case .settings: "Настройки"
        }
    }

    var icon: String {
        switch self {
        case .home: "house"
        case .explore: "magnifyingglass"
        case .settings: "gear"
        }
    }
}

@Observable
final class TabRouter {
    var selectedTab: AppTab = .home
    var homePath: [Route] = []
    var explorePath: [Route] = []
    var settingsPath: [Route] = []

    func path(for tab: AppTab) -> [Route] {
        switch tab {
        case .home: homePath
        case .explore: explorePath
        case .settings: settingsPath
        }
    }

    func navigate(to route: Route, in tab: AppTab? = nil) {
        let targetTab = tab ?? selectedTab
        switch targetTab {
        case .home: homePath.append(route)
        case .explore: explorePath.append(route)
        case .settings: settingsPath.append(route)
        }
    }

    func navigateToRoot(in tab: AppTab? = nil) {
        let targetTab = tab ?? selectedTab
        switch targetTab {
        case .home: homePath.removeAll()
        case .explore: explorePath.removeAll()
        case .settings: settingsPath.removeAll()
        }
    }

    func handleTabReselection(_ tab: AppTab) {
        if tab == selectedTab {
            navigateToRoot(in: tab)
        }
    }

    func handleDeepLink(url: URL) {
        guard let routes = DeepLinkParser.parse(url: url) else { return }

        // Определяем целевую вкладку по первому маршруту
        let targetTab: AppTab = switch routes.first {
        case .settings, .settingsDetail: .settings
        case .userSearch: .explore
        default: .home
        }

        navigateToRoot(in: targetTab)
        selectedTab = targetTab

        Task { @MainActor in
            try? await Task.sleep(for: .milliseconds(100))
            switch targetTab {
            case .home: homePath = routes
            case .explore: explorePath = routes
            case .settings: settingsPath = routes
            }
        }
    }
}

Корневое представление с TabView

struct MainTabView: View {
    @State private var tabRouter = TabRouter()

    var body: some View {
        TabView(selection: $tabRouter.selectedTab) {
            Tab(AppTab.home.title, systemImage: AppTab.home.icon,
                value: .home) {
                NavigationStack(path: $tabRouter.homePath) {
                    HomeView()
                        .navigationDestination(for: Route.self) { route in
                            destinationView(for: route)
                        }
                }
            }

            Tab(AppTab.explore.title, systemImage: AppTab.explore.icon,
                value: .explore) {
                NavigationStack(path: $tabRouter.explorePath) {
                    ExploreView()
                        .navigationDestination(for: Route.self) { route in
                            destinationView(for: route)
                        }
                }
            }

            Tab(AppTab.settings.title, systemImage: AppTab.settings.icon,
                value: .settings) {
                NavigationStack(path: $tabRouter.settingsPath) {
                    SettingsView()
                        .navigationDestination(for: Route.self) { route in
                            destinationView(for: route)
                        }
                }
            }
        }
        .environment(tabRouter)
        .onOpenURL { url in
            tabRouter.handleDeepLink(url: url)
        }
    }

    @ViewBuilder
    private func destinationView(for route: Route) -> some View {
        switch route {
        case .home:
            HomeView()
        case .profile(let userId):
            ProfileView(userId: userId)
        case .settings:
            SettingsView()
        case .settingsDetail(let section):
            SettingsDetailView(section: section)
        case .articleList(let categoryId):
            ArticleListView(categoryId: categoryId)
        case .articleDetail(let articleId):
            ArticleDetailView(articleId: articleId)
        case .comments(let articleId):
            CommentsView(articleId: articleId)
        case .userSearch:
            UserSearchView()
        }
    }
}

Ключевой момент здесь: каждая вкладка оборачивается в свой NavigationStack с собственным path. Навигация в одной вкладке никак не затрагивает другие. А TabRouter централизует управление всеми стеками — особенно удобно для deep linking, когда нужно одновременно переключить вкладку и задать стек.

Восстановление состояния навигации

Знакомая ситуация: пользователь углубился в навигационный стек, свернул приложение, система его выгрузила — и при следующем запуске всё сбросилось на главный экран. Раздражает, правда? Восстановление состояния решает эту проблему.

Делаем Route Codable

Первый шаг — научить маршруты сериализоваться. Просто добавляем конформность к Codable:

enum Route: Hashable, Codable {
    case home
    case profile(userId: Int)
    case settings
    case settingsDetail(section: SettingsSection)
    case articleList(categoryId: Int)
    case articleDetail(articleId: Int)
    case comments(articleId: Int)
    case userSearch
}

enum SettingsSection: String, Hashable, Codable {
    case general
    case notifications
    case privacy
    case about
}

Поскольку все ассоциированные значения — базовые типы (Int, String), Swift автоматически синтезирует Codable. Если бы в маршрутах были сложные модели, им тоже пришлось бы конформить Codable.

Сохранение и восстановление через UserDefaults

@Observable
final class PersistentRouter {
    var path: [Route] = [] {
        didSet {
            savePath()
        }
    }

    private let encoder = JSONEncoder()
    private let decoder = JSONDecoder()
    private let storageKey = "navigation_path"

    init() {
        restorePath()
    }

    // MARK: - Навигация

    func navigate(to route: Route) {
        path.append(route)
    }

    func navigateToRoot() {
        path.removeAll()
    }

    func navigateBack() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

    // MARK: - Персистентность

    private func savePath() {
        guard let data = try? encoder.encode(path) else { return }
        UserDefaults.standard.set(data, forKey: storageKey)
    }

    private func restorePath() {
        guard let data = UserDefaults.standard.data(forKey: storageKey),
              let savedPath = try? decoder.decode([Route].self, from: data)
        else { return }
        path = savedPath
    }

    func clearSavedState() {
        UserDefaults.standard.removeObject(forKey: storageKey)
    }
}

Использование NavigationPath.CodableRepresentation

Если вы используете NavigationPath вместо типизированного массива, Apple предлагает встроенный механизм сериализации:

@Observable
final class PathBasedRouter {
    var path = NavigationPath()

    private let encoder = JSONEncoder()
    private let decoder = JSONDecoder()
    private let storageKey = "navigation_path_data"

    init() {
        restorePath()
    }

    func savePath() {
        guard let codable = path.codable else {
            print("Путь содержит не-Codable элементы, сохранение невозможно")
            return
        }
        guard let data = try? encoder.encode(codable) else { return }
        UserDefaults.standard.set(data, forKey: storageKey)
    }

    func restorePath() {
        guard let data = UserDefaults.standard.data(forKey: storageKey),
              let codable = try? decoder.decode(
                  NavigationPath.CodableRepresentation.self,
                  from: data
              )
        else { return }
        path = NavigationPath(codable)
    }
}

Важный нюанс: path.codable вернёт nil, если хотя бы один элемент в NavigationPath не конформит Codable. Так что убедитесь, что все типы маршрутов это поддерживают, иначе сохранение молча провалится.

Интеграция с жизненным циклом приложения

Для надёжного сохранения привяжите его к событиям жизненного цикла:

@main
struct MyApp: App {
    @State private var router = PersistentRouter()
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(\.router, router)
        }
        .onChange(of: scenePhase) { _, newPhase in
            if newPhase == .background {
                // Сохраняем состояние при уходе в фон
                router.savePath()
            }
        }
    }
}

Ещё один вариант — @SceneStorage для автоматического сохранения на уровне сцены. Это хорошо работает для multi-window приложений на iPad, где у каждого окна своё навигационное состояние. Но помните, что @SceneStorage поддерживает только базовые типы (String, Int, Data), поэтому путь придётся сериализовать в Data вручную.

Распространённые ошибки и лучшие практики

За время работы с NavigationStack сообщество набило немало шишек. Вот самые частые ошибки, которые я встречал (и совершал сам).

Ошибка 1: мутация path во время обновления представления

Пожалуй, самая коварная из всех. Никогда не изменяйте навигационный путь прямо в body или computed properties. SwiftUI может зависнуть, выбросить предупреждение или повести себя совершенно непредсказуемо:

// НЕПРАВИЛЬНО — мутация path в body
var body: some View {
    let _ = router.navigate(to: .profile(userId: 1)) // Опасно!
    Text("Hello")
}

// ПРАВИЛЬНО — мутация path из действий пользователя
var body: some View {
    Text("Hello")
        .onAppear {
            router.navigate(to: .profile(userId: 1))
        }
        .task {
            // Или из асинхронного контекста
            await loadAndNavigate()
        }
}

Ошибка 2: хранение важного состояния в destination-представлениях

NavigationStack создаёт новые экземпляры представлений при каждом push. Пользователь заполнил форму, вернулся назад, зашёл снова — всё пропало. Храните важное состояние в роутере, вью-моделях или отдельном хранилище данных.

Ошибка 3: вложенные NavigationStack

Классика начинающих. Вложить один NavigationStack в другой — верный путь к двойным навигационным панелям и непредсказуемому поведению. В TabView каждая вкладка имеет свой стек, но они не должны быть вложены.

Ошибка 4: нестабильные значения в маршрутах

Если маршрут содержит мутабельный объект с изменяющимся hashValue, SwiftUI может «потерять» экран в стеке. Используйте только неизменяемые идентификаторы:

// НЕПРАВИЛЬНО — полная модель в маршруте
enum Route: Hashable {
    case product(Product) // Product может измениться
}

// ПРАВИЛЬНО — только идентификатор
enum Route: Hashable {
    case product(productId: Int) // id стабилен
}

Ошибка 5: размещение navigationDestination далеко от NavigationStack

Модификатор .navigationDestination(for:) лучше размещать максимально близко к NavigationStack — в идеале на его прямом дочернем представлении. Если запрятать его глубоко в иерархии, он может пересоздаваться при каждом обновлении родителя.

Ошибка 6: дублирование переходов при быстрых нажатиях

Двойной тап — и маршрут добавляется дважды. Простая защита:

func navigate(to route: Route) {
    // Предотвращаем дублирование последнего маршрута
    guard path.last != route else { return }
    path.append(route)
}

Лучшие практики

  • Один роутер — один стек. Не позволяйте нескольким объектам мутировать один path. Централизуйте логику навигации.
  • Enum вместо NavigationPath. Если все маршруты известны заранее, типизированный [Route] безопаснее.
  • Маршруты должны быть легковесными. Только идентификаторы, а полные данные загружайте на целевом экране.
  • Тестируйте навигацию. Router-паттерн делает unit-тесты навигации тривиальными — пользуйтесь этим.
  • Используйте @Observable. Современный макрос даёт более точное отслеживание зависимостей и лучшую производительность по сравнению с ObservableObject.
  • Обрабатывайте edge-кейсы deep linking. Невалидный URL не должен ронять приложение. Всегда предусматривайте fallback.

Часто задаваемые вопросы (FAQ)

Чем NavigationStack отличается от NavigationView?

NavigationView появился в SwiftUI 1.0 и работал через декларативные NavigationLink с привязкой к destination. Программного управления стеком не было, pop to root требовал хаков, поведение на iPhone и iPad различалось. NavigationStack (iOS 16+) предлагает data-driven подход: стек — это массив, которым управляешь программно. NavigationView официально deprecated, и для новых проектов Apple рекомендует только NavigationStack.

Как вернуться к корневому экрану (pop to root) в SwiftUI?

С NavigationStack — одна строка: path.removeAll() или path = []. Для NavigationPath аналогично: path = NavigationPath(). С Router-паттерном ещё проще — router.navigateToRoot() из любого экрана. То, что раньше требовало обходных путей, теперь делается моментально.

Можно ли использовать NavigationStack с TabView?

Да, и это рекомендуемый подход. Каждая вкладка TabView должна содержать свой NavigationStack с независимым path. Главное — не оборачивайте сам TabView в NavigationStack, иначе получите вложенные стеки и неожиданное поведение. Используйте TabRouter с отдельными массивами для каждой вкладки.

Как реализовать deep linking в SwiftUI-приложении?

Три шага. Первый — зарегистрировать URL-схему или настроить Universal Links. Второй — написать парсер, который превращает URL в массив типизированных маршрутов (именно массив, чтобы пользователь мог вернуться назад по иерархии). Третий — использовать .onOpenURL на корневом представлении для перехвата ссылок и передачи их роутеру.

Нужен ли Router-паттерн для небольших приложений?

Для приложений с 3–5 экранами — скорее всего, нет. Достаточно @State private var path: [Route] = [] в корневом представлении. Но как только появляется deep linking, несколько вкладок с навигацией, сохранение состояния или навигация из бизнес-логики — Router-паттерн окупается моментально. Мой совет: начинайте просто, а рефакторьте к роутеру, когда навигационная логика начинает расползаться по нескольким экранам.

Об авторе Editorial Team

Our team of expert writers and editors.