wandres.dev
NAVEGACIÓN · NavigationStack y rutas

NavigationSplitView: dos y tres columnas

Cómo la selección sustituye a la pila como vínculo entre columnas, por qué el detalle suele contener su propio NavigationStack, cómo controlar la visibilidad y el ancho de cada columna, y de qué manera una misma definición se colapsa en pila en el teléfono y se despliega en el iPad y en el Mac.

⏱ 19 min

Una pila responde a la pregunta “dónde estoy”. Una vista dividida responde a otra distinta: “qué tengo seleccionado”. NavigationSplitView no es la versión ancha de NavigationStack, es un modelo de navegación con otra ontología, donde las columnas coexisten y el vínculo entre ellas es un valor seleccionado, no un historial. Y su virtud mayor es que esa misma definición se colapsa sola en una pila cuando el espacio desaparece.

🎯 Al terminar esta lección sabrás
  • Distinguir navegación por selección de navegación por apilamiento.
  • Construir vistas de dos y tres columnas con selecciones enlazadas.
  • Anidar un NavigationStack en el detalle sin duplicar barras ni gestos.
  • Controlar visibilidad, anchos y estilo para adaptarse a iPad y Mac desde una definición única.

La selección como vínculo

En dos columnas hay una barra lateral y un detalle. Lo que las une es una propiedad opcional: el elemento elegido. Si vale nada, el detalle muestra un marcador de posición; si tiene valor, muestra ese contenido.

struct Biblioteca: View {
    @State private var seleccion: Tarea.ID?

    var body: some View {
        NavigationSplitView {
            List(tareas, selection: $seleccion) { tarea in
                Text(tarea.titulo)
            }
            .navigationTitle("Tareas")
        } detail: {
            if let id = seleccion {
                DetalleTarea(id: id)
            } else {
                ContentUnavailableView("Sin selección", systemImage: "sidebar.left")
            }
        }
    }
}

El opcional no es un detalle de implementación: es el estado completo de la navegación. No hay historial, no hay profundidad, no hay retroceso. Hay un valor que cambia y dos columnas que reaccionan. Por eso una List con parámetro selection basta para navegar sin escribir un solo enlace.

flowchart LR
S[seleccion opcional] --> C1[columna lateral resalta]
S --> C2[columna detalle muestra]
C1 --> S
style S fill:#a6e3a1,color:#11111b
💡
El detalle necesita un estado vacío

Al arrancar en pantalla ancha no hay nada seleccionado, y esa es una situación normal, no un error. ContentUnavailableView existe justamente para eso. Rellenar el hueco con la primera fila seleccionada por defecto es una decisión legítima en el escritorio, pero en el iPhone provocaría que la app abra siempre una pantalla de detalle que el usuario no pidió.

Tres columnas y la pila interior

La variante de tres columnas encadena dos selecciones: la primera columna elige categoría, la segunda elige elemento dentro de esa categoría, la tercera muestra el contenido.

struct Correo: View {
    @State private var buzon: Buzon?
    @State private var mensaje: Mensaje.ID?
    @State private var rutaDetalle: [RutaMensaje] = []

    var body: some View {
        NavigationSplitView {
            List(buzones, selection: $buzon) { Text($0.nombre) }
        } content: {
            List(mensajes(de: buzon), selection: $mensaje) { Text($0.asunto) }
        } detail: {
            NavigationStack(path: $rutaDetalle) {
                VistaMensaje(id: mensaje)
                    .navigationDestination(for: RutaMensaje.self) { destino($0) }
            }
        }
    }
}

Dos cosas merecen atención. La primera es la cascada: al cambiar el buzón, la selección de mensaje deja de tener sentido y hay que ponerla a nada, o el detalle mostrará un mensaje que ya no pertenece a la lista visible. La segunda es la pila dentro del detalle, que es el patrón correcto y no una anidación sospechosa: la tercera columna sí tiene historial propio —del mensaje al hilo, del hilo al adjunto— y ese historial no debe alterar las columnas de la izquierda.

.onChange(of: buzon) { _, _ in
    mensaje = nil            // la cascada evita detalles huerfanos
    rutaDetalle.removeAll()  // y limpia el historial de la columna
}
⚠️
Una pila por columna, nunca una envolviendo el conjunto

Colocar un NavigationSplitView dentro de un NavigationStack produce barras superpuestas, títulos duplicados y un botón de retroceso que compite con el control de la barra lateral. La jerarquía correcta es la contraria: la vista dividida es el contenedor exterior y cada columna decide si necesita su propia pila en su interior.

Una definición, tres tamaños

