wandres.dev
NAVEGACIÓN · NavigationStack y rutas

Deep links y restauración de la pila

Cómo traducir una URL en un historial completo en lugar de en una pantalla suelta, por qué NavigationPath es codificable y qué exige de tus tipos de ruta, cómo persistir y recuperar la navegación entre lanzamientos, y qué partes del estado no deben restaurarse nunca.

⏱ 20 min

Abrir una app en una pantalla concreta parece un problema de enlaces, y no lo es. Es un problema de reconstrucción: una notificación, una URL o un reinicio del sistema te entregan una descripción de un destino, y tu trabajo consiste en fabricar a partir de ella un pasado plausible, no solo un presente. Que la pila sea un valor convierte esa tarea en una función pura de traducción, y que ese valor sepa codificarse la convierte además en algo que sobrevive al cierre de la app.

🎯 Al terminar esta lección sabrás
  • Traducir una URL entrante en una pila completa en lugar de en una pantalla aislada.
  • Entender qué exige NavigationPath para poder codificarse y decodificarse.
  • Persistir y recuperar el historial entre lanzamientos con almacenamiento de escena.
  • Decidir qué se restaura, qué se valida y qué debe descartarse siempre.

De una URL a un historial

El error habitual consiste en tratar el enlace profundo como una orden de mostrar una pantalla. Si lo haces así, el usuario aterriza en un detalle sin contexto y el botón de retroceso no lleva a ninguna parte razonable. La traducción correcta produce la pila entera.

enum Ruta: Hashable, Codable {
    case proyecto(id: UUID)
    case tarea(id: UUID)
    case adjunto(id: UUID)
}

func pila(desde url: URL) -> [Ruta]? {
    guard url.scheme == "dios",
          let partes = URLComponents(url: url, resolvingAgainstBaseURL: false)
    else { return nil }
    // dios://proyecto/UUID/tarea/UUID  produce DOS escalones, no uno
    return Parser.rutas(de: partes.path)
}

Fíjate en el tipo de retorno. No devuelve una vista ni ejecuta una acción: devuelve datos, y opcionales, porque una URL puede ser inválida. Toda la lógica de enrutado profundo queda así en una función comprobable sin interfaz, que es exactamente lo que prometía la primera lección del nivel.

.onOpenURL { url in
    guard let nueva = pila(desde: url) else { return }
    path = nueva            // sustituir, no acumular
}

Sustituir el historial completo, y no añadir al existente, es casi siempre lo correcto: el usuario que abre un enlace externo espera llegar a ese sitio, no encontrárselo apilado sobre lo que estuviera haciendo hace tres días.

Un enlace universal llega por otra puerta —una actividad del sistema en lugar de una URL directa— pero desemboca en la misma función, y esa convergencia es justamente lo que quieres.

.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { actividad in
    guard let url = actividad.webpageURL,
          let nueva = pila(desde: url) else { return }
    path = nueva
}
flowchart LR
U[URL entrante] --> P[parser puro]
P --> A[array de rutas]
A --> S[NavigationStack reconstruye]
P --> N[nada si es invalida]
style P fill:#a6e3a1,color:#11111b
style N fill:#f38ba8,color:#11111b
💡
El mismo parser sirve para tres puertas

Un enlace universal, un esquema propio, la carga útil de una notificación y un atajo del sistema son cuatro entradas distintas al mismo problema. Si todas terminan produciendo el mismo tipo de ruta, escribes la traducción una vez y añadir una puerta nueva cuesta media docena de líneas. Si cada una construye vistas por su cuenta, tendrás cuatro versiones divergentes de la misma navegación.

Un historial que sabe escribirse

NavigationPath ofrece una representación codificable, pero solo bajo una condición estricta: todos los valores que contiene deben conformar a Codable además de a Hashable. Si uno solo no lo hace, la propiedad devuelve nada y no hay nada que guardar.

@State private var path = NavigationPath()

var guardable: Data? {
    guard let representable = path.codable else { return nil }
    return try? JSONEncoder().encode(representable)
}

func restaurar(desde datos: Data) {
    guard let rep = try? JSONDecoder()
        .decode(NavigationPath.CodableRepresentation.self, from: datos)
    else { return }
    path = NavigationPath(rep)
}

