NavigationStack у SwiftUI для iOS 26: типобезпечна маршрутизація, NavigationPath і глибокі посилання
NavigationStack у SwiftUI для iOS 26: типобезпечна маршрутизація через navigationDestination, глибокі посилання з onOpenURL, збереження шляху через SceneStorage і робочі приклади коду Swift 6.2.
NavigationStack у SwiftUI — це декларативний контейнер маршрутизації, який у iOS 26 керує стеком навігації через типобезпечний масив значень і модифікатор navigationDestination(for:), замінюючи застарілий NavigationView. Він дозволяє відкривати екрани програмно, будувати глибокі посилання з URL, зберігати стан між запусками через SceneStorage та коректно поводитися з VoiceOver. У цьому посібнику я показую всі патерни, які реально працюють у продакшн-застосунках iOS 26, з робочим кодом (а не з псевдокодом).
NavigationStack у iOS 26 повністю замінив NavigationView: старий API офіційно позначено як deprecated ще з iOS 16, і у 2026 його вже не варто використовувати навіть для сумісності.
Типобезпечна маршрутизація будується через navigationDestination(for: Route.self), де Route є Hashable-переліком із усіма можливими напрямками застосунку.
NavigationPath — це стертий за типом контейнер, який дозволяє змішувати різні Hashable-значення в одному стеку і серіалізувати шлях через NavigationPath.CodableRepresentation.
Глибокі посилання реалізуються через onOpenURL або App Intents; парсер URL повертає масив маршрутів, який ви присвоюєте path-змінній стека.
Стан навігації переживає перезапуск за рахунок @SceneStorage плюс Data-представлення NavigationPath.
Для iPad і Mac використовуйте NavigationSplitView, а не NavigationStack, інакше зламається бічна панель і жести, призначені для великих екранів.
Чому NavigationView мертвий у 2026
Скажу відверто: якщо ваш код у 2026 році все ще використовує NavigationView, у мене до вас питання. Apple офіційно позначила його як deprecated ще в iOS 16 (2022), а в iOS 26 SDK він генерує попередження і в кількох сценаріях просто не поводиться так, як ви очікуєте. Прив'язки @Binding для активації переходів через isActive зникли, а спроби керувати стеком через колишні хакі призводять до непередбачуваних станів у режимі попереднього перегляду Xcode 26.
NavigationStack був представлений на WWDC22 і за чотири роки дозрів до стабільного, добре документованого API. Його головна перевага в тому, що він відокремлює стан навігації від UI. Ви більше не пишете «додай тут NavigationLink, а тут ще один», а описуєте маршрути один раз, і стек оновлюється через масив значень. Це критично для тестування: ви можете відкрити застосунок одразу на третьому екрані, задавши початковий шлях, без жодних UI-хаків.
У поєднанні з App Intents для iOS 26 це дає ще одну перевагу: Siri та Apple Intelligence можуть глибоко навігувати у ваш застосунок, передаючи маршрут як параметр інтенту. Стара модель NavigationView цього не підтримувала.
Анатомія NavigationStack і navigationDestination
Мінімальний робочий приклад складається з трьох елементів: контейнера NavigationStack, NavigationLink(value:), який штовхає значення в стек, і модифікатора navigationDestination(for:), який відображає екран для конкретного типу значення.
import SwiftUI
// 1. Опишіть усі маршрути одним переліком
enum Route: Hashable {
case articleDetail(id: UUID)
case authorProfile(handle: String)
case settings
}
struct ContentView: View {
// 2. Стек — це масив значень
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
List(sampleArticles) { article in
// 3. NavigationLink штовхає значення, а не View
NavigationLink(value: Route.articleDetail(id: article.id)) {
ArticleRow(article: article)
}
}
.navigationTitle("Стрічка")
// 4. Один navigationDestination на кожен тип
.navigationDestination(for: Route.self) { route in
switch route {
case .articleDetail(let id):
ArticleDetailView(id: id)
case .authorProfile(let handle):
AuthorProfileView(handle: handle)
case .settings:
SettingsView()
}
}
}
}
}
Зверніть увагу на дві речі. По-перше, NavigationLink тепер приймає value:, а не destination:. Це фундаментальний зсув: посилання не знає, який екран воно відкриває, воно лише публікує «намір» перейти. Хто його обробить, вирішує navigationDestination, зареєстрований у стеку. По-друге, navigationDestination має бути всередині контейнера NavigationStack, але не вкладеним у List чи ScrollView. Якщо ви поставите його не там, SwiftUI покаже пустий екран і жодного попередження. Я особисто на цьому втратив вечір у 2023 році, тож не робіть моєї помилки.
NavigationPath: типобезпечний стек із різнорідними значеннями
NavigationPath є стертим за типом контейнером, який дозволяє змішувати значення різних Hashable-типів у одному стеку. Використовуйте його, коли маршрути описані не одним переліком, а кількома незалежними типами (наприклад, у модульному застосунку, де фіча «профіль» і фіча «замовлення» не знають одна про одну).
struct RootView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Article.self) { article in
ArticleDetailView(article: article)
}
.navigationDestination(for: User.self) { user in
UserProfileView(user: user)
}
.navigationDestination(for: Order.ID.self) { orderID in
OrderDetailView(id: orderID)
}
}
}
}
// Штовхаємо значення різних типів у той самий стек
path.append(article) // Article
path.append(user) // User
path.append(order.id) // UUID -> Order.ID
Кожен модуль реєструє свій navigationDestination(for:), і NavigationPath сам знаходить правильний обробник за типом. Це відкриває шлях до дійсно модульної архітектури: фіча-модулі не залежать від головного застосунку, а лише експонують свої маршрути через Hashable-значення. Мій продакшн-код у трьох останніх проєктах побудований саме так.
NavigationPath також краще підходить для глибоких стеків. Масив [Route] вимагає, щоб компілятор бачив усі варіанти в одному місці, тоді як NavigationPath просто зберігає стерті типи як список AnyHashable. Для стеку з десятків екранів це впливає на час компіляції: я помічала різницю в 4-5 секунд у Xcode 26.
Як зробити програмну навігацію в SwiftUI
Оскільки path є звичайним масивом (або NavigationPath), ви керуєте навігацією як звичайною колекцією. Штовхаєте: append. Витягуєте: removeLast. Стрибаєте в корінь: присвоюєте порожнє значення. Жодних магічних API, жодних координаторів, які потрібно тримати живими.
Ось де стає в пригоді Approachable Concurrency у Swift 6.2: Router живе на MainActor за замовчуванням, тому ви безпечно змінюєте path з Task-блоків без анотацій. У Swift 6.1 і раніше потрібно було вручну маркувати @MainActor, тепер це поведінка за замовчуванням.
Аніматоване витягування є окремою темою. Обгортайте append у withAnimation(.spring(response: 0.35, dampingFraction: 0.85)), і SwiftUI автоматично зіграє системний push з правильною кривою пружності. Стандартний easing (без явної анімації) виглядає добре на iPhone, але на iPad і Mac помітно «різкий». Я тестувала це на пристроях: пружинна анімація з коефіцієнтом демпфування 0.85 виявилася золотою серединою.
Як реалізувати глибокі посилання з NavigationStack
Глибоке посилання є URL, який відкриває конкретний екран усередині застосунку. У SwiftUI обробка URL відбувається через модифікатор onOpenURL, який ви ставите на верхній рівень сцени. Ваша задача: розпарсити URL, побудувати масив маршрутів і присвоїти його path.
@main
struct SwiftCraftedApp: App {
@State private var router = Router()
var body: some Scene {
WindowGroup {
RootView()
.environment(router)
.onOpenURL { url in
router.handle(deepLink: url)
}
}
}
}
extension Router {
func handle(deepLink url: URL) {
guard url.scheme == "swiftcrafted" else { return }
// swiftcrafted://article/8d3f...
let parts = url.pathComponents.filter { $0 != "/" }
switch (url.host, parts.first) {
case ("article", let id?) where UUID(uuidString: id) != nil:
replace(with: [.articleDetail(id: UUID(uuidString: id)!)])
case ("author", let handle?):
replace(with: [.authorProfile(handle: handle)])
case ("settings", _):
replace(with: [.settings])
default:
popToRoot()
}
}
}
Замість append тут використовується replace(with:), і це важливо. Коли користувач тапає на посилання з Safari, він очікує потрапити саме на цільовий екран, а не побачити старий стек знизу. Якщо ваш дизайн вимагає зберегти попередній стек, обгортайте перехід у withAnimation та додавайте, а не замінюйте.
Для сучасних універсальних посилань (Universal Links) реєструйте домен через apple-app-site-association, як описано в офіційній документації Apple з associated domains. Той самий onOpenURL обробить і https://-посилання після налаштування capability. Якщо ваш застосунок також має віджети та Live Activities, ті самі маршрути можна повторно використати для їхніх тапів, і в цьому головна цінність єдиного Router-координатора.
Як зберегти стан навігації між запусками застосунку
NavigationPath має вбудоване Codable-представлення для випадку, коли всі значення в стеку є Codable. Поєднайте його з @SceneStorage, і ваш стек переживе перезапуск, свопання застосунків у пам'яті iOS і навіть краш.
struct RestorableRoot: View {
@SceneStorage("nav.path") private var pathData: Data?
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Route.self) { route in
routeView(for: route)
}
}
.onAppear { restore() }
.onChange(of: path) { _, newValue in persist(newValue) }
}
private func persist(_ path: NavigationPath) {
guard let repr = path.codable else { return }
pathData = try? JSONEncoder().encode(repr)
}
private func restore() {
guard let data = pathData,
let repr = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self, from: data)
else { return }
path = NavigationPath(repr)
}
}
Три застереження. По-перше, path.codable повертає nil, якщо хоч одне значення в стеку не є Codable. Перевіряйте всі маршрути. По-друге, @SceneStorage прив'язаний до конкретної сцени: на iPad із кількома вікнами кожне матиме власний стек. По-третє, при декодуванні NavigationPath потрібно, щоб типи, зареєстровані як маршрути, залишалися сумісними за іменем. Якщо ви перейменовуєте Route, існуючі збережені стеки стануть невалідними.
NavigationSplitView для iPad і Mac: коли він потрібен
На iPhone у портретній орієнтації NavigationStack є правильним вибором. Але на iPad, Mac і iPhone у ландшафті користувачі очікують бічну панель зі списком та деталями поруч. Це шаблон, який Apple будує через NavigationSplitView.
struct MultiColumnRoot: View {
@State private var selectedCategory: Category?
@State private var selectedArticle: Article?
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
CategoryList(selection: $selectedCategory)
} content: {
if let category = selectedCategory {
ArticleList(category: category, selection: $selectedArticle)
} else {
ContentUnavailableView("Оберіть категорію", systemImage: "folder")
}
} detail: {
NavigationStack {
if let article = selectedArticle {
ArticleDetailView(article: article)
} else {
ContentUnavailableView("Оберіть статтю", systemImage: "doc.text")
}
}
}
}
}
Ключовий момент: колонка деталей сама обгорнута в NavigationStack. Це дає користувачу можливість переходити далі всередині деталей (наприклад, з деталей статті у профіль автора), не втрачаючи бічну панель. У iOS 26 з'явився також параметр preferredCompactColumn, який дозволяє явно вказати, яка колонка показується в компактному розмірі. Уникайте спокуси реалізовувати split-view самостійно з HStack: ви програєте в жестах, VoiceOver-обході й реакції на Stage Manager.
Доступність і VoiceOver у стеку навігації
Це моя улюблена частина, і саме тут більшість застосунків провалюються. VoiceOver має знати, що фокус перемістився на новий екран, як називається кнопка «назад» і що робить кожен елемент керування навігацією.
NavigationStack робить більшість цього за вас: заголовок екрана автоматично оголошується як заголовок сторінки, кнопка «назад» отримує локалізовану підказку «Назад до <назва попереднього екрана>». Але є три речі, які потрібно робити руками.
Явний заголовок для екранів без navigationTitle. Якщо ви ховаєте заголовок через .toolbar(.hidden, for: .navigationBar), додайте .accessibilityLabel на кореневий контейнер, інакше VoiceOver прочитає «немає заголовка».
.accessibilityAddTraits(.isHeader) для власних кастомних заголовків. Якщо ви робите свій хедер замість системного, він має отримати трейт заголовка, щоб VoiceOver міг використати жест «наступний заголовок».
Фокус після переходу. Використовуйте @AccessibilityFocusState, щоб явно поставити фокус на перший значущий елемент нового екрана. За замовчуванням SwiftUI ставить фокус на кнопку «назад», що не завжди правильно.
struct ArticleDetailView: View {
@AccessibilityFocusState private var titleFocused: Bool
let article: Article
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(article.title)
.font(.largeTitle.bold())
.accessibilityAddTraits(.isHeader)
.accessibilityFocused($titleFocused)
Text(article.body)
}
.padding()
}
.onAppear { titleFocused = true }
}
}
Перевірити реальну поведінку можна через Accessibility Inspector у Xcode 26 або Voice Control на пристрої. Не покладайтеся на симулятор: там жести VoiceOver працюють інакше, ніж на реальному iPhone. Практика, до якої я закликаю кожну команду: включайте VoiceOver у чек-лист QA перед кожним релізом, а не тільки перед аудитом доступності.
Анімаційні нюанси переходів у iOS 26
iOS 26 приніс систему Liquid Glass, і NavigationStack отримав два нові переходи: .zoom(sourceID:in:) для розгортання карти в детальний вигляд і покращений navigationTransition(.automatic). Обидва вимагають, щоб ви позначили джерело переходу через matchedTransitionSource(id:in:).
@Namespace private var namespace
var body: some View {
NavigationStack(path: $path) {
ScrollView {
ForEach(articles) { article in
NavigationLink(value: article) {
ArticleCard(article: article)
}
.matchedTransitionSource(id: article.id, in: namespace)
}
}
.navigationDestination(for: Article.self) { article in
ArticleDetailView(article: article)
.navigationTransition(.zoom(sourceID: article.id, in: namespace))
}
}
}
Часовий інтервал переходу становить 350 мс на iPhone, 400 мс на iPad за замовчуванням. Ви можете підмінити криву, але я раджу не робити цього без вагомої причини: користувачі очікують системного відчуття. Якщо порушите, застосунок відразу здається «не рідним». Крива, яку Apple використовує для стандартного push, ближча до .spring(response: 0.4, dampingFraction: 0.86), ніж до .easeInOut; спробуйте самі порівняти в Instruments.
Часті граблі та як їх обійти
Пропущений navigationDestination. Якщо ви пушите значення, для якого не зареєстрований обробник, SwiftUI мовчки нічого не показує. Логуйте всі append у dev-збірках.
NavigationLink у List з NavigationSplitView. У split-view посилання зі списку мають використовувати selection: прив'язку, а не пушити значення в стек. Інакше вибір скидається при обертанні пристрою.
Заглиблені NavigationStack. Не вкладайте один стек в інший. Це технічно можливо, але жести назад плутаються, а стан кожного стека серіалізується окремо. Використовуйте один кореневий стек і один navigationDestination на тип.
Модальні sheet у стеку..sheet не є частиною стека навігації. Це окрема презентація, і всередині sheet ви маєте створювати новий NavigationStack, якщо потрібна навігація там. Не намагайтеся керувати sheet через path: це шлях у пекло стану.
Плутанина зі станом. Якщо ви тримаєте Router як @State в кореневому View, а не в @Observable-класі, він буде перестворюватися при кожному оновленні тіла. Тримайте маршрутизатор у @Observable-класі, зареєстрованому через .environment. Це також описано в офіційній документації Apple з міграції на нові типи навігації.
Поширені запитання
Чим NavigationStack відрізняється від NavigationView?
NavigationView керував переходами через NavigationLink(destination:), який був тісно прив'язаний до конкретного View. NavigationStack відокремлює стан навігації від UI: посилання публікує значення, а стек знаходить відповідний navigationDestination. Це дає типобезпечну маршрутизацію, глибокі посилання та програмний контроль стека.
Чи можна змішувати різні типи маршрутів в одному стеку?
Так, використовуйте NavigationPath замість типізованого масиву. Зареєструйте окремий navigationDestination(for:) для кожного типу, і NavigationPath сам знайде правильний обробник за фактичним типом значення.
Що робити, якщо навігація не працює після onOpenURL?
Перевірте три речі: URL-схему в Info.plist, що path є @State або @Observable (не локальна константа), і що ви присвоюєте новий масив, а не мутуєте старий у місці. SwiftUI реагує на зміну identity значення прив'язки, а не на in-place мутацію непрямих типів.
Як тестувати NavigationStack у прев'ю Xcode?
Створіть прев'ю, яке одразу задає ненульовий path. Наприклад: NavigationStack(path: .constant([Route.articleDetail(id: .init())])) { ... }. Це відкриє прев'ю одразу на цільовому екрані, без потреби тапати посилання.
Чи підтримує NavigationStack watchOS 26?
Так, повністю. API ідентичний iPhone-версії. Єдина відмінність: на watchOS немає жесту «свайп для повернення», тож кнопка «назад» у навігаційній панелі є єдиним способом. Тому продумуйте глибину стеку: 3-4 рівні вже забагато для Apple Watch.
Розберемось, як макрос @Observable у Swift замінює ObservableObject у SwiftUI: гранулярне стеження, @Bindable, @ObservationIgnored, withObservationTracking та новинки iOS 26.
Практичний посібник з хаптики у SwiftUI для iOS 26: модифікатор .sensoryFeedback з 11 типами, умовні тригери, Core Haptics та власні AHAP-патерни з реальними значеннями intensity і sharpness.
Практичний посібник з нового TabView у SwiftUI для iOS 26: новий тип Tab, стиль sidebarAdaptable, TabSection, роль пошуку, кастомізація й адаптація до iPad, Mac Catalyst і visionOS з прикладами коду.