Listas en SwiftUI
Al terminar: Pintar listas con List, ForEach e Identifiable, y entender la identidad de las vistas.
Al terminar esta lección, Marcador tendrá una pestaña «Partidos» con 18 partidos en una lista desplazable, los mismos que la app de Android. Cada fila enseña el equipo local, el resultado y el visitante. La lección siguiente, La identidad de una vista, usa esa lista para ver qué es la identidad de una fila.
Importa porque casi toda app de móvil es una lista de algo (partidos, mensajes, productos) y SwiftUI necesita saber qué fila es cuál. Esta lección te da las piezas para pintar una: List, ForEach e Identifiable.
Trabajas en ~/Marcador-Swift/MarcadorApp, el proyecto de Xcode de las lecciones anteriores. Cuando el título de un bloque es una ruta (MarcadorApp/Partidos/UI/…), es la del fichero que escribes, empezando por la carpeta MarcadorApp del proyecto; los demás títulos solo dicen qué contiene el bloque. Cada paso te dice sobre qué carpeta hacer clic derecho para crear el fichero.
Una lista: List, ForEach e Identifiable
List es la lista de iOS: filas con separadores que se desplazan y que solo se crean cuando se ven, como LazyColumn. ForEach es la pieza que convierte una colección en vistas, y se puede usar dentro de una List o de un VStack. List(partidos) { … } es una List con un ForEach dentro.
La lista necesita saber qué fila es cuál: cada fila lleva una identidad, algo que la distingue de las demás. En la lección siguiente verás por qué importa y qué pasa si se da mal; aquí aprendes las dos formas de dárselo:
- Un tipo que cumple
Identifiable. - Un
id:que dice de dónde sale la identidad.
Identifiable es un protocolo con una sola exigencia: una propiedad id cuyo tipo sea Hashable (un valor que se puede resumir en un número para compararlo rápido; Int y String lo son). Un Partido no tiene id, porque dos partidos pueden repetir equipos. Por eso DatosDeEjemplo.swift, el fichero que copiarás en el Paso 1, lo envuelve en un tipo que sí lo tiene.
// Solo para leer: este tipo ya viene escrito en `DatosDeEjemplo.swift`. No lo escribas en tu proyecto, o se declararía dos veces.
struct PartidoDeLista: Identifiable {
let id: Int // único y estable: el mismo partido, el mismo id
let partido: Partido
}
// `Identifiable`: List y ForEach no piden `id:`
List(partidos) { item in Text(item.partido.local.nombre) }
// Sin Identifiable, se dice de dónde sale el id con un key path
ForEach(nombres, id: \.self) { nombre in Text(nombre) }\.self es un key path (una referencia a una propiedad, escrita \Tipo.propiedad, aquí sin tipo). \.self significa «el propio valor»: sirve con un String o un Int si no se repiten. \.id sería la propiedad id de cada elemento.
Las otras tres piezas que usa la fila del proyecto, en una lista de tareas (no es tu código; solo para leerla): .listStyle(.plain) quita el fondo agrupado gris de iOS, .contentShape(Rectangle()) hace que toda la fila cuente como zona tocable (no solo el texto) y .accessibilityIdentifier("…") pone un nombre para que las pruebas de interfaz encuentren el elemento.
import SwiftUI
struct Tarea: Identifiable { // Identifiable: List no pide id:
let id: Int
let titulo: String
}
struct ListaDeTareas: View {
let tareas = [Tarea(id: 1, titulo: "Comprar pan"), Tarea(id: 2, titulo: "Llamar a Rosa")]
var body: some View {
List(tareas) { tarea in // una fila por tarea
HStack {
Text(tarea.titulo)
Spacer() // empuja el número a la derecha
Text("#\(tarea.id)")
}
.contentShape(Rectangle()) // toda la fila cuenta como zona tocable
.accessibilityIdentifier("tarea-\(tarea.id)") // «tarea-1», «tarea-2»: para las pruebas
}
.listStyle(.plain) // sin el fondo agrupado gris de iOS
}
}Se ve una lista de dos filas, con el título a la izquierda y el número a la derecha:
Comprar pan #1
Llamar a Rosa #2| Compose | SwiftUI |
|---|---|
LazyColumn { items(lista, key = { it.id }) { … } } | List(lista) { … } con un tipo Identifiable |
La key es opcional; sin ella, la posición | El id es obligatorio: sin él no compila |
La key debe ser algo que Android sepa guardar (un número, un texto) | El id debe ser Hashable |
Reto · Ejercicio: una lista de libros
Crea una subcarpeta nueva dentro de Ejercicios, ListaDeLibros, y dentro un fichero ListaDeLibros.swift (la carpeta Ejercicios la creaste en El ciclo de vida de UIViewController). Los ejercicios no se borran y todo lo que pongas ahí compila con la app, así que cada uno usa nombres propios.
Escribe un tipo, Libro, con un número que lo identifique, un título y un autor, que la lista pueda usar sin que le digas cuál es su identidad. Después, una vista, ListaDeLibros, con tres libros (1, «Cien años de soledad», García Márquez; 2, «Rayuela», Cortázar; 3, «Ficciones», Borges) en una lista sin fondo agrupado: cada fila con el título en negrita a la izquierda y el autor a la derecha. Añade al final del fichero una vista previa que la muestre.
En la vista previa del lienzo (Editor → Canvas) debe verse:
Cien años de soledad García Márquez
Rayuela Cortázar
Ficciones BorgesDespués quita Identifiable del tipo, lee el error y devuélvelo.
Ver solución
import SwiftUI
// Un libro con su propio `id`: así la lista sabe qué fila es cuál.
struct Libro: Identifiable {
let id: Int
let titulo: String
let autor: String
}
struct ListaDeLibros: View {
let libros = [
Libro(id: 1, titulo: "Cien años de soledad", autor: "García Márquez"),
Libro(id: 2, titulo: "Rayuela", autor: "Cortázar"),
Libro(id: 3, titulo: "Ficciones", autor: "Borges"),
]
var body: some View {
List(libros) { libro in
HStack {
Text(libro.titulo)
.bold() // negrita
Spacer()
Text(libro.autor)
}
}
.listStyle(.plain)
}
}
#Preview {
ListaDeLibros()
}Sin Identifiable (con struct Libro a secas), sale initializer 'init(_:rowContent:)' requires that 'Libro' conform to 'Identifiable'. Lo arreglas escribiendo otra vez Identifiable, o dejando el tipo sin él y escribiendo List(libros, id: \.id).
Paso 1 · Los datos de ejemplo
Objetivo. Un fichero nuevo, DatosDeEjemplo.swift, con los 18 partidos de ejemplo. Compila, y todavía no lo usa nadie.
DatosDeEjemplo.swift fabrica un torneo con tres vueltas de todos contra todos: 18 partidos con marcadores repartidos de forma fija, los mismos que DatosDeEjemplo.kt en Android. No enseña nada de listas, así que no lo escribes: lo rellenas con un botón.
- Crea la carpeta y el fichero. Los partidos son una funcionalidad nueva,
Partidos, y este fichero va en su carpetaData: de ahí salen los datos (hoy, una lista escrita en el propio código, que vive en la memoria de la app mientras corre y no se guarda en ningún fichero; más adelante, un fichero en disco). En el Project navigator, clic derecho sobre la carpetaMarcadorAppque contieneContentView.swift(la de dentro del proyecto, no el icono azul de arriba) → New Folder, de nombrePartidos; clic derecho sobrePartidos→ New Folder,Data; y clic derecho sobreData→ New File from Template… → Swift File → Next, escribeDatosDeEjemploy pulsa Create. La carpeta está sincronizada con el disco: un.swiftque crees dentro entra solo en la app. Si el menú de tu versión de Xcode se llama distinto, usa la Terminal:mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Partidos/Data && touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Partidos/Data/DatosDeEjemplo.swift, y Xcode lo muestra solo. - Pulsa el botón de abajo. Aparecerá un aviso de que se ha copiado.
- Haz clic dentro del editor con el fichero abierto, selecciona todo con Cmd+A y pega con Cmd+V. Lo pegado sustituye a la plantilla que Xcode pone en un fichero nuevo.
- Compila con Cmd+B. Debe terminar con Build Succeeded.
Dentro hay dos cosas. DatosDeEjemplo.partidos es la lista de 18 partidos. Cada uno viene envuelto en un PartidoDeLista, el tipo Identifiable del ejemplo de arriba, que ya trae el fichero copiado.
Paso 2 · La pestaña «Partidos»
Objetivo. Una pestaña nueva con las 18 filas, que se desplaza. Primero una versión mínima, para verla funcionar, y después la fila con local, resultado y visitante.
Y la lista que los pinta va en Partidos/UI. Crea la carpeta y el fichero igual que antes: clic derecho sobre la carpeta Partidos → New Folder, de nombre UI; clic derecho sobre UI → New File from Template… → Swift File → ListaDePartidosView. Desde la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Partidos/UI && touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Partidos/UI/ListaDePartidosView.swift.
Pieza nueva de este paso. List(partidos) { item in … }: una fila por elemento; pide que el elemento sea Identifiable.
Intenta escribir esta versión mínima: una lista con solo el nombre del equipo local. Cuando termines, abre el bloque.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import SwiftUI
import Torneo // de aquí salen `Partido` y sus equipos
struct ListaDePartidosView: View {
// Las filas: una colección de `PartidoDeLista`.
let partidos: [PartidoDeLista]
var body: some View {
// Una fila por elemento. Sin `id:` porque `PartidoDeLista` es `Identifiable`.
List(partidos) { item in
Text(item.partido.local.nombre)
}
}
}Ahora abre ContentView.swift y añade la pestaña como primera del TabView. La lista recibe los datos de ejemplo:
// …
TabView {
// Primera pestaña: la lista de los 18 partidos.
Tab("Partidos", systemImage: "list.bullet") {
ListaDePartidosView(partidos: DatosDeEjemplo.partidos)
}
// … aquí siguen las pestañas «Marcador» y «Laboratorio» tal como las tienes
}
// …Ejecuta la app con Cmd+R (con un simulador de iPhone elegido arriba). Arranca en «Partidos»: 18 nombres de equipo que se desplazan. Si arranca en el marcador, la pestaña nueva no es la primera.
Intenta escribir la fila completa tú: FilaDePartido recibe los nombres del local y del visitante y el resultado, y los pone en fila: el local pegado a la izquierda, el resultado en negrita en el centro (con todos los dígitos del mismo ancho) y el visitante pegado a la derecha, con toda la fila como zona tocable. La lista, sin el fondo agrupado, usa una por partido, con el resultado como texto («3-2») y un nombre para las pruebas, «partido-1», «partido-2»… Lo que debes ver: 18 filas; la primera, Rayo FC 3-2 Toros. Las piezas son las del ejemplo de la lista de tareas, más monospacedDigit(), que hace que todos los dígitos midan lo mismo.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import SwiftUI
import Torneo
struct ListaDePartidosView: View {
// La colección de filas. Cada `PartidoDeLista` lleva su `id`.
let partidos: [PartidoDeLista]
var body: some View {
// Una fila por elemento, sin `id:` porque `PartidoDeLista` es `Identifiable`.
List(partidos) { item in
FilaDePartido(
local: item.partido.local.nombre, // nombre del equipo local
visitante: item.partido.visitante.nombre, // nombre del visitante
resultado: item.partido.marcador().description // «3-2» en el primer partido
)
// Un nombre para las pruebas de interfaz: «partido-1», «partido-2»…
.accessibilityIdentifier("partido-\(item.id)")
}
// `.plain`: lista sin el fondo agrupado gris de iOS.
.listStyle(.plain)
}
}
// Una fila: local a la izquierda, resultado en el centro, visitante a la derecha.
struct FilaDePartido: View {
let local: String
let visitante: String
let resultado: String
var body: some View {
HStack {
// `maxWidth: .infinity` reparte el ancho sobrante; `alignment` pega el texto a un lado.
Text(local).frame(maxWidth: .infinity, alignment: .leading)
// `monospacedDigit()`: todos los dígitos miden lo mismo, y el resultado no «baila».
// `.bold()` pone el texto en negrita.
Text(resultado).bold().monospacedDigit()
Text(visitante).frame(maxWidth: .infinity, alignment: .trailing)
}
// Toda la fila será tocable, no solo el texto (la lección siguiente le da el toque).
.contentShape(Rectangle())
}
}
// La vista previa del lienzo de Xcode.
#Preview {
ListaDePartidosView(partidos: DatosDeEjemplo.partidos)
}Vuelve a ejecutar (Cmd+R). Cada fila enseña ahora, la primera, Rayo FC 3-2 Toros, y después el resto de los partidos. Por ahora tocar una fila no hace nada: se podrá tocar más adelante.
Paso 3 · Las pruebas
Una prueba unitaria comprueba los datos y una de interfaz comprueba que la lista se pinta. Además, como la app arranca ahora en «Partidos», las dos pruebas que buscan el marcador tienen que tocar antes la pestaña «Marcador».
Dentro de struct MarcadorAppTests (el fichero MarcadorAppTests.swift, en la carpeta MarcadorAppTests), tras la última prueba que ya tienes, escribe esta:
// Los datos de ejemplo: 18 partidos y cada uno con un `id` distinto.
@Test func losDatosDeEjemploTienenDieciochoPartidosConIdUnico() {
let partidos = DatosDeEjemplo.partidos
#expect(partidos.count == 18) // son 18
// `\.id` es un key path («la propiedad `id`»): `map` lo aplica a los 18 partidos y saca sus ids;
// un `Set` no repite elementos, así que 18 ids distintos dan un `Set` de 18.
#expect(Set(partidos.map(\.id)).count == 18)
#expect(partidos.first?.id == 1) // el primero tiene el id 1
}Las pruebas de interfaz son mecánicas (las piezas que usan están en la tabla de piezas de XCUITest de Tus primeras pruebas en iOS). Abre MarcadorAppUITests → MarcadorAppUITests.swift. Hay dos cambios:
- En
testArrancaYMuestraElMarcadory entestLosBotonesDeGolCambianElMarcador, añadeapp.tabBars.buttons["Marcador"].tap()justo debajo deapp.launch(): toca la pestaña «Marcador» antes de buscar nada. - Una prueba nueva,
testLaListaMuestraLosPartidos: espera a que la pestaña «Partidos» pinte las filas. Busca la fila de dos maneras y basta con que valga una (||es «o»): por su identificadorpartido-1, conotherElements(los elementos de la pantalla que iOS no clasifica como botón, texto o imagen; una fila agrupada suele caer ahí), o por el texto «Rayo FC». Según tu versión de iOS la fila se clasifica de una forma u otra, y así la prueba vale con las dos.
Las dos pruebas están seguidas en tu fichero (la de arranque y, debajo, la de los botones), así que lo más fácil es sustituir las dos por estas tres, con la nueva en medio:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
@MainActor
func testArrancaYMuestraElMarcador() throws {
let app = XCUIApplication()
app.launch()
app.tabBars.buttons["Marcador"].tap() // línea nueva
XCTAssertTrue(app.staticTexts["Rayo FC"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["0 - 0"].exists)
capturar(app, "marcador")
}
// La lista de partidos muestra las filas con su marcador.
@MainActor
func testLaListaMuestraLosPartidos() throws {
let app = XCUIApplication()
app.launch()
XCTAssertTrue(app.otherElements["partido-1"].waitForExistence(timeout: 5)
|| app.staticTexts["Rayo FC"].waitForExistence(timeout: 5))
capturar(app, "lista")
}
// Cada botón cambia el @State y la tarjeta se redibuja.
@MainActor
func testLosBotonesDeGolCambianElMarcador() throws {
let app = XCUIApplication()
app.launch()
app.tabBars.buttons["Marcador"].tap() // línea nueva
app.buttons["Gol Rayo FC"].tap()
app.buttons["Gol Rayo FC"].tap()
app.buttons["Gol Toros"].tap()
XCTAssertTrue(app.staticTexts["2 - 1"].waitForExistence(timeout: 3))
capturar(app, "marcador-2-1")
}Pasa todas las pruebas con Cmd+U, o desde la Terminal:
cd ~/Marcador-Swift/MarcadorApp
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' build test(el nombre del simulador tiene que existir en tu Mac; xcrun simctl list devices available te enseña los que tienes).
Punto de control
- La pestaña «Partidos» muestra 18 filas con local, resultado y visitante, y se desplaza.
- Las pruebas (unitarias y de interfaz) pasan y
xcodebuild … testtermina en** TEST SUCCEEDED **: nueve pruebas, tres unitarias y seis de interfaz. - El proyecto tiene
DatosDeEjemplo.swiftyListaDePartidosView.swift, y enEjerciciostienesListaDeLibrosademás de los de antes.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué exige el protocolo
Identifiable?2.¿Qué significa
ForEach(nombres, id: \.self)?3.Escribes
List(fichas)con unstruct FichasinIdentifiableniid:. ¿Qué pasa?4.¿Qué hace
.contentShape(Rectangle())en la fila de una lista?5.¿Por qué
Partidova envuelto enPartidoDeListaen los datos de ejemplo?