El mapa de destinos: diseñar la navegación de una app entera
Las cuatro lecciones anteriores dieron la tesis, los dos modelos, las capacidades que desbloquean y los bugs que eliminan. Falta el trabajo de arquitecto: sentarse ante una app real con pestañas, trayectos largos, modales, alertas y una sesión que puede caducar, y producir el mapa de destinos completo —qué pantalla puede presentar qué, con qué forma y quién decide la transición— antes de escribir una línea de vista. Esta lección construye ese mapa de principio a fin, lo traduce a tipos, cose las transiciones con acciones `delegate` y termina con las reglas que hacen que el mapa siga siendo cierto dentro de un año.
Un mapa de destinos es a la navegación lo que un esquema de base de datos es a la persistencia: el documento breve que decide, antes de que exista una sola pantalla, qué trayectos serán posibles y cuáles no. Casi nadie lo escribe, y por eso casi todas las apps acaban con una navegación que nadie diseñó sino que se sedimentó, pantalla a pantalla, según lo que hiciera falta el martes. El coste no aparece de golpe: aparece el día en que hay que abrir la app desde una notificación, o mostrar la misma pantalla desde dos sitios distintos, o explicarle a alguien nuevo por qué esa alerta vive donde vive. Esta lección hace el ejercicio completo sobre una app plausible —tres pestañas, trayectos que se repiten, modales, una sesión que puede expirar— y demuestra que el mapa cabe en una tabla, que la tabla se traduce a tipos casi mecánicamente y que, una vez escrito, responde solo a las preguntas que antes costaban una tarde de discusión.
- Inventariar las pantallas de una app y clasificar cada transición como trayecto, interrupción o cambio de contexto.
- Traducir ese inventario a un esqueleto de tipos con pestañas, una pila por pestaña y un hueco de destino por pantalla.
- Coser las transiciones con acciones
delegate, dejando claro quién decide empujar y quién solo informa. - Aplicar las cuatro reglas que mantienen el mapa cierto: dueño único, modal local, raíz para lo global y pila por contexto.
El inventario: de pantallas a destinos
Toma una app de recetas con tres pestañas. Explorar permite buscar, abrir una receta, ver el perfil de su autor y desde ahí abrir otra receta suya, indefinidamente. Despensa lista ingredientes y permite editarlos en un formulario y borrarlos con confirmación. Perfil muestra ajustes y puede cerrar sesión. Por encima de todo hay una sesión que, si expira, debe llevar al usuario a la pantalla de acceso sin importar dónde estuviera.
El inventario se escribe clasificando cada transición en tres categorías, y esa clasificación ya decide la forma del tipo. Continúa el trayecto, y entonces es un elemento de pila. Lo interrumpe con un conjunto cerrado de salidas, y entonces es un hueco de destino en la pantalla que interrumpe. O cambia el contexto entero, y entonces pertenece a la raíz.
| Pantalla | Puede llevar a | Forma del destino |
|---|---|---|
| Explorar | Receta | Autor | elemento de la pila de Explorar |
| Receta | Autor | hoja de compartir | alerta de guardado | pila para Autor, hueco propio para lo demás |
| Autor | Receta | elemento de la pila, recursivo |
| Despensa | formulario de ingrediente | alerta de borrado | hueco de destino propio |
| Perfil | Ajustes | elemento de la pila de Perfil |
| Raíz | pantalla de acceso | bienvenida | hueco de destino de la raíz |
La tabla ya contiene todas las decisiones difíciles. Que Receta lleve a Autor y Autor lleve a Receta obliga a la pila, porque un enum de destino no puede contenerse a sí mismo. Que la hoja de compartir viva en Receta y no en la pila impide que el gesto de volver atrás la trate como una pantalla más. Y que la pantalla de acceso viva en la raíz, no dentro de Perfil, es lo que hace que la sesión expirada funcione igual desde cualquier pestaña.
El esqueleto de tipos de una app entera
La traducción de la tabla a tipos es casi mecánica: una pila por pestaña, un enum de camino por pila, un hueco de destino en cada pantalla que interrumpa y un hueco más en la raíz para lo global.
@Reducer
struct AppCocina {
@Reducer
enum Modal { // lo global: cambia el contexto entero
case acceso(Acceso)
case bienvenida(Bienvenida)
}
@Reducer
enum CaminoExplorar { // el trayecto recursivo
case receta(Receta)
case autor(Autor)
}
@ObservableState
struct State: Equatable {
var pestana: Pestana = .explorar
var explorar = Explorar.State()
var caminoExplorar = StackState<CaminoExplorar.State>()
var despensa = Despensa.State()
var perfil = Perfil.State()
var caminoPerfil = StackState<CaminoPerfil.State>()
@Presents var modal: Modal.State?
}
enum Pestana: Equatable { case explorar, despensa, perfil }
enum Action {
case pestanaCambiada(Pestana)
case explorar(Explorar.Action)
case caminoExplorar(StackActionOf<CaminoExplorar>)
case despensa(Despensa.Action)
case perfil(Perfil.Action)
case caminoPerfil(StackActionOf<CaminoPerfil>)
case modal(PresentationAction<Modal.Action>)
case sesionExpiro
}
}
Dos detalles del esqueleto valen más que todo el resto. El primero es que hay una pila por pestaña y no una sola compartida: cada pestaña conserva su propio trayecto cuando el usuario la abandona y vuelve, que es lo que cualquiera espera de una app de pestañas y lo que una pila global rompería. El segundo es que el modal global vive en la raíz, no dentro de ninguna pestaña, y por eso la sesión expirada se resuelve en una línea desde cualquier punto de la app.
Las costuras: quién empuja, quién descarta y quién no se entera
Con los tipos puestos, falta decidir quién toma cada decisión de navegación. La regla es la del bloque de composición, aplicada sin excepciones: una pantalla nunca se navega a sí misma hacia fuera. Informa de un hecho con un delegate y el dueño de la pila decide qué hacer con él.
var body: some ReducerOf<Self> {
Scope(state: \.explorar, action: \.explorar) { Explorar() }
Scope(state: \.despensa, action: \.despensa) { Despensa() }
Scope(state: \.perfil, action: \.perfil) { Perfil() }
Reduce { state, action in
switch action {
case let .explorar(.delegate(.recetaAbierta(id))):
state.caminoExplorar.append(.receta(Receta.State(id: id)))
return .none
case let .caminoExplorar(.element(id: _, action: .receta(.delegate(.autorAbierto(id))))):
state.caminoExplorar.append(.autor(Autor.State(id: id)))
return .none
case let .caminoExplorar(.element(id: _, action: .autor(.delegate(.recetaAbierta(id))))):
state.caminoExplorar.append(.receta(Receta.State(id: id)))
return .none
case .sesionExpiro:
state.caminoExplorar.removeAll()
state.caminoPerfil.removeAll()
state.modal = .acceso(Acceso.State())
return .none
default:
return .none
}
}
.ifLet(\.$modal, action: \.modal)
.forEach(\.caminoExplorar, action: \.caminoExplorar)
.forEach(\.caminoPerfil, action: \.caminoPerfil)
}
Fíjate en el caso de la sesión expirada: tres asignaciones y la app entera queda en un estado coherente, con los dos trayectos vaciados y el acceso presentado. En un modelo imperativo eso mismo exigiría recorrer la jerarquía de vistas descartando presentaciones en el orden correcto y esperando animaciones, con resultado distinto según dónde estuviera el usuario. Aquí no hay orden ni espera: hay un valor nuevo.
Dueño único
Cada colección de navegación tiene un dueño y solo él escribe en ella. Los hijos informan con delegate; nunca se apilan a sí mismos.
Modal local
Lo que interrumpe una pantalla vive en el hueco de esa pantalla. Si sube a la raíz, la raíz acaba conociendo detalles que no le incumben.
Raíz para lo global
Acceso, bienvenida, bloqueo por versión: todo lo que invalida el contexto entero pertenece al hueco de la raíz y a ninguno más.
Una pila por contexto
Cada pestaña conserva su trayecto. Una pila compartida obliga al usuario a empezar de cero cada vez que cambia de sitio.
flowchart TD R[Raiz] --> M[Modal global: acceso o bienvenida] R --> T1[Pestana Explorar] R --> T2[Pestana Despensa] R --> T3[Pestana Perfil] T1 --> P1[Pila de Explorar] P1 --> RC[Receta] P1 --> AU[Autor] RC -->|delegate| P1 AU -->|delegate| P1 RC --> D1[Hueco: compartir o alerta] T2 --> D2[Hueco: formulario o alerta] T3 --> P3[Pila de Perfil] style M fill:#f9e2af,color:#11111b style P1 fill:#a6e3a1,color:#11111b style R fill:#89b4fa,color:#11111b
La forma barata de comprobar si un mapa está bien es escribir la función de URL a estado antes de implementar las pantallas. Si cada trayecto que el producto promete se puede expresar como una dirección y construir como un valor, el mapa es correcto. Si alguno exige información que solo se produce recorriendo el camino —un identificador que se calcula al pulsar, un dato que la pantalla anterior deja en una variable— ahí tienes un destino mal modelado, y es mucho más barato descubrirlo ahora que cuando existan cuarenta vistas.
Vale la pena preguntarse por qué un documento tan corto y tan decisivo se omite prácticamente siempre. La razón es que la navegación se percibe como una consecuencia de las pantallas y no como una estructura propia: primero se hacen las vistas, que es donde está el producto visible, y la navegación aparece después como el pegamento que las une. Pero el orden real de dependencia es el inverso. El mapa de destinos determina qué estados puede tener la app, y los estados posibles determinan qué pantallas tienen sentido, no al revés. Cuando se construye sin mapa, cada pantalla nueva se conecta con la que hacía falta en ese momento, y la estructura que emerge no es un árbol ni una pila sino un grafo sin invariantes, en el que la respuesta a preguntas elementales —puede el usuario llegar aquí desde dos sitios, qué pasa si expira la sesión mientras está en este formulario, qué se restaura al volver— hay que averiguarla leyendo código en vez de consultarla. Escribir el mapa antes cambia la naturaleza de esas preguntas: dejan de resolverse discutiendo y pasan a resolverse mirando una tabla, porque la tabla es el sitio donde las decisiones viven. Y hay algo más, que es lo que hace que este mapa cierre el nivel y no solo lo resuma. Un mapa de destinos escrito con la disciplina de las lecciones anteriores no es documentación que se desincroniza del código: es el código. Las filas de la tabla son casos de enums, la columna de la forma es la elección entre pila y hueco, y la columna de quién decide es el conjunto de acciones delegate que hay que declarar. Por eso no puede quedar obsoleto sin que el compilador se entere, y por eso conviene escribirlo como tabla y traducirlo de inmediato en lugar de dejarlo en una pizarra. La navegación como dato empezó, en la primera lección, como una tesis sobre qué clase de cosa es estar en una pantalla; termina aquí como algo más práctico y más raro, que es una arquitectura en la que el documento de diseño y el programa son la misma cosa escrita dos veces, y en la que cualquier divergencia entre ambos es un error de compilación y no un malentendido que alguien descubrirá tarde.
- Inventaria todas las pantallas de una app tuya y escribe la tabla del mapa: pantalla, a qué puede llevar, y forma del destino según continúe, interrumpa o cambie de contexto.
- Marca las transiciones recursivas —una pantalla que puede llevar a otra de su mismo tipo— y comprueba que todas quedan en pilas y ninguna en un enum anidado.
- Decide para cada pila quién es su dueño y escribe los casos
delegateque necesita recibir. Ninguna pantalla debe escribir en una colección que no le pertenece. - Localiza lo global —acceso, bloqueo por versión, bienvenida— y súbelo al hueco de la raíz. Escribe la acción que restablece el contexto entero en una sola transición.
- Escribe la función de
URLa estado para los cinco trayectos que tu producto promete y un test por cada uno. El primero que no puedas construir sin recorrer el camino señala el destino que hay que rediseñar.