Cards En Jetpack Compose

En este tutorial veremos el uso de Cards en Jetpack Compose para presentarle al usuario contenido y acciones asociadas a un tema.

 Tipos de cards
Figura 1. Tipos de cards

A lo largo de la lección verás los elementos que componen una card, los tipos de cards que existen, la función componible de Jetpack Compose que las despliega, como estilizarlas y la creación de una lista con ellas.

Usa la siguiente tabla para ir al escenario que necesites:

Equivalente En Sistema De Views: Usa la clase CardView si diseñas tu UI con layouts XML


Ejemplo Cards En Jetpack Compose

Para mejorar la explicación del uso de cards he añadido el paquete examples/Card al módulo :p7_componentes de mi repositorio de Jetpack Compose:

La pantalla principal CardsScreen consta de varias pestañas que representan las secciones enumeradas del tutorial. En cada una se mostrarán los resultados de los ejemplos aquí descritos.

App Android con ejemplos de cards en Compose
Figura 2. App Android con ejemplos de cards en Compose

Si notas que se omite cierto código en la explicación, revisa el repositorio para no perderte. Recuerda presionar Star para apoyar mi labor.


1. Cómo Usar Cards En Jetpack Compose

Invoca la función componible Card() para desplegar una superficie de color blanco en la pantalla. Luego añade su contenido escribiendo una una lambda al final:

Card {
    Text(text = "Cards En Jetpack Compose")
}

Recuerda que dispones de los layouts estándar Row, Column y Box para agrupar y ordenar los componentes del tópico que deseas proyectar.

En detalle, el componete Card posee la siguiente definición:

@Composable
@NonRestartableComposable
fun Card(
    modifier: Modifier! = Modifier,
    shape: Shape! = MaterialTheme.shapes.medium,
    backgroundColor: Color! = MaterialTheme.colors.surface,
    contentColor: Color! = contentColorFor(backgroundColor),
    border: BorderStroke? = null,
    elevation: Dp! = 1.dp,
    content: (@Composable () -> Unit)?
): Unit

// Versión con click
@ExperimentalMaterialApi
@Composable
@NonRestartableComposable
fun Card(
    onClick: (() -> Unit)?,    
    enabled: Boolean! = true,
    //...
): Unit

Donde:

  • onClick: Función invocada cuando se cliquea la card
  • enabled: Determina si la tarjeta está activa o no
  • shaped: Especifica la forma de los bordes de la tarjeta y por ende la sombra proyectada
  • backgroundColor: Color de fondo de la card
  • contentColor: Color aplicado sobre los componentes hijo de la card
  • border: Borde aplicado a la card
  • elevation: Posición en el eje z de la card en Dps. La sombra depende de este valor

1.1 Elementos De Una Card

Anatomía de una card
Figura 3. Anatomía de una card

Google nos indica que el único elemento obligatorio de una tarjeta es su contenedor. Luego nos ofrece otros elementos opcionales que podrían llegar a configurar layouts según el propósito de la card como:

  • Miniatura
  • Encabezado
  • Subtítulo
  • Multimedia
  • Texto de ayuda
  • Botones
  • Iconos

Teniendo en cuenta lo anterior, materialicemos el anterior diseño al interior de un componente Card en la función componible StandardCard:

