Saltar al contenido
K
Arquitectura en iOSPaso a paso35 min Intermedio

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 cada Tab(…, value: .partidos) dice a qué valor corresponde. El enum lleva : Hashable: los casos de un enum sin datos ya lo cumplen por sí solos, pero Tab(value:) lo pide, y escribirlo deja claro por qué está ahí.
  • La pila ruta también sube, y ListaDePartidosView la 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.

ejemplo mínimo: la raíz cablea el valor y una vista de abajo lo llama (con comentarios)
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.

ejemplo mínimo: volver hasta una pantalla o abrirla encima (con comentarios)
// 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:

HacesDebe verse
Abrir la vista previaPestaña «Inicio» con la lista y, abajo, la barra «Imagine · 1:15»
Tocar la barraPestañ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:

Ejercicios/BarraDeMusica/BarraDeMusicaView.swift
// 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.

MarcadorApp
MarcadorApp
ContentView.swiftse modifica
Partidos
UI
ListaDePartidosView.swiftse modifica
Marcador
UI
PartidoEnDirectoView.swiftse modifica
MarcadorAppUITests
MarcadorAppUITests.swiftse modifica
Los ficheros de la lección en el Project navigator.

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: ruta deja de ser un @State privado 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 @State y la pasa con $ruta; su .environment(contenedor.partidoEnJuego) se queda, sin el comentario.
  • ContentView: un enum Pestana con tres casos (partidos, marcador, laboratorio), y en la vista dos @State, pestana y ruta. El TabView elige por pestana, y cada pestaña lleva .barraDelPartidoEnJuego(…); la de «Partidos», con pantallaActual: ruta.last. Al TabView se 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
MarcadorApp/Partidos/UI/ListaDePartidosView.swift
    // 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]
las tres líneas que se borran de ListaDePartidosView.swift
        // 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)
MarcadorApp/Partidos/UI/ListaDePartidosView.swift
#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:

MarcadorApp/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
MarcadorApp/Marcador/UI/PartidoEnDirectoView.swift
// 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
MarcadorAppUITests.swift: la prueba nueva, al final de la clase
    // 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 Succeeded y Cmd+U da 73 pruebas en verde: 56 unitarias y 17 de interfaz (con tus ejercicios de pruebas, 76). La nueva es testLaBarraLlevaAlPartidoYSeCierraAlFinal.
  • 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 RaizDeMusicaView deja 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:) con removeSubrange cuando 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. 1.¿Por qué la pestaña elegida y la pila de navegación suben a ContentView?

  2. 2.La pila es [.partido(2), .partido(1), .goleadores] y se pide abrir el partido 1. ¿Cómo queda?

  3. 3.La raíz escribe .environment(\.abrirPartido) { id in … }. ¿Qué ocurre cuando la barra, varias vistas más abajo, llama a abrirPartido(1)?

  4. 4.¿Para qué sirve @Previewable @State var ruta: [Destino] = [] dentro de un #Preview?

  5. 5.¿Por qué «Ir al partido» pasó de ser un NavigationLink a un Button que llama a abrirPartido?

Faltan 5 de 5