Saltar al contenido
K
Arquitectura en iOSPaso a paso35 min Intermedio

Solo un partido a la vez, en iOS

Al terminar: Impedir que haya dos partidos en juego: el aviso en el otro partido y el selector de duración de cada partido.

Al terminar, en Marcador solo podrá haber un partido en juego a la vez. Con el partido 1 jugándose, abrirás el partido 2 y, en vez del botón «Empezar partido», verás un aviso, «Ya hay un partido en juego», con un botón «Ir al partido» que te lleva al 1. Y el selector de 90 o 60 minutos de cada partido marcará la duración de ese partido, no la del último ajuste que tocaste en otro.

Importa porque la lección anterior dejó dos cosas a medias: un botón que no hacía nada y un selector que mentía. Las dos tienen la misma raíz: la pantalla del partido 2 no sabía que había otro en juego. Temas: cómo se entera una pantalla del partido de otra, una tarjeta de aviso, Binding(get:set:) para un selector que lee de un sitio y escribe en dos, y la prueba. Nivel 2: usar.

Uno a la vez: la regla vive en el objeto, el aviso en la pantalla

PartidoEnJuego.empezar(_:) devuelve un Bool: false, sin hacer nada, si ya hay otro partido sin terminar. La regla está en el objeto de toda la app y no en la pantalla, porque la pantalla no sabe de las demás.

Pero la pantalla sí puede preguntar y avisar. El ViewModel calcula un dato nuevo para el estado de la pantalla, otroEnJuego: el número del otro partido que se está jugando, o nil si no hay ninguno. Si hay otro, la pantalla enseña el aviso con «Ir al partido» en vez del botón. Cuando el otro partido termina, otroEnJuego vuelve a nil y el botón «Empezar partido» reaparece.

¿Cómo se entera la pantalla del partido 2 de que el 1 ha empezado, si no se lo ha dicho nadie? PartidoEnJuego es @Observable, y el ViewModel lee enJuego.directo dentro de uiState, que la vista lee en su body. Esa lectura basta: SwiftUI apunta «esta vista depende de directo» y la redibuja cuando cambia, sin que nadie avise. (El @ObservationIgnored del ViewModel está sobre su referencia a enJuego, que nunca cambia; no impide observar lo que hay dentro de enJuego.)

La duración: un Binding hecho a mano

Hoy el selector de duración es Picker(selection: $duracion), con @AppStorage detrás: lee y escribe el mismo sitio. Ahora tiene que leer la duración de este partido (la que lleva su Directo) y, al elegir, escribir en dos sitios: el ajuste guardado, para el próximo partido, y este partido, si aún no ha empezado.

¿Por qué no basta $duracion? Si el partido 1 se juega a 90 y en el 2 eliges 60, al volver al 1 el selector (gris, porque se juega) diría 60 mientras el partido llega al 90. El selector tiene que leer del partido, no del ajuste.

Para leer de un sitio y escribir en otro hace falta un enlace hecho a mano: Binding(get:set:). Le das dos funciones, una que lee y otra que escribe.

Un ejemplo para leer: una radio, donde lo que suena y lo que se guarda como «última emisora» son dos cosas. No hace falta que lo ejecutes; debajo tienes lo que imprimiría.

ejemplo mínimo: un Binding con get y set (con comentarios)
import SwiftUI
 
// Una radio: suena una emisora y, aparte, se guarda cuál fue la última que sintonizaste.
var sonando = "Radio Jazz"          // lo que suena AHORA
var ultimaGuardada = "Radio Rock"   // lo que se recordará al abrir la app otra vez
 
// Un enlace hecho a mano: lee de un sitio (`sonando`) y escribe en dos (`sonando` y `ultimaGuardada`).
let enlace = Binding(
    get: { sonando },                // «¿qué valor tienes?»: lo que suena ahora
    set: { nueva in                  // «te dan este valor»: se apunta en los dos sitios
        sonando = nueva
        ultimaGuardada = nueva
    }
)
 
