wandres.dev
LISTAS Y COLECCIONES · rendimiento y edición

List frente a ScrollView con LazyVStack

Las dos formas de mostrar una colección desplazable en SwiftUI no son intercambiables: una delega en la maquinaria de reutilización de celdas de la plataforma y otra construye una pila perezosa que casi nunca suelta lo que creó. Qué regala cada opción, qué cobra en memoria y en libertad de diseño, y cómo decidir con un criterio explícito en lugar de por costumbre.

⏱ 18 min

Las dos maneras de mostrar una colección desplazable producen pantallas casi idénticas y no se parecen en nada por dentro. List es una fachada declarativa sobre la maquinaria de vistas de colección del sistema: reutiliza celdas, estima alturas y trae consigo una lista larga de comportamientos que la plataforma espera encontrar. ScrollView con LazyVStack es SwiftUI puro: crea las filas cuando se acercan al área visible y las conserva. Esta es la primera decisión del nivel y también la más cara de revertir, porque cada opción arrastra un modelo distinto de memoria, de personalización y de lo que el sistema hace por ti sin que lo pidas.

🎯 Al terminar esta lección sabrás
  • Distinguir qué máquina ejecuta cada opción y qué consecuencias se derivan de esa diferencia.
  • Enumerar el comportamiento de plataforma que List regala y que LazyVStack obliga a reconstruir.
  • Explicar por qué la memoria de una pila perezosa crece con la distancia recorrida y la de una lista no.
  • Elegir entre ambas con un criterio explícito y detectar cuándo esa elección ha caducado.

Dos máquinas bajo la misma apariencia

List no dibuja nada por sí misma. Traduce su contenido a una vista de colección nativa y deja que sea esa vista quien gestione el desplazamiento, el reciclaje y la interacción. Las filas que salen del área visible se destruyen y su espacio se reutiliza para las que entran, de modo que el número de vistas materializadas se mantiene aproximadamente constante sin importar si la colección tiene doscientos elementos o doscientos mil.

Ese reciclaje no es gratis: cada fila cruza una frontera entre el mundo declarativo y el imperativo, y ese puente tiene un coste fijo por fila que la pila perezosa no paga. En la práctica ese coste es pequeño comparado con lo que ahorra, pero explica por qué una lista de veinte filas triviales puede medir peor que una pila con el mismo contenido: en el rango pequeño, el andamiaje pesa más que lo que sostiene.

LazyVStack dentro de un ScrollView es una pila vertical ordinaria con una única promesa añadida: no construye un elemento hasta que se aproxima a la ventana visible. Esa promesa es sobre la creación, no sobre la destrucción. Una vez materializada, la fila permanece en el árbol; la pila perezosa evita el coste inicial de arrancar con todo construido, no el coste acumulado de haber construido mucho.

// Delegacion: la maquina de colecciones del sistema gestiona el ciclo de vida
List(articulos) { articulo in
    FilaArticulo(articulo: articulo)
}

// Construccion propia: creacion diferida, sin reciclaje ni comportamiento heredado
ScrollView {
    LazyVStack(alignment: .leading, spacing: 12) {
        ForEach(articulos) { articulo in
            FilaArticulo(articulo: articulo)
        }
    }
    .padding(.horizontal)
}

Esa diferencia de propiedad tiene una consecuencia inmediata en el estado. Cuando una fila de List sale de pantalla, el estado local que declarase dentro muere con ella y, al volver, la fila se reconstruye desde cero: un desplegable abierto aparece cerrado, un vídeo en pausa vuelve al principio. En la pila perezosa eso no ocurre porque nada se destruyó. No es que una sea correcta y la otra no; es que en la primera el estado efímero de la fila debe subir al modelo si quieres que sobreviva, y en la segunda puede quedarse donde está.

Hay una asimetría menos visible en el cálculo del tamaño. List conoce de antemano cuántas filas hay y usa alturas estimadas, así que la barra de desplazamiento es honesta desde el primer fotograma y saltar a un elemento arbitrario funciona aunque ese elemento nunca se haya materializado. El ScrollView solo conoce el tamaño de lo que ya existe: el contenido crece a medida que avanzas, el indicador se recalibra sobre la marcha y desplazarse a un identificador que aún no se ha creado es, en el mejor de los casos, aproximado.

Lo que cada opción regala y lo que cobra

🏗️

List regala comportamiento

Separadores, deslizamiento para acciones, selección, modo de edición, reordenación, gesto de recarga, navegación por teclado en iPad y Mac, y la semántica de accesibilidad que el sistema espera de una tabla.

🧹

List cobra en personalización

Trae decoración propia que hay que desactivar pieza a pieza. Cada capa que quitas es una confesión de que quizá no querías una lista.

🎨

LazyVStack regala libertad

Cualquier disposición, contenido heterogéneo, encabezados fijados a voluntad, mezcla con otras secciones dentro del mismo desplazamiento y equivalentes horizontales o en rejilla.

