Saltar al contenido
K
iOS con SwiftUIPaso a paso35 min de lectura · 10 min de máquina Básico

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.

ejemplo mínimo: un tipo Identifiable (con comentarios)
// 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.

ejemplo mínimo: una List con filas en HStack, listStyle y contentShape (con comentarios)
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
ComposeSwiftUI
LazyColumn { items(lista, key = { it.id }) { … } }List(lista) { … } con un tipo Identifiable
La key es opcional; sin ella, la posiciónEl 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                           Borges

Después quita Identifiable del tipo, lee el error y devuélvelo.

Ver solución
MarcadorApp/Ejercicios/ListaDeLibros/ListaDeLibros.swift
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.

MarcadorApp (proyecto)
MarcadorApp
Partidos
Data
DatosDeEjemplo.swiftse crea
Lo que tocas en este paso, en el Project navigator (el panel de la izquierda de Xcode, Cmd+1).

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.

  1. Crea la carpeta y el fichero. Los partidos son una funcionalidad nueva, Partidos, y este fichero va en su carpeta Data: 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 carpeta MarcadorApp que contiene ContentView.swift (la de dentro del proyecto, no el icono azul de arriba) → New Folder, de nombre Partidos; clic derecho sobre Partidos → New Folder, Data; y clic derecho sobre Data → New File from Template… → Swift File → Next, escribe DatosDeEjemplo y pulsa Create. La carpeta está sincronizada con el disco: un .swift que 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.
  2. Pulsa el botón de abajo. Aparecerá un aviso de que se ha copiado.
  3. 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.
DatosDeEjemplo.swift

  1. 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.

MarcadorApp (proyecto)
MarcadorApp
Partidos
UI
ListaDePartidosView.swiftse crea
ContentView.swiftse modifica
Un fichero se crea y otro se modifica.

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
MarcadorApp/Partidos/UI/ListaDePartidosView.swift
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:

ContentView.swift: la pestaña nueva, la primera
// …
        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
ListaDePartidosView.swift: versión final
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:

MarcadorAppTests.swift: una prueba nueva
    // 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 testArrancaYMuestraElMarcador y en testLosBotonesDeGolCambianElMarcador, añade app.tabBars.buttons["Marcador"].tap() justo debajo de app.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 identificador partido-1, con otherElements (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
MarcadorAppUITests.swift: las dos pruebas cambiadas y la nueva
    @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 … test termina en ** TEST SUCCEEDED **: nueve pruebas, tres unitarias y seis de interfaz.
  • El proyecto tiene DatosDeEjemplo.swift y ListaDePartidosView.swift, y en Ejercicios tienes ListaDeLibros además de los de antes.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Qué exige el protocolo Identifiable?

  2. 2.¿Qué significa ForEach(nombres, id: \.self)?

  3. 3.Escribes List(fichas) con un struct Ficha sin Identifiable ni id:. ¿Qué pasa?

  4. 4.¿Qué hace .contentShape(Rectangle()) en la fila de una lista?

  5. 5.¿Por qué Partido va envuelto en PartidoDeLista en los datos de ejemplo?

Faltan 5 de 5