Navegar a los goleadores
Al terminar: Navegar a la pantalla de goleadores y volver.
Al terminar, desde el partido, «Ver goleadores» apila una tercera pantalla con el ranking, y «Volver al partido» la quita. Además sabrás qué le pasa al partido mientras tiene otra pantalla encima: se pausa y, al volver, sigue por donde iba.
Importa porque apilar una pantalla tiene un efecto sobre el trabajo de la pantalla que queda debajo, y si no lo entiendes, el partido se reinicia o se pierde sin que ningún error te avise.
Temas: apilar una tercera pantalla, @Environment(\.dismiss), .safeAreaInset, por qué .task se cancela al quedar otra pantalla delante y un jugar que retoma.
Una tercera pantalla: goleadores
Es la tercera pantalla de la pila: lista → partido → goleadores. Aquí no hay un ViewModel como el de El ViewModel y el hilo principal, y es a propósito: esta pantalla no tiene estado propio ni acciones que atender, solo lee los partidos del repositorio compartido y calcula un ranking. Un ViewModel que se limitara a envolver esa lectura sería una capa sin trabajo; si la pantalla creciera (filtros, cargar de la red), pasaría a tener el suyo.
Piezas nuevas:
@Environment(\.dismiss): una función que quita de la pila la pantalla actual. El nombrecerrarde la propiedad es una elección tuya (@Environment(\.dismiss) private var cerrar) y se usacerrar()..navigationTitle("…")y.navigationBarTitleDisplayMode(.inline): el título de la barra de arriba, en tamaño pequeño..safeAreaInset(edge: .bottom) { … }: pone una vista fija abajo, respetando la zona segura (la franja de abajo que iOS deja libre para la barra de inicio del iPhone, y que ningún contenido debe tapar).partidos.map(\.partido).goleadores():\.partidoes una ruta a una propiedad usada como función, ygoleadores()viene del paqueteTorneo(el mismo ranking que en Kotlin). Devuelve un ranking conordenadosypuntosDe(_:).extension RepositorioEnMemoria { … }: añade una función al repositorio desde otro fichero. Va aquí porque solo esta pantalla la usa.
Las piezas de pantalla, en una pantalla de autores de la app de noticias de la lección anterior:
import SwiftUI
struct Autor: Identifiable {
let nombre: String
let articulos: Int
var id: String { nombre } // una propiedad calculada hace de id
}
struct AutoresView: View {
@Environment(\.dismiss) private var cerrar // quita esta pantalla de la pila
let autores = [Autor(nombre: "Rosa", articulos: 12), Autor(nombre: "Leo", articulos: 7)]
var body: some View {
List(autores) { autor in
HStack {
Text(autor.nombre)
Spacer()
Text("\(autor.articulos)").monospacedDigit()
}
}
.navigationTitle("Autores") // el título de la barra de arriba
.navigationBarTitleDisplayMode(.inline) // pequeño, centrado
.safeAreaInset(edge: .bottom) { // una vista fija abajo, respetando la zona segura
Button("Volver") { cerrar() }
.padding()
}
}
}Lo que se ve, una vez apilada en una pila: el título «Autores» pequeño y centrado arriba, con el botón «atrás»; dos filas, «Rosa 12» y «Leo 7», con el número pegado a la derecha; y, fijo abajo, un botón «Volver» que quita la pantalla.
Apilar una pantalla cancela el .task
.task cancela su tarea cuando la vista deja de estar delante, no solo cuando se destruye. Al apilar «Goleadores» sobre el partido, SwiftUI cancela el .task del partido: el partido se pausa. Al volver, relanza el .task y llama otra vez a jugar. Pero el PartidoViewModel sigue vivo (su @State sobrevive), y su reloj sigue en el minuto en que se pausó.
Si jugar empezara siempre de cero, volver de «Goleadores» pondría el minuto a 0 y borraría los goles. Por eso jugar ahora retoma: si ya hay un reloj con esta duración, sigue desde su minuto; si terminó, no hace nada; y solo empieza de nuevo la primera vez o si cambia la duración.
Para retomar, el bucle de jugar ya no cuenta un número fijo de vueltas: avanza el reloj mientras no haya llegado al último minuto. Un while repite su bloque mientras la condición sea cierta, así que puede empezar por donde iba, no por 1:
var paginaLeida = 3
let total = 6
// while repite MIENTRAS la condición sea cierta: aquí empieza por donde iba, no por 1.
while paginaLeida < total {
paginaLeida += 1
print("página \(paginaLeida)")
}Salida:
página 4
página 5
página 6Y la pausa y el retomar, vistos con una cuenta de segundos en vez de un partido, son justo el ejercicio:
Reto · Ejercicio: una cuenta que se pausa
Crea una subcarpeta nueva dentro de MarcadorApp/Ejercicios, CuentaConPausa, como en El ciclo de vida de UIViewController, con un fichero, CuentaConPausaView.swift. La vista tiene una pila de navegación cuya raíz se titula «Cuenta» y enseña «Segundos: 0» en grande y un enlace «Ver detalles». Al aparecer, una tarea suma uno cada segundo y, si la cancelan, deja de contar. Importa dónde cuelgas esa tarea: va en la vista de dentro de la pila (la raíz), no en la pila misma, que sigue ahí cuando apilas otra pantalla y no cancelaría nada. «Ver detalles» apila una segunda vista, en el mismo fichero, con el texto «Aquí la cuenta no avanza», el título «Detalles» en pequeño y, fijo abajo, un botón destacado «Volver» que la quita de la pila. Añade la vista previa de la primera.
Lo que debe pasar: la cuenta sube; mientras estás en «Detalles», no avanza (puede sumar uno o dos durante la animación de entrar y salir); al volver sigue desde donde iba, sin empezar de cero. Si el lienzo no responde a los toques, comprueba al menos que compila con Cmd+B.
Ver solución
import SwiftUI
// Una cuenta de segundos que se pausa cuando otra pantalla se apila encima.
struct CuentaConPausaView: View {
@State private var segundos = 0 // sobrevive mientras la vista siga en la pila
var body: some View {
NavigationStack {
VStack(spacing: 16) {
Text("Segundos: \(segundos)")
.font(.largeTitle)
.monospacedDigit()
NavigationLink("Ver detalles", value: "detalles") // apila otra pantalla
}
.navigationTitle("Cuenta")
// Se cancela al apilar «Detalles» y se relanza al volver; la cuenta sigue donde iba.
.task {
while true {
do {
try await Task.sleep(for: .seconds(1))
} catch {
return // cancelada: deja de contar
}
segundos += 1
}
}
.navigationDestination(for: String.self) { _ in
DetallesDeLaCuentaView()
}
}
}
}
// La pantalla de encima: título pequeño y un botón fijo abajo que la quita de la pila.
struct DetallesDeLaCuentaView: View {
@Environment(\.dismiss) private var cerrar
var body: some View {
Text("Aquí la cuenta no avanza")
.navigationTitle("Detalles")
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
Button("Volver") { cerrar() }
.buttonStyle(.borderedProminent)
.padding()
}
}
}
#Preview {
CuentaConPausaView()
}Hay más de una forma válida de escribirlo; lo que importa es que .task cancele la espera y la cuenta viva en un @State que sobrevive a la pausa. Si la tarea no se cancelara al apilar «Detalles», la cuenta seguiría subiendo debajo y al volver verías un número mucho mayor.
Paso 1 · Destino gana los goleadores
Objetivo: que el destino pueda ser también la pantalla de goleadores.
Abre Destino.swift. Añade el caso goleadores, que no necesita datos. Intenta escribirlo tú; Xcode marcará un error en el switch de la lista, que aún no cubre el caso nuevo: es lo esperado, lo arreglas en el paso 3.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Las pantallas a las que se puede ir, escritas como DATOS
enum Destino: Hashable {
case partido(Int) // la pantalla de un partido; lleva el id (el dato viaja en el destino)
case goleadores // la pantalla del ranking; no necesita datos
}Paso 2 · La pantalla de goleadores: GoleadoresView.swift
Objetivo: una pantalla con el ranking de goleadores y un botón «Volver al partido».
Crea GoleadoresView.swift como en NavigationStack: primero la carpeta: clic derecho sobre la carpeta MarcadorApp del código (la que tiene MarcadorApp.swift) → New Folder → Goleadores, y dentro de ella otra, UI. Esta funcionalidad solo tiene UI: sus datos son los del repositorio de partidos. goleadores(), que calcula el ranking, también va en UI: solo esta pantalla lo usa, y lo que es de una sola funcionalidad se queda en ella. Después, clic derecho sobre MarcadorApp/Goleadores/UI → New File from Template… → Swift File → nombre GoleadoresView, con solo el target MarcadorApp marcado.
Intenta escribirlo tú:
Goleador, una fila del ranking, comparable e identificable por el nombre del jugador, con eljugador(su nombre) y susgoles.- En una extensión del repositorio,
goleadores, que calcula el ranking a partir de los partidos (con el de goleadores del paqueteTorneo) y lo devuelve como una lista deGoleador, de más a menos goles. GoleadoresView, que lee el repositorio del entorno y enseña una lista sin fondo agrupado, una fila por goleador con el nombre a la izquierda y «3 goles» a la derecha; el título «Goleadores», pequeño; y abajo, fijo, un botón destacado «Volver al partido» que quita la pantalla de la pila. Su vista previa, dentro de una pila y con un repositorio.
Cmd+B debe compilar y el lienzo (#Preview) enseñar el ranking.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import SwiftUI
import Torneo
// Una fila del ranking
struct Goleador: Identifiable, Equatable { // Identifiable: la lista necesita saber cuál es cuál
let jugador: String
let goles: Int
var id: String { jugador } // la identidad es el nombre del jugador
}
extension RepositorioEnMemoria {
// Los goleadores no se guardan: se CALCULAN de los partidos, que son la única fuente de la verdad
func goleadores() -> [Goleador] {
let ranking = partidos.map(\.partido).goleadores() // `goleadores()` viene del paquete Torneo
return ranking.ordenados.map { Goleador(jugador: $0.nombre, goles: ranking.puntosDe($0)) }
}
}
struct GoleadoresView: View {
@Environment(RepositorioEnMemoria.self) private var repositorio
@Environment(\.dismiss) private var cerrar // función que quita esta pantalla de la pila
var body: some View {
// Se lee dentro del body: si un partido cambia, el ranking se recalcula solo
List(repositorio.goleadores()) { goleador in
HStack {
Text(goleador.jugador)
Spacer() // empuja los goles al borde derecho
Text("\(goleador.goles) goles").monospacedDigit()
}
}
.listStyle(.plain)
.navigationTitle("Goleadores") // el título de la barra de arriba
.navigationBarTitleDisplayMode(.inline) // en tamaño pequeño
.safeAreaInset(edge: .bottom) { // una vista fija abajo, sin tapar la zona segura
Button("Volver al partido") { cerrar() }
.buttonStyle(.borderedProminent)
.padding()
}
}
}
#Preview {
NavigationStack { GoleadoresView() } // la vista previa necesita una pila y el repositorio
.environment(RepositorioEnMemoria())
}Paso 3 · La lista y el partido llevan a goleadores
Objetivo: que «Ver goleadores» apile el ranking.
Dos cambios, y los dos bloques de abajo traen el trozo que cambia.
- En
ListaDePartidosView.swift, elswitchde.navigationDestinationgana la rama del caso nuevo, justo debajo de la del partido (primer bloque). - En
PartidoEnDirectoView.swift, dentro delVStackdel minuto (el que contieneMinuto …', el estado, el último aviso y las pausas), justo después del texto de las pausas (Veces que la pantalla pasó a segundo plano), un enlace «Ver goleadores» que apila el destino de los goleadores (segundo bloque).
Intenta escribirlos tú. Lo que debes ver: «Ver goleadores» abre el ranking y «Volver al partido» lo quita. Pero fíjate en lo que pasa al volver: el partido se reinicia. Es lo que arregla el paso siguiente.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
switch destino {
case let .partido(id):
PartidoContenedor(id: id)
case .goleadores: // la rama nueva: la pantalla del ranking
GoleadoresView()
} NavigationLink("Ver goleadores", value: Destino.goleadores) // empuja OTRO valor en la pilaPaso 4 · jugar retoma el partido
Objetivo: volver de «Goleadores» sin que el minuto vuelva a 0 ni se pierdan los goles.
Abre PartidoViewModel.swift y sustituye la función jugar(duracion:) entera, con el comentario que lleva encima. Intenta escribirla tú: si ya hay un reloj con esta misma duración, retoma (y si ya había terminado, no hace nada); si es la primera vez o cambió la duración, prepara todo desde cero como antes. Antes de seguir, tres piezas de esa función: let reloj: Cronometro declara una constante sin valor, que cada rama del if rellena (Swift exige que tenga valor antes de usarla); if let vivo = cronometro, duracion == nueva entra solo si ya hay un reloj Y la duración no cambió; y el bucle avanza el reloj mientras no haya llegado al último minuto, en vez de contar un número fijo de vueltas, para poder empezar por donde iba. El resto (la cancelación y las anotaciones) no cambia, y el resto del fichero tampoco. Lo que debes ver: volver de «Goleadores» sin que el minuto vuelva a 0 ni se pierdan los goles.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// La vista la llama desde `.task(id:)`. Si otra pantalla se apila encima, SwiftUI cancela esa tarea y
// la relanza al volver; el ViewModel sigue vivo, así que `jugar` RETOMA el partido en vez de empezar de cero
func jugar(duracion nueva: Int) async {
cargar()
guard let partido else { return }
let reloj: Cronometro // se declara aquí y se le da valor en UNA de las dos ramas de abajo
// `if let vivo = …, condición`: entra si hay un reloj (no es `nil`) Y además la duración es la misma
if let vivo = cronometro, duracion == nueva {
if vivo.minuto >= nueva { return } // ya terminó: volver a la pantalla no lo relanza
reloj = vivo // en marcha o pausado: sigue donde iba
} else { // primera vez, o cambió la duración: empieza de cero
duracion = nueva
minuto = 0
golesEnVivo = Marcador(local: 0, visitante: 0)
golesAMano = Marcador(local: 0, visitante: 0)
ultimoAviso = "ninguno"
eventosDelGuion = partido.eventos.filter { $0.minuto <= nueva }
reloj = Cronometro()
cronometro = reloj
prepararReloj(reloj)
}
corriendo = true
while reloj.minuto < nueva { // `while` en vez de `for 1...nueva`: puede empezar a mitad
do {
try await Task.sleep(for: .milliseconds(msPorMinuto))
} catch {
Registro.anotar("partido CANCELADO en el minuto \(minuto)")
corriendo = false
return
}
reloj.avanzar()
}
corriendo = false
Registro.anotar("partido TERMINADO en el minuto \(minuto)")
}Ejecuta con Cmd+R, abre un partido, espera al menos al minuto 15, toca «Ver goleadores», mira el ranking unos segundos y toca «Volver al partido». El minuto sigue donde iba; no vuelve a 0.
Paso 5 · Pruebas y comprobación
Una prueba unitaria y dos de interfaz, que escribes.
Primero, al final del struct MarcadorAppTests de MarcadorAppTests.swift (el que trae la plantilla, en la raíz de MarcadorAppTests), intenta escribir tú una prueba nueva, losGoleadoresSumanLosGolesDeTodosLosPartidos: con un repositorio de ejemplo, la suma de los goles de los goleadores tiene que ser la de todos los goles de todos los partidos (cada partido tiene golesLocal y golesVisitante), y los goleadores tienen que salir de más a menos goles. El resto del fichero no cambia. Necesita tres piezas: acumular una lista en un solo número, sacar de una lista solo un campo de cada elemento, y ordenar de mayor a menor.
Las tres, con los pedidos de una cafetería:
// Una tupla con nombres agrupa varios datos, cada uno con su etiqueta: `.euros` lee ese campo.
let pedidos = [(mesa: 1, euros: 12), (mesa: 2, euros: 30)]
// reduce(0): $0 es lo acumulado y $1 el elemento de esta vuelta.
let caja = pedidos.reduce(0) { $0 + $1.euros } // 42
let importes = pedidos.map(\.euros) // [12, 30]
print(caja, importes == importes.sorted(by: >)) // 42 false: no están de mayor a menorSalida:
42 falseIntenta primero. Cuando termines, compara con la solución.Mostrar el código
// Los goleadores se calculan de los partidos: la suma de sus goles es la de todos los goles
@Test func losGoleadoresSumanLosGolesDeTodosLosPartidos() {
let repositorio = RepositorioEnMemoria()
let golesTotales = repositorio.partidos.reduce(0) { $0 + $1.partido.golesLocal + $1.partido.golesVisitante }
let golesDeGoleadores = repositorio.goleadores().reduce(0) { $0 + $1.goles }
#expect(golesDeGoleadores == golesTotales)
let goles = repositorio.goleadores().map(\.goles)
#expect(goles == goles.sorted(by: >)) // ordenados de más a menos goles
}Segundo, dos pruebas de interfaz, que te doy hechas, en MarcadorAppUITests.swift, dentro de MarcadorAppUITests y justo encima de testAbreYCierraUnPartidoParaMedirLaMemoria (con el comentario y el @MainActor que lleva):
testNavegaDeLaListaAlPartidoYALosGoleadoresrecorre la pila entera: toca el partido 1, comprueba el título «Partido n.º 1», toca «Ver goleadores», comprueba «Goleadores», toca «Volver al partido» y por último el botón «atrás» hasta volver a «Partidos».testVolverDeGoleadoresNoReiniciaElPartidoabre el partido 1, espera a que pase del minuto 14 (con los dos goles del minuto 10 ya en el marcador), apila «Goleadores», vuelve y comprueba que el minuto no baja y que el marcador no vuelve a0 - 0.
Las piezas habituales (XCUIApplication, buttons[…], navigationBars.buttons.firstMatch, waitForExistence, NSPredicate, expectation(for:evaluatedWith:)…) están en la tabla de piezas de XCUITest de Tus primeras pruebas en iOS. capturar(app, "…") es la ayuda de capturas que ya tienes en Ayudas.swift. Las que quizá no estén allí, las de la segunda prueba, una a una:
app.launchArguments = ["-msPorMinuto", "150"]: los argumentos con los que arranca la app; este le dice que cada minuto de partido dure 150 ms.staticTexts.matching(NSPredicate(format: "label MATCHES %@", "\\d+ - \\d+")).firstMatch: el primer texto de la pantalla cuyo contenido tiene la forma «cifras, guion, cifras» (el marcador);\d+significa «una o más cifras» (en un texto de Swift la barra invertida se escribe doble, por eso en el código aparece\\d).NSPredicate { objeto, _ in … }: una condición escrita como closure;objeto as? XCUIElementconvierte el objeto a un elemento de pantalla (onilsi no lo es), ytexto.filter(\.isNumber)se queda solo con las cifras de un texto, de modo que «Minuto 16'» da «16».Int(…) ?? 0lo convierte en número, o0si no puede.XCTAssertGreaterThanOrEqual(a, b)yXCTAssertNotEqual(a, b): fallan siano es mayor o igual queb, o siaybson iguales; el último texto de cada una es el mensaje que sale si fallan.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Lista → partido → goleadores → atrás → atrás.
@MainActor
func testNavegaDeLaListaAlPartidoYALosGoleadores() throws {
let app = XCUIApplication()
app.launchArguments = ["-msPorMinuto", "60"]
app.launch()
app.buttons["partido-1"].tap()
XCTAssertTrue(app.navigationBars["Partido n.º 1"].waitForExistence(timeout: 5))
app.buttons["Ver goleadores"].tap()
XCTAssertTrue(app.navigationBars["Goleadores"].waitForExistence(timeout: 5))
capturar(app, "goleadores")
app.buttons["Volver al partido"].tap()
XCTAssertTrue(app.navigationBars["Partido n.º 1"].waitForExistence(timeout: 5))
capturar(app, "partido")
app.navigationBars.buttons.firstMatch.tap()
XCTAssertTrue(app.navigationBars["Partidos"].waitForExistence(timeout: 5))
}
// Volver de «Goleadores» NO reinicia el partido: sigue mientras se mira otra pantalla. Si `jugar`
// empezara de cero al volver, el minuto volvería a 0 y el marcador a 0 - 0.
@MainActor
func testVolverDeGoleadoresNoReiniciaElPartido() throws {
let app = XCUIApplication()
app.launchArguments = ["-msPorMinuto", "150"]
app.launch()
app.buttons["partido-1"].tap()
let minuto = app.staticTexts["minuto"]
let marcador = app.staticTexts.matching(NSPredicate(format: "label MATCHES %@", "\\d+ - \\d+")).firstMatch
// Espera a que el reloj pase del minuto 14: los goles del minuto 10 ya están en el marcador.
let pasaDel14 = NSPredicate { objeto, _ in
let texto = (objeto as? XCUIElement)?.label ?? ""
return (Int(texto.filter(\.isNumber)) ?? 0) >= 14
}
expectation(for: pasaDel14, evaluatedWith: minuto)
waitForExpectations(timeout: 15)
let minutoAntes = Int(minuto.label.filter(\.isNumber)) ?? 0
let marcadorAntes = marcador.label
XCTAssertNotEqual(marcadorAntes, "0 - 0", "Los goles del minuto 10 deberían estar ya en el marcador")
app.buttons["Ver goleadores"].tap()
XCTAssertTrue(app.navigationBars["Goleadores"].waitForExistence(timeout: 5))
Thread.sleep(forTimeInterval: 1)
app.buttons["Volver al partido"].tap()
XCTAssertTrue(app.navigationBars["Partido n.º 1"].waitForExistence(timeout: 5))
XCTAssertTrue(minuto.waitForExistence(timeout: 5))
let minutoDespues = Int(minuto.label.filter(\.isNumber)) ?? -1
let marcadorDespues = marcador.label
XCTAssertGreaterThanOrEqual(minutoDespues, minutoAntes, "El minuto volvió atrás: antes \(minutoAntes), después \(minutoDespues)")
XCTAssertNotEqual(marcadorDespues, "0 - 0", "El marcador se reinició al volver de Goleadores (antes «\(marcadorAntes)»)")
print("VOLVER-DE-GOLEADORES minuto antes=\(minutoAntes) despues=\(minutoDespues); marcador antes=\(marcadorAntes) despues=\(marcadorDespues)")
// Y el partido SIGUE: el minuto sigue avanzando después de volver.
let avanza = NSPredicate { objeto, _ in
let texto = (objeto as? XCUIElement)?.label ?? ""
return (Int(texto.filter(\.isNumber)) ?? 0) > minutoDespues
}
expectation(for: avanza, evaluatedWith: minuto)
waitForExpectations(timeout: 10)
capturar(app, "tras-volver-de-goleadores")
}Comprueba que volver no reinicia el partido
Ejecuta la segunda de las nuevas desde la Terminal, en ~/Marcador-Swift/MarcadorApp:
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -parallel-testing-enabled NO -only-testing:MarcadorAppUITests/MarcadorAppUITests/testVolverDeGoleadoresNoReiniciaElPartido testTermina con ** TEST SUCCEEDED **. Lo que la prueba imprime con print puede salir o no en la Terminal, según se ejecuten las pruebas en paralelo o no; con -parallel-testing-enabled NO en la orden sale tal cual, y también se ve en el informe de la ejecución en Xcode (el Report navigator, Cmd+9; los menús pueden variar según tu versión de Xcode). Si no la encuentras, no pasa nada: la prueba en verde ya dice que el partido no se reinició. Con 150 ms por minuto, la línea sale así:
VOLVER-DE-GOLEADORES minuto antes=16 despues=36; marcador antes=1 - 1 despues=2 - 2Las cifras cambian con cada máquina; lo que importa es que el minuto de después no sea menor que el de antes y que el marcador conserve los goles. «Después» es mayor porque, mientras la prueba comprueba el título, el partido ya ha retomado su marcha: con 150 ms por minuto, cada segundo son unos 7 minutos de partido.
Punto de control
- Tocar un partido de la lista lo apila con el título «Partido n.º 1» y el botón «atrás» arriba. «Ver goleadores» apila el ranking (ordenado de más a menos goles) y «Volver al partido» lo quita.
- Al volver de «Goleadores», el minuto no vuelve a 0 y el marcador conserva los goles.
- Las pruebas de la app pasan. Cmd+U lanza las 25: 12 unitarias (7 de
PartidoViewModelTestsy 5 deMarcadorAppTests) y 13 de interfaz (12 deMarcadorAppUITestsytestLaunch). Para lanzar solo las unitarias, desde~/Marcador-Swift/MarcadorApp,xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:MarcadorAppTests testtermina con** TEST SUCCEEDED **(si tu simulador se llama distinto, mira su nombre conxcrun simctl list devices available). - En el Project navigator están
Destino.swiftyGoleadoresView.swift, y los ejerciciosPilaDeNumerosyCuentaConPausadentro deMarcadorApp/Ejercicios.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Apilas «Goleadores» encima de un partido que se juega con
.task. ¿Qué pasa con el partido, con eljugarde esta lección?2.¿Cuál es el equivalente en Swift de
backStack.removeLastOrNull()de Navigation 3?3.¿Para qué sirve
.safeAreaInset(edge: .bottom) { … }?4.¿Por qué
jugarusawhile reloj.minuto < nuevaen vez defor _ in 1...nueva?