Padrões do NavigationStack do SwiftUI para fluxos multi‑etapa previsíveis
Padrões de NavigationStack no SwiftUI para fluxos multi‑etapa: roteamento claro, comportamento previsível do Voltar e exemplos práticos para onboarding e wizards de aprovação.

O que dá errado em fluxos multi‑etapa
Um fluxo multi‑etapa é qualquer sequência onde o passo 1 precisa acontecer antes do passo 2 fazer sentido. Exemplos comuns: onboarding, um pedido de aprovação (revisar, confirmar, enviar) e entrada de dados no estilo wizard onde alguém constrói um rascunho em várias telas.
Esses fluxos parecem fáceis apenas quando Voltar se comporta como as pessoas esperam. Se Voltar leva o usuário para um lugar surpreendente, ele para de confiar no app. Isso aparece como envios errados, onboarding abandonado e tickets de suporte do tipo “não consigo voltar para a tela em que estava”.
Navegação confusa normalmente parece uma destas coisas:
- O app pula para a tela errada, ou sai do fluxo cedo demais.
- A mesma tela aparece duas vezes porque foi empurrada duas vezes.
- Um passo é reiniciado ao voltar e o usuário perde o rascunho.
- O usuário consegue chegar ao passo 3 sem completar o passo 1, criando um estado inválido.
- Após um deep link ou reinício do app, a tela certa aparece mas com dados errados.
Um modelo mental útil: um fluxo multi‑etapa é duas coisas movendo‑se juntas.
Primeiro, uma pilha de telas (por onde o usuário pode voltar). Segundo, o estado compartilhado do fluxo (dados do rascunho e progresso que não deveriam desaparecer só porque uma tela some).
Muitos setups com NavigationStack desandam quando a pilha de telas e o estado do fluxo se afastam um do outro. Por exemplo, um onboarding pode empurrar “Criar perfil” duas vezes (rotas duplicadas), enquanto o rascunho do perfil vive dentro da view e é recriado ao re‑renderizar. O usuário aperta Voltar, vê uma versão diferente do formulário e assume que o app é instável.
Comportamento previsível começa por nomear o fluxo, definir o que Voltar deve fazer em cada passo e dar ao estado do fluxo um único lar claro.
O básico do NavigationStack que você realmente precisa
Para fluxos multi‑etapa, use NavigationStack em vez do mais antigo NavigationView. NavigationView pode se comportar de maneiras diferentes entre versões do iOS e é mais difícil de raciocinar quando você empurra, remove ou restaura telas. NavigationStack é a API moderna que trata navegação como uma pilha real.
Um NavigationStack armazena um histórico de onde o usuário esteve. Cada push adiciona um destino na pilha. Cada ação de voltar remove um destino. Essa regra simples é o que faz um fluxo parecer estável: a UI deve espelhar uma sequência clara de etapas.
O que a pilha realmente guarda
O SwiftUI não está armazenando seus objetos de view. Ele guarda os dados que você usou para navegar (seu valor de rota) e usa isso para reconstruir a view de destino quando necessário. Isso traz algumas consequências práticas:
- Não confie que uma view permaneça viva para manter dados importantes.
- Se uma tela precisa de estado, mantenha‑o em um modelo (como um
ObservableObject) que viva fora da view empurrada. - Se você empurrar o mesmo destino duas vezes com dados diferentes, o SwiftUI os trata como entradas diferentes na pilha.
NavigationPath é o que você usa quando seu fluxo não é apenas um ou dois pushes fixos. Pense nele como uma lista editável de valores “para onde vamos”. Você pode anexar rotas para avançar, remover a última rota para voltar, ou substituir todo o path para pular para uma etapa posterior.
É uma boa escolha quando você precisa de passos no estilo wizard, precisa resetar o fluxo após a conclusão ou quer restaurar um fluxo parcial a partir do estado salvo.
Previsível vence esperto. Menos regras escondidas (pulos automáticos, pops implícitos, efeitos colaterais dirigidos pela view) significa menos bugs estranhos na pilha de Voltar mais tarde.
Modele o fluxo com um enum de rotas pequeno
Navegação previsível começa com uma decisão: mantenha o roteamento em um só lugar e faça de cada tela do fluxo um valor pequeno e claro.
Crie uma fonte única de verdade, como um FlowRouter (um ObservableObject) que é dono do NavigationPath. Isso mantém cada push e pop consistente, em vez de espalhar navegação por várias views.
Uma estrutura simples de router
Use um enum para representar passos. Adicione valores associados apenas para identificadores leves (como IDs), não modelos inteiros.
enum Step: Hashable {
case welcome
case profile
case verifyCode(phoneID: UUID)
case review(applicationID: UUID)
case done
}
final class FlowRouter: ObservableObject {
@Published var path = NavigationPath()
func go(_ step: Step) { path.append(step) }
func back() { if !path.isEmpty { path.removeLast() } }
func reset() { path = NavigationPath() }
}
Mantenha o estado do fluxo separado do estado de navegação
Trate navegação como “onde o usuário está” e estado do fluxo como “o que ele inseriu até agora”. Coloque os dados do fluxo em sua própria store (por exemplo, OnboardingState com nome, email, documentos enviados) e mantenha‑os estáveis enquanto as telas aparecem e somem.
Uma regra simples:
FlowRouter.pathcontém apenas valoresStep.OnboardingStatecontém as entradas do usuário e os dados do rascunho.- Steps carregam IDs para procurar dados, não os dados em si.
Isso evita hashing frágil, caminhos gigantes e resets surpresa quando o SwiftUI reconstrói views.
Passo a passo: construa um wizard com NavigationPath
Para telas no estilo wizard, a abordagem mais simples é controlar a pilha você mesmo. Mire em uma fonte de verdade para “onde estou no fluxo?” e uma única forma de avançar ou voltar.
Comece com um NavigationStack(path:) ligado a um NavigationPath. Cada tela empurrada é representada por um valor (geralmente um caso de enum) e você registra destinos uma vez.
import SwiftUI
enum WizardRoute: Hashable {
case profile
case verifyEmail
case permissions
case review
}
struct OnboardingWizard: View {
@State private var path = NavigationPath()
@State private var currentIndex = 0
private let steps: [WizardRoute] = [.profile, .verifyEmail, .permissions, .review]
var body: some View {
NavigationStack(path: $path) {
StartScreen {
goToStep(0) // push first step
}
.navigationDestination(for: WizardRoute.self) { route in
switch route {
case .profile:
ProfileStep(onNext: { goToStep(1) })
case .verifyEmail:
VerifyEmailStep(onNext: { goToStep(2) })
case .permissions:
PermissionsStep(onNext: { goToStep(3) })
case .review:
ReviewStep(onEditProfile: { popToStep(0) })
}
}
}
}
private func goToStep(_ index: Int) {
currentIndex = index
path.append(steps[index])
}
private func popToStep(_ index: Int) {
let toRemove = max(0, currentIndex - index)
if toRemove > 0 { path.removeLast(toRemove) }
currentIndex = index
}
}
Para manter o Voltar previsível, siga alguns hábitos: anexe exatamente uma rota para avançar, mantenha “Próximo” linear (só empurre o próximo passo) e quando precisar pular para trás (como “Editar perfil” a partir de Revisão), corte a pilha até um índice conhecido.
Isso evita telas duplicadas acidentais e faz com que Voltar combine com o que os usuários esperam: um toque equivale a um passo.
Mantenha os dados estáveis enquanto as telas aparecem e somem
Um fluxo multi‑etapa parece pouco confiável quando cada tela é dona do seu próprio estado. Você digita um nome, avança, volta e o campo está vazio porque a view foi recriada.
A solução é direta: trate o fluxo como um único objeto de rascunho e deixe cada etapa editá‑lo.
No SwiftUI, isso normalmente significa um ObservableObject compartilhado criado uma vez no início do fluxo e passado para cada etapa. Não guarde valores de rascunho no @State de cada view a menos que pertençam realmente só àquela tela.
final class OnboardingDraft: ObservableObject {
@Published var fullName = ""
@Published var email = ""
@Published var wantsNotifications = false
var canGoNextFromProfile: Bool {
!fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& email.contains("@")
}
}
Crie‑o no ponto de entrada e compartilhe‑o com @StateObject e @EnvironmentObject (ou passe‑o explicitamente). Agora a pilha pode mudar sem perder dados.
Decida o que sobrevive ao navegar para trás
Nem tudo deve persistir para sempre. Defina suas regras desde o começo para que o fluxo fique consistente.
Mantenha entradas do usuário (campos de texto, toggles, seleções), a menos que sejam explicitamente resetadas. Reinicie estados específicos de tela (spinners de carregamento, alertas temporários, animações curtas). Limpe campos sensíveis (como códigos de uso único) ao sair daquela etapa. Se uma escolha altera etapas posteriores, limpe apenas os campos dependentes.
Validação se encaixa naturalmente aqui. Em vez de permitir que usuários avancem e depois mostrar um erro na tela seguinte, mantenha‑os no passo atual até que seja válido. Desabilitar o botão com base numa propriedade calculada como canGoNextFromProfile costuma ser suficiente.
Salve checkpoints sem exagerar
Alguns rascunhos podem viver apenas na memória. Outros devem sobreviver a reinícios do app ou crashes. Um padrão prático:
- Mantenha dados na memória enquanto o usuário está ativamente avançando pelas etapas.
- Persista localmente em marcos claros (conta criada, aprovação enviada, pagamento iniciado).
- Persista antes se o fluxo for longo ou a entrada de dados levar mais de um minuto.
Assim, as telas podem vir e ir livremente, e o progresso do usuário ainda parece estável e respeitoso com o tempo dele.
Deep links e restaurando um fluxo parcialmente concluído
Deep links importam porque fluxos reais raramente começam no passo 1. Alguém toca num email, numa notificação push ou num link compartilhado e espera chegar na tela certa, como o passo 3 do onboarding ou a tela final de aprovação.
Com NavigationStack, trate um deep link como instruções para construir um path válido, não como um comando para saltar para uma view. Comece pelo início do fluxo e anexe apenas as etapas que são verdadeiras para esse usuário e para essa sessão.
Transforme um link externo numa sequência segura de rotas
Um bom padrão é: parseie o ID externo, carregue os dados mínimos que precisa e então converta‑os numa sequência de rotas.
enum Route: Hashable {
case start
case profile
case verifyEmail
case approve(requestID: String)
}
func pathForDeepLink(requestID: String, hasProfile: Bool, emailVerified: Bool) -> [Route] {
var routes: [Route] = [.start]
if !hasProfile { routes.append(.profile) }
if !emailVerified { routes.append(.verifyEmail) }
routes.append(.approve(requestID: requestID))
return routes
}
Essas verificações são suas guardrails. Se pré‑requisitos estiverem faltando, não deixe o usuário caír no passo 3 com um erro e sem caminho de volta. Envie‑o para o primeiro passo faltante e assegure que a pilha de Voltar conte uma história coerente.
Restaurando um fluxo parcialmente concluído
Para restaurar após um relançamento, salve duas coisas: o último estado de rota conhecido e os dados de rascunho inseridos pelo usuário. Depois decida como retomar sem surpreender as pessoas.
Se o rascunho for recente (minutos ou horas), ofereça uma opção clara de “Retomar”. Se for antigo, comece do início mas use o rascunho para preencher campos. Se requisitos mudaram, reconstrua o path usando as mesmas guardrails.
Push vs modal: mantenha o fluxo fácil de sair
Um fluxo parece previsível quando há uma forma principal de avançar: empurrar telas numa única pilha. Use sheets e full‑screen covers para tarefas laterais, não para o caminho principal.
Push (NavigationStack) encaixa quando o usuário espera que Voltar refaça os passos. Modais (sheet ou fullScreenCover) servem para tarefas laterais, decisões rápidas ou confirmação de ações arriscadas.
Um conjunto simples de regras evita a maioria das esquisitices de navegação:
- Use push para o caminho principal (Passo 1, Passo 2, Passo 3).
- Use sheet para tarefas opcionais pequenas (escolher data, selecionar país, escanear um documento).
- Use fullScreenCover para “mundos separados” (login, captura de câmera, um longo documento legal).
- Use modal para confirmações (cancelar fluxo, deletar rascunho, enviar para aprovação).
O erro comum é colocar telas principais em sheets. Se o Passo 2 for um sheet, o usuário pode descartá‑lo com um swipe, perder contexto e acabar com uma pilha que diz que ele está no Passo 1 enquanto os dados dizem que terminou o Passo 2.
Confirmações são o oposto: empurrar uma tela “Tem certeza?” dentro do wizard lota a pilha e pode criar loops (Passo 3 -> Confirmar -> Voltar -> Passo 3 -> Voltar -> Confirmar).
Como fechar tudo limpo após “Concluído”
Decida primeiro o que significa “Concluído”: voltar para a tela inicial, voltar para uma lista ou mostrar uma tela de sucesso.
Se o fluxo foi empurrado, resete seu NavigationPath para vazio para voltar ao início. Se o fluxo foi apresentado como modal, chame dismiss() do environment. Se você tem ambos (um modal contendo um NavigationStack), descarte o modal, não cada tela empurrada individualmente. Após submissão bem‑sucedida, também limpe qualquer estado de rascunho para que um fluxo reaberto comece limpo.
Comportamento do botão Voltar e momentos “Tem certeza?”
Para a maioria dos fluxos multi‑etapa, o melhor é não fazer nada: deixe o botão de voltar do sistema (e o gesto de swipe‑back) funcionarem. Isso corresponde à expectativa do usuário e evita bugs onde a UI diz uma coisa mas o estado de navegação diz outra.
Intercepção só vale a pena quando voltar causaria dano real, como perder um formulário longo não salvo ou abandonar uma ação irreversível. Se o usuário pode voltar com segurança e continuar, não adicione fricção.
Uma abordagem prática é manter a navegação do sistema, mas adicionar uma confirmação apenas quando a tela for “dirty” (editada). Isso significa fornecer sua própria ação de voltar e perguntar uma vez, com uma saída clara.
@Environment(\.dismiss) private var dismiss
@State private var showLeaveConfirm = false
let hasUnsavedChanges: Bool
var body: some View {
Form { /* fields */ }
.navigationBarBackButtonHidden(hasUnsavedChanges)
.toolbar {
if hasUnsavedChanges {
ToolbarItem(placement: .navigationBarLeading) {
Button("Voltar") { showLeaveConfirm = true }
}
}
}
.confirmationDialog("Descartar alterações?", isPresented: $showLeaveConfirm) {
Button("Descartar", role: .destructive) { dismiss() }
Button("Continuar editando", role: .cancel) {}
}
}
Evite que isso vire uma armadilha:
- Pergunte somente quando você pode explicar a consequência em uma frase curta.
- Ofereça uma opção segura (Cancelar, Continuar editando) mais uma saída clara (Descartar, Sair).
- Não esconda botões de voltar a menos que você os substitua por um Back ou Close óbvio.
- Prefira confirmar a ação irreversível (como “Aprovar”) em vez de bloquear navegação por toda parte.
Se você se pega lutando frequentemente contra o gesto de voltar, isso geralmente é sinal de que o fluxo precisa de autosave, rascunho salvo ou passos menores.
Erros comuns que criam pilhas de Voltar estranhas
A maioria dos bugs “por que voltou para ali?” não é SwiftUI sendo aleatório. Geralmente vêm de padrões que tornam o estado de navegação instável. Para comportamento previsível, trate a pilha de Voltar como dados do app: estável, testável e possuído por um único lugar.
Pilhas extras acidentais
Uma armadilha comum é acabar com mais de um NavigationStack sem perceber. Por exemplo, cada tab tem sua própria pilha raiz e então uma view filha adiciona outra pilha dentro do fluxo. O resultado é comportamento de voltar confuso, barras de navegação faltando ou telas que não são removidas como esperado.
Outro problema frequente é recriar seu NavigationPath com muita frequência. Se o path for criado dentro de uma view que re‑renderiza, ele pode resetar em mudanças de estado e pular o usuário de volta ao passo 1 depois que ele digitou num campo.
Os erros por trás da maioria das pilhas estranhas são diretos:
- Aninhar
NavigationStackdentro de outra pilha (frequente em tabs ou conteúdo de sheet) - Re‑inicializar
NavigationPath()durante atualizações de view em vez de mantê‑lo em estado de longa duração - Colocar valores não estáveis na rota (como um objeto de modelo que muda), o que quebra
Hashablee causa destinos desencontrados - Espalhar decisões de navegação por vários manipuladores de botão até que ninguém consiga explicar o que “próximo” significa
- Dirigir o fluxo por múltiplas fontes ao mesmo tempo (por exemplo, tanto um view model quanto uma view mutando o path)
Se precisar passar dados entre etapas, prefira identificadores estáveis na rota (IDs, enums de passo) e mantenha os dados reais do formulário em estado compartilhado.
Um exemplo concreto: se sua rota for .profile(User) e User mudar enquanto a pessoa digita, o SwiftUI pode tratá‑lo como uma rota diferente e reconfigurar a pilha. Faça a rota .profile e armazene o rascunho do perfil em estado compartilhado.
Checklist rápido para navegação previsível
Quando um fluxo parecer errado, geralmente é porque a pilha de Voltar não está contando a mesma história que o usuário. Antes de polir a UI, faça uma verificação rápida nas regras de navegação.
Teste em um dispositivo real, não só em previews, e experimente toques lentos e rápidos. Toques rápidos frequentemente revelam pushes duplicados e estado faltando.
- Volte um passo por vez da última tela até a primeira. Confirme que cada tela mostra os mesmos dados que o usuário inseriu antes.
- Acione Cancelar de cada passo (incluindo o primeiro e o último). Confirme que sempre retorna a um lugar sensato, não a uma tela anterior aleatória.
- Force quit no meio do fluxo e relance. Garanta que você possa retomar com segurança, seja restaurando o path ou reiniciando num passo conhecido com dados salvos.
- Abra o fluxo usando um deep link ou atalho de app. Verifique que a etapa destino é válida; se faltarem dados necessários, redirecione para o passo mais cedo que possa coletá‑los.
- Termine com Concluído e confirme que o fluxo foi removido limpo. O usuário não deveria poder apertar Voltar e reentrar num wizard concluído.
Um jeito simples de testar: imagine um wizard de onboarding com três telas (Perfil, Permissões, Confirmar). Digite um nome, avance, volte, edite, então vá direto para Confirm por um deep link. Se Confirm mostrar o nome antigo, ou se Voltar levar você a uma tela Profile duplicada, suas atualizações de path não estão consistentes.
Se você passar pelo checklist sem surpresas, seu fluxo vai parecer calmo e previsível, mesmo quando usuários saem e voltam mais tarde.
Um exemplo realista e próximos passos
Imagine um fluxo de aprovação de despesas por um gerente. Ele tem quatro passos: Revisar, Editar, Confirmar e Recibo. O usuário espera uma coisa: Voltar sempre volta ao passo anterior, não para uma tela aleatória que ele visitou antes.
Um enum de rotas simples mantém isso previsível. Seu NavigationPath deve guardar apenas a rota e quaisquer pequenos identificadores necessários para recarregar estado, como um expenseID e um mode (review vs edit). Evite empurrar modelos grandes e mutáveis no path porque isso torna restores e deep links frágeis.
Mantenha o rascunho de trabalho em uma única fonte de verdade fora das views, como um @StateObject do fluxo (ou uma store). Cada etapa lê e escreve nesse modelo, então as telas podem aparecer e desaparecer sem perder entradas.
No mínimo, você está acompanhando três coisas:
- Rotas (por exemplo:
review(expenseID),edit(expenseID),confirm(expenseID),receipt(expenseID)) - Dados (um objeto rascunho com itens de linha e notas, mais um status como
pending,approved,rejected) - Localização (rascunho no seu modelo de fluxo, registro canônico no servidor, e um pequeno token de restauração local: expenseID + último passo)
Casos de borda são onde fluxos ganham ou perdem confiança. Se o gerente rejeita em Confirm, decida se Voltar retorna para Edit (para corrigir) ou se sai do fluxo. Se ele retornar depois, restaure o último passo a partir do token salvo e recarregue o rascunho. Se trocar de dispositivo, trate o servidor como verdade: reconstrua o path a partir do status no servidor e envie‑o para o passo certo.
Próximos passos: documente seu enum de rotas (o que cada case significa e quando é usado), adicione alguns testes básicos para construção de path e comportamento de restauração, e siga uma regra: views não tomam decisões de navegação.
Se você está construindo o mesmo tipo de fluxos multi‑etapa sem escrever tudo do zero, plataformas como AppMaster (appmaster.io) aplicam a mesma separação: mantenha navegação de etapas e dados de negócio separados para que telas possam mudar sem quebrar o progresso do usuário.
FAQ
Use NavigationStack com um único NavigationPath que você controla. Adicione exatamente uma rota por ação “Próximo” e remova exatamente uma rota por ação Voltar. Quando precisar pular (por exemplo, “Editar perfil” a partir da tela de Revisão), corte o path até um passo conhecido em vez de empurrar mais telas.
Porque o SwiftUI reconstrói as views de destino a partir do valor da rota, não a partir de uma instância de view preservada. Se os dados do formulário ficarem em @State da view, eles podem ser reiniciados quando a view for recriada. Coloque os dados de rascunho em um modelo compartilhado (por exemplo, um ObservableObject) que viva fora das views empurradas.
Normalmente acontece quando você adiciona a mesma rota mais de uma vez (muitas vezes por toques rápidos ou por múltiplos caminhos de código que disparam a navegação). Desative o botão Próximo enquanto a navegação ou validação/carregamento estiver em andamento e centralize as mutações de navegação para garantir que apenas um append ocorra por etapa.
Mantenha valores pequenos e estáveis na rota — por exemplo, um caso de enum e IDs leves. Guarde dados mutáveis (o rascunho) em um objeto compartilhado separado e recupere‑os por ID quando necessário. Empurrar modelos grandes e mutáveis para o path pode quebrar as expectativas de Hashable e causar destinos inconsistentes.
Navegação é “onde o usuário está” e estado do fluxo é “o que ele inseriu”. Tenha a NavigationPath em um router (ou em um estado de topo) e o rascunho em um ObservableObject separado. Cada tela edita o rascunho; o router apenas muda as etapas.
Trate o deep link como instruções para construir uma sequência válida de etapas, não como um teleporte para uma única tela. Monte o path adicionando primeiro as etapas pré‑requisito (com base no que o usuário já completou) e então adicione a etapa alvo. Assim a pilha de Voltar permanece coerente e evita estados inválidos.
Salve duas coisas: a última rota significativa (ou identificador do passo) e os dados do rascunho. Ao reiniciar, reconstrua o path usando as mesmas verificações de pré‑requisitos usadas para deep links e carregue o rascunho. Se o rascunho for antigo, reiniciar o fluxo com campos pré‑preenchidos costuma ser menos surpreendente do que colocar o usuário no meio do wizard.
Empurre telas para o caminho principal passo a passo para que Voltar refaça o fluxo naturalmente. Use sheets para tarefas opcionais e fullScreenCover para experiências separadas como login ou captura de câmera. Evite colocar etapas principais em modais porque gestos de dismiss podem desincronizar a UI do estado do fluxo.
Não interfira no Voltar por padrão; deixe o comportamento do sistema agir. Adicione confirmação apenas quando sair significar perda de trabalho não salvo relevante, e apenas quando a tela estiver realmente “dirty”. Prefira autosave ou persistência de rascunho quando você perceber que está precisando de confirmações com frequência.
As causas mais comuns são: ter múltiplos NavigationStacks aninhados, recriar NavigationPath durante atualizações de view e ter vários donos mutando o path. Mantenha um stack por fluxo, a NavigationPath em estado de longa duração (@StateObject ou um router) e centralize a lógica de push/pop em um único lugar.


