LazyColumn — un componente de Jetpack Compose para mostrar listas desplazables con carga perezosa. A diferencia de RecyclerView, LazyColumn no crea ViewHolders ni usa XML — todos los elementos se describen declarativamente mediante funciones composables. Te mostramos qué es LazyColumn, cómo funcionan LazyListState y el API items, y cómo lograr un rendimiento de 60 FPS. En IT Sectr usamos LazyColumn en todos los proyectos nuevos de Compose. Para comparar con el enfoque clásico, lee el artículo sobre RecyclerView.
Puntos clave
items(), itemsIndexed(), items() con claves para vinculación de datos.LazyColumn es una función composable de la biblioteca Foundation Compose que muestra una lista vertical de elementos con carga perezosa: solo se crean y componen los elementos visibles en pantalla o cercanos (zona de prefetch). LazyColumn apareció en Compose 1.0 (2021) junto con LazyRow (lista horizontal) y LazyVerticalGrid (cuadrícula).
La carga perezosa funciona mediante el mecanismo SubcomposeLayout: LazyColumn mide el espacio disponible, solicita el rango visible a LayoutInfo y compone solo los elementos en ese rango. Los elementos que salen de pantalla abandonan la composición (excepto el búfer de prefetch). Según Google (Android Performance, 2026), LazyColumn mantiene 60 FPS al desplazarse por una lista de 10000+ elementos en dispositivos de gama media.
Column con scroll vertical es una disposición normal de todos los elementos uno debajo de otro con el modificador verticalScroll aplicado. Column no es perezosa: todos los elementos hijos se componen inmediatamente, incluso si no son visibles. Para una lista de 200+ elementos, Column causa un lag significativo al iniciar. LazyColumn compone solo los elementos visibles + búfer (1 pantalla adelante/atrás por defecto), lo que la hace indispensable para listas largas.
items API es un conjunto de funciones de extensión para LazyColumn que aceptan una lista de datos y un descriptor DSL para cada elemento. Las funciones principales son: items(count, key, itemContent) para cantidad fija, items(list, key, itemContent) para una lista, itemsIndexed(list, key, itemContent) con índice. El parámetro key es obligatorio para el rendimiento — le da a Compose un identificador estable de elemento.
// LazyColumn básica con itemContent
@Composable
fun ArticleList(articles: List<Article>) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header — no perezoso, siempre al inicio
item {
Text("Últimos artículos", style = MaterialTheme.typography.headlineMedium)
}
// Lista de artículos con clave por id
items(articles, key = { it.id }) { article ->
ArticleCard(
title = article.title,
summary = article.summary,
onClick = { onArticleClick(article.id) }
)
}
// Footer con indicador de carga
item {
CircularProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(16.dp)
)
}
}
}
// Elemento de lista personalizado
@Composable
fun ArticleCard(title: String, summary: String, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text(text = summary, style = MaterialTheme.typography.bodyMedium)
}
}
}
item {} vs items(): usa item {} para elementos individuales que siempre están presentes en la lista (header, footer, divisores). Usa items() para una lista dinámica de datos. items() acepta un Iterable o Int y un bloque DSL que se llama para cada elemento. Combina item e items en un mismo LazyColumn: el orden de las llamadas determina el orden de visualización.
LazyListState es un objeto que almacena el estado de la lista: posición actual de scroll (firstVisibleItemIndex, firstVisibleItemScrollOffset), elementos visibles (layoutInfo) y métodos de desplazamiento programático (scrollToItem, animateScrollToItem). LazyListState se crea mediante rememberLazyListState() y se pasa a LazyColumn a través del parámetro state.
// LazyColumn con preservación de posición de scroll
@Composable
fun ScrollingList(items: List<Item>) {
val listState = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(state = listState) {
items(items, key = { it.id }) { item ->
ListItemView(item = item)
}
}
// Botón "Subir" aparece después del 10.º elemento
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 10 }
}
AnimatedVisibility(visible = showButton) {
FloatingActionButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
onClick = {
coroutineScope.launch {
listState.animateScrollToItem(0)
}
}
) {
Icon(Icons.Default.KeyboardArrowUp, "Subir")
}
}
}
}
// Obtención de información de elementos visibles
@Composable
fun ListDebugInfo(listState: LazyListState) {
val visibleItems = listState.layoutInfo.visibleItemsInfo
val totalItems = listState.layoutInfo.totalItemsCount
val firstItem = visibleItems.firstOrNull()?.index
val lastItem = visibleItems.lastOrNull()?.index
Text("Mostrando $firstItem–$lastItem de $totalItems")
}
El desplazamiento programático se realiza mediante funciones suspend de listState: scrollToItem(index, scrollOffset) — movimiento instantáneo (sin animación), animateScrollToItem(index) — con animación. Para desplazamiento desde una corrutina, usa LaunchedEffect o coroutineScope.launch. Importante: no llames funciones suspend desde la composición (dentro de @Composable) — usa lambdas (onClick, LaunchedEffect).
LazyColumn y RecyclerView resuelven el mismo problema — visualización eficiente de listas largas. La diferencia está en la arquitectura: RecyclerView usa ViewHolder y Adapter (vista clásica de Android), LazyColumn usa funciones composables declarativas sin XML ni ViewHolder. La elección depende del stack tecnológico del proyecto: Compose vs UI basada en View.
| Parámetro | LazyColumn (Compose) | RecyclerView (View) |
|---|---|---|
| Diseño | Funciones composables (Kotlin DSL) | Archivos XML + ViewBinding |
| Adaptador | items() / itemsIndexed() DSL | RecyclerView.Adapter + ViewHolder |
| Ordenamiento/Filtrado | Snapshot state + derivedStateOf | DiffUtil + AsyncListDiffer |
| Animación | AnimatedVisibility + Modifier.animateItem | DefaultItemAnimator |
| Tipos de elementos | item {}/items() + when por tipo | getItemViewType + varios ViewHolders |
| Scroll a posición | listState.animateScrollToItem() | layoutManager.scrollToPosition() |
| Prefetch | En proceso (búfer SubcomposeLayout) | RecycledViewPool + GapWorker |
| Red/Paginación | collectAsLazyPagingItems() + Paging 3 | PagingDataAdapter + Paging 3 |
Recomendación de Google (Android Developers, 2026): para proyectos nuevos en Jetpack Compose, usa LazyColumn. Para proyectos basados en View, usa RecyclerView. LazyColumn requiere Compose, que añade unos 3–5 MB al APK — tenlo en cuenta al soportar dispositivos antiguos. Si un proyecto ya usa el sistema View, LazyColumn no puede coexistir en una misma pantalla con diseño XML sin AndroidView — en tales casos, es más fácil quedarse con RecyclerView.
LazyVerticalGrid es una versión de LazyColumn para cuadrículas con número fijo de columnas. LazyHorizontalGrid es una cuadrícula horizontal con filas fijas. Ambos componentes usan los mismos principios de carga perezosa y el mismo API items que LazyColumn. El parámetro columns determina el número de columnas mediante GridCells.Fixed(N) o GridCells.Adaptive(minSize).
// Cuadrícula con columnas adaptativas
@Composable
fun PhotoGrid(photos: List<Photo>) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 128.dp),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(photos, key = { it.id }) { photo ->
AsyncImage(
model = photo.url,
contentDescription = photo.title,
modifier = Modifier.aspectRatio(1f)
)
}
}
}
// Lista horizontal (LazyRow)
@Composable
fun CategoryCarousel(categories: List<Category>) {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(categories, key = { it.id }) { category ->
FilterChip(
selected = category.isSelected,
onClick = { onCategoryClick(category.id) },
label = { Text(category.name) }
)
}
}
}
GridCells.Adaptive calcula automáticamente el número de columnas basándose en el tamaño mínimo de celda. Por ejemplo, GridCells.Adaptive(128.dp) en una pantalla de 360 dp de ancho coloca 2 columnas, en una tableta de 600 dp — 4 columnas. GridCells.Fixed(2) siempre muestra exactamente 2 columnas. Adaptive es preferible para adaptación a múltiples pantallas — se ajusta automáticamente al ancho de pantalla sin consultas de medios.
El rendimiento de LazyColumn depende de tres factores: estabilidad de clave (key), tipo de contenido (contentType) y búfer de prefetch (beyondBounds). Por defecto, LazyColumn almacena en búfer 1 pantalla adelante y 1 atrás. Google (Android Developers, 2026) recomienda configurar estos parámetros para listas con diferentes tipos de elementos o composición compleja.
| Parámetro | Descripción | Recomendación |
|---|---|---|
| key | Identificador estable de elemento | Especifica siempre key = { it.id }. Sin key, Compose usa el índice, y el reordenamiento de elementos rompe la animación. |
| contentType | Tipo de contenido para separación de grupos | Especifica para listas con diferentes tipos de elementos (texto + imagen + anuncio). Compose reutilizará la composición solo dentro del mismo contentType. |
| beyondBounds | Número de pantallas de prefetch | Aumenta a 2–3 para listas con imágenes pesadas. Por defecto es 1 pantalla. |
| Modifier.animateItem | Animación de cambio de posición | Úsalo para ordenamiento y filtrado animados (Compose 1.7+). |
// LazyColumn optimizada con contentType y prefetch
@Composable
fun OptimizedFeed(items: List<FeedItem>) {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
beyondBoundsPageCount = 2 // prefetch 2 pantallas
) {
items(
items = items,
key = { it.id },
contentType = { it.type } // división por tipo
) { item ->
when (item) {
is FeedItem.Post -> PostView(item)
is FeedItem.Ad -> AdView(item)
is FeedItem.Suggested -> SuggestedView(item)
}
}
}
}
// Medición de rendimiento mediante listState
@Composable
fun ScrollPerformance(listState: LazyListState) {
val scrollInfo = listState.layoutInfo
val visibleCount = scrollInfo.visibleItemsInfo.size
val total = scrollInfo.totalItemsCount
// derivedStateOf — no se recompone si el valor no ha cambiado
val scrollProgress by remember {
derivedStateOf {
if (total > 0) visibleCount.toFloat() / total else 0f
}
}
LinearProgressIndicator(
progress = scrollProgress,
modifier = Modifier.fillMaxWidth().height(2.dp)
)
}
Errores comunes: (1) falta de key — los elementos se recomponen ante cualquier cambio en la lista; (2) usar mutableStateListOf sin flujos snapshot — los cambios pueden no rastrearse; (3) llamar funciones suspend dentro de itemContent — interrumpe la composición; (4) sin contentType para tipos mixtos — Compose reutiliza la composición de anuncios para publicaciones de texto, causando artefactos visuales. En IT Sectr añadimos contentType a todas las listas con tres o más tipos de contenido.
Preguntas frecuentes
Revisa tres parámetros: (1) key — sin una clave estable, Compose no puede reutilizar la composición, (2) contentType — para listas con diferentes tipos de elementos, (3) beyondBoundsPageCount — aumenta a 2 para imágenes. Mueve los cálculos pesados dentro de itemContent a remember con una clave por datos. Usa Modifier.drawWithContent en lugar de Image para gráficos simples.
Usa LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) para espaciado entre elementos. Para un divisor visual con línea, añade item { Divider() } entre los items. Divisor automático: items(items, key = { it.id }) { item -> ... }, y entre cada elemento inserta item { HorizontalDivider() }. Para esto, usa LazyListScope.items(list, key) { /* elemento */ } y por separado LazyListScope.item { Divider() }.
LazyColumn tiene altura infinita (fillMaxSize) por defecto. Dentro de una Column sin altura fija, LazyColumn no puede determinar su tamaño. Solución: (1) asigna a LazyColumn una altura fija: Modifier.height(400.dp), (2) usa Modifier.weight(1f) dentro de Column, (3) no anides LazyColumn dentro de un contenedor con scroll vertical — usa LazyColumn como elemento raíz con item {} para header y footer.
LazyColumn reacciona automáticamente a los cambios de State. Si los datos se almacenan en mutableStateListOf o mutableStateOf, Compose recompone solo los elementos cambiados. Para listas desde ViewModel, usa collectAsState() con Flow. Al actualizar la lista con key, Compose anima automáticamente los cambios (adición, eliminación, reordenamiento) mediante Modifier.animateItemPlacement().
LazyColumn — para listas de una columna (feed, chat, comentarios). LazyVerticalGrid — para cuadrículas (galería, catálogo, iconos). Si los elementos deben estar en una columna en teléfono y en dos en tableta — usa LazyVerticalGrid con GridCells.Adaptive. Si se necesita una cuadrícula mixta (diferente número de columnas en una sección) — usa LazyColumn e inserta LazyRow horizontal o LazyVerticalGrid anidada dentro de los items.
Resumen
Desarrollaremos una aplicación móvil llave en mano
IT Sectr crea aplicaciones para iOS y Android para startups y empresas desde 2017. Le asesoraremos y le propondremos la mejor solución.
Lea también