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

La identidad de una vista

Al terminar: Entender qué es la identidad de una vista y por qué importa en las listas.

Al terminar esta lección, la pestaña «Laboratorio» de Marcador tendrá un experimento que enseña qué es la identidad de una fila: marcas la primera fila de dos listas, insertas un nombre arriba y en una la marca se queda quieta mientras en la otra sigue al dato. Una prueba de interfaz lo comprueba por ti.

Importa porque SwiftUI necesita saber qué fila es cuál entre un dibujado y el siguiente. Si se lo dices mal, el estado de cada fila (una marca, un texto escrito) se queda pegado a la posición y no al dato.

Trabajas en ~/Marcador-Swift/MarcadorApp. Los ficheros nuevos se crean con clic derecho sobre la carpeta que te indique cada paso (dentro de MarcadorApp, la que contiene MarcadorApp.swift) → New File from Template… → Swift File → Next, el nombre y Create. Si el menú de tu versión de Xcode se llama distinto, usa la Terminal: touch del fichero dentro de ~/Marcador-Swift/MarcadorApp/MarcadorApp/<carpeta>/, y Xcode lo muestra solo.

Qué es la identidad de una vista

Identidad es lo que hace que SwiftUI considere «la misma vista» entre una pasada y la siguiente. De ella depende que el estado de una vista (su @State) se conserve y que los cambios se muevan con suavidad. Hay dos formas:

IdentidadDe dónde saleSigue a…Buena para
EstructuralLa posición en el código: la vista que va primero, la que va segundaLa posiciónVistas fijas, VStack { A; B }
ExplícitaUn id que das tú (id: \.id, o un tipo Identifiable)El datoListas que cambian

Con una lista que cambia, la identidad por posición falla: si insertas un elemento arriba, la fila 0 pasa a ser otro dato, pero SwiftUI la sigue tratando como «la misma» y su estado se queda.

Lo ves con una lista de colores. Interruptor es una fila con su propia marca (un Toggle, un interruptor con etiqueta, conectado a su @State con $). La primera lista recorre las posiciones de los colores: colores.indices es 0..<colores.count, es decir, 0, 1, 2…, y con id: \.self cada posición se identifica por sí misma. La segunda recorre los colores y los identifica por su propio texto. El botón inserta «azul» al principio con insert("azul", at: 0) (at: 0 es «en la posición 0», el principio de la lista).

ejemplo mínimo: ForEach por posición y por dato, Toggle e insert(at:) (con comentarios)
import SwiftUI
 
struct Avisos: View {
    @State private var colores = ["rojo", "verde"]
 
    var body: some View {
        VStack(alignment: .leading) {
            Button("Añadir azul arriba") {
                colores.insert("azul", at: 0)              // at: 0 = al principio del array
            }
            // indices son las posiciones (0, 1, …): la identidad de cada fila es su posición.
            ForEach(colores.indices, id: \.self) { posicion in
                Interruptor(texto: "\(posicion): \(colores[posicion])")
            }
            // id: \.self: la identidad de cada fila es el propio texto (no deben repetirse).
            ForEach(colores, id: \.self) { color in
                Interruptor(texto: color)
            }
        }
    }
}
 
// private: este struct solo existe dentro de este fichero.
private struct Interruptor: View {
    let texto: String
    @State private var activo = false                  // cada fila guarda el suyo
 
    var body: some View {
        Toggle(texto, isOn: $activo)                   // $activo: el enlace en el que escribe el interruptor
    }
}

Al empezar se ve el botón y cuatro interruptores: 0: rojo y 1: verde (por posición), rojo y verde (por dato). Marca 0: rojo y rojo y pulsa el botón: en la lista por posición la marca se queda en 0: azul, porque la identidad es la posición 0; en la lista por dato rojo conserva su marca y azul aparece sin marcar, porque la identidad es el color y el estado sigue al dato.

Reto · Ejercicio: la marca que se va con el producto

Crea una subcarpeta nueva dentro de Ejercicios, ListaDeLaCompra, y dentro un fichero ListaDeLaCompra.swift (la carpeta Ejercicios la creaste en El ciclo de vida de UIViewController).

Escribe una vista, ListaDeLaCompra, con una lista de la compra que empieza en Pan, Leche y Huevos, y un botón «Quitar el primero». Debajo, la lista dos veces, cada fila con su propio interruptor:

  • Bajo «A · por posición», identificada por la posición: «A Pan», «A Leche», «A Huevos».
  • Bajo «B · por producto», identificada por el propio producto: «B Pan», «B Leche», «B Huevos».

Pieza nueva: remove(at:) quita un elemento de un array por su posición, lo contrario de insert(at:). Añade al final del fichero una vista previa que la muestre.

