Patrones de NavigationStack de SwiftUI para flujos multi-paso previsibles
Patrones de NavigationStack en SwiftUI para flujos multi-paso: enrutamiento claro, comportamiento seguro de Atrás y ejemplos prácticos para onboarding y asistentes de aprobación.

Qué sale mal en los flujos multi-paso
Un flujo multi-paso es cualquier secuencia donde el paso 1 debe ocurrir antes de que el paso 2 tenga sentido. Ejemplos comunes son el onboarding, una solicitud de aprobación (revisar, confirmar, enviar) y la entrada de datos estilo asistente donde alguien construye un borrador a través de varias pantallas.
Estos flujos se sienten fáciles solo cuando Atrás se comporta como la gente espera. Si Atrás los lleva a un sitio sorprendente, los usuarios dejan de confiar en la app. Eso aparece como envíos erróneos, onboarding abandonado y tickets de soporte tipo “No puedo volver a la pantalla en la que estaba”.
La navegación desordenada suele verse así:
- La app salta a la pantalla equivocada o sale del flujo demasiado pronto.
- La misma pantalla aparece dos veces porque se empujó dos veces.
- Un paso se reinicia al retroceder y el usuario pierde su borrador.
- El usuario alcanza el paso 3 sin completar el paso 1, creando un estado inválido.
- Tras un deep link o reinicio de la app, se muestra la pantalla correcta pero con datos equivocados.
Un modelo mental útil: un flujo multi-paso son dos cosas moviéndose juntas.
Primero, una pila de pantallas (por donde el usuario puede retroceder). Segundo, el estado compartido del flujo (datos en borrador y progreso que no deberían desaparecer solo porque una pantalla desaparece).
Muchas configuraciones de NavigationStack fallan cuando la pila de pantallas y el estado del flujo se separan. Por ejemplo, un flujo de onboarding podría empujar “Crear perfil” dos veces (rutas duplicadas), mientras que el perfil en borrador vive dentro de la vista y se recrea en cada re-render. El usuario pulsa Atrás, ve una versión diferente del formulario y asume que la app es poco fiable.
El comportamiento predecible empieza por nombrar el flujo, definir qué debe hacer Atrás en cada paso y darle al estado del flujo un único hogar claro.
Conceptos básicos de NavigationStack que realmente necesitas
Para flujos multi-paso, usa NavigationStack en lugar del antiguo NavigationView. NavigationView puede comportarse distinto según versiones de iOS y es más difícil de razonar cuando empujas, haces pop o restauras pantallas. NavigationStack es la API moderna que trata la navegación como una pila real.
Un NavigationStack almacena un historial de por dónde ha pasado el usuario. Cada push añade un destino a la pila. Cada acción Atrás hace pop de un destino. Esa regla simple es lo que hace que un flujo se sienta estable: la UI debe reflejar una secuencia clara de pasos.
Qué es lo que realmente guarda la pila
SwiftUI no está almacenando tus objetos de vista. Guarda los datos que usaste para navegar (tu valor de ruta) y los usa para reconstruir la vista de destino cuando hace falta. Eso tiene algunas consecuencias prácticas:
- No dependas de que una vista permanezca viva para mantener datos importantes.
- Si una pantalla necesita estado, guárdalo en un modelo (por ejemplo un
ObservableObject) que viva fuera de la vista empujada. - Si empujas el mismo destino dos veces con datos diferentes, SwiftUI los tratará como entradas distintas en la pila.
NavigationPath es lo que debes usar cuando tu flujo no es solo uno o dos pushes fijos. Piénsalo como una lista editable de valores “a dónde vamos”. Puedes añadir rutas para avanzar, eliminar la última para retroceder o reemplazar todo el path para saltar a un paso posterior.
Encaja bien cuando necesitas pasos tipo asistente, reiniciar el flujo tras completarlo o restaurar un flujo parcial desde estado guardado.
Predecible gana a ingenioso. Menos reglas ocultas (saltos automáticos, pops implícitos, efectos secundarios impulsados por vistas) significa menos bugs extraños en la pila Atrás más adelante.
Modela el flujo con un pequeño enum de rutas
La navegación predecible comienza con una decisión: mantén el enrutamiento en un solo lugar y haz que cada pantalla del flujo sea un valor pequeño y claro.
Crea una fuente única de verdad, por ejemplo un FlowRouter (un ObservableObject) que posea el NavigationPath. Eso mantiene cada push y pop consistente, en lugar de dispersar la navegación entre vistas.
Una estructura simple de router
Usa un enum para representar los pasos. Añade valores asociados solo para identificadores ligeros (como IDs), no para modelos enteros.
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() }
}
Mantén el estado del flujo separado del estado de navegación
Trata la navegación como “dónde está el usuario” y el estado del flujo como “qué ha introducido hasta ahora”. Pon los datos del flujo en su propio store (por ejemplo, OnboardingState con nombre, email, documentos subidos) y mantenlos estables mientras las pantallas aparecen y desaparecen.
Una regla sencilla:
FlowRouter.pathcontiene solo valores deStep.OnboardingStatecontiene las entradas del usuario y los datos en borrador.- Los pasos llevan IDs para buscar datos, no los datos en sí.
Esto evita hashing frágil, paths enormes y reinicios sorpresa cuando SwiftUI reconstruye vistas.
Paso a paso: construye un asistente con NavigationPath
Para pantallas estilo asistente, el enfoque más simple es controlar la pila tú mismo. Busca una fuente de verdad para “¿dónde estoy en el flujo?” y una única manera de avanzar o retroceder.
Empieza con un NavigationStack(path:) ligado a un NavigationPath. Cada pantalla empujada está representada por un valor (a menudo un caso de enum) y registras los destinos una 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 mantener Atrás predecible, sigue algunos hábitos. Añade exactamente una ruta para avanzar, mantén “Siguiente” lineal (solo empuja el siguiente paso) y cuando necesites saltar atrás (como “Editar perfil” desde Revisar), recorta la pila hasta un índice conocido.
Esto evita pantallas duplicadas accidentales y hace que Atrás coincida con lo que los usuarios esperan: un toque equivale a un paso.
Mantén los datos estables mientras las pantallas aparecen y desaparecen
Un flujo multi-paso se siente poco fiable cuando cada pantalla posee su propio estado. Escribes un nombre, avanzas, vuelves y el campo está vacío porque la vista se recreó.
La solución es sencilla: trata el flujo como un único objeto en borrador y deja que cada paso lo edite.
En SwiftUI eso suele significar un ObservableObject compartido creado una vez al inicio del flujo y pasado a cada paso. No guardes valores de formulario en el @State de cada vista a menos que pertenezcan realmente solo a esa pantalla.
final class OnboardingDraft: ObservableObject {
@Published var fullName = ""
@Published var email = ""
@Published var wantsNotifications = false
var canGoNextFromProfile: Bool {
!fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& email.contains("@")
}
}
Créalo en el punto de entrada y compártelo con @StateObject y @EnvironmentObject (o pásalo explícitamente). Ahora la pila puede cambiar sin perder datos.
Decide qué sobrevive a la navegación hacia atrás
No todo debe persistir para siempre. Decide tus reglas desde el inicio para que el flujo se mantenga consistente.
Conserva la entrada del usuario (campos de texto, toggles, selecciones) a menos que la borren explícitamente. Reinicia el estado UI específico del paso (spinners de carga, alertas temporales, animaciones cortas). Borra campos sensibles (como códigos de un solo uso) al salir de ese paso. Si una elección afecta pasos posteriores, borra solo los campos dependientes.
La validación encaja naturalmente aquí. En lugar de permitir que los usuarios avancen y luego mostrar un error en la siguiente pantalla, mantenlos en el paso actual hasta que sea válido. Deshabilitar el botón según una propiedad calculada como canGoNextFromProfile suele ser suficiente.
Guarda puntos de control sin pasarte
Algunos borradores pueden vivir solo en memoria. Otros deberían sobrevivir reinicios de app o un crash. Un valor predeterminado práctico:
- Mantén los datos en memoria mientras el usuario avanza activamente por los pasos.
- Persiste localmente en hitos claros (cuenta creada, aprobación enviada, pago iniciado).
- Persiste antes si el flujo es largo o la entrada de datos toma más de un minuto.
Así, las pantallas pueden aparecer y desaparecer libremente y el progreso del usuario sigue siendo estable y respetuoso con su tiempo.
Deep links y restaurar un flujo parcialmente terminado
Los deep links importan porque los flujos reales rara vez empiezan en el paso 1. Alguien toca un email, una notificación push o un enlace compartido y espera aterrizar en la pantalla correcta, como el paso 3 del onboarding o la pantalla final de aprobación.
Con NavigationStack, trata un deep link como instrucciones para construir un path válido, no como un comando para saltar a una vista. Empieza desde el comienzo del flujo y añade solo los pasos que son verdaderos para este usuario y esta sesión.
Convierte un enlace externo en una secuencia de rutas segura
Un buen patrón es: parsea el ID externo, carga los datos mínimos que necesites y conviértelo en una secuencia de rutas.
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
}
Esas comprobaciones son tus guardarraíles. Si faltan prerrequisitos, no dejes al usuario en el paso 3 con un error y sin camino a seguir. Llévalo al primer paso faltante y asegura que la pila Atrás cuente una historia coherente.
Restaurar un flujo parcialmente terminado
Para restaurar tras un relanzamiento, guarda dos cosas: el último estado de ruta conocido y los datos del borrador introducidos por el usuario. Luego decide cómo reanudar sin sorprender a la gente.
Si el borrador está fresco (minutos u horas), ofrece una opción clara de “Reanudar”. Si es antiguo, empieza desde el principio pero usa el borrador para rellenar campos. Si cambian requisitos, reconstruye la ruta usando los mismos guardarraíles.
Push vs modal: facilita salir del flujo
Un flujo se siente predecible cuando hay una forma principal de avanzar: empujar pantallas en una sola pila. Usa sheets y full-screen covers para tareas secundarias, no para el camino principal.
Push (NavigationStack) encaja cuando el usuario espera que Atrás rehaga sus pasos. Los modales (sheet o fullScreenCover) encajan cuando el usuario hace una tarea lateral, toma una decisión rápida o confirma una acción riesgosa.
Un conjunto simple de reglas evita la mayoría de los comportamientos extraños:
- Haz push para el camino principal (Paso 1, Paso 2, Paso 3).
- Usa un sheet para tareas pequeñas y opcionales (elegir una fecha, país, escanear un documento).
- Usa
fullScreenCoverpara “mundos separados” (login, captura de cámara, un documento legal largo). - Usa un modal para confirmaciones (cancelar flujo, eliminar borrador, enviar para aprobación).
El error común es poner pantallas principales dentro de sheets. Si el Paso 2 es un sheet, el usuario puede descartarlo con un swipe, perder contexto y acabar con una pila que dice que está en Paso 1 mientras sus datos indican que completó Paso 2.
Las confirmaciones son lo contrario: empujar una pantalla “¿Estás seguro?” en el asistente ensucia la pila y puede crear bucles (Paso 3 -> Confirmar -> Atrás -> Paso 3 -> Atrás -> Confirmar).
Cómo cerrar todo ordenadamente tras “Hecho”
Decide primero qué significa “Hecho”: volver a la pantalla principal, volver a la lista o mostrar una pantalla de éxito.
Si el flujo fue empujado, reinicia tu NavigationPath a vacío para hacer pop hasta el inicio. Si el flujo fue presentado modalmente, llama a dismiss() desde el environment. Si tienes ambos (un modal que contiene un NavigationStack), descarta el modal, no cada pantalla empujada. Tras un envío exitoso, también borra cualquier estado de borrador para que un flujo reabierto comience fresco.
Comportamiento del botón Atrás y momentos de “¿Estás seguro?”
Para la mayoría de los flujos multi-paso, la mejor opción es no hacer nada: deja que el botón de sistema (y el gesto de swipe-back) funcionen. Coincide con las expectativas del usuario y evita bugs donde la UI dice una cosa pero el estado de navegación otra.
Interceptar solo vale la pena cuando retroceder causaría daño real, como perder un formulario largo no guardado o abandonar una acción irreversible. Si el usuario puede volver y continuar con seguridad, no añadas fricción.
Un enfoque práctico es mantener la navegación del sistema, pero añadir una confirmación solo cuando la pantalla está “dirty” (editada). Eso implica proporcionar tu propia acción Atrás y preguntar una vez, con una salida 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("Back") { showLeaveConfirm = true }
}
}
}
.confirmationDialog("Discard changes?", isPresented: $showLeaveConfirm) {
Button("Discard", role: .destructive) { dismiss() }
Button("Keep Editing", role: .cancel) {}
}
}
Evita que esto se convierta en una trampa:
- Pregunta solo cuando puedas explicar la consecuencia en una frase corta.
- Ofrece una opción segura (Cancelar, Seguir editando) y una salida clara (Descartar, Salir).
- No ocultes botones Atrás a menos que los reemplaces por un Back o Close obvio.
- Prefiere confirmar la acción irreversible (como “Aprobar”) en lugar de bloquear la navegación en todas partes.
Si te ves luchando frecuentemente contra el gesto de retroceso, eso suele ser señal de que el flujo necesita autosave, borrador guardado o pasos más pequeños.
Errores comunes que crean pilas Atrás raras
La mayoría de los “¿por qué volvió ahí?” no son SwiftUI siendo aleatorio. Suelen venir de patrones que hacen inestable el estado de navegación. Para un comportamiento predecible, trata la pila Atrás como datos de la app: estable, testeable y propiedad de un único lugar.
Pilas extra accidentales
Una trampa común es acabar con más de un NavigationStack sin darte cuenta. Por ejemplo, cada tab tiene su propia pila raíz y luego una vista hija añade otra stack dentro del flujo. El resultado es comportamiento confuso de Atrás, barras de navegación faltantes o pantallas que no hacen pop como esperas.
Otro problema frecuente es recrear tu NavigationPath con demasiada frecuencia. Si el path se crea dentro de una vista que re-renderiza, puede resetearse en cambios de estado y saltar al usuario al paso 1 después de escribir en un campo.
Los errores detrás de la mayoría de pilas raras son sencillos:
- Anidar
NavigationStackdentro de otra stack (a menudo dentro de tabs o contenido de sheets). - Re-inicializar
NavigationPath()durante actualizaciones de vista en lugar de mantenerlo en estado de larga vida. - Poner valores inestables en tu ruta (como un objeto modelo que cambia), lo que rompe
Hashabley causa destinos desajustados. - Dispersar decisiones de navegación entre manejadores de botones hasta que nadie puede explicar qué significa “siguiente”.
- Conducir el flujo desde múltiples fuentes a la vez (por ejemplo, un view model y una vista mutando el path).
Si necesitas pasar datos entre pasos, prefiere identificadores estables en la ruta (IDs, enums de paso) y guarda los datos reales del formulario en estado compartido.
Un ejemplo concreto: si tu ruta es .profile(User) y User cambia mientras la persona escribe, SwiftUI puede tratarlo como una ruta distinta y recolocar la pila. Haz la ruta .profile y almacena el perfil en borrador en estado compartido.
Lista rápida para navegación predecible
Cuando un flujo se siente mal, suele ser porque la pila Atrás no cuenta la misma historia que el usuario. Antes de pulir la UI, repasa tus reglas de navegación.
Prueba en un dispositivo real, no solo en previews, y prueba taps lentos y rápidos. Los taps rápidos suelen revelar pushes duplicados y estado perdido.
- Retrocede un paso a la vez desde la última pantalla hasta la primera. Confirma que cada pantalla muestra los mismos datos que el usuario había introducido.
- Dispara Cancel desde cada paso (incluyendo el primero y el último). Confirma que siempre vuelve a un lugar sensato, no a una pantalla previa aleatoria.
- Fuerza el cierre de la app a mitad de flujo y relanza. Asegúrate de poder reanudar de forma segura, ya sea restaurando el path o reiniciando en un paso conocido con datos guardados.
- Abre el flujo usando un deep link o acceso directo de la app. Verifica que el paso destino sea válido; si faltan datos requeridos, redirige al paso más temprano que pueda recogerlos.
- Termina con Hecho y confirma que el flujo se elimina limpiamente. El usuario no debería poder pulsar Atrás y volver a entrar en un asistente completado.
Una forma simple de probar: imagina un asistente de onboarding con tres pantallas (Perfil, Permisos, Confirmar). Escribe un nombre, avanza, vuelve, edítalo, luego salta a Confirm mediante un deep link. Si Confirm muestra el nombre antiguo, o si Atrás te lleva a una pantalla Perfil duplicada, tus actualizaciones del path no son consistentes.
Si pasas la lista sin sorpresas, tu flujo se sentirá calmado y predecible, incluso cuando los usuarios salgan y vuelvan más tarde.
Un ejemplo realista y pasos siguientes
Imagina un flujo de aprobación por un manager para una solicitud de gastos. Tiene cuatro pasos: Revisar, Editar, Confirmar y Recibo. El usuario espera una cosa: Atrás siempre va al paso anterior, no a alguna pantalla aleatoria que visitó antes.
Un enum de rutas simple mantiene esto predecible. Tu NavigationPath debería almacenar solo la ruta y los identificadores pequeños necesarios para recargar estado, como un expenseID y un mode (review vs edit). Evita empujar modelos grandes y mutables en el path porque hace frágiles las restauraciones y los deep links.
Mantén el borrador de trabajo en una única fuente de verdad fuera de las vistas, como un @StateObject del flujo (o un store). Cada paso lee y escribe ese modelo, de modo que las pantallas pueden aparecer y desaparecer sin perder entradas.
Como mínimo, estás siguiendo tres cosas:
- Rutas (por ejemplo:
review(expenseID),edit(expenseID),confirm(expenseID),receipt(expenseID)) - Datos (un objeto en borrador con partidas y notas, más un estado como
pending,approved,rejected) - Ubicación (borrador en tu modelo de flujo, registro canónico en el servidor y un token pequeño de restauración local: expenseID + último paso)
Los casos límite son donde los flujos ganan o pierden confianza. Si el manager rechaza en Confirm, decide si Atrás vuelve a Edit (para arreglar) o sale del flujo. Si vuelven después, restaura el último paso desde el token guardado y recarga el borrador. Si cambian de dispositivo, trata el servidor como la verdad: reconstruye la ruta desde el estado del servidor y envíalos al paso correcto.
Pasos siguientes: documenta tu enum de rutas (qué significa cada caso y cuándo se usa), añade un par de pruebas básicas para la construcción de paths y comportamiento de restauración, y sigue una regla: las vistas no poseen decisiones de navegación.
Si estás construyendo el mismo tipo de flujos multi-paso sin escribir todo desde cero, plataformas como AppMaster (appmaster.io) aplican la misma separación: mantener navegación de pasos y datos de negocio separados para que las pantallas puedan cambiar sin romper el progreso del usuario.
FAQ
Usa NavigationStack con un único NavigationPath que controles. Añade exactamente una ruta por cada acción “Siguiente” y elimina exactamente una ruta por cada acción Atrás. Cuando necesites saltar (por ejemplo, “Editar perfil” desde Revisar), recorta la ruta hasta un paso conocido en lugar de apilar más pantallas.
Porque SwiftUI reconstruye las vistas de destino a partir del valor de la ruta, no de una instancia de vista preservada. Si tus datos del formulario están en el @State de la vista, pueden resetearse cuando la vista se recrea. Coloca los datos en borrador en un modelo compartido (por ejemplo, un ObservableObject) que viva fuera de las vistas empujadas.
Suele ocurrir cuando añades la misma ruta más de una vez (por pulsaciones rápidas o por múltiples caminos de código que disparan la navegación). Deshabilita el botón Siguiente mientras navegas o mientras corre la validación/carga, y centraliza las mutaciones de navegación para que solo ocurra un append por paso.
Mantén valores de enrutamiento pequeños y estables, como un caso de enum y IDs ligeros. Guarda los datos mutables (el borrador) en un objeto compartido separado y recupéralo por ID cuando haga falta. Meter modelos grandes y cambiantes en la ruta puede romper las expectativas de Hashable y provocar destinos desincronizados.
La navegación responde a “dónde está el usuario” y el estado del flujo responde a “qué ha escrito”. Posee la ruta de navegación en un router (o en un estado de alto nivel) y el borrador en un ObservableObject separado. Cada pantalla edita el borrador; el router solo cambia pasos.
Trata un deep link como instrucciones para construir una secuencia válida de pasos, no como un teletransporte a una vista. Construye la ruta añadiendo primero los pasos previos necesarios (según lo que el usuario ya haya completado) y luego añade el paso objetivo. Así la pila Atrás se mantiene coherente y evitas estados inválidos.
Guarda dos cosas: la última ruta significativa (o identificador del paso) y los datos del borrador. Al relanzar, reconstruye la ruta usando las mismas comprobaciones de prerrequisitos que usas para deep links, y carga el borrador. Si el borrador es antiguo, reiniciar el flujo pero rellenar los campos suele ser menos sorprendente que dejar al usuario a mitad del asistente.
Empuja pantallas para el camino principal paso a paso para que Atrás recorra el flujo de forma natural. Usa sheets para tareas opcionales y fullScreenCover para experiencias separadas como login o captura de cámara. Evita poner pasos principales dentro de modales porque los gestos de descartar pueden desincronizar la UI del estado del flujo.
No interceptes Atrás por defecto; deja que el sistema funcione. Agrega una confirmación solo cuando al salir se perdería trabajo no guardado significativo, y solo cuando la pantalla esté realmente “dirty”. Prefiere autosave o persistencia de borrador si necesitas confirmaciones con frecuencia.
Las causas comunes son anidar varios NavigationStack, recrear NavigationPath durante actualizaciones de vista, o tener múltiples propietarios que mutan la ruta. Mantén un stack por flujo, conserva la ruta en un estado de larga vida (@StateObject o un router único) y centraliza la lógica de push/pop en un solo lugar.


