NavigationStack
Al terminar: Navegar con NavigationStack y navegación por valor.
Al terminar, Marcador tendrá navegación de verdad: tocas un partido de la lista y se apila su pantalla, con la barra de arriba, el título «Partido n.º 1» y el botón «atrás» que pone SwiftUI. La hoja que abría el partido (la pantalla que sube desde abajo y se cierra deslizándola) desaparece de la lista; LaboratorioView conserva las suyas, que son un ejemplo aparte. En la lección siguiente apilarás una tercera pantalla encima, la de goleadores.
Importa porque en una app real las pantallas se suceden, y quien manda sobre qué pantalla se ve es la pila. Y porque en SwiftUI navegas por valor: dices «ve a este dato» y un único sitio decide qué pantalla toca.
Temas: NavigationStack, NavigationLink y .navigationDestination, destinos como datos (enum y Hashable) y la pila en un array.
Trabajas en tu app, en ~/Marcador-Swift/MarcadorApp: abre MarcadorApp.xcodeproj con Xcode. Cuando la lección te diga sobre qué carpeta hacer clic derecho, es una de las que ves dentro de MarcadorApp en el Project navigator (Marcador/UI, Partidos/Data…); la raíz de todas ellas es la carpeta MarcadorApp del código (la que tiene MarcadorApp.swift).
La pila de navegación
Una pila de navegación guarda las pantallas en el orden en que las abriste: solo se ve la de arriba, y «atrás» la quita y deja ver la anterior. En SwiftUI se llama NavigationStack. Se usa con tres piezas:
| Pieza | Para qué |
|---|---|
NavigationLink(value: x) | Al tocarlo, empuja el valor x a la pila |
.navigationDestination(for: Tipo.self) { valor in … } | Dice qué pantalla corresponde a cada valor de ese tipo |
NavigationStack(path: $ruta) | Opcional: la pila entera en un array tuyo, para leerla y cambiarla |
Un ejemplo con otra app, la de unas ciudades:
struct Ciudad: Hashable { let nombre: String } // un destino: un valor que se puede comparar
struct Ciudades: View {
let ciudades = [Ciudad(nombre: "Cali"), Ciudad(nombre: "Pasto")]
var body: some View {
NavigationStack { // la pila; esta vista es su raíz
List(ciudades, id: \.self) { ciudad in
NavigationLink(ciudad.nombre, value: ciudad) // al tocar, empuja `ciudad` a la pila
}
.navigationTitle("Ciudades")
.navigationDestination(for: Ciudad.self) { ciudad in // qué pantalla toca para un valor `Ciudad`
Text("Bienvenido a \(ciudad.nombre)")
}
}
}
}Lo que se ve: una lista con «Cali» y «Pasto», bajo el título «Ciudades». Tocar «Cali» empuja Ciudad("Cali"); navigationDestination lo traduce en un Text que dice «Bienvenido a Cali», y la barra muestra el botón «atrás» sola. La forma antigua, NavigationLink(destination: Vista()), construye la vista destino al pintar la fila aunque nadie la abra y no da una pila que manejar; con el valor, la vista destino solo se crea al navegar y un único sitio decide las pantallas.
| Android (Navigation 3) | iOS (SwiftUI) | |
|---|---|---|
| Destino | data class PartidoKey(val partidoId: Int) : NavKey | case partido(Int) de un enum Destino: Hashable |
| La pila | backStack, una lista | @State var ruta: [Destino] |
| Qué pantalla dibuja cada destino | entryProvider { entry<…> { … } } | .navigationDestination(for:) |
| Navegar | backStack.add(clave) | NavigationLink(value:) o ruta.append(…) |
| Quitar la pantalla de arriba | backStack.removeLastOrNull() | @Environment(\.dismiss) o ruta.removeLast() |
Los destinos, escritos como datos
En vez de decir «abre esta vista», dirás «ve a este valor». Un enum con valores asociados es el destino ideal: cada case es una pantalla y sus valores son los argumentos. Tiene que cumplir Hashable, el protocolo que permite comparar valores y usarlos como claves (Equatable y Hashable): la pila guarda los valores y SwiftUI los compara para saber qué cambió.
// Los destinos de una app de noticias, escritos como datos.
enum Seccion: Hashable { // Hashable: la pila puede compararlos y guardarlos
case articulo(Int) // lleva el id del artículo
case portada // no necesita datos
}Este ejemplo no imprime nada ni se ve: es solo la definición de los destinos que usa el siguiente.
La pila en un array tuyo
Con NavigationStack(path:) la pila entera vive en un array tuyo, guardado en un @State (@State). Mientras no la leas ni la cambies, funciona igual; si la lees o la cambias, manejas la navegación: añadir un valor abre una pantalla, vaciar el array vuelve a la raíz.
Aquí, la pila con path, un enlace con una fila propia y un switch que decide la pantalla de cada Seccion:
import SwiftUI
struct Noticias: View {
@State private var ruta: [Seccion] = [] // la pila entera, como un array en un @State
var body: some View {
NavigationStack(path: $ruta) { // la pila, enlazada a ruta
List(1...3, id: \.self) { id in
NavigationLink(value: Seccion.articulo(id)) { // al tocar, empuja este valor
Text("Artículo \(id)") // la fila: una vista cualquiera
}
}
.navigationTitle("Noticias")
.navigationDestination(for: Seccion.self) { seccion in // un solo sitio decide la pantalla
switch seccion {
case let .articulo(id):
Text("Leyendo el artículo \(id)")
case .portada:
Text("Portada")
}
}
}
}
}Lo que se ve: tres filas, «Artículo 1» a «Artículo 3», bajo el título «Noticias»; tocar «Artículo 2» abre «Leyendo el artículo 2» y ruta pasa a valer [.articulo(2)].
Reto · Ejercicio: una pila de números
Crea una subcarpeta nueva dentro de MarcadorApp/Ejercicios, PilaDeNumeros, como en El ciclo de vida de UIViewController, y en ella un fichero, PilaDeNumerosView.swift. Escribe una vista, PilaDeNumerosView, con una pila de navegación cuya ruta lleves tú: un array de enteros guardado en el estado de la vista y vacío al empezar. La raíz es una lista con el título «Números», diez enlaces («Número 1» a «Número 10») y, debajo, un botón «Ir al 7» (dentro de la lista, después de los diez enlaces). Tocar un número abre una pantalla con el texto «El 3» (el número que toques) en grande y un botón «Volver al inicio». Para vaciar un array existe removeAll(). Añade su vista previa.
Lo que debe pasar en el lienzo:
- «Número 3» abre «El 3», y arriba aparece el botón «atrás» con el texto «Números».
- «Volver al inicio» te deja en la lista de un golpe.
- «Ir al 7» abre «El 7» sin tocar ningún enlace.
Si el lienzo no aparece, se muestra con Editor → Canvas (Opción+Cmd+Intro). Si no responde a los toques, comprueba al menos que el fichero compila con Cmd+B.
Ver solución
import SwiftUI
// Una pila de pantallas que llevas tú: la ruta es un array de números en un @State.
struct PilaDeNumerosView: View {
@State private var ruta: [Int] = [] // la pila entera; vacía = solo la lista
var body: some View {
NavigationStack(path: $ruta) { // la pila, enlazada a ruta
List {
ForEach(1...10, id: \.self) { numero in
NavigationLink("Número \(numero)", value: numero) // al tocar, empuja el número
}
Button("Ir al 7") { ruta.append(7) } // empujar sin tocar un enlace
}
.navigationTitle("Números")
.navigationDestination(for: Int.self) { numero in // qué pantalla toca para un Int
VStack(spacing: 16) {
Text("El \(numero)").font(.largeTitle)
Button("Volver al inicio") { ruta.removeAll() } // vaciar la pila = volver a la raíz
}
}
}
}
}
#Preview {
PilaDeNumerosView()
}Vaciar el array deja la pila en la raíz de un golpe, y añadir un valor la empuja por programación: es posible porque la pila es tuya, un array en un @State enlazado con path: $ruta. El proyecto sigue compilando con este ejercicio dentro: no lo borres.
Paso 1 · Los destinos: Destino.swift
Objetivo: un tipo que liste las pantallas a las que se puede ir, escritas como datos. Por ahora, una sola: el partido.
Este fichero es el mapa de la app: una sola lista de todas las pantallas de la pila. Crea Destino.swift en el Project navigator (el panel izquierdo de Xcode): clic derecho sobre la carpeta MarcadorApp del código (la que tiene MarcadorApp.swift; Destino no es de ninguna funcionalidad, es la cáscara, lo que une todo, y la cáscara va en esa raíz) → New File from Template… → Swift File → Next, nombre Destino, con solo el target MarcadorApp marcado. Si prefieres la Terminal, touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Destino.swift hace lo mismo.
Intenta escribirlo tú: Destino, los sitios a los que se puede navegar, con un caso, partido, que lleva el id del partido. Tiene que poder guardarse en una pila de navegación (para eso, ser Hashable). Cmd+B debe compilar.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Las pantallas a las que se puede ir, escritas como DATOS
enum Destino: Hashable { // Hashable: SwiftUI compara y guarda estos valores en la pila
case partido(Int) // la pantalla de un partido; lleva el id (el dato viaja en el destino)
}Paso 2 · La lista abre partidos en una pila
Objetivo: que la lista deje de abrir hojas y empuje el destino de cada partido a una pila.
Abre ListaDePartidosView.swift. La hoja modal desaparece (se van elegido, el Button y el .sheet) y entra la pila. Solo cambia la estructura ListaDePartidosView; FilaDePartido y la vista previa de debajo se quedan como están.
La pila entera vive en @State private var ruta: [Destino] = []: NavigationStack(path: $ruta) la enlaza, y cada fila es un NavigationLink(value:).
Intenta escribirlo tú: ListaDePartidosView conserva su repositorio y gana una propiedad nueva, ruta, la pila de destinos, que empieza vacía. En el body, la lista va dentro de una pila de navegación enlazada a ruta; cada fila (la misma FilaDePartido, con su nombre para las pruebas) empuja el destino de su partido al tocarla; la lista lleva el título «Partidos»; y un solo sitio decide qué pantalla corresponde a cada destino: el partido abre PartidoContenedor con su id.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
struct ListaDePartidosView: View {
// Ya la tenías: la lista lee los partidos del repositorio compartido
@Environment(RepositorioEnMemoria.self) private var repositorio
// La pila entera, como un array de destinos en un @State: se puede mirar y manipular
@State private var ruta: [Destino] = []
var body: some View {
NavigationStack(path: $ruta) { // la pila de pantallas, enlazada a `ruta`
List(repositorio.partidos) { item in
NavigationLink(value: Destino.partido(item.id)) { // al tocar, empuja este valor a la pila
FilaDePartido(
local: item.partido.local.nombre,
visitante: item.partido.visitante.nombre,
resultado: item.partido.marcador().description
)
}
.accessibilityIdentifier("partido-\(item.id)")
}
.listStyle(.plain)
.navigationTitle("Partidos")
// UN solo sitio decide qué pantalla corresponde a cada destino
.navigationDestination(for: Destino.self) { destino in
switch destino {
case let .partido(id):
PartidoContenedor(id: id)
}
}
}
}
}PartidoContenedor ya no recibe el item entero, sino el id que viaja en el destino; lo cambias en el paso siguiente. Hasta entonces, Xcode marcará un error en PartidoContenedor(id: id): es lo esperado.
Paso 3 · El partido recibe su id
Objetivo: que el partido tenga título y botón «atrás» en la barra.
Abre PartidoEnDirectoView.swift. PartidoContenedor, al final del fichero, recibe el id (no el item), se lo pasa a la vista del partido y pone en la barra, pequeño, el título «Partido n.º 1» con ese id. Sustituye la estructura entera.
Intenta escribirlo tú. Lo que debes ver: tocar un partido lo apila con su título y el botón «atrás».
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
struct PartidoContenedor: View {
let id: Int // el destino lleva solo el id
@Environment(RepositorioEnMemoria.self) private var repositorio
@State private var redibujados = 0
var body: some View {
VStack(spacing: 0) {
HStack {
Spacer()
Button("Redibujar el padre (\(redibujados))") { redibujados += 1 }
.buttonStyle(.bordered)
}
.padding([.horizontal, .top], 16)
PartidoEnDirectoView(partidoId: id, repositorio: repositorio)
}
.navigationTitle("Partido n.º \(id)") // el título de la barra, con el botón «atrás» incluido
.navigationBarTitleDisplayMode(.inline) // título pequeño, en la franja de la barra (no el grande de arriba)
}
}Compila con Cmd+B y ejecuta con Cmd+R: tocar un partido lo apila con su título y el botón «atrás» arriba a la izquierda.
Paso 4 · Las pruebas ya no cierran hojas
Objetivo: que las pruebas de interfaz vuelvan atrás con el botón «atrás» y no arrastrando una hoja.
Dos pruebas de interfaz abrían un partido y lo cerraban arrastrando la hoja hacia abajo. Ahora el partido es una pantalla apilada: no hay hoja que arrastrar, se vuelve con el botón «atrás» de la barra. Abre MarcadorAppUITests.swift. En testAlSalirDelPartidoSeCancelaElTask y en testAbreYCierraUnPartidoParaMedirLaMemoria, las dos líneas que arrastran (app.coordinate(…) y la que sigue, con .press(forDuration: 0.1, thenDragTo: …)) se sustituyen por una sola, y el comentario que las precede deja de tener sentido y se quita. Las otras dos pruebas que arrastran, testTaskFrenteAOnAppear y testAbreYCierraElControlador, cierran hojas del Laboratorio y no cambian.
Las piezas de estas pruebas (XCUIApplication, buttons[…], navigationBars.buttons.firstMatch, waitForExistence…) están en la tabla de piezas de XCUITest de Tus primeras pruebas en iOS. Los bloques de abajo traen tal cual las líneas que ya tienes (con su comentario) para que veas dónde van: sustituye cada «antes» por su «después».
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Se desliza la hoja de arriba abajo para cerrarla.
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
.press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)))
XCTAssertTrue(app.buttons["partido-2"].waitForExistence(timeout: 5)) app.navigationBars.buttons.firstMatch.tap() // «atrás»: el primer botón de la barra
XCTAssertTrue(app.buttons["partido-2"].waitForExistence(timeout: 5)) Thread.sleep(forTimeInterval: 2)
// Se desliza la hoja de arriba abajo para cerrarla.
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
.press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)))
XCTAssertTrue(app.buttons["partido-1"].waitForExistence(timeout: 5)) Thread.sleep(forTimeInterval: 2)
app.navigationBars.buttons.firstMatch.tap() // «atrás»
XCTAssertTrue(app.buttons["partido-1"].waitForExistence(timeout: 5))Si no cambias esas dos líneas, fallan exactamente esas dos pruebas, porque ya no hay hoja que arrastrar. Ejecuta todo con Cmd+U.
Punto de control
- Tocar un partido de la lista lo apila con el título «Partido n.º 1» y el botón «atrás» arriba. La lista ya no abre hojas.
- En el Project navigator está
Destino.swift, con un solo caso, y el ejercicioPilaDeNumerosdentro deMarcadorApp/Ejercicios. - Las pruebas de la app pasan. Cmd+U lanza las 22: 11 unitarias y 11 de interfaz, las mismas que al empezar.
Siguiente: Navegar a los goleadores añade una tercera pantalla y enseña por qué el partido se pausa al apilarla.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.En navegación por valor, ¿qué hace
NavigationLink(value: Destino.partido(3))al tocarlo?2.¿Por qué un destino de navegación tiene que cumplir
Hashable?3.Guardas la pila en
@State var ruta: [Destino]y la enlazas conNavigationStack(path: $ruta). ¿Cómo vuelves a la lista de un golpe?4.Tienes
NavigationStack(path: $ruta)yrutaes un array vacío. ¿Qué se ve?