En la UI: PagingData, LoadState y reintento
Consumir datos paginados en Compose exige abandonar el reflejo de tratar la colección como una lista y aprender a leer una superficie de estados mucho más rica que el habitual par cargando y cargado. Esta lección conecta el flujo con la lista perezosa, explica por qué las claves y los marcadores de posición cambian el comportamiento, desglosa los tres extremos de carga con sus errores independientes, y construye las cabeceras, los pies y las pantallas de estado que hacen que una lista paginada resulte honesta con el usuario.
La interfaz es donde la paginación deja de ser un problema de datos y se convierte en un problema de comunicación. Un usuario que llega al final de la pantalla necesita saber, sin pensarlo, en cuál de cuatro situaciones muy distintas se encuentra: está llegando más contenido, ha fallado la llegada y puede reintentarlo, no hay más contenido, o nunca hubo contenido. Las cuatro se parecen visualmente si nadie las distingue —una lista que simplemente termina— y las cuatro exigen del usuario reacciones opuestas. La librería te entrega esa información con precisión quirúrgica, separada por extremo y con la operación de recuperación asociada, y sin embargo es habitual ver aplicaciones que la reducen a un único indicador giratorio en el centro. Esta lección va de gastar esa información en lugar de tirarla, y de hacerlo con el conjunto de reglas que la lista perezosa impone cuando su contenido puede aparecer, desaparecer y desplazarse por debajo del dedo.
- Conectar un flujo de
PagingDatacon unaLazyColumnrespetando claves y tipos de contenido. - Interpretar
LoadStateen sus tres extremos y distinguir carga, error y fin de paginación. - Componer cabeceras y pies de carga que reaccionen al estado sin romper el desplazamiento.
- Implementar reintento y refresco, incluyendo el estado vacío y el error de carga inicial.
Del flujo a la lista perezosa
El puente entre el modelo y la interfaz es una función de composición que recolecta el flujo y devuelve un objeto que expone los elementos y el estado de carga. Ese objeto es consciente del ciclo de vida y cancela la recolección al salir, de modo que no hay que envolverlo en nada.
@Composable
fun PantallaArticulos(viewModel: ArticulosViewModel) {
val articulos = viewModel.articulos.collectAsLazyPagingItems()
LazyColumn {
items(
count = articulos.itemCount,
key = articulos.itemKey { it.id },
contentType = articulos.itemContentType { "articulo" },
) { indice ->
val articulo = articulos[indice]
if (articulo != null) FilaArticulo(articulo) else FilaEsqueleto()
}
}
}
Tres cosas de este bloque merecen comentario. El acceso por índice no es una lectura inocente: además de devolver el elemento, registra esa posición como ancla, y es lo que dispara la carga de la página siguiente cuando el índice se acerca al borde de lo cargado. Por eso hay que acceder dentro del cuerpo del elemento y no antes: leer todos los índices en un bucle mataría la pereza y pediría todas las páginas de golpe.
El valor devuelto puede ser nulo, y esa nulabilidad no es un descuido de la API sino la representación de un marcador de posición. Si los tienes desactivados, nunca será nulo y la rama del esqueleto es defensiva; si los tienes activados, el nulo significa este elemento existe pero aún no ha llegado, y ahí es donde se dibuja el esqueleto que mantiene la altura de la fila.
Las claves, por último, son obligatorias en la práctica aunque el compilador no las exija. Sin ellas, cuando una carga inserta veinte elementos por delante, la lista perezosa reasigna estado por posición y el resultado son filas que parpadean, animaciones que saltan y desplazamiento que se desplaza solo. El auxiliar de claves incorpora además el manejo correcto de los marcadores nulos, cosa que una implementación manual olvida.
La tentación de recoger todos los elementos en una lista normal para poder ordenarla, buscarla o contarla anula por completo el mecanismo: obliga a cargar cada página y a mantenerlas todas vivas. Si necesitas una operación sobre el conjunto entero, esa operación pertenece a la consulta o al servidor, nunca a la interfaz.
Los tres extremos de la carga
El estado de carga no es un valor sino un objeto con tres campos independientes, cada uno de los cuales puede estar en curso, terminado o en error. El refresco describe la carga inicial o la reconstrucción completa. El anexado describe la carga hacia el final de la lista. El prefijado describe la carga hacia el principio, que solo existe cuando la fuente admite retroceso.
Esa separación es lo que permite mostrar simultáneamente una lista con contenido y un error solo en el pie, situación perfectamente normal que un único estado global no sabe representar. El estado terminado incluye además una marca de fin de paginación, que es la forma de saber que no hay más contenido en esa dirección y de distinguirlo de simplemente no estar cargando ahora mismo.
Refresco
Carga inicial o reconstrucción completa. Su error ocupa la pantalla entera porque no hay nada que mostrar debajo.
Anexado
Carga hacia el final. Su indicador y su error viven en el pie de la lista, sin tapar el contenido ya visible.
Prefijado
Carga hacia el principio. Solo aparece en fuentes bidireccionales y su indicador va en la cabecera.
LazyColumn {
if (articulos.loadState.refresh is LoadState.Loading) {
item(contentType = "cargando") { CargaCompleta() }
}
items(count = articulos.itemCount, key = articulos.itemKey { it.id }) { i ->
articulos[i]?.let { FilaArticulo(it) }
}
when (val estado = articulos.loadState.append) {
is LoadState.Loading -> item { PieCargando() }
is LoadState.Error -> item {
PieError(
mensaje = estado.error.mensajeLegible(),
onReintentar = { articulos.retry() },
)
}
is LoadState.NotLoading ->
if (estado.endOfPaginationReached && articulos.itemCount > 0) {
item { PieFinal() }
}
}
}
Un indicador de carga que aparece y desaparece en el pie cambia la altura total de la lista y puede producir un pequeño salto justo cuando el usuario está desplazándose. Reservar una altura fija para esa zona, ocupada por el indicador, por el mensaje de error o por nada, elimina el salto sin coste alguno.
Reintentar, refrescar y no mentir cuando no hay nada
Hay dos operaciones de recuperación y confundirlas produce comportamientos desconcertantes. El reintento repite exactamente la carga que falló, conservando todo lo que ya estaba cargado; es lo que debe hacer el botón del pie cuando falla el anexado. El refresco descarta todo y reconstruye desde la clave de refresco; es lo que debe hacer el gesto de tirar hacia abajo y el botón de una pantalla de error inicial.
El caso que más se descuida es el estado vacío legítimo, que exige una conjunción de condiciones que hay que escribir con cuidado para no mostrarlo durante la primera carga.
val vacio = articulos.loadState.refresh is LoadState.NotLoading &&
articulos.loadState.append.endOfPaginationReached &&
articulos.itemCount == 0
when {
articulos.loadState.refresh is LoadState.Loading -> PantallaCargando()
articulos.loadState.refresh is LoadState.Error -> PantallaError(
onReintentar = { articulos.refresh() },
)
vacio -> PantallaSinResultados()
else -> ListaDeArticulos(articulos)
}
Un matiz sobre el error de refresco cuando ya hay datos en pantalla, situación típica del patrón que veremos en la lección siguiente: sustituir la lista por una pantalla de error completa es destruir información útil por un fallo transitorio. Lo correcto es mantener el contenido y comunicar el fallo de forma no destructiva, con una barra emergente o un aviso en la cabecera.
flowchart TD
R{Estado de refresco}
R -->|Loading| P1[Pantalla de carga completa]
R -->|Error y sin datos| P2[Pantalla de error con refrescar]
R -->|Error y con datos| P3[Mantener lista y avisar sin tapar]
R -->|NotLoading| C{Hay elementos}
C -->|No y fin de paginacion| P4[Estado vacio explicito]
C -->|Si| L[Lista con pie segun estado de anexado]
L --> A{Estado de anexado}
A -->|Loading| F1[Indicador en el pie]
A -->|Error| F2[Mensaje con boton de reintentar]
A -->|Fin de paginacion| F3[Marca de final de lista]
style P4 fill:#f9e2af,color:#11111b
style F3 fill:#a6e3a1,color:#11111bDetalles que separan una lista correcta de una agradable
El gesto de tirar para refrescar debe invocar el refresco de los elementos paginados y no una recarga propia del modelo, o acabarás con dos mecanismos compitiendo. El indicador debe permanecer visible mientras el estado de refresco esté en curso, lo que se lee directamente del propio estado en lugar de mantener un booleano paralelo que se desincroniza.
Los tipos de contenido importan más de lo que parece cuando la lista mezcla filas de dominio con esqueletos, cabeceras y pies. Sin ellos, la lista perezosa no puede reutilizar la estructura de composición entre elementos de aspecto distinto y acaba recomponiendo desde cero al reciclar. Declarar un tipo por familia visual es una línea que se paga en fluidez de desplazamiento.
Conviene también recordar que el objeto de elementos paginados es un estado de composición vivo: cada carga que llega provoca una recomposición del ámbito que lo lee. Leerlo en el nivel más alto de la pantalla invalida toda la jerarquía en cada página nueva. Lo correcto es leer el recuento y el estado de carga lo más abajo posible, dentro del ámbito de la lista, y pasar hacia los hijos solo el elemento concreto.
Por último, una advertencia sobre las pruebas manuales. Casi todos los defectos de esta capa aparecen únicamente con latencia real y con fallos intermitentes, que es precisamente lo que no ocurre en el emulador con datos simulados instantáneos. Introducir un retardo artificial de un segundo y un fallo aleatorio en la fuente durante el desarrollo es la forma más barata de ver la interfaz que verán tus usuarios.
Hay una razón estructural por la que la superficie de estados de una lista paginada resulta tan incómoda a quien viene de manejar un único indicador de carga, y merece la pena nombrarla porque trasciende la librería y reaparece en cualquier sistema distribuido. Un booleano de cargando codifica una suposición muy fuerte: que el conocimiento de la aplicación sobre el mundo es uniforme, que hay un solo hecho pendiente y que cuando ese hecho llegue el estado será completo. Esa suposición se sostiene mientras la unidad de carga coincida con la unidad de pantalla. En cuanto los datos se fragmentan en páginas, deja de haber un único frente de ignorancia: hay varios, avanzan a velocidades distintas, fallan de forma independiente y se recuperan por separado. El principio de la lista puede estar completo y consolidado mientras el final está roto por un error de red y el conjunto entero está siendo reconstruido por un refresco en curso, y las tres cosas son simultáneamente ciertas. Un booleano no puede representar eso, y lo que ocurre cuando se intenta es siempre lo mismo: la interfaz elige una de las tres verdades y miente sobre las otras dos. El usuario que ve un indicador central mientras ya hay contenido legible cree que no puede leer; el que ve una lista que termina limpiamente cree que ha llegado al final cuando en realidad falló una petición; el que ve un mensaje de sin resultados durante la carga inicial cree que su búsqueda no encontró nada. Los tres están siendo engañados por una simplificación del modelo de estado, no por un fallo de la red. Por eso los tres extremos de carga no son un exceso de la API sino la representación mínima fiel de una realidad que ya era así antes de que existiera la librería; y por eso el criterio de calidad de una lista paginada no es que se vea bien cuando todo funciona, sino que el usuario pueda saber en cualquier instante qué parte de lo que mira es conocimiento firme, qué parte es una promesa en vuelo y qué parte es una promesa rota que él puede reintentar.
- Conecta un flujo de
PagingDataa unaLazyColumnsin claves, provoca una inserción por delante y observa el parpadeo; añade después las claves y compara. - Implementa los tres estados del pie —cargando, error con reintento y fin de lista— y verifica cada uno cortando la red en el momento adecuado.
- Escribe la condición de estado vacío y comprueba que no aparece ni un fotograma durante la carga inicial.
- Introduce un retardo de un segundo y un fallo aleatorio en la fuente y recorre la pantalla durante cinco minutos anotando cada comportamiento confuso.
- Mide las recomposiciones de la pantalla al llegar cada página y baja la lectura del estado todo lo que puedas hasta reducirlas.