NavigationStack: la pila como estado
Por qué NavigationView era irreparable, qué significa que el historial de navegación sea un array observable que tú posees, cómo path convierte empujar y retroceder en simples mutaciones de un valor, y qué reglas impone esa pila cuando la app crece.
Durante años la navegación fue el único rincón de SwiftUI donde la doctrina se rompía: todo era función del estado menos las pantallas, que se apilaban solas al tocar un enlace y sin forma decente de decir “vuelve tres atrás”. NavigationStack cerró esa grieta convirtiendo el historial en lo que siempre debió ser: un array observable que tú posees. Esta lección no trata de cómo empujar una vista, sino de qué cambia cuando el pasado de tu app es un valor.
- Entender por qué
NavigationViewera irreparable y qué corrige exactamenteNavigationStack. - Leer la pila como un array de valores en lugar de como una secuencia de eventos.
- Manejar
pathpara empujar, retroceder y reescribir el historial completo. - Distinguir cuándo basta una pila implícita y cuándo hace falta una gobernada por estado.
La grieta que NavigationStack cierra
El modelo antiguo empujaba pantallas mediante un booleano por enlace. Cada NavigationLink llevaba su propio isActive, y el estado real de la navegación —cuántas pantallas hay y cuáles— no existía en ninguna parte: estaba repartido entre banderas independientes que nadie sincronizaba.
// iOS 15: la navegacion como efecto secundario de un booleano
NavigationView {
NavigationLink(destination: Detalle(), isActive: $mostrandoDetalle) {
Text("Ir al detalle")
}
}
// tres niveles de profundidad = tres booleanos sueltos
// volver a la raiz = ponerlos todos a false en el orden correcto
El problema no era la ergonomía, era ontológico: la pila no era representable. No podías escribirla, ni serializarla, ni comprobarla en un test, ni reconstruirla al arrancar. El framework la guardaba en su interior y solo te dejaba empujar el escalón siguiente.
// iOS 16 en adelante: el enlace propone un VALOR, no una vista
NavigationStack {
List(tareas) { tarea in
NavigationLink(tarea.titulo, value: tarea)
}
.navigationDestination(for: Tarea.self) { tarea in
Detalle(tarea: tarea)
}
}
La diferencia decisiva está en value. El enlace ya no construye un destino: añade un dato a una colección. Quién traduce ese dato en una pantalla es asunto de navigationDestination, y eso es materia de la lección siguiente. Lo que importa ahora es que empujar una pantalla se ha vuelto indistinguible de un append.
flowchart LR subgraph Antes B1[bool 1] --> B2[bool 2] --> B3[bool 3] --> X[pila implicita del framework] end subgraph Ahora P[array de valores] --> S[NavigationStack lo renderiza] S --> P end style X fill:#f38ba8,color:#11111b style P fill:#a6e3a1,color:#11111b
path: el historial como valor
Un NavigationStack acepta un binding a una colección. Esa colección es la pila: cada elemento representa una pantalla apilada sobre la raíz, en orden.
struct App: View {
@State private var path: [Tarea] = []
var body: some View {
NavigationStack(path: $path) {
ListaDeTareas()
.navigationDestination(for: Tarea.self) { Detalle(tarea: $0) }
}
}
}
Con eso, todo el vocabulario de la navegación se reduce a operaciones de array:
path.append(tarea) // empujar una pantalla
path.removeLast() // atras
path.removeLast(2) // atras dos veces, en una sola animacion
path.removeAll() // volver a la raiz
path = [proyecto, tarea] // reescribir el historial entero
let profundidad = path.count
El binding es bidireccional, y ahí está la elegancia: cuando el usuario pulsa el botón de retroceso o hace el gesto de deslizar desde el borde, el sistema no te avisa con un callback, sino que quita el último elemento de tu array. No hay dos verdades que reconciliar. La interfaz y el modelo son la misma cosa vista desde dos lados.
La vista raíz es el contenido del NavigationStack, no un elemento de la colección. Por eso un path vacío significa “estoy en la raíz” y path.count es la profundidad, no el número de pantallas visibles a lo largo del tiempo. Confundir ambas cosas produce el clásico error por uno al intentar retroceder programáticamente.
Los elementos del path deben conformar a Hashable. Esa exigencia no es un capricho de implementación: es lo que permite al framework identificar una pantalla, decidir si dos rutas son la misma y reutilizar la vista en lugar de reconstruirla. Un array homogéneo sirve cuando la app navega sobre un solo tipo; para pilas heterogéneas existe NavigationPath, un contenedor con borrado de tipos que acepta cualquier valor Hashable y que reservamos para la lección cuarta, donde su capacidad de codificarse se vuelve el eje del asunto.
Si empujar una pantalla es mutar un array, una prueba de navegación deja de necesitar interfaz. Puedes ejecutar la lógica que decide una ruta y comprobar el path resultante como comprobarías cualquier otro valor. Esta es la consecuencia práctica más infravalorada del cambio: la navegación entra por fin en el mismo régimen de verificación que el resto del estado.
Lo que la pila te obliga a decidir
Implícita
Sin path. El enlace apila y el botón de atrás desapila. Correcta para jerarquías simples donde nadie navega desde código.
Gobernada
Con path. Obligatoria si necesitas deep links, restauración, volver a la raíz o saltar varios niveles de golpe.
Una por escena
Un NavigationStack por columna o por modal. Anidar pilas dentro de pilas produce barras duplicadas y gestos que se pelean.
Valores estables
El elemento del path identifica la pantalla. Si su hash cambia entre redibujados, la pantalla se reconstruye sin motivo.
Del último punto se sigue una regla de diseño que ahorra horas: en el path van identificadores, no modelos completos. Guardar una estructura grande con quince campos significa que cualquier cambio en uno de ellos altera su identidad para la pila. Guardar el identificador y consultar el modelo en el destino desacopla el historial de los datos, y de paso hace la pila trivialmente serializable.
Queda una pregunta de arquitectura: dónde vive ese estado. Mientras la pila solo la manipule la vista que la posee, un @State local basta. En cuanto haya lógica externa que decida rutas —una notificación entrante, el resultado de una operación, un cierre de sesión que debe devolver a la raíz—, conviene extraer la pila a un objeto observable con métodos con nombre.
@Observable
final class Enrutador {
var path: [Ruta] = []
func abrir(_ ruta: Ruta) { path.append(ruta) }
func volverALaRaiz() { path.removeAll() }
func cerrarSesion() { path = [] }
}
// en la vista
NavigationStack(path: $enrutador.path) { Raiz() }
La ganancia no es solo de organización. Con la pila detrás de métodos con nombre, la intención queda escrita —volverALaRaiz dice mucho más que removeAll— y cualquier invariante de negocio, como impedir rutas duplicadas consecutivas, tiene un único sitio donde imponerse.
Si un efecto asíncrono empuja una pantalla mientras el usuario está retrocediendo, ambas escrituras compiten sobre el mismo array y la animación resultante es impredecible. Toda mutación del path debe salir de un único punto de decisión, en el actor principal, y nunca desde el cuerpo de una vista durante su cálculo: modificar estado mientras se construye una vista es una escritura durante el redibujado, y el framework te lo advertirá.
Merece la pena detenerse en la magnitud del cambio, porque no es una mejora de API sino un cambio de categoría. En el modelo antiguo, navegar era un verbo: ocurría, dejaba un rastro invisible dentro del framework y era irrecuperable. En el modelo actual, navegar es un sustantivo: hay un valor que dice dónde estás, ese valor lo posees tú, y la pantalla que ves es una función de ese valor exactamente igual que un texto es una función de una cadena. Toda la potencia que descubrirás en las cuatro lecciones siguientes se deriva mecánicamente de ese único hecho. Los deep links funcionan porque una URL se traduce en un array. La restauración funciona porque un array se guarda y se recupera. La navegación multiplataforma funciona porque un array se puede renderizar como pila en el teléfono y como columnas en el escritorio. Las pruebas funcionan porque comparar dos arrays no requiere pantalla. Ninguna de esas cosas es una característica añadida al framework: todas son corolarios de haber hecho representable algo que antes no lo era. Y esa es la lección general que conviene llevarse más allá de SwiftUI: cuando un sistema te frustra de forma persistente, el diagnóstico casi nunca es que le falte una función, sino que hay un concepto central del que no existe ningún valor que lo nombre. Dale un nombre y un tipo a ese concepto, y los problemas que parecían independientes se disuelven a la vez.
- Construye una pila con
pathde tipo array y muestrapath.counten la barra de cada pantalla. - Añade un botón que ejecute
path.removeAll()desde tres niveles de profundidad y observa la animación. - Empuja dos pantallas en una sola mutación asignando un array completo. Compara el resultado con dos
appendseguidos. - Retrocede con el gesto del borde y comprueba que tu array pierde el último elemento sin que escribas ningún callback.
- Sustituye el modelo completo por su identificador dentro del
pathy explica qué garantía has ganado.