fun StandardCard(
    modifier: Modifier = Modifier,
    elevation: Dp = 1.dp,
    border: BorderStroke? = null,
    background: Color = MaterialTheme.colors.surface,
    contentColor: Color = contentColorFor(background),
    shape: Shape = MaterialTheme.shapes.medium
) {
    Card(
        backgroundColor = background,
        contentColor = contentColor,
        shape = shape,
        elevation = elevation,
        border = border,
        modifier = modifier
    ) {
        // Contenedor
        Column {
            Row(
                Modifier
                    .fillMaxWidth()
                    .height(72.dp)
                    .padding(start = 16.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                // Miniatura
                Box(
                    modifier = Modifier
                        .background(color = Color.LightGray, shape = CircleShape)
                        .size(40.dp),
                    contentAlignment = Alignment.Center
                ) {
                    Image(
                        painter = painterResource(R.drawable.ic_image),
                        contentDescription = null
                    )
                }

                Spacer(modifier = Modifier.width(32.dp))

                Column(Modifier.fillMaxWidth()) {
                    // Encabezado
                    Text(text = "Título", style = MaterialTheme.typography.h6)

                    // Subtítulo
                    CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                        Text(text = "Texto secundario", style = MaterialTheme.typography.body1)
                    }
                }
            }

            // Multimedia
            Image(
                painterResource(id = R.drawable.ic_image),
                contentDescription = "Multimedia de tarjeta",
                Modifier
                    .background(color = Color.LightGray)
                    .fillMaxWidth()
                    .height(194.dp)
            )

            Row(Modifier.padding(start = 16.dp, end = 24.dp, top = 16.dp)) {

                // Texto de ayuda
                CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                    Text(
                        text = LoremIpsum(50).values.take(10).joinToString(separator = " "),
                        maxLines = 2,
                        overflow = TextOverflow.Ellipsis,
                        style = MaterialTheme.typography.body2,
                    )
                }
            }

            Spacer(modifier = Modifier.height(24.dp))

            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {

                Box(
                    Modifier
                        .padding(horizontal = 8.dp)
                        .fillMaxWidth()
                ) {

                    // Botones
                    Row(modifier = Modifier.align(Alignment.CenterStart)) {

                        TextButton(onClick = { /*TODO*/ }) {
                            Text(text = "ACCIÓN 1")
                        }

                        Spacer(modifier = Modifier.width(8.dp))

                        TextButton(onClick = { /*TODO*/ }) {
                            Text(text = "ACCIÓN 2")
                        }
                    }

                    // Iconos
                    Row(modifier = Modifier.align(Alignment.CenterEnd)) {
                        IconButton(onClick = { /*TODO*/ }) {
                            Icon(Icons.Default.Favorite, contentDescription = null)
                        }

                        IconButton(onClick = { /*TODO*/ }) {
                            Icon(Icons.Default.Share, contentDescription = null)
                        }
                    }
                }
            }
        }
    }
}

En el código anterior encuentras cada parte está señalada con un comentario en el diseño anterior. Adicionalmente, StandardCard recibe como parámetros la mayor parte de los atributos de Card() para mantener la personalización.


2. Tipos De Cards

En el Material Design 2 existen dos tipos de tarjetas: Elevated (elevada) y Outlined (delineada). No obstante el Material Design 3 incluye un tercer tipo llamado Filled (rellena) (todo).

2.1 Elevated Card

Card elevada
Figura 4. Card elevada

Como su nombre lo indica, una card elevada posee una distancia de elevación desde el eje z, la cual es reflejada por una sombra en sus lados.

La función Card crea por defecto tarjetas elevadas, ya que si te fijas en su firma, esta trae consigo una elevación (elevation) por defecto de 1dp.

2.2 Outlined Card

Card delineada
Figura 5. Card delineada

En el caso de las cards delineadas, no poseen posición de elevación (0dp) y cuentan con un borde de 1dp en su contorno.

Creemos la función OutlinedCard() para representar este componente:

@Composable
fun OutlinedCard() {
    StandardCard(
        elevation = 0.dp,
        border = BorderStroke(1.dp, Color.LightGray)
    )
}

3. Estilizar Una Card

3.1 Cambiar Color De Background

Modificación de parámetro backgroundColor
Figura 6. Modificación de parámetro backgroundColor

Modificar el color del fondo de una card es cuestión de pasar una instancia de Color al parámetro backgroundColor.

Para evidenciar este comportamiento, se creó una función llamada StylingCard() con el objetivo de intercambiar el color del fondo de una card entre Amarillo, Azul y Rojo en tono 300.

@Composable
fun StylingCard() {
    val backgroundColorOptions = listOf("Amarillo", "Azul", "Rojo")
    //...

    var backgroundColorSelection by remember { mutableStateOf(backgroundColorOptions.first()) }
    //...

    Column {

        //...

        StandardCard(
            modifier = Modifier.padding(16.dp),
            background = configBackgroundColor(backgroundColorSelection),
            contentColor = configContentColor(contentColorSelection),
            shape = configShape(shapeSelection)
        )
    }
}

@Composable
private fun configBackgroundColor(backgroundColorSelection: String) =
    when (backgroundColorSelection) {
        "Amarillo" -> Color(0xFFFFF176)
        "Azul" -> Color(0xFF64B5F6)
        "Rojo" -> Color(0xFFE57373)
        else -> Color.White
    }

Si lees el código, verás que la selección del background es conservada como un estado String. Dicho valor es usado como parámetro por la función configBackgroundColor, la cual usa una expresión when, que produce la instancia Color asociada.

El resultado es:

Cambiar color de background de Card en Compose

3.2 Cambiar Color De Contenido

contentColor con el valor de Color.Red
Figura 7. contentColor con el valor de Color.Red

El parámetro contentColor determina el color preferido para el texto e iconos pasados en el parámetro content.

De forma similar al color del fondo, la función StylingCard permite cambiar el color del contenido entre Gris Azulado, Índigo y Marrón tono 900. La selección del RadioButton aplica el valor asociado:

@Composable
fun StylingCard() {
    val contentColorOptions = listOf("Gris Azulado", "Indigo", "Marrón")

    var contentColorSelection by remember { mutableStateOf(contentColorOptions.first()) }

    Column {

        //...
        StandardCard(
            modifier = Modifier.padding(16.dp),
            contentColor = configContentColor(contentColorSelection),
        )
    }
}

private fun configContentColor(contentColorSelection: String) = when (contentColorSelection) {
    "Gris Azulado" -> Color(0xFF263238)
    "Índigo" -> Color(0xFF1A237E)
    "Marrón" -> Color(0xFF3e2723)
    else -> Color.Black
}

Visualmente se refleja así:

Cambiar contentColor Card

3.3 Cambiar Forma

shape con valor RoundedCornerShape(all =  8.dp)
Figura 8. shape con valor RoundedCornerShape(all = 16.dp)

Tenemos presente que el parámetro shape recibe la forma personalizada de las esquinas que deseamos en la tarjeta.

Así que, si quisieras aplicar una esquina de corte rectangular, pasa una instancia de CutCornerShape o en el caso de redondear, entonces RoundedCornerShape:

En el repositorio StylingCard también se encarga de cambiar entre esquinas redondeadas y rectilíneas desde la configuración:

@Composable
fun StylingCard() {
    val shapeOptions = listOf("Redondeada", "Recortada")

    var shapeSelection by remember { mutableStateOf(shapeOptions.first()) }

    Column {

        StandardCard(
            modifier = Modifier.padding(16.dp),
            background = configBackgroundColor(backgroundColorSelection),
            contentColor = configContentColor(contentColorSelection),
            shape = configShape(shapeSelection)
        )
    }
}

@Composable
private fun configShape(
    selection: String
): Shape {
    val cornerSize = 16.dp

    return if (selection == "Redondeada")
        RoundedCornerShape(cornerSize)
    else
        CutCornerShape(cornerSize)
}

De esta forma podemos evidenciar entre esquinas circulares y rectangulares en la card.

Cambiar valor de shape Card

4. Lista De Cards

Lista de cards
Figura 9. Lista de cards

Crear una lista que contenga cards obedece al mismo proceso que vimos en Listas En Jetpack Compose.

La función componible que representa a los ítems de la lista es un diseño con un componente Card como padre.

Por ejemplo: Crear una lista con cards elevadas que contengan un título y texto secundario como se ven en la figura 9.

1. Comenzamos definiendo una clase de datos con la definición del ítem. Por cuestiones de simplicidad, tendremos dos propiedades para representar título y subtítulo:

data class CardItem(
    val title: String,
    val text: String
)

2. Creamos la función CardRow() para satisfacer el diseño propuesto. Aquí enlazamos los datos de los objetos CardItem en la jerarquía de UI:

@Composable
fun CardRow(cardItem: CardItem) {
    Card {
        Column(
            Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
            Text(text = cardItem.title, style = MaterialTheme.typography.h6)
            Text(text = cardItem.text, style = MaterialTheme.typography.body1)
        }
    }
}

3. Luego creamos la función CardList() donde ubicamos un componente LazyColumn, cuyo contenido es la iteración sobre una lista de elementos CardItem:

@Composable
fun CardList() {
    val items = List(10) { index ->
        val position = index + 1
        CardItem(
            title = "Título $position",
            text = "Texto secundario $position"
        )
    }

    LazyColumn(
        contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(items) { item ->
            CardRow(
                cardItem = item
            )
        }
    }
}

Ahora, respondamos a algunos gestos sobre los ítems.

4.1 Clickear Una Card

Click en card de lista

La aplicación de este caso de uso es sencillo, ya que conocemos de la existencia del parámetro onClick.

Con esto en mente, mostraremos un Toast al hacer click sobre las cards de la lista. Este incluirá el título asociado.