print("Lee: \(enlace.wrappedValue)")
enlace.wrappedValue = "Radio Clásica"   // lo que haría un selector al elegir una opción
print("Suena: \(sonando)")
print("Guardada: \(ultimaGuardada)")
Lee: Radio Jazz
Suena: Radio Clásica
Guardada: Radio Clásica

enlace.wrappedValue es el valor al que apunta el enlace. Cuando un Picker recibe uno de estos, leer es llamar a get y elegir una opción es llamar a set. get: y set: reciben dos closures (Closures): { sonando } es una función sin nombre que devuelve un valor, y { nueva in … } es otra que recibe el valor que se elige, con el nombre nueva.

Reto · Ejercicio: el tamaño de letra de un libro

Es el gemelo del selector de duración, con otros datos: un lector de libros. Cada libro se abre con su propio tamaño de letra, y lo que eliges en el selector es el tamaño de este libro y el que se recordará para el próximo. En la carpeta Ejercicios de tu proyecto, crea la subcarpeta SelectorDeLetra (clic derecho sobre MarcadorApp/Ejercicios → New Folder) y dentro un fichero SelectorDeLetraView.swift (clic derecho sobre SelectorDeLetra → New File from Template… → Swift File, solo con el target MarcadorApp).

Escribe una vista SelectorDeLetraView, con su vista previa, que tenga dos @State de tipo número: tamanoDeEsteLibro y tamanoParaElProximo, los dos en 16. Y, en una columna:

  • Un Picker segmentado, «Tamaño de letra», con las opciones 12, 16 y 20 (cada una escrita «12 pt», «16 pt», «20 pt»). Su selección es un Binding(get:set:): lee tamanoDeEsteLibro y, al elegir, escribe en los dos @State.
  • Dos textos: «Este libro: N pt» y «Para el próximo libro: N pt».
  • Un botón «Abrir un libro de 12 pt» que pone tamanoDeEsteLibro en 12 sin tocar el otro.

Pruébalo en la vista previa del lienzo, en modo interactivo (Editor → Canvas; si dice que está en pausa, pulsa Resume). Lo que debe verse:

HacesDebe verse
NadaSelector en «16 pt»; «Este libro: 16 pt» y «Para el próximo libro: 16 pt»
Eliges «20 pt»Los dos textos pasan a 20 pt
Pulsas «Abrir un libro de 12 pt»El selector pasa a «12 pt» y «Este libro: 12 pt»; «Para el próximo libro» sigue en 20 pt

La última fila es la que no saldría con $tamanoParaElProximo a secas: el selector leería siempre del segundo.

Ver solución
Ejercicios/SelectorDeLetra/SelectorDeLetraView.swift
import SwiftUI
 
struct SelectorDeLetraView: View {
    @State private var tamanoDeEsteLibro = 16    // lo que se ve en ESTE libro
    @State private var tamanoParaElProximo = 16  // lo que se recordará para el próximo libro
 
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Picker("Tamaño de letra", selection: Binding(
                get: { tamanoDeEsteLibro },          // el selector LEE de este libro...
                set: { nuevo in                      // ...y al elegir ESCRIBE en los dos sitios
                    tamanoDeEsteLibro = nuevo
                    tamanoParaElProximo = nuevo
                }
            )) {
                ForEach([12, 16, 20], id: \.self) { tamano in
                    Text("\(tamano) pt").tag(tamano)
                }
            }
            .pickerStyle(.segmented)
 
            Text("Este libro: \(tamanoDeEsteLibro) pt")
            Text("Para el próximo libro: \(tamanoParaElProximo) pt")
 
            Button("Abrir un libro de 12 pt") { tamanoDeEsteLibro = 12 }
                .buttonStyle(.bordered)
        }
        .padding()
    }
}
 
#Preview {
    SelectorDeLetraView()
}

