Навигация в SwiftUI: Пълно ръководство за NavigationStack, NavigationSplitView и Deep Linking

Научете как да изградите модерна навигация в SwiftUI с NavigationStack, NavigationSplitView, Router шаблон и deep linking. Ръководство с реални примери на код за iOS, iPad и macOS.

Въведение: Защо навигацията е толкова важна за всяко iOS приложение

Ако сте правили приложения за iOS (дори съвсем прости), знаете, че навигацията е гръбнакът на цялото потребителско изживяване. Тя определя как хората се движат между екраните, как намират функциите, които търсят, и как възприемат структурата на приложението ви. Честно казано, лошата навигация може да съсипе дори най-добрата идея.

В SwiftUI навигацията претърпя сериозна еволюция — от ранните дни на NavigationView до модерните NavigationStack и NavigationSplitView, въведени в iOS 16.

В това ръководство ще разгледаме всичко, от което имате нужда: NavigationStack за линейна навигация, NavigationSplitView за многоколонни интерфейси, програмна навигация с NavigationPath, Router/Coordinator шаблон и дълбоко свързване (deep linking). Всяка концепция идва с реални примери на код, които можете да ползвате директно в проектите си.

Еволюция на навигацията в SwiftUI

Преди да навлезем в съвременните API-та, нека хвърлим бърз поглед назад. Разбирането на историята помага да оцените защо новите инструменти са толкова по-добри.

NavigationView — наследеният подход

NavigationView беше първият инструмент за навигация в SwiftUI, въведен заедно с фреймуърка през 2019 г. Предоставяше основна stack-базирана навигация, но имаше доста ограничения:

  • Навигацията беше тясно свързана с изгледите (views), което правеше тестването трудно
  • Програмната навигация изискваше неудобни заобикалящи решения с isActive binding-и
  • Нямаше вградена поддръжка за дълбоко свързване
  • Поведението се различаваше значително между iPhone и iPad (което е доста досадно)

С iOS 16 Apple обяви NavigationView за остарял (deprecated) и го замени с два нови компонента: NavigationStack и NavigationSplitView.

Новата парадигма: Данно-ориентирана навигация

Модерният подход е фундаментално различен. Вместо да управлявате навигацията чрез булеви binding-и, вие описвате навигационното състояние като данни. И това наистина променя играта.

  • Типова безопасност: Маршрутите (routes) са дефинирани като типове, не като низове
  • Тестваемост: Навигационната логика може да се тества отделно от изгледите
  • Мащабируемост: Централизираното управление на маршрути улеснява работата с големи приложения
  • Дълбоко свързване: Програмното управление на стека позволява лесна интеграция на deep links

NavigationStack: Основата на модерната навигация

NavigationStack е главният контейнер за stack-базирана навигация в SwiftUI. Управлява стек от изгледи, където всеки нов екран се „добавя отгоре" на предишния, а потребителят може да се връща назад с жест или бутон.

Основно използване

Най-простият начин да започнете е с NavigationLink:

import SwiftUI

struct ContentView: View {
    let fruits = ["Ябълка", "Банан", "Череша", "Диня", "Грозде"]

    var body: some View {
        NavigationStack {
            List(fruits, id: \.self) { fruit in
                NavigationLink(fruit, value: fruit)
            }
            .navigationTitle("Плодове")
            .navigationDestination(for: String.self) { fruit in
                FruitDetailView(name: fruit)
            }
        }
    }
}

struct FruitDetailView: View {
    let name: String

    var body: some View {
        VStack {
            Text(name)
                .font(.largeTitle)
            Text("Детайли за \(name)")
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
        .navigationTitle(name)
    }
}

Обърнете внимание на ключовата разлика: вместо да вграждаме destination изгледа директно в NavigationLink, използваме .navigationDestination(for:) модификатор. Той свързва тип данни с конкретен изглед и по този начин разделя навигационните данни от визуалното представяне. Просто и чисто.

Работа с множество типове destinations

В истинско приложение ще имате различни типове екрани. Спокойно можете да регистрирате няколко .navigationDestination модификатора:

struct Product: Identifiable, Hashable {
    let id: UUID
    let name: String
    let price: Double
    let category: Category
}

struct Category: Identifiable, Hashable {
    let id: UUID
    let name: String
}

struct ShopView: View {
    let products: [Product]
    let categories: [Category]

