La barra abre el partido
Al terminar: Subir la pestaña y la pila de navegación a la raíz para que tocar la barra abra el partido, y probarlo.
Al terminar, tocar la barra del partido en juego te lleva a ese partido, estés en la pestaña que estés: la app cambia a «Partidos» y pone el partido arriba. Si el partido ya estaba abierto debajo de otras pantallas, no se apila una segunda copia: la app vuelve hasta él. Y el botón «Ir al partido» del aviso «Ya hay un partido en juego» hace exactamente lo mismo que la barra, de modo que no tendrás dos formas distintas de llegar al mismo sitio.
Importa porque, para cambiar de pestaña y apilar una pantalla, la barra tiene que poder tocar dos cosas que hoy no son suyas: la pestaña elegida y la pila de «Partidos». Hay que ponerlas en la única vista que lo ve todo, la raíz. Es una de las decisiones de arquitectura más corrientes en SwiftUI, y la verás repetida en cualquier app con pestañas. Temas: subir la pestaña y la pila a la raíz con TabView(selection:) y @Binding, dar el valor del entorno con .environment, volver hasta una pantalla ya abierta, @Previewable y una prueba de interfaz del recorrido completo. Nivel 2: usar.
Subir la pestaña y la pila a la raíz
Tocar la barra tiene que cambiar de pestaña y apilar el partido en la pila de «Partidos». Ahora mismo no se puede: TabView elige la pestaña por su cuenta y la pila es un @State privado de ListaDePartidosView. Hay que subir las dos a ContentView, la raíz, que es la única que ve todas las pestañas:
- La pestaña elegida pasa a ser un dato: un
enum Pestana(con un caso por pestaña) guardado en un@State.TabView(selection: $pestana)la lee y la escribe, y cadaTab(…, value: .partidos)dice a qué valor corresponde. Elenumlleva: Hashable: los casos de unenumsin datos ya lo cumplen por sí solos, peroTab(value:)lo pide, y escribirlo deja claro por qué está ahí. - La pila
rutatambién sube, yListaDePartidosViewla recibe como@Binding. Así la raíz la puede cambiar, y la lista sigue funcionando igual.
Las barras de cada pestaña ya no tienen que saber de las otras: la raíz les da abrirPartido con .environment(\.abrirPartido) { … } y cualquier vista de debajo, la barra o el aviso, la llama. Por eso la barra de «Partidos» se mueve también a la raíz: la lista ya no tiene ruta propia, y es ContentView quien conoce ruta.last, la pantalla de arriba.
Dar el valor al entorno
En la lección anterior declaraste el valor con @Entry y su valor por defecto. Ahora una vista de arriba, la raíz, lo sustituye para todo lo que tiene debajo, con .environment(\.abrirPartido) { … }. Es el mismo .environment que ya usas con objetos, pero con la ruta del valor y, como segundo argumento, la función escrita con llaves al final (Closures). El ejemplo siguiente es para leer, con otros datos (una biblioteca): no lo escribas en tu proyecto.
import SwiftUI
// El valor propio del entorno: «abre el libro con este número». Por defecto no hace nada.
extension EnvironmentValues {
@Entry var abrirLibro: (Int) -> Void = { _ in }
}
// Una pestaña con un botón que pide abrir el libro 7. Lee la función del entorno y la llama:
// no sabe qué hace, ni siquiera si hay pestañas.
struct EstanteView: View {
@Environment(\.abrirLibro) private var abrirLibro
var body: some View {
Button("Abrir el libro n.º 7") { abrirLibro(7) }
}
}
// La raíz: sube la pestaña elegida a un `@State`, para poder cambiarla desde fuera,
// y pone el valor de verdad del entorno.
struct RaizDeLaBibliotecaView: View {
enum Pestana: Hashable { case estante, lectura } // un caso por pestaña
@State private var pestana = Pestana.estante
@State private var libroAbierto: Int?
var body: some View {
TabView(selection: $pestana) {
Tab("Estante", systemImage: "books.vertical", value: Pestana.estante) {
EstanteView()
}
Tab("Lectura", systemImage: "book", value: Pestana.lectura) {
if let libroAbierto {
Text("Libro n.º \(libroAbierto)")
} else {
Text("No hay ningún libro abierto")
}
}
}
// El valor de verdad: cambia de pestaña y recuerda qué libro se abrió.
.environment(\.abrirLibro) { numero in
pestana = .lectura
libroAbierto = numero
}
}
}
#Preview { RaizDeLaBibliotecaView() }Lo que verás en la vista previa: la pestaña «Estante» con un botón «Abrir el libro n.º 7». Al tocarlo, la app cambia a la pestaña «Lectura» y el texto del centro pasa de «No hay ningún libro abierto» a «Libro n.º 7». Si quitas el .environment(\.abrirLibro) { … } de la raíz, el botón se ve igual, pero tocarlo no hace nada: es el valor por defecto.
Volver hasta el partido si ya está abierto
Si el partido ya está en la pila, abrirlo otra vez no debe apilarlo por segunda vez: hay que volver hasta él y quitar lo que haya encima. Con la pila como array, son dos funciones de Array: lastIndex(of:), que da la posición de la última pantalla igual (o nil si no está), y removeSubrange, que quita un tramo.
// Las pantallas abiertas de una app de recetas, de la primera a la última.
var pila = ["Inicio", "Postres", "Tarta de queso", "Lista de la compra"]
// Ir a una pantalla: si ya está abierta, se vuelve hasta ella; si no, se abre encima.
func ir(a pantalla: String) {
// `lastIndex(of:)` devuelve la posición de la ÚLTIMA pantalla igual, o `nil` si no está.
if let donde = pila.lastIndex(of: pantalla) {
// `index(after:)` es la posición siguiente; `...` significa «hasta el final».
// Se quita todo lo que hay después de `pantalla`.
pila.removeSubrange(pila.index(after: donde)...)
} else {
pila.append(pantalla)
}
print(pila)
}
ir(a: "Postres") // ya está abierta: se vuelve hasta ella
ir(a: "Tarta de chocolate") // no está abierta: se abre encima["Inicio", "Postres"]
["Inicio", "Postres", "Tarta de chocolate"]En Marcador, ruta es un array de Destino y el partido es Destino.partido(id): es lo mismo con otros datos.
Reto · Ejercicio: cablear la barra de la música
Continúa el ejercicio de la lección anterior. En Ejercicios/BarraDeMusica/BarraDeMusicaView.swift ya tienes RaizDeMusicaView con sus dos pestañas y la barra «Imagine · 1:15», que al tocarla no hace nada. Cámbiala para que sí haga algo, sin tocar la barra: la barra sigue llamando a abrirReproductor y no sabe nada de pestañas. Toda la novedad está en la raíz.
Al tocar la barra, la app pasa a la pestaña «Buscar» y su texto del centro pasa de «Buscar» a «Reproductor abierto».
Lo que debe verse en la vista previa:
| Haces | Debe verse |
|---|---|
| Abrir la vista previa | Pestaña «Inicio» con la lista y, abajo, la barra «Imagine · 1:15» |
| Tocar la barra | Pestaña «Buscar» con «Reproductor abierto» en el centro, y la barra abajo |
| Volver a «Inicio» y a «Buscar» | La barra sigue en las dos; «Buscar» sigue diciendo «Reproductor abierto» |
Ver solución
Se añade un enum con las dos pestañas y cambia RaizDeMusicaView; abrirReproductor y BarraDeMusica quedan como estaban:
// Las pestañas, como datos (un caso por pestaña).
enum PestanaDeMusica: Hashable {
case inicio, buscar
}
struct RaizDeMusicaView: View {
// La pestaña elegida sube a un dato, para que la raíz pueda cambiarla.
@State private var pestana = PestanaDeMusica.inicio
@State private var abierto = false
var body: some View {
TabView(selection: $pestana) {
Tab("Inicio", systemImage: "house", value: PestanaDeMusica.inicio) {
List(1...30, id: \.self) { Text("Canción \($0)") }
.modifier(BarraDeMusica(titulo: "Imagine · 1:15"))
}
Tab("Buscar", systemImage: "magnifyingglass", value: PestanaDeMusica.buscar) {
Text(abierto ? "Reproductor abierto" : "Buscar")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.modifier(BarraDeMusica(titulo: "Imagine · 1:15"))
}
}
// El valor de verdad: cambia de pestaña y marca el reproductor como abierto.
.environment(\.abrirReproductor) {
pestana = .buscar
abierto = true
}
}
}La pestaña elegida es un @State de la raíz y por eso la raíz puede cambiarla; la barra solo llama a la función del entorno, sin saber qué hace. abierto ? "…" : "…" es el operador ? : de La barra del partido en juego, en iOS: elige un texto u otro según un valor verdadero o falso. Como pestaña servía cualquier valor Hashable, un número también; un enum dice mejor de qué pestaña se trata. El proyecto sigue compilando con este ejercicio dentro: no lo borres.
Paso · La barra abre el partido
Objetivo: que tocar la barra, desde cualquier pestaña, abra el partido en «Partidos» (o vuelva hasta él si ya está en la pila), que «Ir al partido» use la misma vuelta, y que una prueba de interfaz recorra todo el camino.
1 · La raíz y la lista: ContentView.swift y ListaDePartidosView.swift
Piezas nuevas de este paso: enum Pestana: Hashable y TabView(selection: $pestana) con Tab(…, value: .partidos), @Binding var ruta en la lista, .environment(\.abrirPartido) { … } y @Previewable. Esta última es una marca para la vista previa: @Previewable @State var ruta = … crea una variable @State que solo existe dentro de un #Preview, y así la vista previa de la lista puede tener su propia pila y navegar por ella.
Intenta escribirlo tú.
ListaDePartidosView:rutadeja de ser un@Stateprivado y pasa a ser un@Binding. Borra las tres líneas de la barra que añadiste en la lección anterior (se mueven a la raíz). La vista previa crea su propia pila con@Previewable @Statey la pasa con$ruta; su.environment(contenedor.partidoEnJuego)se queda, sin el comentario.ContentView: unenum Pestanacon tres casos (partidos,marcador,laboratorio), y en la vista dos@State,pestanayruta. ElTabViewelige porpestana, y cada pestaña lleva.barraDelPartidoEnJuego(…); la de «Partidos», conpantallaActual: ruta.last. AlTabViewse le pone el valor del entorno: cambia a la pestaña «Partidos» y, si el partido ya está en la pila, vuelve hasta él; si no, lo apila.
Hasta que termines los dos ficheros, el proyecto estará en rojo (ContentView crea ListaDePartidosView sin su pila).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// La pila entera es un array de destinos: se puede inspeccionar y manipular. Es de la raíz
// (`ContentView`), que la necesita para la barra del partido en juego: aquí llega con `@Binding`.
@Binding var ruta: [Destino] // La barra del partido en juego, debajo de toda la pila. Con la pantalla de arriba (`ruta.last`)
// sabe si se está viendo el propio partido, y entonces no sale.
.barraDelPartidoEnJuego(pantallaActual: ruta.last)#Preview {
// La pila, como `@State` de la vista previa (`@Previewable`): así se puede navegar en ella.
@Previewable @State var ruta: [Destino] = []
// Un contenedor sin almacenes: la vista previa no lee ni escribe en el disco.
let contenedor = ContenedorDeLaApp(servicio: ServicioJSONPlaceholder(), almacenDelTorneo: nil, almacenDeArbitros: nil)
ListaDePartidosView(ruta: $ruta)
.environment(contenedor)
.environment(contenedor.partidos)
.environment(contenedor.notificador) // lo lee el partido al navegar
.environment(contenedor.partidoEnJuego)
}El resto del fichero no cambia, incluido NavigationStack(path: $ruta): al ser ruta un @Binding, su $ruta es el mismo enlace que recibió la lista, y la pila sigue guardándose en la raíz. El primer bloque sustituye a @State private var ruta: [Destino] = [], con su comentario; el segundo son las tres líneas que borras del final del body; y el tercero sustituye a la vista previa de siempre.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Sustituye todo el contenido de ContentView.swift:
import SwiftUI
// Las pestañas de la app, como DATOS: así la raíz puede cambiar de pestaña por código.
enum Pestana: Hashable {
case partidos, marcador, laboratorio
}
// La raíz de la app. `TabView` es la barra de pestañas de iOS.
//
// La pestaña elegida y la pila de Partidos SUBEN aquí (antes la pila era un `@State` de la lista): la
// barra del partido en juego, que sale en todas las pestañas, necesita cambiarlas. La lista recibe la
// pila con `@Binding`.
struct ContentView: View {
@State private var pestana: Pestana = .partidos
@State private var ruta: [Destino] = []
var body: some View {
TabView(selection: $pestana) {
Tab("Partidos", systemImage: "list.bullet", value: .partidos) {
ListaDePartidosView(ruta: $ruta)
.barraDelPartidoEnJuego(pantallaActual: ruta.last)
}
Tab("Marcador", systemImage: "sportscourt", value: .marcador) {
PantallaMarcador()
.barraDelPartidoEnJuego()
}
Tab("Laboratorio", systemImage: "flask", value: .laboratorio) {
LaboratorioView()
.barraDelPartidoEnJuego()
}
}
// Abrir un partido desde cualquier sitio: a la pestaña Partidos y, si ya está en la pila, se
// vuelve hasta él; si no, se apila.
.environment(\.abrirPartido) { id in
pestana = .partidos
if let donde = ruta.lastIndex(of: .partido(id)) {
ruta.removeSubrange(ruta.index(after: donde)...)
} else {
ruta.append(.partido(id))
}
}
}
}
#Preview {
// La lista y el partido leen el contenedor, el repositorio, el notificador y el partido en juego del
// entorno: aquí se los da la vista previa, con un contenedor sin almacenes (no toca el disco).
let contenedor = ContenedorDeLaApp(servicio: ServicioJSONPlaceholder(), almacenDelTorneo: nil, almacenDeArbitros: nil)
ContentView()
.environment(contenedor)
.environment(contenedor.partidos)
.environment(contenedor.notificador)
.environment(contenedor.partidoEnJuego)
}La pestaña de «Partidos» es la única que pasa pantallaActual: es la única con pila. Y la barra se aplica por fuera de ListaDePartidosView (que lleva dentro la NavigationStack), así que se queda quieta cuando se apilan pantallas.
2 · «Ir al partido» usa la misma vuelta
El aviso de «Ya hay un partido en juego» tenía un NavigationLink, que apila el partido aunque ya esté en la pila. Con abrirPartido hace lo mismo que la barra. En PartidoEnDirectoView.swift, cambia AvisoDePartidoEnJuego:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// El aviso de «uno a la vez»: lleva al partido que se está jugando.
struct AvisoDePartidoEnJuego: View {
let otro: Int
@Environment(\.abrirPartido) private var abrirPartido
var body: some View {
HStack {
Text("Ya hay un partido en juego")
.font(.subheadline.weight(.semibold))
Spacer()
Button("Ir al partido") { abrirPartido(otro) }
.buttonStyle(.borderedProminent)
}
.padding(12)
.background(.fill.tertiary, in: .rect(cornerRadius: 12))
}
}El resto del fichero no cambia: el aviso gana una línea (@Environment) y el NavigationLink pasa a ser un Button.
Ejecuta con Cmd+R en un iPhone del simulador.
- Entra en el partido 1 y pulsa «Empezar partido». Vuelve atrás y pasa a «Laboratorio».
- Toca la barra: la app vuelve a la pestaña «Partidos» con el partido 1 arriba, y la barra desaparece.
- Vuelve atrás y espera a que el partido termine: la barra dice «Final · …» y aparece una X. Pulsa la X y la barra se quita.
- Empieza el partido 1, vuelve atrás y abre el 2: sale «Ya hay un partido en juego», y «Ir al partido» abre el 1 con el 2 debajo; atrás vuelve al 2. Mira que la barra sí sale en esta pantalla, porque no es la del partido que se juega.
- Volver hasta el partido, sin segunda copia. Con el partido 1 en juego, entra en él y pulsa «Ver goleadores»: Goleadores se apila encima del partido, y la barra sale abajo porque esa pantalla no es la del partido. Tócala: Goleadores desaparece y quedas en el partido 1, no en un segundo partido 1 encima. Comprueba que lo es: «atrás» te lleva directo a la lista.
3 · La prueba de interfaz
Una prueba nueva, al final de la clase MarcadorAppUITests, que recorre lo que acabas de ver con tus manos: empieza el partido 1, comprueba que no hay barra en él, cambia a «Laboratorio» y espera la barra, la toca y vuelve al partido, vuelve atrás, espera a la X (hasta 15 segundos), comprueba que la barra empieza por «Final · », y la cierra. Encuentra la barra por el identificador barraDelPartido y la X por su etiqueta, Cerrar.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// LA BARRA DEL PARTIDO EN JUEGO: sale en otra pestaña, lleva al partido, y al final se cierra con la X.
@MainActor
func testLaBarraLlevaAlPartidoYSeCierraAlFinal() throws {
let app = XCUIApplication()
app.launchArguments = ["-msPorMinuto", "60"] + sinAvisos
app.launch()
app.buttons["partido-1"].tap()
app.buttons["Empezar partido"].tap()
let barra = app.buttons["barraDelPartido"]
XCTAssertFalse(barra.exists, "La barra no debe salir en el propio partido")
app.tabBars.buttons["Laboratorio"].tap()
XCTAssertTrue(barra.waitForExistence(timeout: 5))
capturar(app, "barra-en-juego")
barra.tap()
XCTAssertTrue(app.navigationBars["Partido n.º 1"].waitForExistence(timeout: 5))
XCTAssertFalse(barra.exists)
app.navigationBars.buttons.firstMatch.tap() // «atrás»: ahora sale en la lista
XCTAssertTrue(barra.waitForExistence(timeout: 5))
let cerrar = app.buttons["Cerrar"]
XCTAssertTrue(cerrar.waitForExistence(timeout: 15), "Al terminar debería salir la X")
XCTAssertTrue(barra.label.hasPrefix("Final · "), barra.label)
capturar(app, "barra-final")
cerrar.tap()
XCTAssertFalse(barra.waitForExistence(timeout: 2))
}Lanza Cmd+U.
Punto de control
- Cmd+B termina en
Build Succeededy Cmd+U da 73 pruebas en verde: 56 unitarias y 17 de interfaz (con tus ejercicios de pruebas, 76). La nueva estestLaBarraLlevaAlPartidoYSeCierraAlFinal. - En el simulador, tocar la barra desde «Laboratorio» te lleva a «Partidos» con el partido arriba. Al terminar, dice «Final · …» con una X, y la X la quita.
- «Ir al partido», en el aviso del partido 2, abre el 1 y atrás vuelve al 2.
- Con el partido 1 abierto y «Ver goleadores» encima, tocar la barra vuelve al partido 1 (sin apilarlo otra vez): «atrás» lleva a la lista.
- La vista previa de
RaizDeMusicaViewdeja la barra «Imagine · 1:15» en las dos pestañas y, al tocarla, cambia a «Buscar» con «Reproductor abierto». - Sabes decir por qué la pestaña y la pila se suben a la raíz, qué hace
lastIndex(of:)conremoveSubrangecuando el partido ya está en la pila, y qué ocurre con un valor del entorno al que nadie da valor.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Por qué la pestaña elegida y la pila de navegación suben a
ContentView?2.La pila es
[.partido(2), .partido(1), .goleadores]y se pide abrir el partido 1. ¿Cómo queda?3.La raíz escribe
.environment(\.abrirPartido) { id in … }. ¿Qué ocurre cuando la barra, varias vistas más abajo, llama aabrirPartido(1)?4.¿Para qué sirve
@Previewable @State var ruta: [Destino] = []dentro de un#Preview?5.¿Por qué «Ir al partido» pasó de ser un
NavigationLinka unButtonque llama aabrirPartido?