Saltar al contenido
← Todos los posts

Navegación en SwiftUI sin file-based routing: NavigationStack, un enum de rutas y deep links

Cómo migrar mentalmente de Expo Router a SwiftUI: NavigationStack con un enum Route, el patrón Coordinator, deep links y Universal Links, y qué aporta swift-navigation cuando sheets y alerts se vuelven estado.

Ilustración de una pila de pantallas apiladas en profundidad, con un enlace externo que entra directamente a una pantalla intermedia

Expo Router resolvió la navegación en React Native con una idea prestada de la web: la estructura de carpetas es la estructura de rutas, cada archivo es una pantalla y un deep link es una URL que ya coincide con un archivo. En SwiftUI no hay carpetas que definan nada. La navegación es estado: una pila de valores que describe qué pantallas hay abiertas, y las vistas se derivan de esa pila. Este post explica cómo trasladar el modelo mental de Expo Router a NavigationStack con un enum de rutas, cuándo un Coordinator ayuda y cuándo estorba, cómo entran los deep links y los Universal Links en ese modelo, y qué agrega swift-navigation cuando sheets, alerts y pushes se vuelven parte del mismo estado.

Resumen para perezosos
  • En SwiftUI la navegación es un array de valores (NavigationPath o [Route]) que tú controlas. Un enum Route con datos asociados reemplaza a la carpeta app/ de Expo Router, y navigationDestination(for:) reemplaza a los archivos.
  • Un deep link es una función pura URL -> [Route]. Como la pila es estado, abrir un enlace es asignar ese array; no hay que "navegar" paso a paso.
  • Sheets, alerts y confirmaciones también son estado (un opcional que, cuando no es nil, presenta). swift-navigation lo formaliza con un enum de destinos por pantalla para que una vista no pueda mostrar dos cosas a la vez.

En este artículo:

De carpetas a estado: qué cambia respecto a Expo Router

En Expo Router, app/orders/[id].tsx existe y por lo tanto /orders/42 existe. Para ir ahí escribes router.push("/orders/42"), el string se resuelve contra el sistema de archivos, y el stack lo maneja React Navigation por debajo. El estado de navegación es una consecuencia de las URLs que fuiste empujando.

En SwiftUI el orden es inverso. Primero existe el estado (una pila de valores), y las pantallas son una proyección de ese estado. No hay un router al que le pides “ve a tal sitio”; hay un array al que le agregas un valor, y SwiftUI muestra la pantalla que corresponde a ese valor. Las consecuencias:

  • Las rutas son tipos, no strings. Un enum Route con case order(id: Order.ID) no admite /orders/abc si el id es numérico, y el compilador avisa cuando agregas una pantalla y olvidas su destino.
  • La pila se puede leer, escribir, guardar y restaurar. Volver al inicio es path.removeAll(). Abrir un deep link es path = [.orders, .order(id: 42)]. Restaurar la sesión anterior es decodificar el array que guardaste.
  • No hay archivo por pantalla. Puedes organizar las vistas como quieras; la navegación no depende de dónde están.
Expo Router                              SwiftUI

app/                                     enum Route: Hashable {
├── (tabs)/                                case orders
│   ├── orders/                            case order(id: Order.ID)
│   │   ├── index.tsx                      case orderItem(orderId: Order.ID, sku: String)
│   │   └── [id].tsx                       case settings
│   └── settings.tsx                     }
└── orders/[id]/items/[sku].tsx
                                         @State var path: [Route] = []
router.push("/orders/42")                path.append(.order(id: 42))
router.replace("/")                      path.removeAll()
useLocalSearchParams()                   los datos asociados del caso

Lo que se pierde es la ergonomía de la web: no hay URL en la barra, no hay Link href, no hay convención que cualquiera reconozca al abrir el proyecto. Lo que se gana es que la navegación sea tan testeable y tan tipada como el resto del estado.

La pieza central es NavigationStack con un path vinculado y navigationDestination(for:) para mapear cada tipo de valor a su vista. Con un enum de rutas queda así:

enum Route: Hashable {
    case order(id: Order.ID)
    case orderItem(orderId: Order.ID, sku: String)
    case settings
}

struct OrdersFlow: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            OrdersScreen(onSelect: { order in
                path.append(.order(id: order.id))
            })
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .order(let id):
                    OrderDetailScreen(orderId: id) { sku in
                        path.append(.orderItem(orderId: id, sku: sku))
                    }
                case .orderItem(let orderId, let sku):
                    OrderItemScreen(orderId: orderId, sku: sku)
                case .settings:
                    SettingsScreen()
                }
            }
        }
    }
}