    var body: some View {
        NavigationStack {
            List {
                Section("Категории") {
                    ForEach(categories) { category in
                        NavigationLink(category.name, value: category)
                    }
                }

                Section("Продукти") {
                    ForEach(products) { product in
                        NavigationLink(value: product) {
                            HStack {
                                Text(product.name)
                                Spacer()
                                Text("\(product.price, specifier: "%.2f") лв.")
                                    .foregroundStyle(.secondary)
                            }
                        }
                    }
                }
            }
            .navigationTitle("Магазин")
            .navigationDestination(for: Product.self) { product in
                ProductDetailView(product: product)
            }
            .navigationDestination(for: Category.self) { category in
                CategoryView(category: category)
            }
        }
    }
}

NavigationPath: Програмно управление на навигационния стек

NavigationPath е може би най-мощната иновация в модерната SwiftUI навигация. Ако трябва да избера една функция, която промени начина ми на работа — тази е.

Казано просто, NavigationPath е type-erased контейнер, който може да съхранява стойности от различни типове (стига да са Hashable). Чрез binding към него получавате пълен програмен контрол над навигационния стек.

Основно използване на NavigationPath

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

    var body: some View {
        NavigationStack(path: $path) {
            VStack(spacing: 20) {
                Button("Отиди на профила") {
                    path.append("profile")
                }

                Button("Отиди на настройки") {
                    path.append("settings")
                }

                Button("Покажи продукт #42") {
                    path.append(42)
                }
            }
            .navigationTitle("Начало")
            .navigationDestination(for: String.self) { screen in
                switch screen {
                case "profile":
                    ProfileView()
                case "settings":
                    SettingsView()
                default:
                    Text("Непознат екран: \(screen)")
                }
            }
            .navigationDestination(for: Int.self) { productId in
                ProductDetailView(productId: productId)
            }
        }
    }
}

Управление на стека

NavigationPath ви дава пълен набор от операции за контрол на стека:

// Добавяне на нов екран отгоре на стека
path.append("details")

// Премахване на последния екран (връщане назад)
path.removeLast()

// Премахване на няколко екрана наведнъж
path.removeLast(3)

// Изчистване на целия стек (връщане на root)
path.removeLast(path.count)

// Проверка на броя елементи в стека
print("Екрани в стека: \(path.count)")

// Проверка дали стекът е празен
if path.isEmpty {
    print("На началния екран сме")
}

Тази функционалност е невероятно полезна за неща като: връщане към началния екран след завършване на покупка, навигиране през множество екрани при deep linking, или програмно управление на потребителски потоци.

Type-Safe маршрутизация с Enum

Добре, да бъда честен — използването на обикновени низове и числа за навигация не е най-добрата идея за production код. Много по-разумно е да дефинирате маршрутите си като enum:

Дефиниране на маршрути

enum AppRoute: Hashable {
    case home
    case productList(categoryId: UUID)
    case productDetail(productId: UUID)
    case cart
    case checkout
    case orderConfirmation(orderId: String)
    case profile
    case settings
    case editProfile
}

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

    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: AppRoute.self) { route in
                    switch route {
                    case .home:
                        HomeView()
                    case .productList(let categoryId):
                        ProductListView(categoryId: categoryId)
                    case .productDetail(let productId):
                        ProductDetailView(productId: productId)
                    case .cart:
                        CartView()
                    case .checkout:
                        CheckoutView()
                    case .orderConfirmation(let orderId):
                        OrderConfirmationView(orderId: orderId)
                    case .profile:
                        ProfileView()
                    case .settings:
                        SettingsView()
                    case .editProfile:
                        EditProfileView()
                    }
                }
        }
    }
}

