En este tutorial veremos el uso de Cards en Jetpack Compose para presentarle al usuario contenido y acciones asociadas a un tema.
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.
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 cardenabled
: Determina si la tarjeta está activa o noshaped
: Especifica la forma de los bordes de la tarjeta y por ende la sombra proyectadabackgroundColor
: Color de fondo de la cardcontentColor
: Color aplicado sobre los componentes hijo de la cardborder
: Borde aplicado a la cardelevation
: Posición en el eje z de la card en Dps. La sombra depende de este valor
1.1 Elementos 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
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
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
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:
3.2 Cambiar Color De Contenido
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í:
3.3 Cambiar Forma
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.
4. 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
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
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
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)
}
}
}