📈

LazyVStack cobra en memoria

Sin reciclaje, el número de vistas vivas crece con la distancia recorrida y no vuelve a bajar. En colecciones largas eso es una fuga por diseño, no un fallo.

Conviene desglosar el apartado de la memoria porque es el que más discusiones estériles genera. Nadie nota la diferencia con doscientas filas ligeras: doscientas vistas vivas no son un problema en ningún dispositivo moderno. La diferencia se vuelve cualitativa cuando cada fila arrastra recursos —una imagen decodificada, un reproductor, un mapa, un contexto de dibujo— o cuando la colección no tiene techo porque llega paginada de un servidor. En esos dos casos, la ausencia de reciclaje no es un coste que se paga una vez sino una función creciente del tiempo que el usuario pase desplazándose, y el fallo se manifiesta como un aviso de memoria en sesiones largas, no como lentitud inmediata.

La consecuencia práctica es que las dos opciones no se ordenan por potencia sino por dominio. Si el diseño encaja en el modelo de fila del sistema, List es una decisión de aprovechamiento: media docena de comportamientos difíciles llegan gratis y correctamente implementados en todas las plataformas y todos los tamaños de texto. Si el diseño no encaja, forzarlo dentro de List acaba en una acumulación de modificadores cuya única función es desmontar lo que la lista acababa de montar.

⚠️
Contar filas también cuesta

Que las vistas se creen de forma diferida no significa que la colección se recorra de forma diferida. Tanto List como ForEach necesitan la lista completa de identidades para poder diferenciar el antes y el después, y eso es trabajo proporcional al número de elementos en cada invalidación. Con cien mil filas, el cuerpo de las vistas ya no es el cuello de botella: lo es el cálculo de identidad, y ese solo se arregla no teniendo cien mil elementos en memoria.

Casos límite: rejillas, contenido mixto y desplazamientos anidados

Hay tres situaciones en las que la comparación deja de ser simétrica porque una de las dos opciones simplemente no existe.

Antes de entrar en ellas conviene desmontar una creencia extendida: que la pila perezosa es la opción de alto rendimiento y la lista la opción cómoda. Es al revés en el eje que más importa a largo plazo. La lista acota la memoria y el número de vistas vivas por diseño; la pila perezosa solo aplaza la construcción. En listas cortas ninguna de las dos tiene problemas, y en listas largas la que se degrada sola es la que no recicla.

La primera es la disposición en rejilla o en horizontal. List es vertical y de una columna por definición; las familias perezosas en rejilla y horizontal no tienen equivalente, así que cualquier catálogo de tarjetas, galería o carrusel cae del lado del ScrollView sin discusión. Que ese contenedor no traiga acciones al deslizar ni modo de edición es exactamente el precio del que hablábamos.

Un matiz que evita rehacer trabajo: la pila perezosa horizontal dentro de una fila de List es una combinación perfectamente válida y muy frecuente. La lista gobierna el eje vertical con su reciclaje y cada fila contiene su propio carrusel perezoso. No hay conflicto de gestos porque los ejes son distintos, y se obtienen las dos ventajas a la vez.

La segunda es el contenido heterogéneo dentro de un mismo desplazamiento: una cabecera grande con imagen, luego un carrusel horizontal, después una tabla y al final un bloque de texto. Meterlo en List es posible tratando cada bloque como una fila y desactivando su decoración, y el resultado suele ser peor que construirlo en una pila perezosa donde cada bloque es lo que dice ser.

// Encabezados fijados sin List, con la pila perezosa
ScrollView {
    LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
        ForEach(secciones) { seccion in
            Section {
                ForEach(seccion.elementos) { FilaElemento(elemento: $0) }
            } header: {
                CabeceraSeccion(titulo: seccion.titulo)
            }
        }
    }
}
.scrollPosition(id: $elementoVisible)

Los encabezados fijados del ejemplo anterior ilustran bien la naturaleza de la elección: la pila perezosa te deja decidir exactamente qué vistas se quedan pegadas arriba, algo que en List viene determinado por el estilo y no se negocia. Más control a cambio de más decisiones que tomar y mantener.

La tercera no es una elección sino un error que conviene nombrar: anidar una lista dentro de un ScrollView. Dos desplazamientos verticales encajados compiten por el mismo gesto, la lista interior pierde la referencia de altura disponible y el reciclaje deja de tener sentido porque el contenedor exterior le pide su tamaño completo. Si necesitas una cabecera desplazable encima de una lista, la solución es una sección más dentro de la propia lista, no una lista dentro de otra cosa.

Un criterio de decisión, no una preferencia