Какво печелите с този подход?

  • Компилаторна проверка: Невъзможно е да навигирате към несъществуващ маршрут
  • Автодовършване: Xcode ви подсказва наличните маршрути и параметрите им
  • Рефакториране: Промяна на маршрут се отразява навсякъде автоматично
  • Документация: Enum-ът действа като централна карта на всички екрани

Router шаблон: Централизирано управление на навигацията

За по-сериозни приложения (а и за по-добра архитектура като цяло) е добра идея да извлечете навигационната логика в отделен Router клас. Така навигацията се разделя от изгледите и става тестваема.

Имплементация на Router

import SwiftUI
import Observation

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

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

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

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

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

    func goToRoot() {
        path.removeLast(path.count)
    }

    // MARK: - Удобни методи за чести навигации

    func showProduct(_ productId: UUID) {
        navigate(to: .productDetail(productId: productId))
    }

    func startCheckout() {
        navigate(to: .checkout)
    }

    func completeOrder(orderId: String) {
        // Изчистваме стека и показваме потвърждението
        goToRoot()
        navigate(to: .orderConfirmation(orderId: orderId))
    }
}

Интегриране на Router в приложението

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

    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $router.path) {
                HomeView()
                    .navigationDestination(for: AppRoute.self) { route in
                        routeToView(route)
                    }
            }
            .environment(router)
        }
    }

    @ViewBuilder
    private func routeToView(_ route: AppRoute) -> some View {
        switch route {
        case .home:
            HomeView()
        case .productList(let categoryId):
            ProductListView(categoryId: categoryId)
        case .productDetail(let productId):
            ProductDetailView(productId: productId)
        case .cart:
            CartView()
        case .checkout:
            CheckoutView()
        case .orderConfirmation(let orderId):
            OrderConfirmationView(orderId: orderId)
        case .profile:
            ProfileView()
        case .settings:
            SettingsView()
        case .editProfile:
            EditProfileView()
        }
    }
}

Използване на Router в child изгледи

Ето как изглежда реалното използване на Router вътре в конкретен изглед:

struct ProductDetailView: View {
    let productId: UUID
    @Environment(AppRouter.self) private var router
    @State private var product: Product?

    var body: some View {
        ScrollView {
            if let product {
                VStack(alignment: .leading, spacing: 16) {
                    Text(product.name)
                        .font(.title)

                    Text("\(product.price, specifier: "%.2f") лв.")
                        .font(.title2)
                        .foregroundStyle(.blue)

                    Text(product.description)
                        .font(.body)

                    Button("Добави в кошницата и продължи") {
                        // Добавяме в кошницата...
                        router.navigate(to: .cart)
                    }
                    .buttonStyle(.borderedProminent)

                    Button("Виж подобни продукти") {
                        router.navigate(to: .productList(
                            categoryId: product.category.id
                        ))
                    }
                }
                .padding()
            }
        }
        .navigationTitle("Продукт")
        .task {
            product = await loadProduct(id: productId)
        }
    }
}

NavigationSplitView: Многоколонна навигация

NavigationSplitView е създаден за приложения, които се нуждаят от многоколонен интерфейс — типичен за iPad, macOS и visionOS. Автоматично адаптира поведението си спрямо платформата и размера на екрана, което е страхотно.

Двуколонен интерфейс

struct MailAppView: View {
    @State private var selectedFolder: MailFolder?
    @State private var selectedMessage: Message?

    let folders: [MailFolder] = MailFolder.sampleData

    var body: some View {
        NavigationSplitView {
            // Странична лента (sidebar)
            List(folders, selection: $selectedFolder) { folder in
                Label(folder.name, systemImage: folder.icon)
            }
            .navigationTitle("Пощенски кутии")
        } detail: {
            // Детайлна колона
            if let selectedFolder {
                MessageListView(
                    folder: selectedFolder,
                    selectedMessage: $selectedMessage
                )
            } else {
                ContentUnavailableView(
                    "Изберете папка",
                    systemImage: "tray",
                    description: Text("Изберете папка от страничната лента")
                )
            }
        }
    }
}

