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.
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ásicaenlace.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
Pickersegmentado, «Tamaño de letra», con las opciones 12, 16 y 20 (cada una escrita «12 pt», «16 pt», «20 pt»). Su selección es unBinding(get:set:): leetamanoDeEsteLibroy, 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
tamanoDeEsteLibroen 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:
| Haces | Debe verse |
|---|---|
| Nada | Selector 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
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.
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? = nilen el estado: el número del otro partido que se está jugando, onil. 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 }: unguardcon tres condiciones seguidas. Si falla cualquiera, sale connil. 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óndirecto(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
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):
// 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: PartidoEnJuegoEn 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:
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):
// «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 (yotroes 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 queRoundedRectangle(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, guardaduracion(el ajuste) y llama aviewModel.cambiarDuracion. Se desactiva sidatos.corriendo. Se va el.onChangede la lección anterior: elsethace su trabajo. AvisoDePartidoEnJuegoes 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
// 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)
} // 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)// 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
// 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 Succeededy Cmd+U da 70 pruebas en verde: 54 unitarias y 16 de interfaz (con tus ejercicios de pruebas, 73). La nueva esotroPartidoVeElAvisoYNoEmpieza. - 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
EjerciciostieneSelectorDeLetra, 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.Con el partido 1 en juego, ¿qué valor tiene
otroEnJuegoen el estado de la pantalla del partido 2?2.El partido 1 está en juego. La pantalla del partido 2 llama a
empezar(). ¿Qué pasa?3.El selector de duración usa
Binding(get:set:)en vez de$duracion. ¿Qué consigue?4.El partido 1 empieza mientras tienes abierta la pantalla del partido 2, y el aviso aparece solo. ¿Por qué?
5.¿Qué hace el botón «Ir al partido», un
NavigationLink("Ir al partido", value: Destino.partido(otro))?