Tres detalles que importan:

El destino se declara una vez, cerca de la raíz del stack. SwiftUI acepta navigationDestination(for:) en cualquier vista dentro de la jerarquía del NavigationStack (el ejemplo de Apple lo pone sobre una List); lo que no acepta es dentro de un contenedor perezoso como el contenido de una List o un LazyVStack, donde puede ignorarse con un warning. Centralizar el switch sobre Route en un solo sitio es una decisión de diseño, no una restricción del framework, y la tomo porque deja el mapa completo de pantallas en un solo archivo. Si vienes de React Navigation, es el Stack.Navigator con todas sus Stack.Screen declaradas arriba, no en cada componente.

Las pantallas no empujan rutas; reciben closures. OrdersScreen no sabe que existe Route; recibe onSelect y avisa. Esto mantiene la pantalla reutilizable en otro flujo y testeable sin navegación. El que sabe de rutas es el flujo, no la vista.

NavigationLink(value:) es la alternativa declarativa al append manual: NavigationLink(value: Route.order(id: order.id)) { OrderRow(order: order) } empuja el valor al tocarlo. Es cómodo en listas y funciona con el mismo navigationDestination. Uso append cuando la navegación es consecuencia de una acción (guardar y pasar a la siguiente pantalla) y NavigationLink cuando es una fila que se toca.

Sobre NavigationPath frente a [Route]: NavigationPath es un contenedor con borrado de tipo que acepta valores de cualquier tipo Hashable, útil cuando distintas partes de la app empujan tipos distintos. Con un solo enum de rutas, [Route] es más simple, se puede inspeccionar en un test (XCTAssertEqual(path, [.order(id: 42)])) y se codifica con Codable sin CodableRepresentation. Prefiero el array.

Tabs, cada una con su pila

En Expo Router, (tabs)/ con carpetas anidadas te daba un stack por tab automáticamente. En SwiftUI, cada tab es un NavigationStack propio con su propio path, y el estado de qué tab está seleccionada es otro valor más:

enum Tab: Hashable { case orders, profile }

@Observable
final class AppNavigation {
    var tab: Tab = .orders
    var ordersPath: [Route] = []
    var profilePath: [Route] = []
}

struct RootView: View {
    @State private var nav = AppNavigation()

    var body: some View {
        @Bindable var nav = nav
        TabView(selection: $nav.tab) {
            Tab("Pedidos", systemImage: "list.bullet", value: .orders) {
                OrdersFlow(path: $nav.ordersPath)
            }
            Tab("Perfil", systemImage: "person", value: .profile) {
                ProfileFlow(path: $nav.profilePath)
            }
        }
        .environment(nav)
    }
}

Tener el objeto de navegación en el entorno permite que cualquier pantalla, o el manejador de deep links, cambie de tab y empuje rutas sin conocer la jerarquía de vistas. Es lo más cercano a router.push global que vas a tener, y con la diferencia de que sigue siendo estado observable.

Un detalle de comportamiento: tocar la tab activa en iOS vuelve a la raíz de esa pila, y con TabView y NavigationStack estándar ese comportamiento lo pone el sistema. No intentes reimplementarlo con .onChange(of: nav.tab): una reselección no cambia el valor del binding, así que no hay cambio que observar. Si necesitas interceptarla (por ejemplo, para hacer scroll al inicio además de vaciar la pila), un Binding propio sobre tab cuyo set compara el valor nuevo con el actual es el punto donde detectarla.

El patrón Coordinator: cuándo ayuda y cuándo estorba

El Coordinator viene de UIKit: un objeto por flujo que decide qué pantalla sigue, para que los ViewController no se conozcan entre sí. En SwiftUI, con el path como estado, el objeto AppNavigation de arriba ya es un Coordinator en la práctica. La pregunta es cuánta lógica le pones.

Cuándo ayuda:

  • Flujos con ramas que dependen del dominio. Un onboarding que salta pasos según el tipo de usuario, un checkout que pide verificación solo si el monto supera un umbral. Esa decisión no debería vivir en la vista; vive en un método del coordinator: func proceedFromCart() que consulta el estado y hace el append correcto.
  • Navegación disparada desde fuera de la UI. Una notificación push, un deep link, un cambio de sesión que expulsa al usuario. Todos necesitan un punto único que sepa mutar la pila.
  • Tests de flujo. “Después de pagar con éxito, la pila debe ser [.confirmation]” es un test de una función del coordinator, sin UI.