Триколонен интерфейс

За приложения като пощенски клиент или файлов мениджър, NavigationSplitView поддържа и три колони. Ето пример:

struct ThreeColumnMailView: View {
    @State private var selectedFolder: MailFolder?
    @State private var selectedMessage: Message?
    @State private var columnVisibility: NavigationSplitViewVisibility = .all

    let folders: [MailFolder] = MailFolder.sampleData

    var body: some View {
        NavigationSplitView(columnVisibility: $columnVisibility) {
            // Странична лента
            List(folders, selection: $selectedFolder) { folder in
                Label(folder.name, systemImage: folder.icon)
            }
            .navigationTitle("Папки")
        } content: {
            // Средна колона — списък със съобщения
            if let selectedFolder {
                List(selectedFolder.messages, selection: $selectedMessage) { message in
                    VStack(alignment: .leading) {
                        Text(message.sender)
                            .font(.headline)
                        Text(message.subject)
                            .font(.subheadline)
                        Text(message.preview)
                            .font(.caption)
                            .foregroundStyle(.secondary)
                            .lineLimit(2)
                    }
                }
                .navigationTitle(selectedFolder.name)
            } else {
                ContentUnavailableView(
                    "Изберете папка",
                    systemImage: "folder"
                )
            }
        } detail: {
            // Детайлна колона — съдържание на съобщението
            if let selectedMessage {
                MessageDetailView(message: selectedMessage)
            } else {
                ContentUnavailableView(
                    "Изберете съобщение",
                    systemImage: "envelope",
                    description: Text("Изберете съобщение за да го прочетете")
                )
            }
        }
        .navigationSplitViewStyle(.balanced)
    }
}

Адаптивно поведение на NavigationSplitView

Едно от най-хубавите неща при NavigationSplitView е, че автоматично се адаптира:

  • iPhone: Показва се като stack навигация — по един екран наведнъж
  • iPad (портрет): Страничната лента е overlay
  • iPad (пейзаж): Всички колони са видими едновременно
  • macOS: Класически многоколонен интерфейс с полупрозрачна странична лента
  • visionOS: Материал от стъкло с пространствена дълбочина

Можете да контролирате видимостта на колоните и програмно:

// Показване на всички колони
columnVisibility = .all

// Показване само на детайлната колона
columnVisibility = .detailOnly

// Автоматично поведение (по подразбиране)
columnVisibility = .automatic

// Двуколонен режим (скриване на страничната лента)
columnVisibility = .doubleColumn

Комбиниране на NavigationStack и NavigationSplitView

Една от по-мощните техники (и лично за мен — любима) е да вградите NavigationStack вътре в NavigationSplitView. Така добавяте допълнителна stack навигация в детайлната колона:

struct AdvancedNavigationView: View {
    @State private var selectedCategory: Category?
    @State private var detailPath = NavigationPath()

    let categories: [Category]

    var body: some View {
        NavigationSplitView {
            List(categories, selection: $selectedCategory) { category in
                Text(category.name)
            }
            .navigationTitle("Категории")
        } detail: {
            NavigationStack(path: $detailPath) {
                if let selectedCategory {
                    ProductListView(categoryId: selectedCategory.id)
                        .navigationDestination(for: AppRoute.self) { route in
                            switch route {
                            case .productDetail(let id):
                                ProductDetailView(productId: id)
                            case .cart:
                                CartView()
                            default:
                                EmptyView()
                            }
                        }
                } else {
                    ContentUnavailableView(
                        "Изберете категория",
                        systemImage: "square.grid.2x2"
                    )
                }
            }
        }
        .onChange(of: selectedCategory) {
            // Изчистваме навигационния стек при смяна на категория
            detailPath.removeLast(detailPath.count)
        }
    }
}

Важен момент: NavigationSplitView автоматично обвива кореновите изгледи в NavigationStack, така че не е нужно да го добавяте ръчно, освен ако не искате програмен контрол на пътя.

