@ObservedObject y el partido que se reinicia
Al terminar: Ver qué pasa con @ObservedObject cuando la vista se recrea.
Al terminar esta lección, Marcador tendrá un partido en directo: tocas un partido de la lista, se abre una hoja, pulsas «Empezar partido» y el minuto avanza solo. Antes de arreglarlo, provocarás a propósito un fallo que se ve a simple vista: al recalcularse la vista que contiene el partido, el minuto vuelve a 0 y el marcador a 0-0. El culpable es un solo atributo, @ObservedObject, y el arreglo es cambiarlo por @StateObject.
Importa porque el fallo compila, funciona en la primera prueba y no da ningún error: solo se nota cuando el padre se redibuja. Y porque @ObservedObject sí tiene su sitio: el de recibir un objeto que otro ya creó.
El orden de la lección: primero escribes la pantalla bien (con @StateObject), luego cambias una palabra para romperla y ver el fallo, y por último la arreglas.
@ObservedObject: observar sin ser dueño
Hay vistas que no crean el objeto: lo reciben de la vista que sí es dueña y solo lo observan. Para eso es @ObservedObject: la vista se recalcula cuando cambian las propiedades @Published del objeto que le pasan, pero no lo guarda ni lo conserva, y por eso no lleva valor inicial.
La tabla de la lección anterior (ObservableObject y @StateObject) lo resume: @StateObject es la vista dueña (SwiftUI conserva el objeto aunque el struct se recree) y @ObservedObject solo observa un objeto que otro mantiene vivo.
Un detalle de @StateObject, que la lección anterior dejó para aquí: cuando el init escribe _modelo = StateObject(wrappedValue: TemporizadorModelo(nombre: nombre)), el parámetro wrappedValue es un @autoclosure. Eso significa que escribes una expresión normal y el compilador la envuelve en una closure sin llaves, que no se ejecuta en el acto: SwiftUI la ejecuta solo la primera vez que monta la vista y en las recreaciones la ignora. Con @ObservedObject, en cambio, modelo = TemporizadorModelo(…) se ejecuta en cada recreación: de ahí el fallo de hoy.
La dueña crea el modelo con @StateObject y se lo pasa a una hija que solo lo observa. Un ejemplo, con el TemporizadorModelo de la lección anterior (una clase observable con nombre, segundos, contando y arrancar(hasta:)). Tiene tres piezas de SwiftUI que no has visto:
.disabled(condicion): deja el control gris mientras la condición seatrue..padding([.horizontal, .top], 16): margen de 16 solo a los lados y arriba; una lista entre corchetes elige los bordes.if … elsecomo valor: entextoDelBoton, cada rama es el texto que devuelve la propiedad (sinreturn).
Y una más de Swift: let alCambiar: (Int) -> Void es una closure guardada en una propiedad (Closures): una función que recibe un Int y no devuelve nada. BotonesDeAjuste no escribe en ningún sitio: avisa de qué pasó (alCambiar(-10)) y quien la usa decide qué hacer. Es el state hoisting de Compose.
import SwiftUI
// La DUEÑA: crea el modelo una vez y se lo pasa a quien lo necesite.
struct TemporizadorView: View {
@StateObject private var modelo: TemporizadorModelo
init(nombre: String) {
_modelo = StateObject(wrappedValue: TemporizadorModelo(nombre: nombre))
}
var body: some View {
PanelDelTemporizador(modelo: modelo) // se lo pasa por el constructor
}
}
// La OBSERVADORA: recibe el modelo ya creado, sin valor inicial.
struct PanelDelTemporizador: View {
@ObservedObject var modelo: TemporizadorModelo
var body: some View {
VStack(spacing: 12) {
Text("\(modelo.nombre): \(modelo.segundos) s")
Button(textoDelBoton) { modelo.arrancar(hasta: 180) }
.buttonStyle(.borderedProminent)
.disabled(modelo.contando) // gris mientras la condición sea true
BotonesDeAjuste { cambio in print(cambio) } // la hija avisa con una closure
}
.padding([.horizontal, .top], 16) // margen solo a los lados y arriba
}
// if … else como valor: cada rama es el texto, sin return.
private var textoDelBoton: String {
if modelo.contando { "Contando…" } else { "Arrancar" }
}
}
// Recibe una closure en vez de un enlace: dice QUÉ pasó y el padre decide.
struct BotonesDeAjuste: View {
let alCambiar: (Int) -> Void // recibe un Int y no devuelve nada
var body: some View {
HStack {
Button("−10 s") { alCambiar(-10) }
Button("+10 s") { alCambiar(10) }
}
}
}Al pulsar «−10 s» sale -10 en la consola de Xcode, y al pulsar «+10 s», 10. El botón «Arrancar» se pone gris y dice «Contando…» mientras el temporizador cuenta.
Para abrir una pantalla desde una lista, el paso 2 usa dos piezas más. Una hoja (.sheet) es una pantalla que sube desde abajo y se cierra deslizándola; .sheet(item: $elegido) { valor in … } la abre cuando el estado opcional elegido deja de ser nil y le pasa ese valor (al cerrarla, vuelve a nil). Pide que el tipo sea Identifiable: el PartidoDeLista de Listas en SwiftUI. Y Button { acción } label: { contenido } es un botón cuyo contenido no es un texto sino una vista (aquí, la fila entera): la acción va entre las primeras llaves y la vista, tras label:. Las verás con comentarios en el paso 2.
Ejercicio: la pizarra y sus botones
Reto · Ejercicio: la pizarra y sus botones
Crea la subcarpeta Pizarra dentro de Ejercicios (recuerda: ningún tipo puede llamarse igual que otro del proyecto). En PizarraModelo.swift, una clase observable con los puntos apuntados (empiezan en 0; solo ella los cambia), un método que suma uno y otro que resta uno sin bajar de 0. En PizarraView.swift, tres vistas:
PizarraViewes la dueña de la pizarra. Enseña «Puntos: 0» y, debajo, los botones.BotonesDePizarrarecibe la pizarra ya creada y tiene dos botones en fila, «−1» y «+1».AulaViewes el padre. Tiene un botón «Nueva clase (0)» que cuenta sus toques y, debajo, la pizarra.
Añade una vista previa de AulaView. Pulsa «+1» tres veces, «−1» una y después «Nueva clase». Lo que debe verse:
Nueva clase (1)
Puntos: 2Para pensar: ¿qué pasaría si BotonesDePizarra creara su propia pizarra en vez de recibirla?
Ver solución
import Combine
import Foundation
// Los puntos apuntados en una pizarra.
final class PizarraModelo: ObservableObject {
@Published private(set) var puntos = 0
func sumar() { puntos += 1 }
func restar() { if puntos > 0 { puntos -= 1 } }
}import SwiftUI
// La DUEÑA: crea la pizarra una vez y se la pasa a quien la necesite.
struct PizarraView: View {
@StateObject private var pizarra = PizarraModelo()
var body: some View {
VStack(spacing: 12) {
Text("Puntos: \(pizarra.puntos)")
BotonesDePizarra(pizarra: pizarra)
}
}
}
// Solo OBSERVA: la recibe ya creada, sin valor inicial.
struct BotonesDePizarra: View {
@ObservedObject var pizarra: PizarraModelo
var body: some View {
HStack {
Button("−1") { pizarra.restar() }
Button("+1") { pizarra.sumar() }
}
.buttonStyle(.bordered)
}
}
// El padre que se redibuja.
struct AulaView: View {
@State private var clases = 0
var body: some View {
VStack(spacing: 24) {
Button("Nueva clase (\(clases))") { clases += 1 }
PizarraView()
}
.padding()
}
}
#Preview {
AulaView()
}Tras «Nueva clase» siguen los «Puntos: 2»: el padre recrea PizarraView, pero el @StateObject conserva la pizarra. Respuesta de «para pensar»: los botones cambiarían otra pizarra y el número de arriba no se movería. Además, cada «Nueva clase» crearía una pizarra nueva.
Paso 1 · La vista del partido, con @StateObject
Objetivo. Una pantalla que posee su modelo con @StateObject, enseña el minuto y el marcador, y tiene el botón «Empezar partido» y los dos botones de gol.
Trabajas en ~/Marcador-Swift/MarcadorApp. Crea PartidoEnDirectoView.swift en Marcador/UI, con las demás vistas del marcador: clic derecho sobre la carpeta UI (dentro de Marcador) → New File from Template… → Swift File → Next, nombre PartidoEnDirectoView. Contiene tres vistas: PartidoEnDirectoView (el partido), BotonesDeGolConAccion (los botones, la versión con closure de BotonesDeGol) y PartidoContenedor (su padre, con un botón «Redibujar el padre» que sirve para reproducir el fallo del paso 3). TarjetaDePartido es la que dejaste con su marcador en @Binding.
Intenta escribirlo tú, con la vista como dueña del modelo (@StateObject):
PartidoEnDirectoViewrecibe un partido y crea con él su modelo en elinit. Dentro de una zona que se desplaza, en columna: la tarjeta con el marcador del modelo; los botones de gol, que avisan al modelo del lado que marcó; el minuto en grande («Minuto 12'», con el nombre internominuto); un botón destacado que empieza el partido de 90 minutos, gris mientras corre o si ya empezó, y que dice «Empezar partido», «En juego» o «Partido terminado»; y el texto «Último aviso (cada 15'): …».BotonesDeGolConAccionrecibe el partido y qué hacer cuando marca un lado: dos botones destacados en fila, «Gol Rayo FC» y «Gol Toros», que solo avisan.PartidoContenedorrecibe unitemde la lista: arriba, el título «Partido n.º 1» y un botón «Redibujar el padre (0)» que cuenta los toques; debajo, la pantalla del partido.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import SwiftUI
import Torneo
// La pantalla del partido en directo.
struct PartidoEnDirectoView: View {
// `@StateObject`: esta vista es la DUEÑA del modelo. SwiftUI lo crea una vez y lo conserva
// aunque el struct de la vista se vuelva a crear.
@StateObject private var modelo: PartidoEnVivoModelo
init(partido: Partido) {
// El guion bajo es el almacenamiento oculto del `@StateObject`: el modelo se entrega
// aquí, envuelto en `StateObject(wrappedValue:)`.
_modelo = StateObject(wrappedValue: PartidoEnVivoModelo(partido: partido))
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
// La tarjeta de «@Binding», con el marcador que calcula el modelo.
TarjetaDePartido(partido: modelo.partido, marcador: modelo.marcador)
// Los botones avisan de qué lado marcó; no escriben en nada.
BotonesDeGolConAccion(partido: modelo.partido) { lado in
modelo.golAMano(lado)
}
VStack(alignment: .leading, spacing: 8) {
Text("Minuto \(modelo.minuto)'")
.font(.title)
.monospacedDigit()
.accessibilityIdentifier("minuto") // para las pruebas de interfaz
Button(textoDelBoton) { modelo.empezar(duracion: 90) }
.buttonStyle(.borderedProminent)
// Gris mientras el partido corre o ya empezó.
.disabled(modelo.corriendo || modelo.minuto > 0)
Text("Último aviso (cada 15'): \(modelo.ultimoAviso)")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
}
}
// El texto del botón según cómo vaya el partido.
private var textoDelBoton: String {
if modelo.minuto >= 90 { "Partido terminado" }
else if modelo.corriendo { "En juego" }
else { "Empezar partido" }
}
}
// Los dos botones de gol. En vez de un `@Binding`, reciben una closure: avisan de QUÉ pasó
// y quien los usa decide qué hacer.
struct BotonesDeGolConAccion: View {
let partido: Partido
let alGol: (Lado) -> Void // `(Lado) -> Void`: una función que recibe un `Lado` y no devuelve nada
var body: some View {
HStack(spacing: 8) {
Button("Gol \(partido.local.nombre)") { alGol(.local) }
Button("Gol \(partido.visitante.nombre)") { alGol(.visitante) }
}
.buttonStyle(.borderedProminent)
}
}
// El PADRE de la pantalla del partido. Su botón fuerza que se recalcule su `body`, y con él se
// vuelve a crear el struct de `PartidoEnDirectoView`: justo lo que hay que vigilar.
struct PartidoContenedor: View {
let item: PartidoDeLista
@State private var redibujados = 0 // un contador que solo sirve para provocar el recálculo
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Partido n.º \(item.id)").font(.headline)
Spacer()
Button("Redibujar el padre (\(redibujados))") { redibujados += 1 }
.buttonStyle(.bordered)
}
.padding([.horizontal, .top], 16)
PartidoEnDirectoView(partido: item.partido)
}
}
}Compila con Cmd+B. Todavía no se ve en la app: falta abrirla desde la lista.
Paso 2 · La lista abre el partido
Objetivo. Tocar una fila de «Partidos» abre una hoja con PartidoContenedor.
Hacen falta tres cambios en ListaDePartidosView.swift, con las dos piezas del ejemplo de las recetas. Intenta escribirlos tú:
- Un estado nuevo,
elegido: el partido tocado, o ninguno. - Cada fila pasa a ser un botón que guarda su partido en
elegido, con la fila de siempre como contenido y con aspecto de fila normal, no de botón azul. El nombre para las pruebas se queda en el botón. - Tras el estilo de la lista, una hoja que se abre cuando hay un partido elegido y enseña su
PartidoContenedor.
Lo que debes ver: al tocar el primer partido, la hoja con la tarjeta y los dos botones de gol.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// …
struct ListaDePartidosView: View {
let partidos: [PartidoDeLista]
// El partido tocado. `nil` = ninguno: la hoja está cerrada.
@State private var elegido: PartidoDeLista?
var body: some View {
List(partidos) { item in
// Cada fila es ahora un botón que guarda el partido tocado.
Button {
elegido = item
} label: {
FilaDePartido(
local: item.partido.local.nombre,
visitante: item.partido.visitante.nombre,
resultado: item.partido.marcador().description
)
}
// `.plain`: que parezca una fila normal y no un botón azul.
.buttonStyle(.plain)
.accessibilityIdentifier("partido-\(item.id)")
}
.listStyle(.plain)
// `sheet(item:)`: se abre una hoja cuando `elegido` deja de ser `nil`, con ese valor dentro.
.sheet(item: $elegido) { item in
PartidoContenedor(item: item)
}
}
}
// …Ejecuta con Cmd+R. Toca el primer partido: se abre la hoja con la tarjeta y los dos botones de gol. Pulsa «Empezar partido»: el texto pasa a «En juego», el minuto avanza (un minuto cada cuarto de segundo) y cada 15 minutos cambia el último aviso. Pulsa «Redibujar el padre» un par de veces: con @StateObject el minuto y el marcador no cambian. Cierra la hoja deslizándola hacia abajo.
Paso 3 · Provoca el fallo
Objetivo. Ver con tus ojos cómo @ObservedObject reinicia el partido, y que una prueba de interfaz lo detecta.
Primero la prueba, que te doy hecha. Casi todas las piezas están en la tabla de XCUITest de Tus primeras pruebas en iOS (XCUIApplication, launch, buttons[…], staticTexts[…], label, NSPredicate, expectation(for:evaluatedWith:) y waitForExpectations). La prueba nueva, testElPartidoSobreviveAlRedibujadoDelPadre:
- arranca la app con
app.launchArguments = ["-msPorMinuto", "40"]: los argumentos de lanzamiento son los de la lección anterior, y hacen que un minuto de partido dure 40 ms; - toca
partido-1(abre la hoja) y «Empezar partido»; - espera, con un
NSPredicateescrito como closure, a que el texto deminutomarque 10 o más; - toca el botón «Redibujar el padre» (como su nombre lleva un número, lo busca con
buttons.matching(NSPredicate(format: "label BEGINSWITH 'Redibujar'")).firstMatch: el primer botón cuyo texto empieza así); - comprueba que el minuto de después no es menor que el de antes. Las dos llamadas a
capturarguardan una captura de pantalla como adjunto de la prueba, con la función que ya tienes enAyudas.swift.
Tres líneas del bloque merecen una explicación:
(objeto as? XCUIElement)?.label ?? "": elNSPredicaterecibe el elemento como un tipo genérico;as? XCUIElementintenta verlo como elemento de interfaz (o danil),?.labelpide su texto si lo es y?? ""pone un texto vacío si no.Int(texto.filter(\.isNumber)) ?? 0:filter(\.isNumber)se queda solo con los caracteres que son dígitos («Minuto 31'» pasa a «31»);Int(…)lo convierte en número, pero devuelve un opcional (el texto podría no ser un número), y?? 0pone 0 si falla.XCTAssertGreaterThanOrEqual(a, b, "mensaje"): falla la prueba siano es mayor o igual queb, y muestra el mensaje.
Abre MarcadorAppUITests → MarcadorAppUITests.swift y añade la prueba debajo de la última que ya tienes, antes de la llave que cierra la clase:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// … la última prueba que ya tienes termina aquí
}
// El partido NO se reinicia cuando el padre se redibuja (con @ObservedObject sí lo hacía).
@MainActor
func testElPartidoSobreviveAlRedibujadoDelPadre() throws {
let app = XCUIApplication()
app.launchArguments = ["-msPorMinuto", "40"]
app.launch()
app.buttons["partido-1"].tap()
app.buttons["Empezar partido"].tap()
let minuto = app.staticTexts["minuto"]
// Espera a que el reloj pase del minuto 10.
let pasaDel10 = NSPredicate { objeto, _ in
let texto = (objeto as? XCUIElement)?.label ?? ""
let numero = Int(texto.filter(\.isNumber)) ?? 0
return numero >= 10
}
expectation(for: pasaDel10, evaluatedWith: minuto)
waitForExpectations(timeout: 15)
let antes = minuto.label
capturar(app, "partido-antes-de-redibujar")
app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Redibujar'")).firstMatch.tap()
let despues = minuto.label
capturar(app, "partido-despues-de-redibujar")
let numeroAntes = Int(antes.filter(\.isNumber)) ?? 0
let numeroDespues = Int(despues.filter(\.isNumber)) ?? -1
XCTAssertGreaterThanOrEqual(numeroDespues, numeroAntes, "El partido se reinició: antes «\(antes)», después «\(despues)»")
}
} // ← la llave que cierra la clasePásala con Cmd+U, o solo esta con la Terminal (-only-testing: lleva el camino Objetivo/Clase/método: el grupo de pruebas, la clase y la prueba, como en Tus primeras pruebas en iOS):
cd ~/Marcador-Swift/MarcadorApp
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:MarcadorAppUITests/MarcadorAppUITests/testElPartidoSobreviveAlRedibujadoDelPadre test(El nombre del simulador tiene que existir en tu Mac: xcrun simctl list devices available te enseña los que tienes.)
Con @StateObject pasa. Ahora rómpelo: en PartidoEnDirectoView.swift, sustituye la declaración del modelo y el init (desde @StateObject private var modelo hasta la llave que cierra el init, con sus comentarios) por esto, con @ObservedObject:
@ObservedObject private var modelo: PartidoEnVivoModelo
init(partido: Partido) {
modelo = PartidoEnVivoModelo(partido: partido)
}Pasa la prueba otra vez: falla con un mensaje como este (los minutos varían en cada ejecución):
XCTAssertGreaterThanOrEqual failed: ("0") is less than ("31") - El partido se reinició: antes «Minuto 31'», después «Minuto 0'»Para verlo a mano, en el esquema de Xcode añade el argumento -msPorMinuto 40 (Product → Scheme → Edit Scheme… → Run → Arguments → Arguments Passed On Launch → +), ejecuta con Cmd+R, toca el partido 1, pulsa «Empezar partido» y, pasados unos minutos, «Redibujar el padre».
Para contar los modelos, mira la consola de Xcode (el panel de abajo; si no lo ves, View → Debug Area → Activate Console, o Cmd+Shift+Y). Con @ObservedObject salen dos líneas modelo del partido CREADO con un identificador distinto: una al abrir la hoja y otra al pulsar «Redibujar el padre» (y una más por cada toque siguiente):
modelo del partido CREADO (Rayo FC-Toros) 9b45a
modelo del partido CREADO (Rayo FC-Toros) 8b33bCon @StateObject sale una sola línea, la de abrir la hoja: ninguna más al redibujar el padre. Devuelve el @StateObject y repite la prueba: pasa.
Quita el argumento -msPorMinuto 40 del esquema antes de seguir (el mismo menú, y la casilla del argumento, o el botón −). Si se queda puesto, un minuto dura 40 ms en lugar de 250 y las lecciones siguientes (que cuentan con 250 ms por minuto) no cuadrarían.
Los síntomas son los que acabas de ver: una vista es un struct barato que SwiftUI recrea cada vez que su padre recalcula el body, y con @ObservedObject cada recreación fabrica un modelo nuevo. El minuto vuelve a 0, el marcador a 0-0 y el botón «Empezar partido» se rehabilita. El primer modelo no se libera mientras su Task lo retenga; por eso no hay ningún error.
La regla para recordar: el que crea, @StateObject; el que recibe, @ObservedObject. Para devolverlo a su sitio, pon de nuevo el @StateObject y su init:
@StateObject private var modelo: PartidoEnVivoModelo
init(partido: Partido) {
_modelo = StateObject(wrappedValue: PartidoEnVivoModelo(partido: partido))
}Punto de control
- Tocar una fila de «Partidos» abre la hoja; «Empezar partido» hace avanzar el minuto y el botón pasa a «En juego».
- Con
@StateObject, «Redibujar el padre» no cambia el minuto ni el marcador, y la consola de Xcode (ejecutando con Cmd+R) muestra un solomodelo del partido CREADO, el de abrir la hoja. - Con
@ObservedObject, la prueba falla con el mensajeXCTAssertGreaterThanOrEqual failed: …del paso 3 y hay un «CREADO» más por cada «Redibujar». Déjalo otra vez en@StateObject. - El esquema de Xcode ya no lleva el argumento
-msPorMinuto 40: un minuto de partido vuelve a durar 250 ms. - Con el
@StateObjectpuesto, Cmd+U termina en** TEST SUCCEEDED **con 11 pruebas: 3 unitarias y 8 de interfaz (7 deMarcadorAppUITestsytestLaunch).
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Una vista declara
@ObservedObject private var modeloy lo crea en suinit. El padre se redibuja. ¿Qué ocurre?2.Con
@ObservedObjectcreando el modelo, el partido vuelve al minuto 0 al redibujarse el padre, pero la app no da ningún error. ¿Por qué no se queja nadie del modelo descartado?3.Para abrir una hoja con
.sheet(item:), ¿qué tiene que cumplir el tipo del valor que se pasa?4.Una subvista recibe del padre un modelo ya creado y solo lo observa. ¿Qué escribes en su declaración?