navigationDestination: rutas por tipo
Cómo el enrutado por tipo separa quién propone un destino de quién sabe construirlo, dónde debe registrarse un destino para que exista cuando la pila lo necesita, cómo convivir con varios tipos de ruta y cómo organizar el enrutado de una app completa sin acoplar módulos.
Si la pila es un array de valores, alguien tiene que traducir cada valor en una pantalla. Ese alguien es navigationDestination, y su firma esconde una decisión de arquitectura mucho más grande de lo que parece: el registro no se hace por enlace ni por pantalla, sino por tipo. Una app entera se enruta declarando qué significa cada tipo de dato cuando aparece en la pila, y eso convierte el enrutado en un problema de diseño de tipos, no de cableado de vistas.
- Comprender por qué el destino se registra por tipo y qué desacopla esa decisión.
- Saber dónde debe vivir el modificador para que el destino exista cuando la pila lo resuelve.
- Combinar varios tipos de ruta en una misma pila sin ambigüedad.
- Diseñar el enrutado de una app modular con un tipo de ruta por dominio.
El destino se declara por tipo
El enlace y el destino se comunican a través de un tipo, no de una referencia. El enlace dice “aquí hay un valor de tipo Tarea” y el modificador dice “cuando aparezca un valor de tipo Tarea en la pila, esta es la pantalla”.
NavigationStack(path: $path) {
List(tareas) { tarea in
NavigationLink(tarea.titulo, value: tarea) // propone un valor
}
.navigationDestination(for: Tarea.self) { tarea in // interpreta el tipo
DetalleTarea(tarea: tarea)
}
}
La consecuencia inmediata es que la fila de la lista no conoce la pantalla de detalle. No la importa, no la construye y no depende de ella. Puede vivir en un módulo distinto, mostrarse en cinco pantallas diferentes y comportarse igual en todas, porque su única responsabilidad es señalar un dato. Quien decide qué se ve al tocarlo es el contenedor.
// la misma fila, reutilizada, sin saber nada del destino
struct FilaTarea: View {
let tarea: Tarea
var body: some View {
NavigationLink(value: tarea) {
HStack { Image(systemName: "circle"); Text(tarea.titulo) }
}
}
}
Esta inversión es la razón de ser del diseño. En el modelo antiguo, el enlace contenía el destino, así que cada punto de entrada acarreaba una dependencia hacia la pantalla final. Ahora la dependencia va en sentido contrario: el enrutador conoce las pantallas, y las pantallas no conocen el enrutador.
flowchart LR L[NavigationLink con value] --> P[path acumula valores] P --> D[navigationDestination por tipo] D --> V[vista destino] style D fill:#a6e3a1,color:#11111b
Dónde registrarlo y por qué ahí
El modificador no se aplica al NavigationStack: se aplica a una vista dentro de él, y su alcance sube hasta la pila más cercana. La regla práctica que evita casi todos los fallos es registrarlo en la vista raíz de la pila, nunca dentro de un contenedor perezoso.
// CORRECTO: el destino esta disponible desde el primer instante
NavigationStack(path: $path) {
Raiz()
.navigationDestination(for: Tarea.self) { DetalleTarea(tarea: $0) }
}
// FRAGIL: dentro de una fila de List, que solo existe si esa fila esta visible
List(tareas) { tarea in
FilaTarea(tarea: tarea)
.navigationDestination(for: Tarea.self) { DetalleTarea(tarea: $0) }
}
El segundo caso falla de una forma especialmente desconcertante: funciona mientras la fila esté en pantalla y deja de funcionar cuando el usuario ha desplazado la lista o cuando la pila se restaura desde cero, porque List construye sus filas bajo demanda y un destino declarado en una fila descartada sencillamente no existe. Si la pila contiene un valor cuyo tipo no tiene destino registrado, el framework no puede resolverlo, lo notifica por consola y la navegación no ocurre.
Registrar dos veces el mismo tipo en la misma pila es ambiguo: gana el registro más profundo en la jerarquía y el otro queda silenciosamente ignorado. Si necesitas que el mismo dato abra pantallas distintas según el contexto, no dupliques el registro: crea dos tipos de ruta distintos. El tipo es la clave del enrutado, así que dos comportamientos exigen dos claves.
Una pila puede registrar tantos tipos como necesite, y cada registro es independiente. Así conviven varios dominios en el mismo historial sin mezclarse.
NavigationStack(path: $path) { // path de tipo NavigationPath
Inicio()
.navigationDestination(for: Tarea.self) { DetalleTarea(tarea: $0) }
.navigationDestination(for: Proyecto.self) { DetalleProyecto(p: $0) }
.navigationDestination(for: Usuario.self) { Perfil(usuario: $0) }
}
Con un array homogéneo esto sería imposible, porque un array solo guarda un tipo. Aquí es donde NavigationPath gana su sitio: acepta cualquier valor Hashable, borra su tipo al guardarlo y lo recupera al resolverlo, de modo que la pila puede alternar tareas, proyectos y usuarios en cualquier orden.
El precio del borrado de tipos es que pierdes la lectura directa del contenido: puedes contar los elementos y quitar los últimos, pero no inspeccionarlos ni buscar dentro. Si tu lógica necesita preguntar qué hay en la pila, usa un array de un tipo de ruta propio; si solo necesita empujar y retroceder sobre dominios heterogéneos, NavigationPath es la elección correcta.
Enrutar una app entera
Cuando la app crece, la técnica que escala no es acumular registros de tipos del dominio, sino declarar un tipo propio de ruta por módulo. Un enumerado con datos asociados describe exactamente el conjunto de pantallas alcanzables y nada más.
enum RutaTareas: Hashable {
case detalle(id: Tarea.ID)
case edicion(id: Tarea.ID)
case adjuntos(id: Tarea.ID, filtro: Filtro)
}
extension View {
func rutasDeTareas() -> some View {
navigationDestination(for: RutaTareas.self) { ruta in
switch ruta {
case .detalle(let id): DetalleTarea(id: id)
case .edicion(let id): EditorTarea(id: id)
case .adjuntos(let id, let f): Adjuntos(id: id, filtro: f)
}
}
}
}
Ese pequeño enumerado hace cuatro cosas a la vez, y conviene verlas por separado.
Cierra el universo
El compilador conoce todas las pantallas alcanzables. Añadir una obliga a tratarla en el switch.
Transporta contexto
Los datos asociados llevan lo que el destino necesita, sin depender de un estado global.
Encapsula el módulo
El módulo expone su tipo de ruta y su función de registro. Nadie fuera necesita conocer sus vistas.
Habilita el deep link
Traducir una URL se reduce a construir valores de este enumerado, materia de la lección cuarta.
Un caso de un enumerado de ruta debería llevar identificadores y parámetros, no el modelo completo. La pila es historial, no caché: si el objeto cambia mientras está apilado, una ruta que lo contenga entero quedará desincronizada y además romperá su propia identidad. Con el identificador dentro, el destino consulta siempre el dato vivo.
La tentación al leer navigationDestination es archivarlo como el sitio donde se conecta un enlace con su pantalla, es decir, como cableado. Pero fíjate en lo que ocurre cuando escribes el enumerado de rutas de un módulo: acabas de definir, en una docena de líneas y de forma verificable por el compilador, el conjunto completo de estados de navegación que tu módulo admite. Eso no es una tabla de conexiones, es una especificación. Y como toda buena especificación por tipos, empieza a rechazar programas incorrectos antes de que existan: no puedes navegar a una pantalla de edición sin proporcionar el identificador que necesita, no puedes olvidar tratar un caso nuevo, y no puedes alcanzar una combinación de parámetros que el enumerado no contemple. El desacoplamiento viene de regalo, y es real —una fila no depende de una pantalla, un módulo no depende de otro—, pero es el efecto, no la causa. La causa es que has movido el enrutado del terreno de las referencias entre objetos al terreno de los valores, y en ese terreno se aplican todas las herramientas que ya dominas: se comparan, se serializan, se construyen desde una URL, se generan en una prueba y se enumeran exhaustivamente. Por eso la pregunta correcta al diseñar la navegación de una app nunca es “qué pantalla abre este botón”, sino “qué tipo describe todos los sitios a los que se puede llegar”. Responde a esa, y los botones se cablean solos.
- Define un enumerado
Hashablede rutas con tres casos, uno de ellos con dos datos asociados. - Encapsula su registro en una extensión de vista y aplícala en la raíz de la pila.
- Registra además un segundo tipo de ruta de otro dominio y alterna ambos en el mismo
NavigationPath. - Mueve un registro dentro de una fila de
List, desplaza la lista y documenta el fallo que aparece. - Añade un caso nuevo al enumerado y comprueba qué te obliga a hacer el compilador.