NavigationStack no SwiftUI: Guia de Navegação Programática e Deep Links (iOS 26)
NavigationStack no iOS 26 exige um novo mindset: a pilha vira dado. Veja como fazer navegação programática, deep links tipados e acessibilidade correta com código Swift 6.2 testado.
NavigationStack é o container de navegação declarativo do SwiftUI que substituiu o antigo NavigationView desde o iOS 16 e, agora no iOS 26, ganhou refinamentos importantes de acessibilidade e integração com NavigationPath tipada. Neste guia mostro como estruturar navegação programática, tratar deep links, manter estado durante rotação e VoiceOver, e por que abandonei de vez o NavigationLink "solto" em projetos novos. Todo o código foi testado no Xcode 26.1 com Swift 6.2.
NavigationStack é o único container de pilha suportado no iOS 26. O NavigationView foi removido do compilador em novos targets iOS 18+.
Use NavigationPath quando a pilha contém tipos heterogêneos; use [Route] tipado quando todos os destinos compartilham um enum.
Sempre declare .navigationDestination(for:) no nível mais alto possível. Declarar dentro de um ForEach quebra deep links.
Deep links devem operar sobre o path, nunca sobre @State private var showX. É a única forma segura de restaurar estado.
VoiceOver anuncia mudanças de tela via UIAccessibility.Notification.screenChanged; o NavigationStack emite automaticamente, mas transições customizadas precisam de .accessibilityFocused.
NavigationSplitView substitui NavigationStack em iPad e Mac. Combine ambos com size classes compact/regular.
O que é NavigationStack no SwiftUI
NavigationStack é um container declarativo, apresentado no iOS 16 e refinado no iOS 26, que gerencia uma pilha de views empurrando destinos identificados por um valor Hashable. Diferente da API antiga, o estado da pilha é representado explicitamente por um Binding<NavigationPath> ou Binding<[T]>, o que permite salvar, restaurar e manipular a navegação como se fosse dados normais. Na minha experiência migrando três apps de produção este ano, o maior ganho não foi performance. Foi conseguir escrever testes unitários que empurram rotas sem precisar renderizar views.
import SwiftUI
struct RootView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
HomeScreen(path: $path)
.navigationDestination(for: Route.self) { route in
switch route {
case .article(let id): ArticleScreen(id: id)
case .profile(let user): ProfileScreen(user: user)
case .settings: SettingsScreen()
}
}
}
}
}
enum Route: Hashable {
case article(UUID)
case profile(String)
case settings
}
Repare em três detalhes: (1) o Binding do path vive na raiz, nunca dentro do destino; (2) o modificador .navigationDestination é declarado uma única vez, o mais alto possível; (3) Route é um enum, o que dá exaustividade. Se você adicionar um caso, o compilador vai forçar o switch a tratá-lo. Essa combinação é a razão pela qual eu recuso revisar PRs que ainda usam NavigationLink(destination:) sem valor associado.
Qual a diferença entre NavigationStack e NavigationView?
NavigationView foi marcado como deprecated no iOS 16 e, a partir do SDK do iOS 18, o compilador emite warning obrigatório; em targets iOS 26 novos ele foi removido da documentação primária. A diferença fundamental é que NavigationView apresentava a pilha como side-effect (cada NavigationLink mutava um estado interno inacessível), enquanto NavigationStack torna a pilha observável e mutável pelo desenvolvedor. Isso muda a forma como se pensa: navegação vira dado.
Característica
NavigationView (deprecated)
NavigationStack (iOS 16+)
Estado da pilha exposto
Não
Sim, via path Binding
Pop programático para raiz
Hack com id
path.removeAll()
Deep links tipados
Manual e frágil
Nativo com NavigationPath
Restauração de estado
Manual
Codificável via Codable
Split view no iPad
Automático mas rígido
Use NavigationSplitView
iOS mínimo
13 (removido no 26)
16+
Testabilidade
Difícil
Trivial: assert em path
Como fazer navegação programática com NavigationPath
NavigationPath é um container type-erased que aceita qualquer valor Hashable. Útil quando sua pilha combina, por exemplo, artigos, perfis e telas de configuração sem um enum comum. A navegação programática funciona apenas mutando a coleção: append empurra, removeLast faz pop, e removeAll volta para a raiz. Tudo é reativo, tudo é animado, e (o ponto mais importante para acessibilidade) o VoiceOver reconhece as mudanças automaticamente.
struct ArticleListView: View {
@Binding var path: NavigationPath
let articles: [Article]
var body: some View {
List(articles) { article in
Button {
path.append(Route.article(article.id))
} label: {
ArticleRow(article: article)
}
.accessibilityHint("Abre o artigo \(article.title)")
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Configurações") {
path.append(Route.settings)
}
}
}
}
}
extension NavigationPath {
mutating func popToRoot() { removeLast(count) }
}
Um padrão que uso religiosamente: encapsulo a pilha num objeto @Observable chamado Router. Isso mantém a lógica de navegação fora das views, facilita testes unitários e permite que qualquer botão em qualquer camada da árvore acesse a navegação via @Environment. Se você ainda está migrando de ObservableObject, dê uma olhada no meu guia sobre como migrar para @Observable no SwiftUI antes de aplicar esse padrão.
@Observable
final class Router {
var path = NavigationPath()
func push(_ route: Route) { path.append(route) }
func pop() { if !path.isEmpty { path.removeLast() } }
func popToRoot() { path.removeLast(path.count) }
func replace(with route: Route) {
path.removeLast(path.count)
path.append(route)
}
}
struct AppRoot: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
HomeScreen()
.navigationDestination(for: Route.self) { route in
DestinationView(route: route)
}
}
.environment(router)
}
}
Como implementar deep links tipados
Deep links no iOS 26 vêm via onOpenURL, UserActivity, App Intents ou notificações push. A regra que ninguém enfatiza o suficiente: o deep link precisa modificar apenas o path, não uma tela específica. Se você tenta setar @State private var showArticle = true em resposta a uma URL, quebra assim que o usuário rotaciona o dispositivo. Já perdi uma tarde inteira debugando exatamente isso num app de podcast, então acredite em mim: a pilha é a única fonte da verdade.
struct AppRoot: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
HomeScreen()
.navigationDestination(for: Route.self) { route in
DestinationView(route: route)
}
}
.environment(router)
.onOpenURL { url in
handleDeepLink(url)
}
}
private func handleDeepLink(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = components.host else { return }
router.popToRoot()
switch host {
case "article":
if let idString = components.path.split(separator: "/").first,
let id = UUID(uuidString: String(idString)) {
router.push(.article(id))
}
case "profile":
if let user = components.path.split(separator: "/").first {
router.push(.profile(String(user)))
}
case "settings":
router.push(.settings)
default:
break
}
}
}
Restauração de estado com Codable
Uma vitória silenciosa do NavigationStack é que NavigationPath tem um inicializador Codable quando todos os valores empurrados adotam o protocolo. Isso significa que salvar e restaurar a pilha entre sessões fica trivial:
enum Route: Hashable, Codable {
case article(UUID)
case profile(String)
case settings
}
@Observable
final class Router {
var path = NavigationPath()
func save() throws -> Data? {
guard let representation = path.codable else { return nil }
return try JSONEncoder().encode(representation)
}
func restore(from data: Data) throws {
let representation = try JSONDecoder().decode(
NavigationPath.CodableRepresentation.self, from: data
)
path = NavigationPath(representation)
}
}
NavigationSplitView para iPad e Mac
Em iPad e Mac, o NavigationStack sozinho ignora completamente a coluna lateral e o layout de três painéis que o usuário espera. A resposta é o NavigationSplitView, que apresenta sidebar, content e detail, e (crucialmente) colapsa para uma NavigationStack no iPhone. O erro típico é aninhar NavigationStack dentro de NavigationSplitView: faça isso apenas na coluna detail e nunca na coluna raiz.
struct SplitRoot: View {
@State private var selectedCategory: Category?
@State private var detailPath: [Route] = []
var body: some View {
NavigationSplitView {
SidebarView(selection: $selectedCategory)
} detail: {
NavigationStack(path: $detailPath) {
if let category = selectedCategory {
CategoryDetailView(category: category, path: $detailPath)
.navigationDestination(for: Route.self) { route in
DestinationView(route: route)
}
} else {
ContentUnavailableView(
"Selecione uma categoria",
systemImage: "sidebar.left"
)
}
}
}
}
}
Note o uso de ContentUnavailableView, disponível desde iOS 17 e agora estilizado com Liquid Glass no iOS 26. Usar essa view em vez de um placeholder manual mantém a estética do sistema e é reconhecida pelo VoiceOver como estado vazio, não como conteúdo faltando.
Acessibilidade e VoiceOver em navegação
Este é o assunto que menos aparece em tutoriais e onde encontro mais bugs em code review. O NavigationStack emite UIAccessibility.Notification.screenChanged automaticamente quando um destino é empurrado; o VoiceOver anuncia o novo título e move o foco para o primeiro elemento acessível. Bom padrão por padrão. O problema aparece quando você usa transições customizadas, fullScreenCover alinhado a rotas, ou empurra múltiplas telas no mesmo tick.
struct ArticleScreen: View {
let id: UUID
@AccessibilityFocusState private var titleFocused: Bool
@State private var article: Article?
var body: some View {
ScrollView {
if let article {
Text(article.title)
.font(.largeTitle)
.accessibilityAddTraits(.isHeader)
.accessibilityFocused($titleFocused)
Text(article.body)
.padding(.top)
} else {
ProgressView()
.accessibilityLabel("Carregando artigo")
}
}
.navigationTitle(article?.title ?? "Carregando")
.navigationBarTitleDisplayMode(.large)
.task {
article = try? await ArticleStore.shared.load(id)
titleFocused = true
}
}
}
Quatro regras que aplico em todo projeto SwiftUI:
Título antes de conteúdo. Sempre use .navigationTitle mesmo em telas modais, pois é o que o VoiceOver lê primeiro.
Botão de voltar customizado. Se você substituir o back button padrão, adicione .accessibilityLabel("Voltar") e .accessibilityHint descrevendo o destino.
Foco explícito em cargas assíncronas. Use @AccessibilityFocusState para mover o foco quando o conteúdo real chegar, como no exemplo acima.
Anúncios de push múltiplo. Se você empurra duas rotas em sequência (ex.: deep link, login, artigo), o VoiceOver pode se perder. Use UIAccessibility.post(notification: .screenChanged, argument: nil) na tela final para forçar re-anúncio.
Erros comuns e como evitá-los
Depois de revisar dezenas de PRs de navegação, esses são os padrões que mais custam tempo de debug.
Declarar navigationDestination dentro de um ForEach
É tentador colocar .navigationDestination(for:) ao lado do NavigationLink(value:) dentro do List, mas o modificador precisa estar em um ancestral do link, não em irmão. O SwiftUI aceita silenciosamente, os push funcionam, mas deep links falham porque a rota é despachada antes de a lista renderizar. Regra: um navigationDestination por tipo, no nível mais alto possível.
Usar NavigationLink sem valor associado
O inicializador NavigationLink(destination:) ainda compila, mas cria uma pilha "opaca". O destino é construído mesmo quando não é visitado, e o path não sabe da rota. Use sempre NavigationLink(value:) combinado com navigationDestination(for:). A única exceção legítima é para navegação em Form puro sem estado externo.
Passar objetos grandes como valor de rota
Rotas são Hashable e ficam na pilha até a tela ser removida. Passar o modelo inteiro é anti-pattern: passe o id e carregue no destino, seja via SwiftData, @Environment ou um store. Isso mantém a pilha leve, torna a restauração viável e evita bugs de identidade quando o modelo é atualizado externamente.
Documentação e sessões WWDC de referência
Para fontes primárias, consulte a documentação oficial de NavigationStack e a referência de NavigationPath no site da Apple. A sessão WWDC "The SwiftUI cookbook for navigation" continua sendo a explicação mais didática dos padrões acima e vale a hora e meia; a versão de 2026 atualizou os exemplos para Swift 6.2 e @Observable. Complementarmente, o repositório swift-evolution lista as propostas aceitas que afetam concorrência em callbacks de navigationDestination. Leitura recomendada para quem faz push a partir de tasks assíncronas.
Perguntas frequentes
Preciso usar NavigationPath ou posso usar apenas um array tipado?
Use um array tipado ([Route]) quando todos os destinos compartilham um único enum. Dá exaustividade no switch e é mais performático. Use NavigationPath quando precisa misturar tipos heterogêneos na mesma pilha, por exemplo em fluxos que combinam telas modulares de features diferentes.
Como faço para voltar duas telas de uma vez no NavigationStack?
Use path.removeLast(2). Se estiver com NavigationPath, o mesmo método funciona. Nunca chame dismiss() duas vezes seguidas, pois o SwiftUI não garante que a segunda execução veja o estado atualizado do primeiro pop.
NavigationLink ainda funciona no iOS 26?
Sim, mas apenas o inicializador NavigationLink(value:) combinado com navigationDestination(for:). A variante NavigationLink(destination:) compila com warning e é reconhecida como legacy. Evite em código novo, pois quebra deep linking e restauração.
Posso aninhar NavigationStack dentro de outro NavigationStack?
Pode, mas não deve. Cada NavigationStack mantém sua própria pilha, e o VoiceOver anuncia mudanças duplicadas confundindo o usuário. O padrão correto é uma única NavigationStack por aba e usar sheet ou fullScreenCover para fluxos modais separados.
Como testar navegação programática em unit tests?
Encapsule a pilha em um objeto @Observable (o padrão Router mostrado acima) e teste diretamente os métodos push, pop e popToRoot. Como path é apenas dados, as assertions são triviais e não requerem ViewInspector nem UI tests.
O guia completo de App Intents 2.0 no iOS 26: Interactive Snippets em SwiftUI, View Annotations, Deferred Properties e o novo framework de testes com Swift 6.2.
Aprenda a migrar de ObservableObject para @Observable no SwiftUI (iOS 26): rastreamento por propriedade, @Bindable, @State, @Environment e as armadilhas mais comuns, com exemplos de codigo praticos.
Guia prático de Swift Macros no Swift 6.2: como criar macros @attached e @freestanding com SwiftSyntax, testar com assertMacroExpansion e depurar no Xcode 26 com padrões reais de @Observable, @Model e Swift Testing.