Programación en Swift y SwiftUI para iOS Developers

Cómo usar NavigationStack en SwiftUI

Si has desarrollado aplicaciones en SwiftUI desde sus inicios, conoces el dolor. Durante años, la navegación fue el talón de Aquiles del framework. NavigationView era rígido, la navegación programática dependía de hacks con isActive o tag, y volver a la raíz (“pop to root”) era una odisea de bindings anidados.

Con la llegada de iOS 16, Apple introdujo un cambio de paradigma radical: NavigationStack.

Este componente no es simplemente un cambio de nombre; es una reingeniería total de cómo SwiftUI entiende el flujo de pantallas. NavigationStack transforma la navegación de ser una jerarquía visual estática a ser una estructura de datos dinámica.

En este tutorial masivo, desglosaremos cada átomo de NavigationStack. Aprenderás a desacoplar tu UI de tu lógica de navegación, implementar arquitecturas tipo “Coordinator”, manejar Deep Links y persistir el estado del usuario.


1. El Cambio de Paradigma: De Vistas a Datos

Para dominar NavigationStack, primero debes olvidar NavigationView.

En el modelo antiguo, la navegación estaba definida por la ubicación del enlace. Si querías ir de la Vista A a la Vista B, tenías que colocar un NavigationLink dentro de la Vista A que contuviera físicamente a la Vista B. Esto acoplaba fuertemente las pantallas.

NavigationStack introduce la navegación basada en valores. La premisa es simple: La pila de navegación es solo un Array.

  • Si el array está vacío, estás en la vista raíz.
  • Si añades un elemento al array, haces “Push” de una nueva pantalla.
  • Si eliminas el último elemento, haces “Pop”.
  • Si vacías el array, vuelves al inicio.

Conceptos Clave

  1. El Contenedor (NavigationStack): El marco que gestiona la visualización.
  2. El Camino (Path): La colección de datos que representa dónde estás.
  3. El Destino (navigationDestination): La regla que dice “cuando veas este tipo de dato, muestra esta vista”.

2. Implementación Básica: Navegación Declarativa

Empecemos con el uso más sencillo, ideal para listas y flujos lineales donde no necesitas control programático complejo.

El cambio fundamental aquí es el modificador .navigationDestination(for:).

struct User: Identifiable, Hashable {
    let id = UUID()
    let username: String
}

struct BasicStackView: View {
    let users = [
        User(username: "Ana"),
        User(username: "Beto"),
        User(username: "Carla")
    ]

    var body: some View {
        NavigationStack {
            List(users) { user in
                // 1. El enlace ahora transporta DATOS, no Vistas.
                NavigationLink(value: user) {
                    Text(user.username)
                }
            }
            .navigationTitle("Usuarios")
            // 2. Definimos el destino una sola vez para este tipo de dato.
            .navigationDestination(for: User.self) { user in
                UserProfileView(user: user)
            }
        }
    }
}

struct UserProfileView: View {
    let user: User
    var body: some View {
        Text("Perfil de \(user.username)")
            .font(.largeTitle)
    }
}

¿Por qué es mejor esto?

  1. Desacoplamiento: La lista no sabe qué vista se mostrará. Solo sabe que envía un objeto User.
  2. Rendimiento (Lazy Loading): En el antiguo NavigationView, si tenías una lista de 1000 elementos con NavigationLink(destination:...), SwiftUI instanciaba las 1000 vistas de destino inmediatamente. Con NavigationLink(value:), la vista de destino solo se crea cuando el usuario pulsa el enlace.
  3. Limpieza: Puedes tener múltiples NavigationLink enviando objetos User desde diferentes partes de la jerarquía, y todos serán capturados por el mismo modificador .navigationDestination.

Nota Importante: Cualquier objeto que pases en value debe conformar el protocolo Hashable.


3. Navegación Programática: El Santo Grial

Aquí es donde NavigationStack brilla. Al controlar el path (el camino), podemos manipular la navegación desde la lógica de negocio, sin tocar la UI.

Gestión de un Path Homogéneo

Si tu navegación solo implica un tipo de dato (por ejemplo, navegar por números de páginas o categorías del mismo tipo), puedes usar un simple Array como estado.

struct ProgrammaticView: View {
    // Esta variable es la "Verdad Única" de tu navegación
    @State private var path: [Int] = []