Дълбоко свързване (Deep Linking)

Дълбокото свързване е способността на приложението да обработва URL-и и да навигира директно до конкретно съдържание. С модерната SwiftUI навигация, реализацията е значително по-чиста от преди.

Обработка на URL схеми

Първо дефинирайте URL схема за приложението (например myshop://) и я добавете в Info.plist. След това обработвайте входящи URL-и с .onOpenURL:

struct DeepLinkHandler {
    static func parse(url: URL) -> [AppRoute]? {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
              let host = components.host else {
            return nil
        }

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

        switch host {
        case "product":
            guard let idString = pathComponents.first,
                  let id = UUID(uuidString: idString) else {
                return nil
            }
            return [.productDetail(productId: id)]

        case "category":
            guard let idString = pathComponents.first,
                  let id = UUID(uuidString: idString) else {
                return nil
            }
            return [.productList(categoryId: id)]

        case "checkout":
            return [.cart, .checkout]

        case "order":
            guard let orderId = pathComponents.first else {
                return nil
            }
            return [.orderConfirmation(orderId: orderId)]

        default:
            return nil
        }
    }
}

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

extension AppRouter {
    func handleDeepLink(url: URL) {
        guard let routes = DeepLinkHandler.parse(url: url) else {
            print("Невалиден deep link: \(url)")
            return
        }

        // Изчистваме текущия стек
        goToRoot()

        // Добавяме маршрутите последователно
        for route in routes {
            path.append(route)
        }
    }
}

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

    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $router.path) {
                HomeView()
                    .navigationDestination(for: AppRoute.self) { route in
                        routeToView(route)
                    }
            }
            .environment(router)
            .onOpenURL { url in
                router.handleDeepLink(url: url)
            }
        }
    }
}

Universal Links

Universal Links използват стандартни HTTPS URL-и вместо custom схеми. Идеята е проста: ако приложението е инсталирано — URL-ът се отваря в него; ако не е — отваря се в браузъра. Конфигурацията изисква:

  1. Добавяне на Associated Domains entitlement (applinks:yourdomain.com)
  2. Хостване на apple-app-site-association файл на сървъра
  3. Обработка на линковете с .onOpenURL или чрез NSUserActivity
// apple-app-site-association файл на сървъра
{
    "applinks": {
        "apps": [],
        "details": [
            {
                "appID": "TEAM_ID.com.example.myshop",
                "paths": [
                    "/products/*",
                    "/categories/*",
                    "/orders/*"
                ]
            }
        ]
    }
}

Тестване на Deep Links в Simulator

Можете да тествате deep links в iOS Simulator с командата xcrun:

// Тестване на custom URL scheme
xcrun simctl openurl booted "myshop://product/550e8400-e29b-41d4-a716-446655440000"

// Тестване на universal link
xcrun simctl openurl booted "https://myshop.com/products/550e8400-e29b-41d4-a716-446655440000"

Запазване и възстановяване на навигационно състояние

Ето нещо, за което много разработчици забравят: запазването на навигационното състояние между сесиите. Това е особено важно при iPad multitasking или когато системата убие приложението ви на заден план.

Codable маршрути

Първо направете маршрутите Codable:

enum AppRoute: Hashable, Codable {
    case home
    case productList(categoryId: UUID)
    case productDetail(productId: UUID)
    case cart
    case checkout
    case orderConfirmation(orderId: String)
    case profile
    case settings
    case editProfile
}

