Listas

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 como la siguiente:

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

Podemos hacer que Column se pueda desplazar usando el modificador verticalScroll(). Consulta la documentación sobre Gestos para obtener más información.

Elementos componibles diferidos

Si necesitas mostrar una gran cantidad de elementos (o una lista de longitud desconocida), usar 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

@Composable
fun MessageList(messages: List<Message>) {
    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.

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),
) {
    // ...
}

Animaciones de elementos

Si usaste el widget de RecyclerView, sabrás que anima los cambios de los elementos automáticamente. Los diseños diferidos aún no proporcionan esa funcionalidad, lo que significa que los cambios de los elementos generan un "ajuste" instantáneo. Puedes seguir este error para realizar un seguimiento de los cambios de esta función.

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:

// TODO: 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)
            }
        }
    }
}

Cuadrículas (función experimental)

El elemento componible LazyVerticalGrid ofrece compatibilidad experimental con la visualización de elementos en una cuadrícula.

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

El parámetro cells controla cómo se forman las celdas en columnas. En el siguiente ejemplo, se muestran elementos de una cuadrícula mediante GridCells.Adaptive para que cada columna tenga al menos 128.dp ancho:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PhotoGrid(photos: List<Photo>) {
    LazyVerticalGrid(
        cells = GridCells.Adaptive(minSize = 128.dp)
    ) {
        items(photos) { photo ->
            PhotoItem(photo)
        }
    }
}

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.

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:

@OptIn(ExperimentalAnimationApi::class) // AnimatedVisibility
@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 == true }
        .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:

import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items

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

    LazyColumn {
        items(lazyPagingItems) { message ->
            if (message != null) {
                MessageRow(message)
            } else {
                MessagePlaceholder()
            }
        }
    }
}

Claves de elementos

De forma predeterminada, el estado de cada elemento se relaciona con la posición del elemento en la lista. 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. Proporcionar una clave estable permite que el estado del elemento sea coherente en los cambios del conjunto de datos:

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(
            items = messages,
            key = { message ->
                // Return a stable + unique key for the item
                message.id
            }
        ) { message ->
            MessageRow(message)
        }
    }
}