Lo notable de este contenedor es que no tienes que ramificar por dispositivo. Cuando el ancho disponible es pequeño, el sistema colapsa las columnas en una pila: la barra lateral pasa a ser la raíz, y seleccionar equivale a empujar. Cuando hay espacio, las despliega. La misma definición sirve para iPhone, iPad, Mac y una ventana redimensionable, y las clases de tamaño hacen el resto.

Que el colapso sea automático no significa que sea gratuito de diseñar. La barra lateral pasará a ser la primera pantalla del teléfono, así que su título, su barra de herramientas y su estado vacío tienen que sostenerse por sí solos; y el detalle, que en el iPad convive con la lista, en el teléfono aparecerá solo y necesita un encabezado que recuerde de dónde viene.

Sobre esa base hay tres palancas de ajuste que conviene conocer.

👁️

Visibilidad

Un binding de tipo NavigationSplitViewVisibility permite ocultar o mostrar la barra lateral desde código y persistir la preferencia del usuario.

📐

Ancho de columna

navigationSplitViewColumnWidth fija mínimo, ideal y máximo. Imprescindible en Mac, donde el usuario arrastra los separadores.

⚖️

Estilo

navigationSplitViewStyle decide si las columnas se equilibran o si el detalle domina y la barra lateral se superpone.

📱

Colapso

En ancho compacto todo se convierte en pila. Diseña sabiendo que la barra lateral será la primera pantalla del teléfono.

Hay además un detalle de la barra lateral que conviene interiorizar: dentro de ella, un NavigationLink con valor no empuja una pantalla sobre la columna, sino que actualiza la selección y por tanto reemplaza el detalle. Es el mismo enlace de la primera lección comportándose distinto según el contenedor que lo alberga, lo cual es coherente con la idea de que el enlace propone un valor y el contenedor decide qué hacer con él.

@State private var columnas: NavigationSplitViewVisibility = .automatic

NavigationSplitView(columnVisibility: $columnas) {
    Lateral().navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 320)
} detail: {
    Detalle()
}
.navigationSplitViewStyle(.balanced)

El estilo equilibrado reparte el ancho entre columnas y desplaza el detalle al mostrar la barra lateral; el estilo que prioriza el detalle mantiene su tamaño y superpone la barra encima. La elección no es estética: si el detalle contiene un lienzo, un mapa o un reproductor cuyo encuadre no debe moverse, superponer es la única opción razonable. Si el detalle es texto que puede refluir, equilibrar da una sensación de continuidad mucho mejor.

ℹ️
Qué se conserva al colapsar y al expandir

El colapso no es gratuito: al pasar de columnas a pila, el sistema traduce la selección en pantallas apiladas, y al volver a expandir intenta la operación inversa. La traducción funciona bien cuando la selección es un valor sencillo y estable. Si tu estado de navegación depende de objetos volátiles o de índices posicionales, rotar el iPad puede dejarte en un sitio inesperado. Un motivo más para que en el estado de navegación viajen identificadores.

Adaptarse no es detectar el dispositivo: es describir relaciones en lugar de disposiciones

Hay dos maneras de escribir una app que funcione en el teléfono y en el escritorio, y solo una envejece bien. La primera consiste en preguntar dónde estás corriendo y ramificar: si es iPad, columnas; si es iPhone, pila. Funciona hasta el día en que aparece una ventana estrecha en el Mac, una app en pantalla partida, un plegable o un tamaño que nadie previó, y entonces cada rama nueva multiplica los caminos que hay que probar. La segunda consiste en describir la relación entre las partes —esta lista contiene estos elementos, este elemento tiene este detalle— y dejar que el sistema elija la disposición según el espacio real disponible. NavigationSplitView es la encarnación de la segunda vía, y por eso su valor no está en que dibuje columnas bonitas, sino en que te obliga a expresar la jerarquía de tu contenido en vez de su geometría. Fíjate en la simetría con lo que ya sabes: la lección primera hizo representable el historial y por eso lo volvió serializable y comprobable; esta hace representable la jerarquía y por eso la vuelve adaptable. En ambos casos el patrón es idéntico, y es probablemente la idea más transferible de todo el nivel: cuando conviertes una decisión implícita en un valor declarado, dejas de ser tú quien enumera los casos y pasa a serlo el sistema, que además conoce casos que tú no puedes conocer porque todavía no existen.

⚔️ La misma app en tres anchos
  1. Construye una vista de dos columnas con selección opcional y un estado vacío explícito en el detalle.
  2. Amplíala a tres columnas y encadena la cascada que limpia la selección secundaria al cambiar la primera.
  3. Añade un NavigationStack con su propio path dentro del detalle y navega dos niveles sin tocar las columnas.
  4. Expón la visibilidad de la barra lateral con un binding y añade un botón que la alterne.
  5. Ejecútalo en iPhone, en iPad y en una ventana estrecha del Mac, y anota qué se conserva al colapsar.