La pregunta útil no es cuál es mejor sino cuál de las dos deudas prefieres contraer. Y conviene formularla explícitamente, porque en un equipo la decisión suele tomarse por inercia: se elige lo que usó la pantalla anterior, sin que nadie compruebe si los supuestos siguen siendo los mismos. Reconstruir el deslizamiento para borrar, la selección múltiple y el modo de edición sobre una pila perezosa es varias semanas de trabajo que envejecen mal con cada versión del sistema. Domar la decoración de una lista para conseguir un diseño que no es de lista es una lucha continua contra un componente que quiere ayudarte.

flowchart TB
A[Necesito mostrar una coleccion desplazable] --> B{El diseno cabe en el modelo de fila del sistema}
B -- Si --> C[List: comportamiento gratis y memoria acotada]
B -- No --> D{Necesito borrar deslizando o seleccion o reordenar}
D -- Si --> E[List con filas muy personalizadas]
D -- No --> F{La coleccion es larga o ilimitada}
F -- Si --> G[List o paginacion agresiva sobre LazyVStack]
F -- No --> H[ScrollView con LazyVStack]
style C fill:#a6e3a1,color:#11111b
style H fill:#89b4fa,color:#11111b
style G fill:#f9e2af,color:#11111b

Hay una tercera vía que aparece poco en los tutoriales y resuelve muchos casos reales: usar List y aceptar que la fila sea una vista completamente propia. La lista aporta el reciclaje, la interacción y la accesibilidad; el contenido de cada fila es tuyo sin restricciones, con sus márgenes, su fondo y su composición. La frontera que no se puede cruzar por ese camino es la disposición general —sigue siendo una columna vertical de bloques— y esa es exactamente la pregunta que hay que hacerse antes de descartar la lista.

Hay además una señal temprana de que la elección fue equivocada y conviene aprender a leerla. Si tu archivo acumula desactivaciones de separadores, anulaciones de márgenes de fila, fondos transparentes y ocultaciones del fondo del contenido, estás pagando el precio de List sin cobrar sus beneficios. Y al revés: si dentro de tu pila perezosa aparece un gesto de deslizamiento hecho a mano, un estado de edición propio y una lógica de selección con conjuntos de identificadores, has reimplementado una lista peor de la que ya tenías.

Elegir contenedor es elegir quién es el dueño del ciclo de vida

Debajo de esta comparación hay una idea que reaparecerá en todo el nivel: quien controla la creación y destrucción de las filas controla todo lo demás. Al escribir List no estás pidiendo una apariencia, estás cediendo la propiedad del ciclo de vida a un sistema que puede destruir una fila en cualquier momento porque sabe reconstruirla; de esa cesión se derivan la memoria acotada, la barra de desplazamiento honesta y también la exigencia de que la identidad de cada fila sea estable, porque sin identidad el sistema no sabría a qué elemento devolver el estado que acaba de tirar. Al escribir LazyVStack conservas esa propiedad: nada se destruye a tus espaldas, el estado local sobrevive sin depender de una identidad correcta y a cambio nadie acota el crecimiento. Vista así, la disyuntiva deja de ser una comparación de funcionalidades y se convierte en un contrato: en un caso prometes identidad estable y recibes gestión; en el otro te quedas la gestión y te ahorras la promesa. Casi todos los fallos exóticos de listas grandes —estado que aparece en la fila equivocada, animaciones que se ven como sustituciones, memoria que sube y nunca baja— son consecuencias directas de haber elegido un contrato y haber programado como si fuera el otro.

La elección tampoco es eterna. Una pantalla que hoy muestra doce elementos fijos puede acabar mostrando un histórico paginado sin que nadie revise el contenedor, y ese es el momento en que aparecen los avisos de memoria. Vale la pena dejar escrito, junto al código, cuál era el supuesto: número esperado de elementos, si la colección tiene techo y qué recursos arrastra cada fila. Cuando alguno de esos tres deje de ser cierto, la decisión debe reabrirse.

💡
Empieza por la lista y justifica salir de ella

Como heurística de trabajo, arranca siempre con List y trata cada modificador de desactivación como una anotación en un presupuesto. Cuando ese presupuesto pase de tres o cuatro apuntes, migra: será más barato que seguir peleando. La migración inversa, de pila perezosa a lista, casi siempre es más costosa porque para entonces ya habrás construido a mano la mitad de lo que List traía puesto.

⚔️ Medir la diferencia en lugar de suponerla
  1. Construye la misma pantalla dos veces, con List y con ScrollView más LazyVStack, sobre una colección de diez mil elementos generados.
  2. Recorre ambas hasta el final y compara el consumo de memoria al principio, a la mitad y al final del recorrido.
  3. Cuenta cuántas veces se ejecuta el cuerpo de la fila en cada versión durante el mismo recorrido, imprimiendo desde un inicializador de la vista de fila.
  4. Añade en las dos un salto directo al elemento número nueve mil y observa qué ocurre con el indicador de desplazamiento.
  5. Escribe en cinco líneas la justificación de tu elección final y guárdala junto al código: es la documentación que echarás de menos dentro de seis meses.