Cards
Back in this track's very first lesson, the "Quick Look Ahead" example sketched a simple
RecipeCard with just a title and a prep time. Now that we know
Scaffold, state-driven dialogs and lists, we can build that card properly:
Card is Compose's surface composable for exactly this kind of grouped, elevated
content block, and this lesson turns it into the real recipe row RecipeBox's list uses —
thumbnail, title, prep time, and a tappable favorite icon.
Card's Core Parameters
| Parameter | Purpose |
|---|---|
shape | Corner shape, e.g. RoundedCornerShape(12.dp) — defaults to Material 3's medium shape |
colors | CardDefaults.cardColors(containerColor = ...) to override the background |
elevation | CardDefaults.cardElevation(defaultElevation = ...) — controls the shadow, giving a "lifted" look |
onClick | Only available if you use the dedicated clickable overload, covered next |
Plain Card vs. Clickable Card
Card has two overloads: a plain one (no interaction) and one that takes an
onClick directly, which automatically adds a ripple and correct accessibility
semantics — this is the one to reach for whenever the whole card is tappable, rather than
manually chaining Modifier.clickable onto a plain Card:
Card(
onClick = { onRecipeClick(recipe) },
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
// card content
}
Worked Example: The RecipeBox Recipe Card
The card's body is an ordinary Row — a thumbnail on the left, a title/prep-time
Column in the middle taking up remaining space via Modifier.weight(1f),
and a favorite toggle on the right:
@Composable
fun RecipeCard(
recipe: Recipe,
isFavorite: Boolean,
onClick: () -> Unit,
onFavoriteToggle: () -> Unit
) {
Card(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(12.dp)
) {
AsyncImage(
model = recipe.photoUri,
contentDescription = recipe.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp))
)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
) {
Text(text = recipe.title, style = MaterialTheme.typography.titleMedium)
Text(
text = "${recipe.prepMinutes} min",
style = MaterialTheme.typography.bodySmall
)
}
IconButton(onClick = onFavoriteToggle) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = "Toggle favorite"
)
}
}
}
}
AsyncImage comes from Coil, covered in the Images lesson earlier in this track — it
handles loading recipe.photoUri off the main thread and caching the result, which
matters here since a recipe list can scroll through dozens of thumbnails.
Dropping It Into the List
Back in the LazyColumn from the Scaffold lesson, each item simply becomes a
RecipeCard instead of a plain Text row:
items(recipes, key = { it.id }) { recipe ->
RecipeCard(
recipe = recipe,
isFavorite = favoriteIds.contains(recipe.id),
onClick = { onRecipeClick(recipe) },
onFavoriteToggle = { onFavoriteToggle(recipe.id) }
)
}
Notice that RecipeCard itself holds no state — isFavorite is passed in
and every event is bubbled up through a callback, the same state-hoisting shape covered earlier
in this track. That's what keeps this card reusable across the plain recipe list, search
results, and any future favorites screen without duplicating logic.