    var body: some View {
        NavigationStack(path: $path) {
            VStack(spacing: 20) {
                Button("Ir a la pantalla 1") {
                    path.append(1) // Navegación manual
                }
                
                Button("Saltar directamente a la 5 y luego a la 8") {
                    path = [5, 8] // Deep linking instantáneo
                }
            }
            .navigationTitle("Inicio")
            .navigationDestination(for: Int.self) { number in
                NumberDetailView(number: number, path: $path)
            }
        }
    }
}

struct NumberDetailView: View {
    let number: Int
    @Binding var path: [Int] // Pasamos el binding para manipular el stack

    var body: some View {
        VStack {
            Text("Pantalla #\(number)")
            
            Button("Siguiente (\(number + 1))") {
                path.append(number + 1)
            }
            
            Button("Volver al Inicio (Pop to Root)") {
                path.removeAll() // La forma más fácil de volver al root en la historia de iOS
            }
            
            Button("Volver 2 pasos atrás") {
                if path.count >= 2 {
                    path.removeLast(2)
                }
            }
        }
    }
}

Análisis de Capacidades

Con este enfoque, tienes control total:

  • Push: path.append(valor)
  • Pop: path.removeLast()
  • Pop to Root: path.removeAll()
  • Manipulación del historial: Puedes insertar vistas intermedias o reemplazar la pila completa asignando un nuevo array.

4. NavigationPath: Navegación Compleja y Heterogénea

En una aplicación real, rara vez navegas solo por un tipo de dato. Es común ir de una lista de Productos -> DetalleProducto -> PerfilUsuario -> Configuración.

Un Array<Int> no sirve aquí. Necesitamos un array que pueda contener tipos distintos. Para esto, Apple creó NavigationPath.

NavigationPath es una colección con “borrado de tipo” (type-erased) que puede almacenar cualquier valor Hashable.

struct Product: Hashable { let name: String }
struct User: Hashable { let name: String }
struct Settings: Hashable { let id: String }

struct ComplexStackView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List {
                Section("Tienda") {
                    NavigationLink("Ver iPhone", value: Product(name: "iPhone 15"))
                    NavigationLink("Ver Mac", value: Product(name: "MacBook Pro"))
                }
                
                Section("Social") {
                    NavigationLink("Perfil de Admin", value: User(name: "Admin"))
                }
                
                Section("Sistema") {
                    Button("Ir a Configuración") {
                        path.append(Settings(id: "General"))
                    }
                }
            }
            // Definimos un destino para CADA tipo posible en el stack
            .navigationDestination(for: Product.self) { product in
                ProductView(product: product)
            }
            .navigationDestination(for: User.self) { user in
                UserView(user: user)
            }
            .navigationDestination(for: Settings.self) { settings in
                SettingsView(settings: settings)
            }
        }
    }
}

Con NavigationPath, SwiftUI sabe automáticamente qué vista renderizar basándose en el tipo del elemento que está en la cima de la pila.


5. Arquitectura Profesional: El Patrón Router / Coordinator

Hasta ahora hemos usado @State dentro de la vista. Pero en una app profesional (MVVM, Clean Architecture), la lógica de navegación no debería estar en la Vista. Debería estar en un objeto responsable del flujo.

Vamos a crear un Router reactivo que podamos inyectar en cualquier parte de la app.

Paso 1: Crear el Router

final class Router: ObservableObject {
    // Publicamos el path para que la UI se redibuje al cambiar
    @Published var path = NavigationPath()
    
    // Métodos semánticos para evitar exponer 'path' directamente
    func navigate(to destination: any Hashable) {
        path.append(destination)
    }
    
    func navigateBack() {
        path.removeLast()
    }
    
    func navigateToRoot() {
        path = NavigationPath()
    }
}

Paso 2: Inyectar en el Entorno

struct AppRoot: View {
    @StateObject private var router = Router()
    
    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: String.self) { text in
                    DetailView(text: text)
                }
                .navigationDestination(for: Int.self) { number in
                    NumberView(number: number)
                }
        }
        .environmentObject(router) // Disponible para todos los hijos
    }
}

Paso 3: Usar en Vistas Hijas

Ahora, cualquier vista secundaria puede navegar sin saber de dónde viene ni a dónde va, simplemente pidiendo el Router.

struct DetailView: View {
    @EnvironmentObject var router: Router
    let text: String
    