Cuándo estorba:

  • Cuando cada append pasa por él. Si OrdersScreen tiene que llamar a coordinator.showOrder(id:) para una fila que podría ser un NavigationLink(value:), agregaste indirección sin decisión. El coordinator entra cuando hay lógica, no para todos los pushes.
  • Cuando se vuelve un singleton que las vistas importan. Se inyecta por entorno o se pasan closures; si una vista de un package de features importa el coordinator de la app, el package ya no es reutilizable.

Mi forma: un objeto @Observable por flujo grande (una por tab suele bastar), con las pilas como propiedades y métodos solo para las transiciones que tienen lógica. Todo lo demás son closures o NavigationLink.

En Expo Router no escribías el mapeo de URL a pantalla, porque la URL ya era la ruta, y Expo te abstraía gran parte de la configuración nativa (el esquema y los dominios asociados salían de app.json). En SwiftUI tienes que escribir las dos cosas, y lo bueno de la primera es que es una función pura: recibe una URL y devuelve la pila que la representa.

enum DeepLink {
    /// Traduce una URL a un destino: tab más pila. Sin efectos.
    static func parse(_ url: URL) -> (tab: Tab, path: [Route])? {
        switch segments(of: url) {
        case ["orders"]:
            return (.orders, [])
        case ["orders", let id]:
            guard let orderId = Order.ID(id) else { return nil }
            return (.orders, [.order(id: orderId)])
        case ["orders", let id, "items", let sku]:
            guard let orderId = Order.ID(id) else { return nil }
            return (.orders, [.order(id: orderId), .orderItem(orderId: orderId, sku: sku)])
        case ["settings"]:
            return (.profile, [.settings])
        default:
            return nil
        }
    }

    /// Normaliza las dos formas de URL que llegan a la app.
    /// `https://miapp.com/orders/42` tiene host `miapp.com` y path `/orders/42`;
    /// `miapp://orders/42` tiene host `orders` y path `/42`. Con un esquema propio,
    /// el host es el primer segmento de la ruta.
    private static func segments(of url: URL) -> [String] {
        let path = url.pathComponents.filter { $0 != "/" }
        guard url.scheme == "miapp", let host = url.host() else { return path }
        return [host] + path
    }
}