En la vista previa del lienzo (Editor → Canvas; se puede tocar como la app), marca «A Pan» y «B Pan» y pulsa «Quitar el primero». Lo que debes ver:

  • En A, la marca pasa a «A Leche»: se quedó en la posición 0.
  • En B no queda ninguna marca, porque se fue con el pan.
Ver solución
Ejercicios/ListaDeLaCompra/ListaDeLaCompra.swift
import SwiftUI
 
// La misma lista de la compra dos veces: A identifica cada fila por su posición; B, por el propio producto.
struct ListaDeLaCompra: View {
    @State private var productos = ["Pan", "Leche", "Huevos"]
 
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Button("Quitar el primero") {
                productos.remove(at: 0)
            }
            .buttonStyle(.bordered)
 
            Text("A · por posición").font(.subheadline.bold())
            ForEach(productos.indices, id: \.self) { indice in
                ProductoConMarca(texto: "A \(productos[indice])")
            }
 
            Text("B · por producto").font(.subheadline.bold())
            ForEach(productos, id: \.self) { producto in
                ProductoConMarca(texto: "B \(producto)")
            }
        }
        .padding()
    }
}
 
// Una fila con su propia marca. `private`: solo existe en este fichero.
private struct ProductoConMarca: View {
    let texto: String
    @State private var comprado = false
 
    var body: some View {
        Toggle(texto, isOn: $comprado)
    }
}
 
#Preview {
    ListaDeLaCompra()
}

Tras quitar el pan, en A queda marcada «A Leche» (y «A Huevos» no); en B, ni «B Leche» ni «B Huevos». Si el lienzo de tu Xcode no responde a los toques, comprueba al menos que el fichero compila con Cmd+B.

Paso 1 · El laboratorio de identidad

Objetivo. Dos listas con los mismos tres nombres, una con identidad por posición y otra por dato, cada fila con su propia marca. Es el experimento de la introducción, dentro de la app.

MarcadorApp
MarcadorApp
Laboratorio
IdentidadView.swiftse crea
LaboratorioView.swiftse modifica
Un fichero se crea y otro se modifica.

Crea IdentidadView.swift en Laboratorio, porque es una prueba para ver cómo funciona SwiftUI, no una parte de Marcador: clic derecho sobre la carpeta Laboratorio → New File from Template… → Swift File → Next, nombre IdentidadView.

Piezas nuevas de este paso. Ninguna: son las del ejemplo y el ejercicio de arriba (ForEach con indices y con id: \.self, Toggle con $, private struct, insert(at:)). Lo nuevo es el contador, que da un nombre distinto a cada inserción («Nuevo1», «Nuevo2»…), y la prueba del paso 2, que repite el experimento sin tocar nada.

Intenta escribirlo tú: IdentidadView, con una lista de nombres que empieza en Ana, Luis y Marta y un botón «Insertar arriba» que añade «Nuevo1», «Nuevo2»… al principio. Debajo, los mismos nombres dos veces, cada uno con un interruptor propio que recuerda si está marcado: bajo el título «A · identidad por posición», cada fila se identifica por su posición y dice «A Ana»; bajo «B · identidad por dato», cada fila se identifica por el propio nombre y dice «B Ana».

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Laboratorio/IdentidadView.swift
import SwiftUI
 
// Un experimento: dos listas con los mismos nombres y distinta identidad.
struct IdentidadView: View {
    // Los nombres de las dos listas. `@State`: si cambian, SwiftUI recalcula `body`.
    @State private var nombres = ["Ana", "Luis", "Marta"]
    // Cuántos nombres nuevos se han insertado («Nuevo1», «Nuevo2»…).
    @State private var contador = 0
 
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Button("Insertar arriba") {
                contador += 1
                // `at: 0`: al principio de la lista.
                nombres.insert("Nuevo\(contador)", at: 0)
            }
            .buttonStyle(.bordered)
 
            Text("A · identidad por posición").font(.subheadline.bold())
            // `nombres.indices` son las posiciones 0, 1, 2…: la identidad de cada fila es SU POSICIÓN.
            ForEach(nombres.indices, id: \.self) { indice in
                FilaConMarca(etiqueta: "A \(nombres[indice])")
            }
 
            Text("B · identidad por dato").font(.subheadline.bold())
            // `id: \.self`: la identidad de cada fila es EL PROPIO NOMBRE.
            ForEach(nombres, id: \.self) { nombre in
                FilaConMarca(etiqueta: "B \(nombre)")
            }
        }
    }
}
 