Es el mismo patrón del selector de duración: lee del partido (datos.duracion), escribe el ajuste y avisa al ViewModel. El proyecto sigue compilando con este ejercicio dentro: no lo borres.

Paso · El aviso y el selector del partido

Objetivo: que, con un partido en juego, el otro enseñe «Ya hay un partido en juego» con «Ir al partido», y que el selector marque la duración de cada partido. Al terminar, las pruebas pasan con la nueva.

MarcadorApp
MarcadorApp
Marcador
UI
MarcadorUiState.swiftse modifica
PartidoEnDirectoView.swiftse modifica
PartidoViewModel.swiftse modifica
MarcadorAppTests
Marcador
UI
PartidoViewModelTests.swiftse modifica
Los ficheros de la lección en el Project navigator.

Son cuatro ficheros que ya existen. Ninguna prueba de interfaz cambia: ninguna pasa por el aviso.

1 · El estado de la pantalla y el ViewModel

Piezas nuevas de este paso:

  • var otroEnJuego: Int? = nil en el estado: el número del otro partido que se está jugando, o nil. Con un valor por defecto, el estado se sigue pudiendo crear sin él.
  • guard let vivo = enJuego.directo, vivo.partidoId != partidoId, !vivo.terminado else { return nil }: un guard con tres condiciones seguidas. Si falla cualquiera, sale con nil. Se lee: «hay un partido en juego, no es este y no ha terminado» (! es «no»).
  • enJuego.directo, sin paréntesis: la propiedad con el partido que esté en juego, sea cual sea (la función directo(id:), la de Estado de toda la app en SwiftUI, pedía uno concreto).

Intenta escribirlo tú. En MarcadorUiState.swift, Exito gana otroEnJuego. En PartidoViewModel.swift, uiState incluye otroEnJuego, que se calcula así: el partido en juego, si es otro y no ha terminado; si no, nil.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Marcador/UI/MarcadorUiState.swift
        let duracion: Int
        // El id de OTRO partido que se está jugando (nil si no hay): entonces no se puede empezar este.
        var otroEnJuego: Int? = nil
    }
Intenta primero. Cuando termines, compara con la solución.Mostrar el código

En PartidoViewModel.swift, sustituye el comentario que hay encima de enJuego (la propiedad es la misma: aquí solo cambia el comentario):

MarcadorApp/Marcador/UI/PartidoViewModel.swift
    // El partido en juego de la app. Es `@Observable`: leerlo en `uiState` basta para que la vista se
    // entere cuando empieza o termina OTRO partido.
    @ObservationIgnored private let enJuego: PartidoEnJuego

En uiState, sustituye el return .exito(…) por el de este bloque (acaba con la llave que cierra uiState). Justo debajo, añade la propiedad otroEnJuego, que es nueva:

MarcadorApp/Marcador/UI/PartidoViewModel.swift
        return .exito(.init(
            partidoId: partidoId, partido: directo.partido, marcador: directo.marcador, minuto: directo.minuto,
            corriendo: directo.corriendo, ultimoAviso: directo.ultimoAviso, duracion: directo.duracion,
            otroEnJuego: otroEnJuego
        ))
    }
 
    // El id de OTRO partido sin terminar (nil si no hay). Entonces este no se puede empezar.
    private var otroEnJuego: Int? {
        guard let vivo = enJuego.directo, vivo.partidoId != partidoId, !vivo.terminado else { return nil }
        return vivo.partidoId
    }

Y sustituye el comentario que hay encima de func empezar() (la función no cambia):

MarcadorApp/Marcador/UI/PartidoViewModel.swift
    // «Empezar»: lo empieza el partido en juego, no la vista. Si hay otro sin terminar, no hace nada
    // (la vista ni siquiera enseña el botón: enseña el aviso).

El resto de los dos ficheros no cambia. Con MarcadorUiState.swift abierto, lo único que cambia es la línea nueva al final de Exito, con su comentario.