Запазване на NavigationPath

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

    private static let savedPathKey = "savedNavigationPath"

    func save() {
        guard let representation = path.codable else { return }

        do {
            let data = try JSONEncoder().encode(representation)
            UserDefaults.standard.set(data, forKey: Self.savedPathKey)
        } catch {
            print("Грешка при запазване на навигационния път: \(error)")
        }
    }

    func restore() {
        guard let data = UserDefaults.standard.data(forKey: Self.savedPathKey) else {
            return
        }

        do {
            let representation = try JSONDecoder().decode(
                NavigationPath.CodableRepresentation.self,
                from: data
            )
            path = NavigationPath(representation)
        } catch {
            print("Грешка при възстановяване на навигационния път: \(error)")
        }
    }
}

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

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

    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $router.path) {
                HomeView()
                    .navigationDestination(for: AppRoute.self) { route in
                        routeToView(route)
                    }
            }
            .environment(router)
            .onOpenURL { url in
                router.handleDeepLink(url: url)
            }
            .task {
                router.restore()
            }
            .onChange(of: scenePhase) { _, newPhase in
                if newPhase == .background {
                    router.save()
                }
            }
        }
    }
}

Модални презентации и навигация

Освен stack навигация, SwiftUI предлага и модални презентации. Използват се за съдържание, което изисква внимание или е временно — нещо като прозорец, който се появява „отгоре".

Sheet и FullScreenCover с Router

enum ModalRoute: Identifiable {
    case login
    case addProduct
    case filter(FilterOptions)
    case imageGallery([URL])

    var id: String {
        switch self {
        case .login: return "login"
        case .addProduct: return "addProduct"
        case .filter: return "filter"
        case .imageGallery: return "imageGallery"
        }
    }
}

@Observable
final class AppRouter {
    var path = NavigationPath()
    var presentedSheet: ModalRoute?
    var presentedFullScreen: ModalRoute?

    func present(_ modal: ModalRoute, fullScreen: Bool = false) {
        if fullScreen {
            presentedFullScreen = modal
        } else {
            presentedSheet = modal
        }
    }

    func dismissModal() {
        presentedSheet = nil
        presentedFullScreen = nil
    }
}

// Използване в главния изглед
struct RootView: View {
    @Environment(AppRouter.self) private var router

    var body: some View {
        @Bindable var router = router

        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: AppRoute.self) { route in
                    routeToView(route)
                }
        }
        .sheet(item: $router.presentedSheet) { modal in
            modalView(for: modal)
        }
        .fullScreenCover(item: $router.presentedFullScreen) { modal in
            modalView(for: modal)
        }
    }

    @ViewBuilder
    private func modalView(for modal: ModalRoute) -> some View {
        switch modal {
        case .login:
            LoginView()
        case .addProduct:
            AddProductView()
        case .filter(let options):
            FilterView(options: options)
        case .imageGallery(let urls):
            ImageGalleryView(urls: urls)
        }
    }
}

Навигационни гардове (Navigation Guards)

В production приложения често трябва да контролирате достъпа до определени екрани — например да изисквате вход преди достъп до профила. Ето един подход, който работи добре:

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

    private var guards: [(AppRoute) -> Bool] = []

    func addGuard(_ guard: @escaping (AppRoute) -> Bool) {
        guards.append(`guard`)
    }

    func navigate(to route: AppRoute) {
        // Проверяваме всички гардове
        for guardCheck in guards {
            if !guardCheck(route) {
                // Навигацията е блокирана
                return
            }
        }
        path.append(route)
    }
}

// Пример: Guard за автентикация
final class AuthService {
    var isAuthenticated = false
}

// Конфигуриране на гардове
func configureGuards(router: AppRouter, auth: AuthService) {
    router.addGuard { route in
        let protectedRoutes: [AppRoute] = [
            .profile, .editProfile, .checkout, .settings
        ]

        // Ако маршрутът е защитен и потребителят не е автентикиран
        if protectedRoutes.contains(route) && !auth.isAuthenticated {
            // Показваме модал за вход
            router.present(.login)
            return false
        }

        return true
    }
}

Toolbar и навигационен бар

SwiftUI ви дава богат набор от инструменти за персонализиране на навигационния бар. Ето един пример, който показва повечето от тях:

struct ProductListView: View {
    @Environment(AppRouter.self) private var router
    @State private var searchText = ""
    @State private var sortOrder: SortOrder = .name