// Una fila con una marca. `private`: solo existe en este fichero.
private struct FilaConMarca: View {
    let etiqueta: String
    // El estado de la fila: cada fila tiene el suyo.
    @State private var visto = false
 
    var body: some View {
        // `$visto`: el enlace que el interruptor necesita para escribir en el `@State`.
        Toggle(etiqueta, isOn: $visto)
    }
}
 
#Preview {
    IdentidadView().padding()
}

El laboratorio ya existe (la pestaña «Laboratorio» de las lecciones anteriores). Abre LaboratorioView.swift y añade IdentidadView() debajo de OrdenDeModifiers(), dentro del VStack:

LaboratorioView.swift: debajo de OrdenDeModifiers
// …
                OrdenDeModifiers()
                // El experimento de esta lección.
                IdentidadView()
// …

Ejecuta (Cmd+R) y abre la pestaña «Laboratorio». En las dos listas, marca «A Ana» y «B Ana» y pulsa «Insertar arriba»:

ListaQué vesPor qué
A · por posiciónLa marca se queda arriba, en «A Nuevo1»; «A Ana» ya no está marcadaLa identidad es la posición 0, y el estado se queda en ella
B · por dato«B Ana» conserva la marca; «B Nuevo1» empieza sin marcarLa identidad es el nombre, y el estado sigue a «Ana»

Paso 2 · La prueba de identidad

Objetivo. Una prueba de interfaz que hace el experimento del laboratorio y comprueba la tabla de arriba.

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). Esta marca «A Ana» y «B Ana» (un Toggle aparece como switches[…], y su value es "1" si está marcado y "0" si no; XCTAssertEqual(a, b) comprueba que a y b son iguales, y value as? String lee ese valor como texto), pulsa «Insertar arriba» y comprueba la tabla del paso anterior. Lleva una línea que merece una explicación: app.swipeUp() sube la pantalla un poco, porque, según el modelo de iPhone del simulador (por ejemplo, el iPhone 16), la fila «B Ana» queda debajo de la barra de pestañas y el toque caería en la barra, no en la fila.

Abre MarcadorAppUITests → MarcadorAppUITests.swift y añade la prueba dentro de la clase, justo debajo de testLaListaMuestraLosPartidos (la que escribiste en la lección anterior). Deja una línea en blanco antes y otra después:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppUITests.swift: una prueba nueva
    // Identidad por posición (A) frente a identidad por dato (B).
    @MainActor
    func testIdentidadPorPosicionFrenteAIdentidadPorDato() throws {
        let app = XCUIApplication()
        app.launch()
        app.tabBars.buttons["Laboratorio"].tap()
        app.swipeUp()                                          // sube la pantalla: «B Ana» queda a la vista
        let marcaA = app.switches["A Ana"]
        let marcaB = app.switches["B Ana"]
        XCTAssertTrue(marcaA.waitForExistence(timeout: 5))
        marcaA.tap()
        marcaB.tap()
        app.buttons["Insertar arriba"].tap()
        // A: la marca se queda en la POSICIÓN 0, que ahora es «Nuevo1»; «Ana» ya no está marcada.
        XCTAssertEqual(app.switches["A Nuevo1"].value as? String, "1")
        XCTAssertEqual(app.switches["A Ana"].value as? String, "0")
        // B: la marca sigue al DATO: «Ana» conserva la suya y «Nuevo1» empieza sin marcar.
        XCTAssertEqual(app.switches["B Ana"].value as? String, "1")
        XCTAssertEqual(app.switches["B Nuevo1"].value as? String, "0")
        capturar(app, "identidad")
    }

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.
  • En «Laboratorio», tras marcar las dos «Ana» e insertar arriba, ves la tabla del paso 1.
  • Las pruebas (unitarias y de interfaz) pasan y xcodebuild … test termina en ** TEST SUCCEEDED **: diez pruebas, tres unitarias y siete de interfaz.
  • El proyecto tiene Laboratorio/IdentidadView.swift, y en Ejercicios tienes ListaDeLaCompra además de los de antes.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Insertas un elemento arriba de una lista con ForEach(nombres.indices, id: \.self) y cada fila tiene su @State. ¿Qué ocurre?

  2. 2.¿Cuál es la identidad estructural de una vista?

  3. 3.Dos fichas se llaman Ana y usas el nombre como identidad (id: \.nombre). ¿Qué pasa?

  4. 4.En el laboratorio marcas «A Ana» y «B Ana» y pulsas «Insertar arriba». ¿Por qué «B Ana» conserva la marca?

  5. 5.Marcas «A Pan» y «B Pan» en las dos listas de la compra y quitas el primer producto (el pan). ¿Dónde queda la marca de la lista A (por posición)?

Faltan 5 de 5