Navegación en SwiftUI: NavigationStack, NavigationPath, navigationDestination y deep linking

La navegación es la columna vertebral de cualquier app. Hasta iOS 15, SwiftUI ofrecía NavigationView, que tenía limitaciones importantes: no permitía navegar de forma programática a destinos arbitrarios, no soportaba deep linking de forma limpia y su comportamiento en iPad era impredecible. iOS 16 introdujo NavigationStack y NavigationPath, una reescritura completa que convierte la navegación en un estado de primer orden, como cualquier otra parte del modelo de datos de la app.

NavigationStack: el stack de navegación como estado

La diferencia fundamental con NavigationView es que el historial de navegación ahora es un array que puedes leer y modificar directamente:

struct AppRaiz: View {
    @State private var ruta: [DestinoApp] = []

    var body: some View {
        NavigationStack(path: $ruta) {
            ListaCategorias()
                .navigationDestination(for: Categoria.self) { categoria in
                    ListaProductos(categoria: categoria)
                }
                .navigationDestination(for: Producto.self) { producto in
                    DetalleProducto(producto: producto)
                }
        }
    }
}

Cuando el usuario toca un NavigationLink, el destino se añade a ruta. Cuando pulsa atrás, se elimina. Y puedes manipular ese array desde cualquier parte de tu código.

navigationDestination: registro de destinos

navigationDestination(for:destination:) es el mecanismo que conecta los tipos de datos con las vistas que los representan. Puedes registrar múltiples destinos en el stack:

NavigationStack(path: $ruta) {
    VistaInicial()
        .navigationDestination(for: Categoria.self) { categoria in
            ListaProductos(categoria: categoria)
        }
        .navigationDestination(for: Producto.self) { producto in
            DetalleProducto(producto: producto)
        }
        .navigationDestination(for: Pedido.self) { pedido in
            DetallePedido(pedido: pedido)
        }
}

Los NavigationLink dentro del stack usan el tipo del valor para seleccionar automáticamente el destino correcto:

// En ListaProductos:
ForEach(productos) { producto in
    NavigationLink(value: producto) { // valor tipo Producto
        ProductoRow(producto: producto)
    }
}

NavigationPath: stack heterogéneo con type erasure

Cuando el historial de navegación puede contener tipos distintos de forma dinámica, [DestinoApp] (un enum) funciona bien. Pero si necesitas un stack truly heterogéneo donde cualquier tipo Hashable pueda ser un destino, usa NavigationPath:

@State private var ruta = NavigationPath()

// Navegar a distintos tipos
ruta.append(Categoria(id: 1))
ruta.append(Producto(id: 42))
ruta.append(Pedido(id: 99))

// Volver N pantallas atrás
ruta.removeLast(2)

// Ir a la raíz
ruta.removeLast(ruta.count)

NavigationPath borra el tipo internamente (type erasure) para poder almacenar valores de tipos distintos en el mismo array.

Navegación programática

El poder real de este sistema llega cuando navegas desde el código sin que el usuario toque ningún enlace:

@Observable
class NavegacionViewModel {
    var ruta: [DestinoApp] = []

    func irAlProducto(_ producto: Producto) {
        ruta = [.categoria(producto.categoria), .producto(producto)]
    }

    func volverAlInicio() {
        ruta.removeAll()
    }

    func irACheckout(pedido: Pedido) {
        ruta.append(.pedido(pedido))
    }
}

struct AppRaiz: View {
    @State private var navModel = NavegacionViewModel()

    var body: some View {
        NavigationStack(path: $navModel.ruta) {
            VistaInicial()
                .navigationDestination(for: DestinoApp.self) { destino in
                    destino.vistaAsociada()
                }
        }
        .environment(navModel)
    }
}

Deep linking con URLs externas

Una de las ventajas más importantes es el deep linking limpio. Cuando la app recibe una URL externa, puedes traducirla a un estado de navegación y aplicarlo directamente:

struct AppRaiz: View {
    @State private var ruta: [DestinoApp] = []

    var body: some View {
        NavigationStack(path: $ruta) {
            VistaInicial()
                .navigationDestination(for: DestinoApp.self) { $0.vista() }
        }
        .onOpenURL { url in
            if let destinos = RouterApp.parsear(url) {
                ruta = destinos
            }
        }
    }
}

struct RouterApp {
    static func parsear(_ url: URL) -> [DestinoApp]? {
        // URL: miapp://producto/42
        guard url.scheme == "miapp" else { return nil }
        let partes = url.pathComponents.dropFirst()

        switch url.host {
        case "producto":
            if let idStr = partes.first, let id = Int(idStr),
               let producto = BDLocal.shared.producto(id: id) {
                return [.producto(producto)]
            }
        case "categoria":
            if let nombre = partes.first,
               let cat = BDLocal.shared.categoria(nombre: nombre) {
                return [.categoria(cat)]
            }
        default: break
        }
        return nil
    }
}

NavigationSplitView: iPad y macOS

Para pantallas grandes donde tiene sentido una columna lateral, NavigationSplitView es la pieza complementaria:

struct AppPrincipal: View {
    @State private var categoriaSeleccionada: Categoria?
    @State private var productoSeleccionado: Producto?

    var body: some View {
        NavigationSplitView {
            // Sidebar: lista de categorías
            ListaCategorias(seleccion: $categoriaSeleccionada)
        } content: {
            // Columna central: productos de la categoría
            if let cat = categoriaSeleccionada {
                ListaProductos(categoria: cat, seleccion: $productoSeleccionado)
            } else {
                Text("Selecciona una categoría").foregroundStyle(.secondary)
            }
        } detail: {
            // Detalle: producto seleccionado
            if let producto = productoSeleccionado {
                DetalleProducto(producto: producto)
            } else {
                Text("Selecciona un producto").foregroundStyle(.secondary)
            }
        }
    }
}

En iPhone, NavigationSplitView se comporta como un NavigationStack automáticamente. En iPad y Mac muestra las columnas en pantalla.

Persistir el estado de navegación

NavigationPath admite codificación si todos sus tipos conforman Codable. Esto permite persistir y restaurar el stack de navegación entre sesiones:

extension NavigationPath {
    func guardar() -> Data? {
        try? codable.flatMap { try JSONEncoder().encode($0) }
    }

    static func cargar(datos: Data) -> NavigationPath {
        guard let representacion = try? JSONDecoder().decode(
            NavigationPath.CodableRepresentation.self, from: datos
        ) else { return NavigationPath() }
        return NavigationPath(representacion)
    }
}

Resumen

NavigationStack convierte la navegación en estado explícito y manipulable. La combinación de navigationDestination para registrar destinos, NavigationPath para stacks heterogéneos y NavigationSplitView para pantallas grandes cubre todos los escenarios de navegación de una app real. El deep linking y la navegación programática, que antes requerían hacks, se vuelven derivaciones naturales del mismo modelo.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP