Listas y cuadrículas

Muchas apps necesitan mostrar colecciones de elementos. En este documento, se explica cómo puedes hacerlo de manera eficiente en Jetpack Compose.

Si sabes que tu caso de uso no requiere ningún desplazamiento, puede que quieras usar una Column o una Row simple (según la dirección) y emitir el contenido de cada elemento iterando en una lista de la siguiente manera:

@Composable
fun MessageList(messages: List<Message>) {
    Column {
        messages.forEach { message ->
            MessageRow(message)
        }
    }
}

Podemos hacer que Column se pueda desplazar usando el modificador verticalScroll().

Listas diferidas

Si necesitas mostrar una gran cantidad de elementos (o una lista de longitud desconocida), el uso de un diseño como Column puede causar problemas de rendimiento, ya que todos los elementos se compondrán y se dispondrán independientemente de si son visibles o no.

Compose proporciona un conjunto de componentes que solo componen y disponen elementos que están visibles en el viewport del componente. Esos componentes incluyen LazyColumn y LazyRow.

Como sugiere el nombre, la diferencia entre LazyColumn y LazyRow es la orientación en la que se integran sus elementos y se desplazan. LazyColumn produce una lista de desplazamiento vertical, mientras que LazyRow produce una de desplazamiento horizontal.

Los componentes diferidos son distintos en la mayoría de los diseños de Compose. En lugar de aceptar un parámetro de bloque de contenido @Composable (lo que permite que las apps omitan directamente los objetos componibles), los componentes diferidos proporcionan un bloque LazyListScope.(). Este bloque LazyListScope ofrece un DSL que permite que las apps describan el contenido del elemento. Luego, el componente diferido es responsable de agregar el contenido de cada elemento según lo requiera el diseño y la posición de desplazamiento.

DSL de LazyListScope

El DSL de LazyListScope proporciona una serie de funciones para describir elementos en el diseño. En el caso más básico, item() agrega un solo elemento, y items(Int) agrega varios:

LazyColumn {
    // Add a single item
    item {
        Text(text = "First item")
    }

    // Add 5 items
    items(5) { index ->
        Text(text = "Item: $index")
    }

    // Add another single item
    item {
        Text(text = "Last item")
    }
}

También hay varias funciones de extensión que te permiten agregar colecciones de elementos, como una List. Esas extensiones nos permiten migrar fácilmente nuestro ejemplo de Column anterior:

/**
 * import androidx.compose.foundation.lazy.items
 */
LazyColumn {
    items(messages) { message ->
        MessageRow(message)
    }
}

También hay una variante de la función de extensión items() llamada itemsIndexed(), que proporciona el índice. Consulta la referencia de LazyListScope para obtener más detalles.

Cuadrículas diferidas

Los elementos que admiten composición LazyVerticalGrid y LazyHorizontalGrid admiten la visualización de elementos en una cuadrícula. Una cuadrícula vertical diferida mostrará sus elementos en un contenedor desplazable de forma vertical, que abarcará varias columnas, mientras que las cuadrículas horizontales diferidas tendrán el mismo comportamiento en el eje horizontal.

Las cuadrículas tienen las mismas capacidades de API potentes que las listas y también usan un DSL muy similar (LazyGridScope.()) para describir el contenido.

Captura de pantalla de un teléfono que muestra una cuadrícula de fotos

El parámetro columns en LazyVerticalGrid y el parámetro rows en LazyHorizontalGrid controlan el modo en que se forman las celdas en columnas o filas. En el siguiente ejemplo, se muestran elementos de una cuadrícula con GridCells.Adaptive para que cada columna tenga al menos 128.dp ancho:

LazyVerticalGrid(
    columns = GridCells.Adaptive(minSize = 128.dp)
) {
    items(photos) { photo ->
        PhotoItem(photo)
    }
}

LazyVerticalGrid te permite especificar un ancho para los elementos y, luego, la cuadrícula podrá tener tantas columnas como es posible. Una vez que se calcula la cantidad de columnas, el ancho restante se distribuye de manera equitativa entre las columnas. Esta forma de tamaño adaptable es especialmente útil para mostrar conjuntos de elementos en diferentes tamaños de pantalla.

Si conoces la cantidad exacta de columnas que se usarán, puedes proporcionar, en cambio, una instancia de GridCells.Fixed que contenga la cantidad de columnas obligatorias.

Si tu diseño solo requiere que algunos elementos tengan dimensiones no estándar, puedes usar la asistencia de cuadrícula a fin de proporcionar intervalos de columnas personalizados para elementos. Especifica el intervalo de columnas con el parámetro span de los métodos LazyGridScope DSL, item y items. maxLineSpan, uno de los valores del alcance del intervalo, es muy útil en particular cuando usas el tamaño adaptable, ya que el número de columnas no es fijo. En este ejemplo, se muestra cómo brindar un intervalo de fila completo:

LazyVerticalGrid(
    columns = GridCells.Adaptive(minSize = 30.dp)
) {
    item(span = {
        // LazyGridItemSpanScope:
        // maxLineSpan
        GridItemSpan(maxLineSpan)
    }) {
        CategoryCard("Fruits")
    }
    // ...
}

Cuadrícula escalonada diferida

LazyVerticalStaggeredGrid y LazyHorizontalStaggeredGrid son elementos componibles que te permiten crear una cuadrícula de elementos escalonada y cargada de forma diferida. Una cuadrícula escalonada vertical diferida muestra sus elementos en un contenedor desplazable vertical que abarca varias columnas y permite que los elementos individuales tengan diferentes alturas. Las cuadrículas horizontales diferidas tienen el mismo comportamiento en el eje horizontal con elementos de diferentes anchos.

El siguiente fragmento es un ejemplo básico del uso de LazyVerticalStaggeredGrid con un ancho de 200.dp por elemento:

LazyVerticalStaggeredGrid(
    columns = StaggeredGridCells.Adaptive(200.dp),
    verticalItemSpacing = 4.dp,
    horizontalArrangement = Arrangement.spacedBy(4.dp),
    content = {
        items(randomSizedPhotos) { photo ->
            AsyncImage(
                model = photo,
                contentScale = ContentScale.Crop,
                contentDescription = null,
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight()
            )
        }
    },
    modifier = Modifier.fillMaxSize()
)

Figura 1. Ejemplo de cuadrícula vertical diferida y escalonada

Para establecer una cantidad fija de columnas, puedes usar StaggeredGridCells.Fixed(columns) en lugar de StaggeredGridCells.Adaptive. Esto divide el ancho disponible por la cantidad de columnas (o filas para una cuadrícula horizontal) y hace que cada elemento ocupe ese ancho (o altura para una cuadrícula horizontal):

LazyVerticalStaggeredGrid(
    columns = StaggeredGridCells.Fixed(3),
    verticalItemSpacing = 4.dp,
    horizontalArrangement = Arrangement.spacedBy(4.dp),
    content = {
        items(randomSizedPhotos) { photo ->
            AsyncImage(
                model = photo,
                contentScale = ContentScale.Crop,
                contentDescription = null,
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight()
            )
        }
    },
    modifier = Modifier.fillMaxSize()
)

Cuadrícula escalonada diferida de imágenes en Compose
Figura 2: Ejemplo de cuadrícula vertical diferida y escalonada con columnas fijas

Padding del contenido

En algunas ocasiones, deberás agregar padding alrededor de los bordes del contenido. Los componentes diferidos te permiten pasar algunos PaddingValues al parámetro contentPadding para admitir lo siguiente:

LazyColumn(
    contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
    // ...
}

En este ejemplo, agregamos 16.dp de padding a los bordes horizontales (izquierda y derecha) y, luego, a 8.dp al principio y al final del contenido.

