ForEach e identidad: el contrato que sostiene las animaciones
Qué significa exactamente que una fila tenga identidad, por qué usar el índice o el propio valor como identificador rompe el estado local y convierte los movimientos en sustituciones, y cómo se relaciona la identidad explícita con las transiciones, el estado de fila y los efectos de geometría compartida.
SwiftUI no conserva vistas: conserva identidades. Cada vez que el estado cambia, el framework construye una descripción nueva del árbol y la compara con la anterior buscando qué elemento de la nueva corresponde a qué elemento de la vieja. Esa correspondencia no se deduce de la posición ni del contenido, se declara, y ForEach es el sitio donde se declara. Todo lo que ocurre después —qué fila se anima al insertarse, qué estado local sobrevive, qué elemento hereda el foco o la selección— es una consecuencia mecánica de esa declaración. Cuando las animaciones de una lista se ven raras, el problema casi nunca está en la animación.
- Definir identidad estructural e identidad explícita y saber cuál gobierna cada fila.
- Explicar por qué el índice como identificador rompe el estado local y desordena las transiciones.
- Reconocer los identificadores inestables y los duplicados, y las formas en que se manifiestan.
- Relacionar identidad con supervivencia del estado, transiciones y geometría compartida.
Qué es exactamente la identidad de una fila
SwiftUI distingue dos maneras de identificar una vista. La identidad estructural viene de la posición en el árbol: la primera rama de un condicional siempre es la misma vista, ocupe el contenido que ocupe. La identidad explícita la das tú con un identificador, y es la única disponible cuando el número de vistas depende de los datos, que es justamente el caso de una lista.
ForEach ofrece tres formas de declararla y no son equivalentes. Sobre una colección de elementos que conforman Identifiable, el identificador sale del modelo. Sobre una colección cualquiera, se indica una ruta de clave hacia una propiedad que sirva de identificador. Sobre un rango constante como 0..<5, la identidad es la posición y el rango no puede cambiar durante la vida de la vista: es el caso de listas estáticas, no de datos.
struct Articulo: Identifiable {
let id: UUID // asignado una vez, al crear el modelo
var titulo: String
var leido: Bool
}
// Identidad del dominio: correcta
ForEach(articulos) { articulo in
FilaArticulo(articulo: articulo)
}
// Identidad por ruta de clave: correcta si esa propiedad es unica y estable
ForEach(articulos, id: \.slug) { articulo in
FilaArticulo(articulo: articulo)
}
La regla que resume todo lo demás es simple de enunciar y difícil de respetar bajo presión: el identificador debe ser único dentro de la colección y no debe cambiar mientras el elemento siga siendo el mismo elemento. Únicos y estables. Cuando falla la unicidad, la correspondencia es ambigua y el resultado es visualmente errático. Cuando falla la estabilidad, SwiftUI concluye que el elemento antiguo desapareció y llegó uno nuevo, con todas las consecuencias que eso arrastra.
Escribir una propiedad id calculada que devuelve un UUID nuevo en cada lectura es sintácticamente válido y semánticamente devastador: cada evaluación produce una identidad distinta, así que SwiftUI destruye y reconstruye la lista entera en cada invalidación, pierde todo el estado de fila y anima cada cambio como un reemplazo total. La variante sutil del mismo error es un identificador derivado de una propiedad que el usuario puede editar, como el título: renombrar un elemento lo mata y lo resucita.
El índice como identidad: por qué falla
Usar la posición como identificador es la tentación más común porque parece funcionar. Con una colección estática funciona de verdad; en cuanto se inserta, se borra o se reordena, el modelo se rompe de una manera que conviene entender en detalle.
// Antipatron: la identidad es la posicion, no el elemento
ForEach(articulos.indices, id: \.self) { indice in
FilaArticulo(articulo: articulos[indice])
}
Al borrar el primer elemento de una colección de diez, la posición cero deja de existir para el modelo pero sigue existiendo para ForEach. La identidad cero se conserva y lo que cambia es su contenido, y lo mismo ocurre con todas las demás: para el framework nadie se ha ido, simplemente nueve filas han cambiado de texto y la décima ha desaparecido del final. La animación que verás es exactamente esa: un desplazamiento de contenidos y una desaparición en el sitio equivocado, en lugar de la retirada limpia de una fila.
flowchart LR subgraph IDX[Identidad por posicion] A1[Antes: 0=A 1=B 2=C] --> B1[Se borra A] B1 --> C1[Despues: 0=B 1=C] C1 --> D1[El framework ve dos filas con contenido nuevo y una que se fue del final] end subgraph EST[Identidad estable] A2[Antes: idA idB idC] --> B2[Se borra A] B2 --> C2[Despues: idB idC] C2 --> D2[El framework ve una retirada y dos filas intactas] end style D1 fill:#f38ba8,color:#11111b style D2 fill:#a6e3a1,color:#11111b
El daño no se limita a la estética. El estado local declarado dentro de la fila está atado a la identidad, así que un campo de texto a medio escribir, una fila expandida o una animación en curso migran al elemento que ahora ocupa esa posición. El usuario ve su texto aparecer en otra fila y no hay ningún mensaje de error en ninguna parte. Hay además un riesgo de bloqueo real: si la vista guarda el índice y la colección se encoge entre la invalidación y la evaluación del cuerpo, el acceso queda fuera de rango.
Usar el propio valor como identificador tiene un problema hermano. Con tipos de valor, la identidad pasa a ser el contenido: editar un campo del elemento produce un valor distinto y por tanto un elemento distinto, con el mismo efecto de muerte y resurrección. Y si dos elementos son iguales, hay identificadores duplicados y la correspondencia deja de estar definida.
Identidad estable y supervivencia del estado
La solución es siempre la misma y siempre está en el modelo, no en la vista: un identificador nacido con el elemento y que lo acompaña hasta que deja de existir. Si los datos vienen de un servidor, el identificador del servidor es la respuesta correcta. Si nacen en el dispositivo, un UUID asignado en el inicializador —almacenado, nunca calculado— cumple el contrato. Para tipos de referencia, la conformidad por defecto de Identifiable usa la identidad del objeto, que es exactamente lo que se quiere.
struct FilaArticulo: View {
let articulo: Articulo
@State private var expandida = false // su vida es la vida de la identidad
var body: some View {
VStack(alignment: .leading) {
Text(articulo.titulo).font(.headline)
if expandida { Text(articulo.resumen) }
}
.onTapGesture { withAnimation { expandida.toggle() } }
}
}
Identificador del servidor
Si los datos vienen de una fuente remota, su clave primaria ya es la respuesta. Estable por definición y comparable entre sesiones.
UUID almacenado
Generado en el inicializador y guardado como propiedad constante. Nunca como propiedad calculada.
Identidad de objeto
Para tipos de referencia, la conformidad por defecto usa la identidad del propio objeto. Suele ser justo lo correcto.
Clave compuesta
Cuando ningún campo es único por sí solo, un valor derivado de varios campos inmutables. Inmutables es la palabra clave.
Cuando la identidad es correcta, todo lo demás encaja sin esfuerzo. Las transiciones de inserción y retirada se aplican a la fila que realmente entró o salió. Los movimientos se animan como movimientos, porque el framework reconoce al mismo elemento en una posición nueva. Los efectos de geometría compartida entre dos pantallas encuentran su pareja. Y el estado local sobrevive a los cambios de contenido, porque el elemento sigue siendo el mismo elemento aunque su texto haya cambiado.
Conviene mencionar la operación inversa, porque también es legítima: el modificador que fuerza una identidad nueva sirve precisamente para pedir un reinicio total, descartando el estado y animando el cambio como una sustitución. Es una herramienta útil cuando de verdad quieres empezar de cero, y una bomba de relojería cuando se coloca por costumbre en una fila de lista.
Cuando la posición sí es una identidad válida
Sería un error salir de aquí con la conclusión de que el índice está prohibido. Está prohibido como identidad de datos que cambian; es perfectamente correcto cuando el conjunto de filas es fijo durante toda la vida de la vista. Un rango constante para pintar cinco estrellas de valoración o siete casillas de una semana no tiene nada de malo, porque no hay inserción ni borrado que pueda romper la correspondencia.
El caso intermedio aparece cuando necesitas la posición dentro del cuerpo de la fila —para numerarla, para alternar el color de fondo o para detectar la última— sin renunciar a la identidad correcta. La solución es recorrer los elementos con su identidad real y obtener el índice aparte, no al revés.
// Identidad del elemento, posicion como dato secundario
ForEach(Array(articulos.enumerated()), id: \.element.id) { posicion, articulo in
FilaArticulo(articulo: articulo)
.background(posicion.isMultiple(of: 2) ? Color.clear : Color.gray.opacity(0.06))
}
Existe además la variante con enlaces de escritura, que permite que cada fila edite su propio elemento sin pasar por el índice: al recorrer un enlace a la colección, cada iteración entrega un enlace al elemento y la identidad sigue saliendo del modelo. Es la forma correcta de tener campos editables dentro de filas, y evita de raíz el patrón de guardar un índice en la vista para escribir en la colección más tarde.
Antes de dar por buena una lista, haz este experimento: borra el primer elemento con animación y añade uno en medio. Si la animación se ve como una retirada limpia y una inserción limpia, la identidad es correcta. Si ves contenidos que se desplazan de una fila a otra o desapariciones en el sitio equivocado, tienes un problema de identificadores por muy bien que se vea la pantalla en reposo.
Decir que dos elementos separados en el tiempo son el mismo elemento es una afirmación filosófica antes que técnica: es el problema de la persistencia a través del cambio, y SwiftUI te obliga a resolverlo explícitamente porque no tiene forma de resolverlo solo. Un framework imperativo esquiva la pregunta porque nunca destruye nada: la celda existe, tú la mutas, la continuidad es un hecho físico. Un framework declarativo describe estados completos y por tanto necesita que alguien declare qué se conserva entre uno y otro; sin esa declaración solo puede ver dos descripciones sin relación. De ahí que el identificador no sea un dato del modelo sino un compromiso: al elegirlo estás decidiendo qué cambios son accidentes que un elemento puede sufrir sin dejar de ser él, y qué cambios lo convierten en otro. Poner el índice equivale a afirmar que un elemento es su posición, y entonces la lista entera pasa a ser un desfile de sustituciones donde nada persiste. Poner el valor equivale a afirmar que un elemento es su contenido, y entonces editar es matar. Poner un identificador estable es afirmar que el elemento tiene una vida propia independiente de dónde está y de cómo es ahora mismo, que es justo la intuición que el usuario ya tiene cuando arrastra una fila y espera que sea la suya la que se mueve. La animación correcta no es el objetivo: es el síntoma visible de haber acertado con la ontología.
- Construye una lista con identidad por índice y borra el primer elemento con animación; graba la pantalla y describe qué se animó realmente.
- Añade a la fila un campo de texto con estado local, escribe en la segunda fila y borra la primera; observa dónde acaba el texto.
- Cambia a un identificador estable del modelo y repite los dos experimentos anteriores.
- Introduce un identificador calculado que genere un valor nuevo en cada lectura y cuenta cuántas veces se ejecuta el cuerpo de cada fila.
- Duplica a propósito un identificador en la colección y documenta el comportamiento observado, incluidas las advertencias en consola.