    var body: some View {
        List {
            // Съдържание...
        }
        .navigationTitle("Продукти")
        .navigationBarTitleDisplayMode(.large)
        .searchable(text: $searchText, prompt: "Търсене на продукти")
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button {
                    router.present(.addProduct)
                } label: {
                    Image(systemName: "plus")
                }
            }

            ToolbarItem(placement: .secondaryAction) {
                Menu {
                    Picker("Сортиране", selection: $sortOrder) {
                        Text("По име").tag(SortOrder.name)
                        Text("По цена").tag(SortOrder.price)
                        Text("По рейтинг").tag(SortOrder.rating)
                    }
                } label: {
                    Image(systemName: "arrow.up.arrow.down")
                }
            }

            ToolbarItem(placement: .topBarLeading) {
                Button {
                    router.navigate(to: .cart)
                } label: {
                    Image(systemName: "cart")
                }
            }
        }
    }
}

Най-добри практики за навигация в SwiftUI

Нека обобщим най-важните правила за изграждане на надеждна навигация. Тези практики са плод на доста проби и грешки (поне от моя опит).

1. Използвайте един NavigationStack на прозорец

Избягвайте влагане на множество NavigationStack компоненти. Това води до непредсказуемо поведение и визуални бъгове. Един NavigationStack на коренно ниво — и управлявайте навигацията чрез NavigationPath.

2. Предпочитайте push над presentation

Използвайте stack навигация за йерархично съдържание. Модални презентации (sheet/fullScreenCover) — само за неща, които прекъсват нормалния поток.

3. Не вграждайте NavigationStack в NavigationSplitView без причина

NavigationSplitView автоматично обвива кореновите изгледи в NavigationStack. Добавяйте изричен NavigationStack само ако имате нужда от програмно управление.

4. Изчиствайте стека при смяна на контекст

Когато потребителят избере нова категория или раздел в NavigationSplitView, не забравяйте да изчистите стека в детайлната колона чрез .onChange.

5. Обработвайте deep links при студен старт

При студен старт universal link може да пристигне преди изгледите да са готови. Използвайте .task или .onAppear за да обработите запазени deep links.

6. Тествайте навигацията

С Router шаблона можете да тествате навигационната логика без да стартирате UI. Ето примери:

import Testing

@Suite("Router Tests")
struct RouterTests {
    @Test("Навигация към продукт добавя маршрут в стека")
    func navigateToProduct() {
        let router = AppRouter()
        let productId = UUID()

        router.showProduct(productId)

        #expect(router.path.count == 1)
    }

    @Test("GoToRoot изчиства целия стек")
    func goToRoot() {
        let router = AppRouter()
        router.navigate(to: .profile)
        router.navigate(to: .settings)
        router.navigate(to: .editProfile)

        router.goToRoot()

        #expect(router.path.isEmpty)
    }

    @Test("Deep link за продукт парсва правилно")
    func parseProductDeepLink() {
        let id = UUID()
        let url = URL(string: "myshop://product/\(id.uuidString)")!

        let routes = DeepLinkHandler.parse(url: url)

        #expect(routes?.count == 1)
    }

    @Test("CompleteOrder изчиства стека и показва потвърждение")
    func completeOrder() {
        let router = AppRouter()
        router.navigate(to: .cart)
        router.navigate(to: .checkout)

        router.completeOrder(orderId: "ORD-123")

        // Стекът трябва да съдържа само потвърждението
        #expect(router.path.count == 1)
    }
}

Примерна архитектура за production приложение

И така, нека обединим всичко в една пълна архитектура. Това е шаблонът, който бих препоръчал за сериозно приложение с TabView и множество навигационни потоци:

// MARK: - Маршрути

enum AppRoute: Hashable, Codable {
    case productList(categoryId: UUID)
    case productDetail(productId: UUID)
    case cart
    case checkout
    case orderConfirmation(orderId: String)
    case profile
    case settings
}

enum ModalRoute: Identifiable {
    case login
    case filter(FilterOptions)
    case addReview(productId: UUID)

    var id: String {
        switch self {
        case .login: "login"
        case .filter: "filter"
        case .addReview: "addReview"
        }
    }
}