Ten en cuenta que este padding se aplica al contenido, no a la LazyColumn. En el ejemplo anterior, el primer elemento agregará un padding de 8.dp al principio, el último elemento agregará 8.dp al final, y todos los elementos tendrán un padding de 16.dp a la izquierda y a la derecha.

Espaciado de contenido

Para agregar espaciado entre elementos, puedes usar Arrangement.spacedBy(). En el siguiente ejemplo, se agregan 4.dp de espacio entre cada elemento:

LazyColumn(
    verticalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

Del mismo modo que para LazyRow:

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

Sin embargo, las cuadrículas aceptan posiciones verticales y horizontales:

LazyVerticalGrid(
    columns = GridCells.Fixed(2),
    verticalArrangement = Arrangement.spacedBy(16.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
    items(photos) { item ->
        PhotoItem(item)
    }
}

Claves de elementos

De forma predeterminada, el estado de cada elemento se relaciona con la posición del elemento en la lista o cuadrícula. Sin embargo, esto puede causar problemas si cambia el conjunto de datos, ya que los elementos que cambian de posición pierden eficazmente cualquier estado recordado. Imagina el caso de una LazyRow dentro de una LazyColumn. Si la fila cambia la posición del elemento, el usuario perderá su posición de desplazamiento dentro de la fila.

Para evitar que esto suceda, puedes proporcionar una clave estable y única para cada elemento, lo que proporciona un bloque al parámetro key. Cuando se brinda una clave estable, se permite que el estado del elemento sea coherente en los cambios del conjunto de datos:

LazyColumn {
    items(
        items = messages,
        key = { message ->
            // Return a stable + unique key for the item
            message.id
        }
    ) { message ->
        MessageRow(message)
    }
}

Si proporcionas claves, ayudarás a Compose a controlar correctamente el reordenamiento. Por ejemplo, si tu elemento contiene un estado recordado, la configuración de las claves permitirá a Compose mover este estado junto con el elemento cuando cambia su posición.

LazyColumn {
    items(books, key = { it.id }) {
        val rememberedValue = remember {
            Random.nextInt()
        }
    }
}

Sin embargo, existe un límite en cuanto a los tipos que puede usar como claves de elemento. El tipo de clave debe ser compatible con Bundle, el mecanismo de Android para mantener los estados cuando se vuelve a crear la actividad. Bundle admite tipos como primitivos, enumeraciones o parcelables.

LazyColumn {
    items(books, key = {
        // primitives, enums, Parcelable, etc.
    }) {
        // ...
    }
}

La clave debe ser compatible con Bundle para que se pueda restablecer el rememberSaveable dentro del elemento que admite composición cuando se vuelva a crear la actividad o incluso cuando te desplaces de este elemento y te desplaces hacia atrás.

LazyColumn {
    items(books, key = { it.id }) {
        val rememberedValue = rememberSaveable {
            Random.nextInt()
        }
    }
}

Animaciones de elementos

Si usaste el widget de RecyclerView, sabrás que anima los cambios de los elementos automáticamente. Los diseños diferidos proporcionan la misma funcionalidad para los reordenamientos de elementos. La API es simple: solo debes configurar el modificador animateItem en el contenido del elemento:

LazyColumn {
    // It is important to provide a key to each item to ensure animateItem() works as expected.
    items(books, key = { it.id }) {
        Row(Modifier.animateItem()) {
            // ...
        }
    }
}

Incluso puedes proporcionar una especificación de animación personalizada. Para ello, haz lo siguiente:

LazyColumn {
    items(books, key = { it.id }) {
        Row(
            Modifier.animateItem(
                fadeInSpec = tween(durationMillis = 250),
                fadeOutSpec = tween(durationMillis = 100),
                placementSpec = spring(stiffness = Spring.StiffnessLow, dampingRatio = Spring.DampingRatioMediumBouncy)
            )
        ) {
            // ...
        }
    }
}

Asegúrate de brindar claves para tus elementos a fin de que sea posible encontrar la posición nueva del elemento movido.

Encabezados fijos (función experimental)

El patrón "encabezado fijo" es útil para mostrar listas de datos agrupados. A continuación, puedes ver un ejemplo de una "lista de contactos", agrupada por la inicial de cada usuario:

Video de un teléfono que se desplaza hacia arriba y hacia abajo en una lista de contactos

Para lograr un encabezado fijo con LazyColumn, puedes usar la función experimental stickyHeader(), que proporciona el contenido del encabezado:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ListWithHeader(items: List<Item>) {
    LazyColumn {
        stickyHeader {
            Header()
        }

        items(items) { item ->
            ItemRow(item)
        }
    }
}

Para obtener una lista con varios encabezados, como el ejemplo de la "lista de contactos" anterior, podrías hacer lo siguiente:

// This ideally would be done in the ViewModel
val grouped = contacts.groupBy { it.firstName[0] }

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(grouped: Map<Char, List<Contact>>) {
    LazyColumn {
        grouped.forEach { (initial, contactsForInitial) ->
            stickyHeader {
                CharacterHeader(initial)
            }

            items(contactsForInitial) { contact ->
                ContactListItem(contact)
            }
        }
    }
}

Cómo reaccionar a la posición de desplazamiento

Muchas apps deben escuchar cambios en la posición de desplazamiento y el diseño de los elementos, y actuar según corresponda. Los componentes diferidos admiten este caso de uso elevando el LazyListState:

@Composable
fun MessageList(messages: List<Message>) {
    // Remember our own LazyListState
    val listState = rememberLazyListState()

    // Provide it to LazyColumn
    LazyColumn(state = listState) {
        // ...
    }
}

En general, para casos de uso simples, las apps solo necesitan saber información sobre el primer elemento visible. Para esto, LazyListState proporciona las propiedades firstVisibleItemIndex y firstVisibleItemScrollOffset.

Si usamos el ejemplo de mostrar y ocultar un botón según si el usuario se desplazó después del primer elemento:

@Composable
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

Leer el estado directamente en la composición es útil cuando necesitas actualizar otros elementos componibles de la IU, pero también hay situaciones en las que el evento no necesita controlarse en la misma composición. Un ejemplo común de esto es enviar un evento de estadísticas una vez que el usuario se desplaza sobre un punto determinado. Para administrar esto de manera eficiente, podemos usar un snapshotFlow():

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

LazyListState también utiliza la propiedad layoutInfo para proporcionar información sobre todos los elementos que se muestran actualmente y sus límites en la pantalla. Consulta la clase LazyListLayoutInfo para obtener más información.

Cómo controlar la posición de desplazamiento

Además de reaccionar a la posición de desplazamiento, también es útil que las apps puedan controlarla. LazyListState permite esto a través de la función scrollToItem(), que toma inmediatamente la posición de desplazamiento, y animateScrollToItem(), que se desplaza con una animación (también conocido como desplazamiento suave):

@Composable
fun MessageList(messages: List<Message>) {
    val listState = rememberLazyListState()
    // Remember a CoroutineScope to be able to launch
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(state = listState) {
        // ...
    }

    ScrollToTopButton(
        onClick = {
            coroutineScope.launch {
                // Animate scroll to the first item
                listState.animateScrollToItem(index = 0)
            }
        }
    )
}

Conjuntos de datos grandes (Paging)

La biblioteca de Paging permite que las apps admitan listas grandes de elementos, y que se carguen y se muestren pequeños fragmentos de la lista según sea necesario. Paging 3.0 y las versiones posteriores ofrecen Compose a través de la biblioteca de androidx.paging:paging-compose.

Para mostrar una lista de contenido paginado, podemos usar la función de extensión collectAsLazyPagingItems() y, luego, pasar los LazyPagingItems que se muestren a items() en nuestra LazyColumn. De manera similar a la compatibilidad con Paging en las vistas, puedes mostrar marcadores de posición mientras se cargan los datos si el item es null:

@Composable
fun MessageList(pager: Pager<Int, Message>) {
    val lazyPagingItems = pager.flow.collectAsLazyPagingItems()

    LazyColumn {
        items(
            lazyPagingItems.itemCount,
            key = lazyPagingItems.itemKey { it.id }
        ) { index ->
            val message = lazyPagingItems[index]
            if (message != null) {
                MessageRow(message)
            } else {
                MessagePlaceholder()
            }
        }
    }
}

Sugerencias para usar diseños diferidos

Hay algunas sugerencias que puedes tener en cuenta para garantizar que tus diseños diferidos funcionen según lo previsto.

Evita usar elementos de 0 píxeles

Esto puede suceder en situaciones en las que, por ejemplo, esperas recuperar asincrónicamente algunos datos como imágenes, para completar los elementos de tu lista en una etapa posterior. Eso provocaría que el diseño diferido componera todos sus elementos en la primera medida, ya que su altura es de 0 píxeles y podría ajustarse a todos en la ventana de visualización. Una vez que se cargaron los elementos y se expandió su altura, los diseños diferidos descartarían todos los demás elementos que se compusieron de forma innecesaria la primera vez, ya que, de hecho, no podrían ajustarse al viewport. Para evitarlo, debes establecer el tamaño predeterminado de tus elementos, de modo que el diseño diferido pueda realizar el cálculo correcto de la cantidad de elementos que pueden caber en el viewport:

@Composable
fun Item(imageUrl: String) {
    AsyncImage(
        model = rememberAsyncImagePainter(model = imageUrl),
        modifier = Modifier.size(30.dp),
        contentDescription = null
        // ...
    )
}

Una vez que conozcas el tamaño aproximado de tus elementos después de que los datos se cargan asincrónicamente, te recomendamos asegurarte de que el tamaño de tus elementos sea el mismo antes y después de la carga, por ejemplo, agregando algunos marcadores de posición. Esta acción ayudará a mantener la posición correcta de desplazamiento.

Evita anidar componentes desplazables en la misma dirección

Este escenario se aplica solo a los casos en los que se anidan los elementos secundarios desplazables sin un tamaño predefinido dentro de otro elemento superior desplazable de la misma dirección. Por ejemplo, anidar un elemento secundario LazyColumn sin una altura fija dentro de un elemento superior Column desplazable de forma vertical:

// throws IllegalStateException
Column(
    modifier = Modifier.verticalScroll(state)
) {
    LazyColumn {
        // ...
    }
}

En su lugar, se puede lograr el mismo resultado si unes todos tus elementos componibles dentro de un LazyColumn superior y usas el DSL para pasar diferentes tipos de contenido. Como consecuencia, se permite la emisión de elementos únicos, así como varios elementos de lista, todo en un solo lugar:

LazyColumn {
    item {
        Header()
    }
    items(data) { item ->
        PhotoItem(item)
    }
    item {
        Footer()
    }
}

Recuerda que se permiten los casos en los que se anidan diferentes diseños de dirección, por ejemplo, un elemento superior desplazable Row y un elemento secundario LazyColumn:

Row(
    modifier = Modifier.horizontalScroll(scrollState)
) {
    LazyColumn {
        // ...
    }
}

Además de casos en los que aún usas los mismos diseños de dirección, pero también configuraste un tamaño fijo para los elementos secundarios anidados:

Column(
    modifier = Modifier.verticalScroll(scrollState)
) {
    LazyColumn(
        modifier = Modifier.height(200.dp)
    ) {
        // ...
    }
}

Ten cuidado cuando colocas varios elementos en uno solo

En este ejemplo, la lambda del segundo elemento emite 2 elementos en un bloque:

LazyVerticalGrid(
    columns = GridCells.Adaptive(100.dp)
) {
    item { Item(0) }
    item {
        Item(1)
        Item(2)
    }
    item { Item(3) }
    // ...
}

Los diseños diferidos se encargarán de esto como se espera: organizarán los elementos uno tras otro como si fueran elementos diferentes. Sin embargo, existen algunos problemas al hacerlo.

Cuando se emiten varios elementos como parte de uno solo, se administran como una entidad, lo que significa que ya no se pueden componer de forma individual. Si un elemento se vuelve visible en la pantalla, todos aquellos correspondientes al elemento deben estar compuestos y medidos. Como consecuencia, se puede ver afectado el rendimiento si se usa de forma excesiva. En el caso extremo de colocar todos los elementos en un elemento, se anula por completo el uso de diseños diferidos. Además de los posibles problemas de rendimiento, si colocas más elementos en otro también interferirá con scrollToItem() y animateScrollToItem().

Sin embargo, existen casos de uso válidos para colocar varios elementos en uno, como tener divisores dentro de una lista. No quieres que los divisores cambien los índices de desplazamiento, ya que no deben considerarse elementos independientes. Además, el rendimiento no se verá afectado, ya que los divisores son pequeños. Es probable que un divisor necesite ser visible cuando el elemento anterior también lo es, por lo que pueden ser parte del elemento anterior:

LazyVerticalGrid(
    columns = GridCells.Adaptive(100.dp)
) {
    item { Item(0) }
    item {
        Item(1)
        Divider()
    }
    item { Item(2) }
    // ...
}

Considera usar arreglos personalizados

Por lo general, las listas diferidas tienen muchos elementos y ocupan más que el tamaño del contenedor de desplazamiento. Sin embargo, cuando la lista se propaga con pocos elementos, tu diseño puede tener requisitos más específicos sobre el modo en que deberían posicionarse en el viewport.

Para lograr esto, puedes usar la vertical personalizada Arrangement y pasarla a LazyColumn. En el siguiente ejemplo, el objeto TopWithFooter solo necesita implementar el método arrange. Primero, posicionará los elementos uno tras otro. En segundo lugar, si la altura total que se usa es inferior a la altura del viewport, se posicionará en el pie de página en la parte inferior:

object TopWithFooter : Arrangement.Vertical {
    override fun Density.arrange(
        totalSize: Int,
        sizes: IntArray,
        outPositions: IntArray
    ) {
        var y = 0
        sizes.forEachIndexed { index, size ->
            outPositions[index] = y
            y += size
        }
        if (y < totalSize) {
            val lastIndex = outPositions.lastIndex
            outPositions[lastIndex] = totalSize - sizes.last()
        }
    }
}

Te recomendamos que agregues contentType.

A partir de Compose 1.2, para maximizar el rendimiento de tu diseño diferido, agrega contentType a tus listas o cuadrículas. Esto te permite especificar el tipo de contenido de cada elemento del diseño en los casos en que compones una lista o una cuadrícula con varios tipos de elementos:

LazyColumn {
    items(elements, contentType = { it.type }) {
        // ...
    }
}

Cuando indicas el contentType, Compose puede reutilizar las composiciones solo entre los elementos del mismo tipo. Como la reutilización es más eficiente cuando compones elementos de estructura similar, proporcionar los tipos de contenido garantiza que Compose no intente componer un elemento de tipo A por encima de un elemento de tipo B completamente diferente. Esto ayuda a maximizar los beneficios de la reutilización de la composición y el rendimiento del diseño diferido.

Mide el rendimiento

Solo puedes medir de manera confiable el rendimiento de un diseño diferido cuando se ejecuta en el modo de lanzamiento y con la optimización de R8 habilitada. En compilaciones de depuración, el desplazamiento diferido del diseño puede parecer más lento. Para obtener más información al respecto, consulta Rendimiento de Compose.