Listas En Jetpack Compose

Esta guía te muestra cómo crear listas en Jetpack Compose con el objetivo de proveer grupos continuos con elementos de texto o imágenes en tus aplicaciones Android. Estas te permitirán presentar colecciones de datos en conjunto de acciones que otorguen valor a tus usuarios.

Listas en Jetpack Compose

El contenido está organizado para que aprendas sobre el uso del componente LazyColumn y la aplicación de los lineamientos en listas para Material Design.


Ejemplo De Listas En Jetpack Compose

Puedes encontrar todo el código completo de los ejemplos de listas en el repositorio Jetpack Compose:

Navega al paquete examples/Lists del módulo :p7_componentes y visualiza los archivos Kotlin para cada sección explicada. En ListsScreen.kt se ubica la función componible de la pantalla que condensa todos los ejemplos:

Ejemplos listas en Jetpack Compose

1. Componentes Para Crear Listas

1.1 Lista Pequeña Con Column

Usa los layouts Column o Row para crear listas pequeñas. Es decir, colecciones con una cantidad fija de ítems, cuyo contenido se mostrará por completo y no requerirá scroll (o solo para unos pocos).

La forma de hacerlo consiste en:

  1. Crear la función componible que representa a la lista (Column para verticales, Row para horizontales)
  2. (Opcional) Aplicar un modificador verticalScroll() u horizontalScroll() si la lista requiere scroll
  3. Crear la función componible para el diseño del ítem
  4. Definir un bucle sobre la colección (normalmente la función forEach())
  5. Invocar la función del ítem al interior del bucle

Evidenciemos estas tareas en la función de ejemplo ListWithColumn():

@Composable
fun ListWithColumn(items: List<String>) { // (1)
    val scrollState = rememberScrollState()

    Column(modifier = Modifier.verticalScroll(scrollState)) { // (2)
        items.forEach { item -> // (4)
            ListItemRow(item) // (5)
        }
    }
}

@Composable // (3)
fun ListItemRow(item: String, modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(48.dp)
            .padding(horizontal = 16.dp, vertical = 8.dp),
        contentAlignment = Alignment.CenterStart
    ) {
        Text(text = item, style = MaterialTheme.typography.subtitle1)
    }
}

El resultado es:

Resultado ejemplo 1 de lista con Column

Si incrementas el número de ítems a proyectar, comenzarás a notar la introducción de un congelamiento de UI. Esto se debe a que Column procesa todos los elementos en cada recomposición, incluso si no se ven. Por lo que a mayor número, menos fluidez.

Justo por esta motivación, necesitamos el siguiente componente.


1.2 Lista Grande Con LazyColumn

Usa las funciones LazyColumn() y LazyRow() para crear una lista desplazable que solo compone y dibuja los elementos visibles en un espacio determinado. Dicho comportamiento nos permite presentar colecciones de gran tamaño sin preocuparnos por el rendimiento.

Nota: El componente LazyColumn equivale al ListView en JetpackCompose (o RecyclerView) del sistema de views.

Su definición es la siguiente:

@Composable
fun LazyColumn(
    modifier: Modifier! = Modifier,
    state: LazyListState! = rememberLazyListState(),
    contentPadding: PaddingValues! = PaddingValues(0.dp),
    reverseLayout: Boolean! = false,
    verticalArrangement: Arrangement.Vertical! = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
    horizontalAlignment: Alignment.Horizontal! = Alignment.Start,
    flingBehavior: FlingBehavior! = ScrollableDefaults.flingBehavior(),
    userScrollEnabled: Boolean! = true,
    content: (@ExtensionFunctionType LazyListScope.() -> Unit)?
): Unit

Donde:

  • state: Representa el estado de la lista, en específico datos sobre el primer ítem visible y estado de scrolling
  • contentPadding: Cantidad de relleno agregado al contenido de toda la lista
  • reverseLayout: Determina si se debe invertir la dirección del scroll y el orden en que se muestran los ítems
  • verticalArrangement: Determina la disposición vertical de los ítems. De gran utilidad para añadir espacio vertical entre elementos y para distribuirlos cuando hay un mínimo de altura establecido
  • horizontalAlignment: Alineación horizontal aplicada a los ítems
  • flingBehavior: Especifica la animación que es ejecutada cuando se termina un evento de arrastre en la lista
  • userScrollEnabled: Determina si el scrolling por eventos del usuario o acciones de accesibilidad está activo
  • content: Una lambda con recibidor de tipo LazyListScope, con un cuerpo que determinará el contenido de la lista. Claramente este representa un DSL que otorga legibilidad y nos exime de integrar los ítems en el layout.

Ahora bien, la siguiente lista de pasos te permite crear el contenido de una lista vertical con LazyColumn:

  1. Crea la función con un componente LazyColumn y eleva los estados necesarios
  2. Crea la diseño del ítem
  3. Crea los ítems con las funciones item() o items() de LazyListScope

Con esta receta en mente podemos adaptar el ejemplo anterior con la nueva función ListWithLazyColumn():

@Composable
fun ListWithLazyColumn(items: List<String>) { // (1)
    LazyColumn {
        items(items) { item ->
            ListItemRow(item)
        }
    }
}

// (2) El mismo diseño anterior
fun ListItemRow(item: String, modifier: Modifier = Modifier) {
...
}

Cómo ves, items() recibe una lista de elementos que serán desplegados como funciones componibles sobre la lista. En el caso de que desees poner ítems individualmente, usa item().


2. Propiedades De LazyColumn

Veamos ejemplos de efectos causados al modificar algunos parámetros estéticos de LazyColumn.


2.1 Cambiar Padding Del Contenido

Resultado de cambiar contentPadding
Resultado de cambiar contentPadding

Cuando desees aumentar el padding general de la lista, pasa otra instancia PaddingValues que determine el valor en cada lado.

Normalmente lo usamos para dar 8dp en la parte superior e inferior de la lista:

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

El siguiente es un ejemplo donde se usa un Slider para aplicar valores de 0 a 100 sobre contentPadding:

@OptIn(ExperimentalUnitApi::class)
@Composable
fun ListWithContentPadding() {
    val items = List(10) { "Item ${it + 1}" }

    Column {
        val range = 0f..100f
        var selection by remember { mutableStateOf(0f) }

        Text(
            text = "contentPadding = PaddingValues(all = ${selection.toInt()}.dp)",
            fontFamily = FontFamily.Monospace,
            fontSize = TextUnit(12f, TextUnitType.Sp),
            modifier = Modifier
                .padding(16.dp)
                .align(CenterHorizontally)
        )
        Slider(
            value = selection,
            valueRange = range,
            onValueChange = {
                selection = it
            },
            modifier = Modifier.padding(horizontal = 16.dp)
        )

        Divider(modifier = Modifier.padding(vertical = 16.dp))

        LazyColumn(
            contentPadding = PaddingValues(all = selection.dp), // Padding
            modifier = Modifier.background(color = Color(0xFFE0F7FA))
        ) {
            items(items) { item ->
                ListItemRow(item, modifier = Modifier.background(Color.White))
            }
        }
    }
}

Como ves, el estado selection es usado como padding de todos los lados al usar el parámetro nombrado all en el constructor de PaddingValues.


2.2 Cambiar Espaciado Del Contenido

Resultado al cambiar verticalArrangement
Resultado al cambiar verticalArrangement

Como viste en la firma de LazyColumn, el parámetro verticalArragement (horizontalArragement en LazyRow) aplica espaciado entre los ítems. Para producir dicho valor usa Arrangement.spacedBy().

Por ejemplo, añadamos 16dp de espaciado entre los ítems:

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

De manera similar al ejemplo anterior, en el repositorio encontrarás la función ListWithContentSpacing() para evidenciar el cambio de espaciado a partir de un Slider.

@Composable
fun ListWithContentSpacing() {
    val items = List(10) { "Item ${it + 1}" }

    Column {
        val range = 0f..100f
        var contentSpacing by remember { mutableStateOf(0f) }

        CodeText(
            text = "verticalArrangement = spacedBy(${contentSpacing.toInt()}.dp)",
            modifier = Modifier.align(CenterHorizontally)
        )
        Slider(
            value = contentSpacing,
            valueRange = range,
            onValueChange = {
                contentSpacing = it
            },
            modifier = Modifier.padding(horizontal = 16.dp)
        )

        Divider(modifier = Modifier.padding(vertical = 16.dp))

        LazyColumn(
            verticalArrangement = Arrangement.spacedBy(contentSpacing.dp),
            modifier = Modifier.background(color = Color(0xFFE0F7FA))
        ) {
            items(items) { item ->
                ListItemRow(item, modifier = Modifier.background(Color.White))
            }
        }
    }
}

2.3 Añadir Divisores

Lista con divisores
Lista con divisores

Usa al componente Divider dentro de item() o Items() para mostrar un divisor entre los ítems de la lista:

@Composable
fun ListWithDividers() {
    val items = List(10) { "Item ${it + 1}" }

    Column {
        LazyColumn {
            items(items) { item ->
                ListItemRow(item)
                Divider() // Divisor por debajo
            }
        }
    }
}

2.4 Añadir Cabeceras Fijas

Lista con cabeceras fijas (stickyHeader)
Lista con cabeceras fijas (stickyHeader)

Si deseas agrupar por algún criterio a los ítems de tu lista, el uso de cabeceras fijas te vendrá excelente. Estos rótulos se ubican arriba del conjunto de ítems y se mantienen en el extremo superior de la lista al scrollear.

Créalas con la función stickyHeader() al interior del contenido, pasando como parámetro la función componible que representa su diseño.

Por ejemplo: Tomemos una lista y dividamosla en grupos de cinco elementos :

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ListWithStickyHeaders() {
    val items = List(30) { "Item ${it + 1}" }
    val groups = items.chunked(5) // División
    LazyColumn {
        groups.forEachIndexed { index, group ->
            stickyHeader {
                Header("Grupo ${index + 1}")
            }

            items(group) { item ->
                ListItemRow(item)
            }
        }
    }
}

@Composable // Diseño de cabecera
private fun Header(title: String) {
    Surface(
        color = Color(0xFFE1F5FE),
        modifier = Modifier.fillMaxWidth()
    ) {

        Text(text = title, Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
    }
}

La función chunked() es de gran utilidad para dividir en grupos de cinco y así iterar para la creación de las cabeceras.

Si agrupas por atributos, entonces usa groupBy() o similares.


2.5 Añadir Lista Horizontal Dentro De Lista Vertical

Lista horizontal dentro de lista vertical

Mostrar una lista horizontal al interior de una vertical solo es cuestión de la invocación de Row o LazyRow en un elemento item() del contenido.

Por ejemplo: Supongamos que mostraremos diez items recomendados como primer ítem de la lista vertical. Estos tendrán un diseño con una imagen superior y una descripción inferior (ver figura anterior).

Comencemos diseñando el layout de los items horizontales:

@Composable
private fun HorizontalItem(text: String) {
    Column(
        modifier = Modifier
            .border(
                width = 1.dp,
                color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
                shape = RoundedCornerShape(topStartPercent = 20)
            )
            .width(120.dp),
        horizontalAlignment = CenterHorizontally
    ) {
        Image(
            painter = painterResource(id = R.drawable.ic_image),
            contentDescription = null,
            modifier = Modifier.size(120.dp),
            contentScale = ContentScale.Crop
        )

        Box(
            Modifier
                .fillMaxWidth()
                .background(Color.LightGray),
            contentAlignment = Center
        ) {

            Text(
                text = text,
                modifier = Modifier.padding(vertical = 8.dp)
            )
        }
    }
}

Seguido, diseñemos el layout de la lista horizontal como item hijo de la vertical:

@Composable
private fun FirstItem(recommended: List<String>) {
    Column {
        Text(text = "Item 1", modifier = Modifier.padding(start = 16.dp, bottom = 8.dp))
        LazyRow(
            contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
            horizontalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            items(
                items = recommended,
                key = { itemAsId -> itemAsId }
            ) { item ->
                HorizontalItem(item)
            }
        }
    }
}

Claves De Ítems: Como notas, se incluyó el uso del atributo key en la función item(). Esto permite recordar la posición del scroll por si los ítems llegan a cambiar de orden.

Y finalmente, dentro del LazyListScope combinemos la lista horizontal con los demás ítems:

@Composable
fun VerticalListWithHorizontalList() {
    val items = List(15) { "Item ${it + 2}" }
    val itemsForFirstRow = List(10) { "Item ${it + 1}" }

    LazyColumn {
        item {
            FirstItem(itemsForFirstRow) // Lista horizontal
        }

        items(items) { item -> // Los demás ítems
            ListItemRow(item)
        }
    }
}

Al ejecutar, verás que al scrollear ambas listas no habrá ningún problema:

Scroll horizontal dentro de scroll vertical
Scroll horizontal dentro de scroll vertical

3. Gestos Sobre Listas

3.1 Click En Item De Lista

Clic en ítem de lista
Clic en ítem de lista

Reaccionar ante el clic de un ítem de la lista es posible al aplicar el modificador clickable() al componente LazyColumn/LazyRow.

Ejemplo: Comprobemos el funcionamiento mostrando un Toast con el nombre del item clickeado.

La solución:

@Composable
fun ClickableListItems() {
    val items = List(5) { "Item ${it + 1}" }
    val context = LocalContext.current

    LazyColumn {
        items(items) { item ->
            ListItemRow(
                item = item,
                modifier = Modifier.clickable {
                    Toast.makeText(context, "Clic en '$item'", Toast.LENGTH_SHORT).show()
                }
            )
        }
    }
}

3.2 Eventos De Scrolling

Estado de scroll de lista
Estado de scroll de lista

Para responder a los cambios de scroll en tu lista usa, crea un estado para recordarlos con rememberLazyListState() y luego pásalo al parámetro state de LazyColumn.

El estado producido es del tipo LazyListState, cuyo constructor recibe las siguientes propiedades:

LazyListState(
    firstVisibleItemIndex: Int!,
    firstVisibleItemScrollOffset: Int!
)

firstVisibleItemIndex hace referencia al índice del primer ítem visible en el viewport y firstVisibleItemScrollOffset es la cantidad de desplazamiento actual desde dicho ítem. Ambas son propiedades observables, por lo que es necesario usar efecto secundarios (todo) al encadenar cambios de estado.

Por ejemplo: Mostremos en pantalla los valores de las dos propiedades de scrolling al interactuar con la lista.

@Composable
fun ScrollingEvents() {
    val items = List(20) { "Item $it" }
    val scrollState = rememberLazyListState()

    val scrollSummary = "firstVisibleItemIndex {${scrollState.firstVisibleItemIndex}}, " +
            "firstVisibleItemScrollOffset {${scrollState.firstVisibleItemScrollOffset}}"

    Column {
        CodeText(text = scrollSummary)
        Divider(modifier = Modifier.padding(vertical = 16.dp))
        LazyColumn(state = scrollState) {
            items(items) { item ->
                ListItemRow(item)
            }
        }
    }
}

3.3 Scrollear Hacia Un Ítem

Scrollear hacia un item desde Kotlin
Scrollear hacia un item desde Kotlin

Si deseas scrollear una lista programáticamente usa los métodos scrollToItem() y animateScrollToItem() de LazyListState.

¿La diferencia entre ambos?

El segundo realiza una animación para que el scroll se vea fluido, mientras que el segundo desplaza inmediatamente el contenido.

Por ejemplo: Usemos un TextField para recibir la posición a la que deseamos scrollear en una lista vertical.

La solución consiste de la toma del estado del campo de texto y luego aplicarlo con animateScrollToItem():

@Composable
fun ProgrammaticScrolling() {
    val items = List(30) { "Item ${it + 1}" }

    val scrollState = rememberLazyListState()
    var itemPosition by remember { mutableStateOf(0) }
    val coroutineScope = rememberCoroutineScope() // (1)

    Column {
        TextField(
            value = if (itemPosition == 0) "" else itemPosition.toString(),
            onValueChange = { value ->
                itemPosition = (value.toIntOrNull() ?: 0)
                coroutineScope.launch { // (2)
                    scrollState.animateScrollToItem((itemPosition - 1).coerceIn(0..items.size))
                }
            },
            label = { Text(text = "Posición del ítem") },
            modifier = Modifier.padding(horizontal = 16.dp)
        )
        Divider(modifier = Modifier.padding(vertical = 16.dp))
        LazyColumn(state = scrollState) {
            items(items) { item ->
                ListItemRow(item)
            }
        }
    }
}

Del código anterior hay dos cosas que destacar. La primera, hemos definido un alcance de corrutina (rememberCoroutineScope()) para ejecutar el scrolling programático.

Y la segunda, usamos coerceIn() para forzar las entradas de texto entre 0 y 30, con el fin de evitar errores de conversión de String a Int.


4. Controles De Lista

En ciertos escenarios, la lista requerirá añadir acciones para los items en el espacio del slot secundario. Veamos algunos de ellos:


4.1 Lista Con CheckBox

Lista con checkboxes
Lista con checkboxes

Incluiremos un componente CheckBox en el ítem cuando deseemos crear una lista seleccionable. Esto es, aplicar acciones sobre los elementos marcados.

Por ejemplo: Contemos los items seleccionados de una lista de una línea.

En primer lugar, crearemos un item de lista que reciba un control en la parte derecha de su diseño:

@Composable
fun ListItemRowWithControl(
    item: String,
    control: @Composable () -> Unit,
    modifier: Modifier = Modifier
) {
    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(48.dp)
            .padding(horizontal = 16.dp, vertical = 8.dp)
    ) {
        Text(
            text = item,
            style = MaterialTheme.typography.subtitle1,
            modifier = Modifier.align(
                Alignment.CenterStart
            )
        )

        Box(modifier = Modifier.align(Alignment.CenterEnd)) {
            control()
        }
    }
}

Luego, creamos la función para la lista seleccionable, donde:

  1. Declaramos un estado para almacenar los items seleccionados
  2. Creamos un texto para mostrar la cantidad de ítems
  3. Creamos una LazyColumn con items SelectableRow
  4. El componente SelectableRow usa a ListItemRowWithControl para introducir el CheckBox y dibujar el estado de marcado
  5. Incrementamos/decrementamos el registro de selecciones cuando cambie el estado del CheckBox

En código es:

@Composable
fun SelectableList() {
    val items = List(5) { "Item ${it + 1}" }
    val selections = remember { mutableStateListOf<String>() } // (1)

    Column {
        Text(text = "Items seleccionados: ${selections.size}", modifier = Modifier.padding(16.dp)) // (2)

        LazyColumn { // (3)
            items(items) { item ->
                SelectableRow(
                    text = item,
                    onCheckedChange = { isChecked ->
                        if (isChecked) { // (5)
                            selections += item
                        } else {
                            selections -= item
                        }
                    }
                )
            }
        }
    }
}

@Composable // (4)
private fun SelectableRow(
    text: String,
    onCheckedChange: (Boolean) -> Unit
) {
    var checked by remember { mutableStateOf(false) }

    ListItemRowWithControl(
        item = text,
        control = {
            Checkbox(
                checked = checked,
                onCheckedChange = { value ->
                    checked = value
                    onCheckedChange(value)
                }
            )
        },
        modifier = Modifier
    )
}

4.2 Lista Con Switch

Ítem de lista con Switch
Ítem de lista con Switch

El componente Switch es agregado mayormente en la pantalla de ajustes, donde encontramos preferencias dependientes del estado activo de otra.

¿Cómo se comporta?

Al activar el switch se despliegan items de la lista asociados a la preferencia particular.

Ejemplo: Supongamos que existe una característica que al estar activa permite configurar dos preferencias asociadas.

Para conseguir dicha relación, añadimos un elemento Switch al ítem definido anteriormente. Cada que su estado cambie, modificaremos su tamaño para mostrar u ocultar los subitems asociados.

Veamos:

@Composable
fun SwitchPreferenceItem() {
    var checked by remember { mutableStateOf(false) }

    ListItemRowWithControl(
        item = "Preferencia",
        control = {
            Switch(checked = checked, onCheckedChange = { checked = it })
        },
        modifier = Modifier
    )

    val modifier = if (checked) Modifier.wrapContentHeight() else Modifier.height(0.dp)

    Column(modifier = modifier.animateContentSize()) {
        ListItemRow("Dependencia 1")
        ListItemRow("Dependencia 2")
    }
}

animateContentSize() nos ayuda a mostrar una animación entre el cambio de altura del ítem con detalles internos.

Nota: Si quieres un mejor control sobre la animación, puedes usar AnimatedVisibility



4.3 Icono Para Expandir Y Colapsar

Expandir y colapsar items de lista
Expandir y colapsar ítems de lista

Crear una lista expandible es similar a lo que hicimos con la acción secundaria que tiene un Switch, solo que aplicado uniformemente sobre todos los ítems de primer nivel de la lista.

Llevarlo a cabo requiere de:

  1. Crear una clase de datos con la definición del ítem con sus detalles a expandir
  2. Crear el componente para las filas expandibles
  3. Ubicar un icono con forma de flecha hacia abajo en estado de expansión y hacia arriba en estado de contracción
  4. Crear la lista expandible

Observa la implementación:

// (1)
data class ExpandableItem(val item: String, val details: List<String>)

// (4)
@Composable
fun ExpandableList() {
    val items = List(5) { item ->
        ExpandableItem(
            item = "Item ${item + 1}",
            details = List(3) { "Detalle ${item + 1}.${it + 1}" }
        )
    }

    LazyColumn {
        items(items) { item ->
            ExpandableRow(item)
        }
    }
}

// (2)
@Composable
private fun ExpandableRow(expandableItem: ExpandableItem) {
    var isExpanded by remember { mutableStateOf(false) }

    if (isExpanded)
        Divider()

    ListItemRowWithControl(
        item = expandableItem.item,
        control = {
            ExpandCollapseIcon( // (3)
                expanded = isExpanded,
                onIconClick = { isExpanded = !isExpanded })
        }
    )

    val modifier = if (isExpanded) Modifier.wrapContentHeight() else Modifier.height(0.dp)

    Column(modifier = modifier.animateContentSize()) {
        expandableItem.details.forEach {
            ListItemRow(it)
        }
    }

    if (isExpanded)
        Divider()
}

// (3)
@Composable
private fun ExpandCollapseIcon(
    expanded: Boolean,
    onIconClick: () -> Unit = {}
) {
    IconButton(onClick = onIconClick) {
        Icon(
            Icons.Filled.ArrowDropDown,
            "Icono de control para lista expandible",
            Modifier.rotate(
                if (expanded)
                    180f
                else
                    360f
            )
        )
    }
}

5. Cambiar El Tema De Una Lista

Lista con tema personalizado
Lista con tema personalizado

Al igual que los demás componentes de Compose, es posible temificar una lista usando modificadores a nivel individual en LazyColumn y sus ítems.

O también puedes crear una instancia de MatherialTheme, copiar el tema actual y modificar colores, tipografía y formas como desees.

Ejemplo: Se requiere una lista con un color de superficie Índigo 500 y que sus ítems tengan un fondo Indigo 900 con letras de color blanco; y una forma redondeada al 50% (con en la figura anterior).

Para satisfacer los requisitos previos, configuramos el tema de esta forma:

@Composable
fun CustomList() {
    val items = List(5) { "Item ${it + 1}" }

    val indigo500 = Color(0xFF3F51B5)
    val indigo900 = Color(0xFF1A237E)
    MaterialTheme(
        colors = MaterialTheme.colors.copy(surface = indigo500, onSurface = Color.White),
    ) {
        Surface(modifier = Modifier.fillMaxSize()) {
            LazyColumn(
                contentPadding = PaddingValues(16.dp),
                verticalArrangement = Arrangement.spacedBy(16.dp)
            ) {
                items(items) { item ->
                    ListItemRow(
                        item = item,
                        modifier = Modifier.background(
                            color = indigo900,
                            shape = RoundedCornerShape(50)
                        )
                    )
                }
            }
        }
    }
}

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!