enum TabRoute: Hashable {
    case home
    case search
    case favorites
    case profile
}

// MARK: - Router

@Observable
final class AppRouter {
    var selectedTab: TabRoute = .home
    var homePath = NavigationPath()
    var searchPath = NavigationPath()
    var favoritesPath = NavigationPath()
    var profilePath = NavigationPath()
    var presentedSheet: ModalRoute?

    var currentPath: NavigationPath {
        get {
            switch selectedTab {
            case .home: homePath
            case .search: searchPath
            case .favorites: favoritesPath
            case .profile: profilePath
            }
        }
        set {
            switch selectedTab {
            case .home: homePath = newValue
            case .search: searchPath = newValue
            case .favorites: favoritesPath = newValue
            case .profile: profilePath = newValue
            }
        }
    }

    func navigate(to route: AppRoute) {
        currentPath.append(route)
    }

    func goToRoot() {
        currentPath.removeLast(currentPath.count)
    }

    func switchTab(to tab: TabRoute) {
        if selectedTab == tab {
            // Двойно натискане — връща към корен
            goToRoot()
        } else {
            selectedTab = tab
        }
    }
}

// MARK: - Главен изглед

struct MainTabView: View {
    @Environment(AppRouter.self) private var router

    var body: some View {
        @Bindable var router = router

        TabView(selection: $router.selectedTab) {
            Tab("Начало", systemImage: "house", value: .home) {
                NavigationStack(path: $router.homePath) {
                    HomeView()
                        .withAppDestinations()
                }
            }

            Tab("Търсене", systemImage: "magnifyingglass", value: .search) {
                NavigationStack(path: $router.searchPath) {
                    SearchView()
                        .withAppDestinations()
                }
            }

            Tab("Любими", systemImage: "heart", value: .favorites) {
                NavigationStack(path: $router.favoritesPath) {
                    FavoritesView()
                        .withAppDestinations()
                }
            }

            Tab("Профил", systemImage: "person", value: .profile) {
                NavigationStack(path: $router.profilePath) {
                    ProfileView()
                        .withAppDestinations()
                }
            }
        }
        .sheet(item: $router.presentedSheet) { modal in
            modalView(for: modal)
        }
    }
}

// MARK: - Удобен модификатор за destinations

extension View {
    func withAppDestinations() -> some View {
        self.navigationDestination(for: AppRoute.self) { route in
            switch route {
            case .productList(let categoryId):
                ProductListView(categoryId: categoryId)
            case .productDetail(let productId):
                ProductDetailView(productId: productId)
            case .cart:
                CartView()
            case .checkout:
                CheckoutView()
            case .orderConfirmation(let orderId):
                OrderConfirmationView(orderId: orderId)
            case .profile:
                ProfileView()
            case .settings:
                SettingsView()
            }
        }
    }
}

Заключение

Навигацията в SwiftUI е изминала дълъг път. С NavigationStack, NavigationSplitView и NavigationPath имате на разположение мощна, типово безопасна и данно-ориентирана система, която покрива почти всеки сценарий.

Ето какво разгледахме:

  • NavigationStack — основният инструмент за линейна навигация с програмно управление
  • NavigationPath — type-erased контейнер за пълен контрол над стека
  • Type-safe маршрутизация с Enum — компилаторна безопасност за маршрутите
  • Router шаблон — централизирана, тестваема навигационна логика
  • NavigationSplitView — многоколонна навигация за iPad, macOS и visionOS
  • Deep Linking — обработка на URL схеми и universal links
  • Запазване на състояние — Codable маршрути за персистиране
  • Навигационни гардове — контрол на достъпа до защитени екрани
  • TabView интеграция — отделни стекове за всеки раздел

Започнете с простия NavigationStack и постепенно добавяйте сложност само когато имате нужда от нея. Няма смисъл да пишете Router за приложение с три екрана. Но когато проектът расте — тези шаблони ще ви спестят много главоболия.

За Автора Editorial Team

Our team of expert writers and editors.