@Composable
fun ListWithClickableCards() {
    val items = List(10) { index ->
        val position = index + 1
        CardItem(
            title = "Titulo $position",
            text = "Texto secundario $position"
        )
    }
    val context = LocalContext.current

    LazyColumn(
        contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(items) { item ->
            CardRow(
                cardItem = item,
                onClick = { context.toast("Clic en $item") } // (!)
            )
        }
    }
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun CardRow(cardItem: CardItem, onClick: () -> Unit) { // (!)
    Card(onClick = onClick) { // (!)
        Column(
            Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
            Text(text = cardItem.title, style = MaterialTheme.typography.h6)
            Text(text = cardItem.text, style = MaterialTheme.typography.body1)
        }
    }
}

Si prestas atención, la función CardRow ahora recibe una lambda onClick que se asigna directamente al parámetro de Card. Esto hace que desde el forEach pasemos el argumento que muestra el Toast en pantalla.

4.2 Expandir Una Card

Lista de cards expandibles en Jetpack Compose

Crea Cards expandibles con el fin de revelar contenido que supera la altura predefinida del contenedor.

Para ello debemos añadir una área de expansión para desplegar el contenido adicional, ya sea aumentando la altura original o transicionando a una vista completa de pantalla.

Ejemplo: Añadir un icono de flecha vertical para expandir y contraer las cards en la lista actual.

¿Cómo solucionarlo?

1. Creamos una clase de datos para representar el ítem que será expandido. Los elementos visibles en contracción los definiremos como propiedades individuales. Pero al contenido que será revelado, lo definimos como un objeto con los elementos en su interior.

data class ExpandableCardItem(
    val title: String,
    val secondaryText: String,
    val details: ItemDetail
) {
    data class ItemDetail(val moreText: String)
}

2. Luego, creamos la función ExpandableCardRow() con el diseño original del ítem y el icono de expansión.

@Composable
fun ExpandableCardRow(
    expandableCardItem: ExpandableCardItem
) {
    var expanded by remember { mutableStateOf(false) }

    Card {
        Column(modifier = Modifier.animateContentSize()) {
            Box(
                Modifier
                    .fillMaxWidth()
                    .padding(16.dp)
            ) {
                Column {
                    Text(text = expandableCardItem.title, style = MaterialTheme.typography.h6)
                    Text(
                        text = expandableCardItem.secondaryText,
                        style = MaterialTheme.typography.body1
                    )
                }

                ExpandableCardIcon(
                    expanded = expanded,
                    onIconClick = { expanded = !expanded },
                    modifier = Modifier.align(
                        Alignment.CenterEnd
                    )
                )
            }

            if (expanded)
                Divider(thickness = Dp.Hairline, modifier = Modifier.padding(horizontal = 16.dp))

            Text(
                text = expandableCardItem.details.moreText,
                modifier = Modifier
                    .height(if (expanded) 56.dp else 0.dp)
                    .padding(16.dp)
            )
        }
    }
}

Como ves, requerimos del estado expanded para determinar si el ítem está expandido. Este elemento es de utilidad para cambiar la altura de los detalles entre 0dp y 56.dp. Además permite determinar si se muestra el divisor de área de expansión.

Y claro, animateContentSize() en el layout padre es fundamental para mostrar la animación de apertura y cierre.

3. Añadimos el icono de la fecha que refleja el estado de expansión.

@Composable
fun ExpandableCardIcon(
    expanded: Boolean,
    onIconClick: () -> Unit,
    modifier: Modifier
) {
    IconButton(onClick = onIconClick, modifier = modifier) {
        Icon(
            Icons.Filled.KeyboardArrowDown,
            "Icono para expandir tarjeta",
            Modifier.rotate(
                if (expanded)
                    180f
                else
                    360f
            )
        )
    }
}

El punto fuerte se da con el uso del modificador rotate() para rotar el icono dependiendo del valor de expanded y la transmisión del evento de clic con onIconClick.


5. Cambiar Tema De Card

Card con tema personalizado
Figura 10. Card con tema personalizado

Aunque ya vimos que es posible estilizar las cards individualmente a partir de sus parámetros normales, es posible que desees aplicar una misma configuración a varias cards de tu App.

Esto lo logramos cubriendo la card con una instancia de MaterialTheme.

Ejemplo:

Crear un tema personalizado para las cards que cumpla los siguiente requerimientos:

Color

  • Surface: Ámbar 50
  • On Surface: Marrón 900

Tipografía

  • H6: Besley, 21sp
  • Body 1: Besley, 16sp
  • Body 2: Besley, 14.sp
  • Button: Besley, 14.sp

Forma

  • Esquinas redondeadas a 0dp

Teniendo en cuenta los anterior criterios, la creación del tema se implementa de la siguiente forma:

@Composable
fun ThemingCard() {
    val Besley = FontFamily(Font(R.font.besley_medium))

    Box(modifier = Modifier.padding(16.dp)) {

        MaterialTheme(
            colors = MaterialTheme.colors.copy(
                surface = Color(0xFFFFF8E1),
                onSurface = Color(0xFF3e2723)
            ),
            typography = MaterialTheme.typography.copy(
                h6 = TextStyle(fontFamily = Besley, fontSize = 21.sp),
                body1 = TextStyle(fontFamily = Besley, fontSize = 16.sp),
                body2 = TextStyle(fontFamily = Besley, fontSize = 14.sp),
                button = TextStyle(fontFamily = Besley, fontSize = 14.sp)
            ),
            shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(0))
        ) {
            StandardCard(elevation = 4.dp)
        }
    }
}

¿Ha sido útil esta publicación?