2 · La vista

Piezas nuevas de este paso:

  • if let otro = datos.otroEnJuego { … } else if … { … }: «si hay otro partido en juego (y otro es su número), el aviso; si no, y el partido está en el minuto 0, el botón».
  • NavigationLink("Ir al partido", value: Destino.partido(otro)): el mismo enlace por valor de NavigationStack, con el número del otro partido. Apila el partido 1 encima del 2.
  • .background(.fill.tertiary, in: .rect(cornerRadius: 12)): un fondo gris suave del sistema (.fill.tertiary) con esquinas redondeadas. .rect(cornerRadius: 12) es la forma, igual que RoundedRectangle(cornerRadius: 12) de SwiftUI: primeros pasos.
  • .buttonStyle(.borderedProminent): el botón relleno de color de los botones de gol.
  • Binding(get: { datos.duracion }, set: { … }): el enlace hecho a mano de arriba, para el selector.

Intenta escribirlo tú. En PartidoEnDirectoView.swift:

  • Debajo del texto de estado, si hay otro partido en juego, el aviso AvisoDePartidoEnJuego(otro:) en vez del botón «Empezar partido».
  • El selector lee la duración del partido (datos.duracion) y, al elegir, guarda duracion (el ajuste) y llama a viewModel.cambiarDuracion. Se desactiva si datos.corriendo. Se va el .onChange de la lección anterior: el set hace su trabajo.
  • AvisoDePartidoEnJuego es una vista nueva: una fila con «Ya hay un partido en juego» a la izquierda y un botón destacado «Ir al partido» a la derecha, con fondo gris suave y esquinas redondeadas.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Marcador/UI/PartidoEnDirectoView.swift
                    // UNO A LA VEZ: si hay otro partido en juego, en vez de «Empezar partido» va el aviso.
                    if let otro = datos.otroEnJuego {
                        AvisoDePartidoEnJuego(otro: otro)
                    } else if datos.minuto == 0 && !datos.corriendo {
                        Button("Empezar partido") { viewModel.empezar() }
                            .buttonStyle(.borderedProminent)
                    }
MarcadorApp/Marcador/UI/PartidoEnDirectoView.swift
                    // El selector enseña la duración de ESTE partido y, al cambiarla, guarda la preferencia
                    // (`duracion`) y prepara el partido con ella. Un Binding hecho a mano: lee de un sitio
                    // y escribe en otro. Bloqueado mientras se juega.
                    Picker("Duración", selection: Binding(
                        get: { datos.duracion },
                        set: { nueva in
                            duracion = nueva
                            viewModel.cambiarDuracion(nueva)
                        }
                    )) {
                        ForEach(Ajustes.duraciones, id: \.self) { minutos in
                            Text("\(minutos) min").tag(minutos)
                        }
                    }
                    .pickerStyle(.segmented)
                    .disabled(datos.corriendo)
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
 
    var body: some View {
        HStack {
            Text("Ya hay un partido en juego")
                .font(.subheadline.weight(.semibold))
            Spacer()
            NavigationLink("Ir al partido", value: Destino.partido(otro))
                .buttonStyle(.borderedProminent)
        }
        .padding(12)
        .background(.fill.tertiary, in: .rect(cornerRadius: 12))
    }
}

El resto del fichero no cambia. Con el selector, lo que desaparece es el .onChange(of: duracion) { … } que había justo debajo de .disabled(datos.corriendo).

Compila con Cmd+B y ejecuta con Cmd+R en un iPhone del simulador:

  • Entra en el partido 1 y pulsa «Empezar partido». Vuelve a la lista y entra en el partido 2: en vez del botón ves «Ya hay un partido en juego» con «Ir al partido». Tócalo: se apila el partido 1, con su minuto avanzando.
  • Con el partido 1 todavía en juego, entra en el 2 y elige 60 min en su selector (allí sí se puede). Vuelve al 1: su selector, gris, sigue marcando 90, que es la duración con la que se juega. En la lección anterior habría marcado 60.
  • Cuando el partido 1 llega al final, vuelve al 2: el aviso ha desaparecido y el botón «Empezar partido» ha vuelto.