Y en la raíz, .onOpenURL recibe la URL, sea de un esquema propio (miapp://orders/42) o de un Universal Link (https://miapp.com/orders/42), y asigna el estado:

.onOpenURL { url in
    guard let target = DeepLink.parse(url) else { return }
    nav.tab = target.tab
    switch target.tab {
    case .orders: nav.ordersPath = target.path
    case .profile: nav.profilePath = target.path
    }
}

Observa que la pila completa se asigna de una vez. En React Navigation, un deep link a una pantalla profunda requería configurar initialRouteName para que el botón de atrás tuviera a dónde volver. Aquí la pila [.order(id: 42), .orderItem(...)] ya incluye las pantallas intermedias, y el botón de atrás funciona porque el estado es correcto, no porque alguien lo configuró.

Lo que sí hay que configurar fuera del código, y que Expo hacía en app.json:

  • Esquema propio: CFBundleURLTypes en Info.plist. Funciona en cualquier dispositivo, pero cualquier app puede registrar el mismo esquema.
  • Universal Links: el entitlement Associated Domains con applinks:miapp.com, y un archivo apple-app-site-association servido por HTTPS en https://miapp.com/.well-known/ con tu Team ID y bundle ID. Sin ese archivo, el sistema abre Safari en vez de la app. Es el paso que más veces falla en producción, casi siempre por el archivo (JSON inválido, Content-Type incorrecto, o el CDN cacheando una versión vieja).

La función parse se testea sin dispositivo: una tabla de URLs y pilas esperadas. Es el test más rentable de toda la capa de navegación, porque cubre justo la parte que no se ve hasta que un usuario toca un enlace de un correo.

Sheets, alerts y swift-navigation: cuando todo es estado

Un push es un valor en la pila. Un sheet, en SwiftUI, es un opcional: .sheet(item: $editingOrder) { order in EditOrderScreen(order: order) } presenta cuando editingOrder no es nil y cierra cuando vuelve a serlo. Un alert es lo mismo con .alert(item:). Y ahí aparece el problema: una pantalla con un sheet de edición, un alert de confirmación de borrado y un sheet de compartir tiene tres opcionales, y nada impide que dos sean no-nil a la vez, cosa que SwiftUI resuelve mostrando uno y descartando el otro sin avisar.

swift-navigation (Point-Free, funciona sin TCA) resuelve eso con un solo enum de destino por pantalla:

import SwiftUINavigation

@Observable
final class OrderDetailModel {
    @CasePathable
    enum Destination {
        case edit(Order)
        case confirmDelete
        case share(URL)
    }

    var destination: Destination?

    func deleteTapped() { destination = .confirmDelete }
    func editTapped(_ order: Order) { destination = .edit(order) }
}
.sheet(item: $model.destination.edit) { order in
    EditOrderScreen(order: order)
}
.alert("¿Eliminar pedido?", isPresented: Binding($model.destination.confirmDelete)) {
    Button("Eliminar", role: .destructive) { model.confirmDelete() }
}
.sheet(item: $model.destination.share) { url in
    ShareSheet(url: url)
}

Un solo destination opcional, un solo caso activo, y la presentación se deriva de él. Los tests verifican model.destination == .confirmDelete después de deleteTapped(), sin SwiftUI. La librería aporta los bindings derivados por caso ($model.destination.edit) que SwiftUI no ofrece de fábrica; el resto es el mismo patrón de “la presentación es estado” aplicado con disciplina.

El cambio que más me costó no fue el API sino la costumbre: en React Native abría un modal con setVisible(true) desde donde fuera. En SwiftUI, cada vez que una vista presenta algo, la pregunta es “¿de qué estado se deriva esto?”. Cuando la respuesta es clara, la navegación se puede testear; cuando no, hay un booleano suelto que va a fallar en un caso que no probaste.

Preguntas frecuentes

¿Hay algo como Expo Router para SwiftUI, con rutas desde archivos?

No, y no creo que llegue: SwiftUI no tiene un sistema de archivos accesible en tiempo de compilación que se pueda convertir en rutas. Lo más cercano es una convención propia: un enum Route por flujo y una función URL -> [Route]. Es más código que Expo Router y, a cambio, es tipado.

¿Cómo hago que la pila sobreviva a que el sistema cierre la app?

Guardando el array. [Route] con Route: Codable se serializa con JSONEncoder y se escribe en UserDefaults o en un archivo al pasar a segundo plano (.onChange(of: scenePhase)), y se restaura al arrancar. SceneStorage funciona para valores simples, pero con un enum con datos asociados es más claro codificarlo tú.

¿Qué pasa con NavigationView? Lo veo en muchos ejemplos.

Está obsoleto desde iOS 16. NavigationStack para pilas, NavigationSplitView para maestro-detalle en iPad y Mac. Todo lo de este post asume NavigationStack; si heredas código con NavigationView e isActive, migrarlo a path es el primer trabajo.

¿Cómo navego desde un ViewModel sin importar SwiftUI?

El ViewModel no navega: expone el resultado de una acción (por ejemplo, didSave: Order? o un evento) y el flujo o el coordinator decide el push. O bien el ViewModel recibe una closure onSaved: (Order) -> Void que el flujo le pasa. En los dos casos, Route y path no aparecen en el ViewModel.

Sí, siempre que el archivo apple-app-site-association esté servido por HTTPS en el dominio y que el entitlement esté en el perfil de firma. En desarrollo se puede agregar ?mode=developer al dominio en el entitlement: con eso el sistema salta el CDN de Apple y lee el archivo directamente de tu dominio, y el dispositivo necesita tener activado Associated Domains Development en los ajustes de desarrollador. Lo que no funciona es escribir o pegar la URL en la barra de Safari: Apple es explícita en que eso no abre la app. Hay que tocar el enlace desde otra superficie (Notas, Mensajes, un correo) para que cuente como enlace universal.

Conclusión

La navegación en SwiftUI no se declara con carpetas; se modela como estado. Un enum Route con datos asociados reemplaza a la estructura de archivos de Expo Router, NavigationStack(path:) con un solo navigationDestination en la raíz reemplaza al stack implícito, cada tab tiene su propia pila, y un deep link es una función pura de URL a [Route] que se testea con una tabla. Los sheets y alerts siguen la misma regla: un solo estado de destino por pantalla, que swift-navigation ayuda a mantener exclusivo.

Para empezar: define el enum de rutas de tu flujo principal, mueve toda la navegación a un path que puedas imprimir en un test, escribe parse(url) antes de configurar los Universal Links y convierte cada isPresented suelto en un caso de un enum de destino. El siguiente post entra en la capa que más se extraña al salir de React Native: el caché de red que TanStack Query manejaba por ti, y cómo diseñar el tuyo con URLSession, AsyncStream y una base de datos local como caché.

Seguir leyendo