La decodificación necesita conocer los tipos originales, que quedan registrados por nombre dentro de la representación. De ahí una consecuencia práctica que sorprende a mucha gente: renombrar o mover un tipo de ruta invalida las pilas guardadas por versiones anteriores de la app. No es un fallo, es el precio de un contenedor con borrado de tipos que debe reconstruirlos, y se gestiona igual que cualquier migración: aceptando que una restauración puede fallar y volviendo a la raíz sin drama.

@SceneStorage("pila") private var pilaGuardada: Data?

NavigationStack(path: $path) { Raiz() }
    .onChange(of: path) { _, _ in pilaGuardada = guardable }
    .task { if let d = pilaGuardada { restaurar(desde: d) } }

El almacenamiento por escena es el sitio correcto porque la navegación es estado de una ventana, no del usuario ni del documento. En iPad y en Mac pueden convivir varias escenas de la misma app, cada una con su propio historial, y guardarlo en un almacén compartido las fundiría todas en una.

⚠️
En la pila van identificadores, nunca datos frescos

Un historial guardado hoy puede restaurarse dentro de una semana. Si en él viajan modelos completos, resucitarás copias obsoletas de datos que quizá ya no existan, y no lo notarás hasta que el usuario edite sobre una versión fantasma. Guardando identificadores, el destino consulta el dato vivo al aparecer y puede decidir qué hacer si ha desaparecido.

Reconstruir con criterio

Restaurar no es aplicar lo guardado sin mirar. Entre lo que se guardó y lo que se recupera pueden haber cambiado los permisos, la sesión, el contenido y hasta el esquema de datos.

🔐

Autorizar antes

Si la sesión caducó, la pila restaurada no debe mostrarse. Valida primero la identidad y luego reconstruye.

🧪

Validar cada escalón

Un identificador puede apuntar a algo borrado. Trunca la pila en el primer escalón que no resuelva.

🧹

No restaurar lo efímero

Diálogos de confirmación, alertas y flujos de compra a medias no se restauran: se descartan.

🪟

Una pila por escena

Cada ventana guarda la suya. Compartir el almacén entre escenas produce saltos inexplicables.

func saneada(_ rutas: [Ruta]) -> [Ruta] {
    var resultado: [Ruta] = []
    for ruta in rutas {
        guard repositorio.existe(ruta) else { break }  // trunca, no descarta todo
        resultado.append(ruta)
    }
    return resultado
}

Truncar en lugar de vaciar es la política más amable: si el adjunto ya no existe pero la tarea sí, dejar al usuario en la tarea conserva la mayor parte del contexto. Vaciar entero castiga por un fallo en el último escalón.

Un deep link no te pide una pantalla: te pide un pasado creíble

Aquí es donde todo el nivel converge, y conviene decirlo sin rodeos. Un enlace profundo no es una petición de mostrar algo, es una petición de haber llegado a algo. La diferencia se ve en el botón de retroceso, que es el detector más honesto de una navegación mal diseñada: si al pulsarlo el usuario cae en un sitio que no explica dónde estaba, tu app le ha teletransportado en vez de llevarle. Reconstruir el camino no es una cortesía, es reconocer que la navegación tiene memoria y que esa memoria forma parte del significado de la pantalla actual. Ahora observa por qué esto solo es posible desde que la pila es un valor. Un historial que vive dentro del framework no se puede fabricar, porque los pasados no ocurridos no se pueden simular con eventos: solo se pueden describir. En cambio, un array de rutas no distingue entre un camino recorrido por el usuario a golpe de dedo y uno construido en tres líneas a partir de una URL, y esa indistinguibilidad es precisamente la propiedad que hace funcionar los enlaces profundos, la restauración, las pruebas automatizadas y las capturas de pantalla generadas. Guarda entonces este criterio como resumen operativo del nivel: si puedes fabricar el estado de tu app desde datos, puedes también guardarlo, compartirlo, probarlo y repararlo. Si solo puedes alcanzarlo tocando la pantalla en el orden correcto, no tienes un estado, tienes una coreografía.

⚔️ Abrir la app dos niveles adentro
  1. Define un tipo de ruta Hashable y Codable, y escribe la función que traduce una URL en un array de rutas.
  2. Prueba esa función sin interfaz, incluyendo una URL malformada que debe devolver nada.
  3. Enlaza el resultado con onOpenURL sustituyendo el historial completo y comprueba adónde lleva el retroceso.
  4. Persiste la pila con almacenamiento de escena y verifica que se recupera al relanzar la app.
  5. Borra el elemento del último escalón, restaura, y comprueba que tu saneado trunca en vez de vaciar.