3 · La prueba

Objetivo: una prueba nueva del ViewModel pasa.

La prueba otroPartidoVeElAvisoYNoEmpieza crea dos partidos en el repositorio falso, empieza el 1 y comprueba dos cosas del ViewModel del 2: que su estado dice otroEnJuego == 1 y que, si llama a empezar(), el partido en juego sigue siendo el 1. RepositorioFalso(partidos:) sirve los partidos que le des, como viste en Tests en iOS: falsos y espías. El ViewModel del partido 1 se crea y se tira en la misma línea, solo para pulsar «Empezar»: el partido sigue porque lo guarda PartidoEnJuego, como en la lección anterior.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests/Marcador/UI/PartidoViewModelTests.swift
    // UNO A LA VEZ: la pantalla de otro partido ve el aviso y su «Empezar» no hace nada.
    @Test func otroPartidoVeElAvisoYNoEmpieza() {
        let repo = RepositorioFalso(partidos: [
            PartidoDeLista(id: 1, partido: Ejemplo.rayoContraToros),
            PartidoDeLista(id: 2, partido: Ejemplo.rayoContraToros),
        ])
        let enJuego = PartidoEnJuego()
        PartidoViewModel(partidoId: 1, repositorio: repo, partidoEnJuego: enJuego, msPorMinuto: 60_000).empezar()
        let segundo = PartidoViewModel(partidoId: 2, repositorio: repo, partidoEnJuego: enJuego, msPorMinuto: 60_000)
        guard case let .exito(datos) = segundo.uiState else {
            Issue.record("Se esperaba .exito")
            return
        }
        #expect(datos.otroEnJuego == 1)
        segundo.empezar()
        #expect(enJuego.directo?.partidoId == 1)
    }

El resto del fichero no cambia: es una prueba más, entre otraPantallaDelMismoPartidoReutilizaElDirecto y laDuracionSoloCambiaSiNoSeJuega. El guard case let .exito(datos) = … else { … } desempaqueta el estado: si no es .exito, la prueba falla con ese mensaje y termina.

Lanza Cmd+U.

Punto de control

  • Cmd+B termina en Build Succeeded y Cmd+U da 70 pruebas en verde: 54 unitarias y 16 de interfaz (con tus ejercicios de pruebas, 73). La nueva es otroPartidoVeElAvisoYNoEmpieza.
  • En el simulador, con el partido 1 en juego, el partido 2 enseña «Ya hay un partido en juego» con «Ir al partido», que apila el partido 1.
  • Cuando el partido 1 termina, el partido 2 vuelve a enseñar «Empezar partido».
  • El selector de cada partido marca la duración de ese partido, y gris mientras se juega.
  • La carpeta Ejercicios tiene SelectorDeLetra, con su vista previa: al abrir un libro de 12 pt, el selector pasa a 12 y «Para el próximo libro» no se mueve.
  • Sabes decir cómo se entera la pantalla de un partido de que otro está en juego, y por qué el selector usa Binding(get:set:) y no $duracion.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Con el partido 1 en juego, ¿qué valor tiene otroEnJuego en el estado de la pantalla del partido 2?

  2. 2.El partido 1 está en juego. La pantalla del partido 2 llama a empezar(). ¿Qué pasa?

  3. 3.El selector de duración usa Binding(get:set:) en vez de $duracion. ¿Qué consigue?

  4. 4.El partido 1 empieza mientras tienes abierta la pantalla del partido 2, y el aviso aparece solo. ¿Por qué?

  5. 5.¿Qué hace el botón «Ir al partido», un NavigationLink("Ir al partido", value: Destino.partido(otro))?

Faltan 5 de 5