Въведение: Защо навигацията е толкова важна за всяко iOS приложение
Ако сте правили приложения за iOS (дори съвсем прости), знаете, че навигацията е гръбнакът на цялото потребителско изживяване. Тя определя как хората се движат между екраните, как намират функциите, които търсят, и как възприемат структурата на приложението ви. Честно казано, лошата навигация може да съсипе дори най-добрата идея.
В SwiftUI навигацията претърпя сериозна еволюция — от ранните дни на NavigationView до модерните NavigationStack и NavigationSplitView, въведени в iOS 16.
В това ръководство ще разгледаме всичко, от което имате нужда: NavigationStack за линейна навигация, NavigationSplitView за многоколонни интерфейси, програмна навигация с NavigationPath, Router/Coordinator шаблон и дълбоко свързване (deep linking). Всяка концепция идва с реални примери на код, които можете да ползвате директно в проектите си.
Еволюция на навигацията в SwiftUI
Преди да навлезем в съвременните API-та, нека хвърлим бърз поглед назад. Разбирането на историята помага да оцените защо новите инструменти са толкова по-добри.
NavigationView — наследеният подход
NavigationView беше първият инструмент за навигация в SwiftUI, въведен заедно с фреймуърка през 2019 г. Предоставяше основна stack-базирана навигация, но имаше доста ограничения:
- Навигацията беше тясно свързана с изгледите (views), което правеше тестването трудно
- Програмната навигация изискваше неудобни заобикалящи решения с
isActivebinding-и - Нямаше вградена поддръжка за дълбоко свързване
- Поведението се различаваше значително между 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-ът се отваря в него; ако не е — отваря се в браузъра. Конфигурацията изисква:
- Добавяне на Associated Domains entitlement (
applinks:yourdomain.com) - Хостване на
apple-app-site-associationфайл на сървъра - Обработка на линковете с
.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 за приложение с три екрана. Но когато проектът расте — тези шаблони ще ви спестят много главоболия.