    var body: some View {
        VStack {
            Text("Detalle: \(text)")
            
            Button("Ir al número 100") {
                // Navegación agnóstica de la vista
                router.navigate(to: 100)
            }
            
            Button("Volver al Inicio") {
                router.navigateToRoot()
            }
        }
    }
}

Este patrón soluciona el problema de pasar Bindings de padres a hijos a través de 5 niveles de jerarquía.


6. Persistencia de Estado y Deep Linking

Una de las características más poderosas de tratar la navegación como datos es que los datos se pueden guardar.

Imagina este escenario: El usuario navega profundamente en tu app, minimiza la aplicación, y el sistema cierra la app para liberar memoria. Cuando el usuario vuelve, espera estar donde lo dejó. Con NavigationView esto era casi imposible. Con NavigationStack y Codable, es trivial.

Serialización del Path

Lamentablemente, NavigationPath no conforma a Codable automáticamente porque contiene tipos borrados. Para persistencia robusta, es mejor usar un Array de un Enum que contenga todas tus rutas posibles.

// 1. Definimos todas las pantallas posibles
enum AppRoute: Codable, Hashable {
    case product(id: Int)
    case userProfile(userId: String)
    case settings
}

class PersistableRouter: ObservableObject {
    @Published var path: [AppRoute] = [] {
        didSet {
            saveState()
        }
    }
    
    private let saveKey = "navigation_history"
    
    init() {
        // Restaurar estado al iniciar
        if let data = UserDefaults.standard.data(forKey: saveKey),
           let decoded = try? JSONDecoder().decode([AppRoute].self, from: data) {
            path = decoded
        }
    }
    
    private func saveState() {
        if let encoded = try? JSONEncoder().encode(path) {
            UserDefaults.standard.set(encoded, forKey: saveKey)
        }
    }
}

Al usar este PersistableRouter en tu NavigationStack, la aplicación recordará automáticamente la historia de navegación del usuario entre sesiones.

Deep Linking (URLs Externas)

Si tu app recibe una URL como miapp://producto/45, manejarlo es tan simple como parsear la URL, crear el enum correspondiente (.product(id: 45)) y añadirlo al array path. SwiftUI reconstruirá toda la interfaz visual instantáneamente.

.onOpenURL { url in
    if let route = parseUrlToRoute(url) {
        // Reseteamos al root o añadimos al historial existente
        router.path.append(route)
    }
}

7. Errores Comunes y Mejores Prácticas

Aunque NavigationStack es superior, hay trampas en las que es fácil caer.

A. No anides NavigationStacks

Nunca pongas un NavigationStack dentro de otro. Esto causará barras de navegación dobles y romperá el historial. Si necesitas dividir la pantalla (como en iPad), usa NavigationSplitView como contenedor principal, no NavigationStack.

B. El modificador .navigationDestination

Coloca los modificadores .navigationDestination lo más alto posible en la jerarquía (usualmente en la vista raíz del stack). Si colocas un modificador de destino dentro de una vista que desaparece (por ejemplo, dentro de un if), la navegación dejará de funcionar.

C. Cuidado con los objetos de Clase

Los objetos que pases en el path deben ser Hashable. Si usas clases (class), asegúrate de implementar hash(into:) y ==correctamente basándote en un ID único, no en la identidad del puntero, para evitar comportamientos erráticos al recargar vistas. Lo ideal es usar struct o enum.


Conclusión

NavigationStack es la pieza que faltaba en el rompecabezas de SwiftUI. Al transformar la navegación en una gestión de estado pura, nos permite escribir aplicaciones más robustas, testables y predecibles.

Resumen de ventajas:

  1. Navegación Tipada: Uso de value y navigationDestination para desacoplar UI.
  2. Control Centralizado: Uso de NavigationPath o Arrays para manejar el flujo desde ViewModels.
  3. Flexibilidad: Deep linking y restauración de estado nativos.
  4. Rendimiento: Carga perezosa de vistas de destino por defecto.

Si todavía estás usando NavigationView, este es el momento de migrar. Tu código futuro te lo agradecerá.

Si tienes cualquier duda sobre este artículo, contacta conmigo y estaré encantado de ayudarte 🙂. Puedes contactar conmigo en mi perfil de X o en mi perfil de Instagram

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Cómo aprender el lenguaje de programación Swift

Next Article

Las mejores IA para desarrolladores iOS con Swift y SwiftUI

Related Posts