Android with Kotlin and Jetpack Compose · Compose Foundations: Screens, Layout, Lists and State
Layouts, Modifiers and Material 3 Theming
Arrange UI with Column, Row and Box, size and space it with modifiers (where order matters), theme it with Material 3, and adapt the layout to phones, foldables and tablets.
What you'll build
A product card: an image placeholder on the left, title and price stacked on the right, and a full-width "Add to cart" button underneath, which becomes a two-column grid of cards on wider screens.
The idea
Compose layouts are built from three basic containers:
Columnstacks children vertically,Rowhorizontally,Boxon top of each other.- Modifiers set size, padding, background, click handling and more. They're applied in order:
Modifier.padding(16.dp).background(Color.Red)leaves the padding uncoloured, while.background(...).padding(...)colours it. - Arrangement spaces children along the main axis; Alignment positions them on the cross axis;
Modifier.weight(1f)shares leftover space.
Material 3 components (Card, Button, TopAppBar, NavigationBar) read colours and typography from MaterialTheme, which supports light and dark schemes and dynamic colour from the user's wallpaper.
Android devices range from small phones to tablets and foldables, so layouts should adapt by window size class: compact (under 600 dp wide), medium (600 to 839 dp) and expanded (840 dp and wider).
The code
@Composable
fun ProductCard(product: Product, onAdd: () -> Unit, modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(64.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.secondaryContainer)
)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(product.title, style = MaterialTheme.typography.titleMedium, maxLines = 2,
overflow = TextOverflow.Ellipsis)
Text(product.priceLabel, color = MaterialTheme.colorScheme.primary)
}
}
Button(onClick = onAdd, modifier = Modifier.fillMaxWidth()) { Text("Add to cart") }
}
}
}
@Composable
fun ProductGrid(products: List<Product>, onAdd: (Product) -> Unit) {
val widthClass = currentWindowAdaptiveInfo().windowSizeClass
val columns = if (widthClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)) 2 else 1
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(products, key = { it.id }) { product -> ProductCard(product, onAdd = { onAdd(product) }) }
}
}
Use dp for sizes and sp (through the typography styles) for text, so text respects the user's font-size setting.
Try the logic
The breakpoints and the price label are plain logic that the UI calls:
enum class WidthClass { COMPACT, MEDIUM, EXPANDED }
fun widthClass(widthDp: Int) = when {
widthDp < 600 -> WidthClass.COMPACT
widthDp < 840 -> WidthClass.MEDIUM
else -> WidthClass.EXPANDED
}
fun columnsFor(widthDp: Int) = when (widthClass(widthDp)) {
WidthClass.COMPACT -> 1
WidthClass.MEDIUM -> 2
WidthClass.EXPANDED -> 3
}
fun priceLabel(cents: Long, currency: String = "EUR") = "%s %d.%02d".format(currency, cents / 100, cents % 100)
fun main() {
for ((device, width) in listOf("phone" to 411, "foldable open" to 673, "tablet landscape" to 1280)) {
println("$device ($width dp): ${widthClass(width)}, ${columnsFor(width)} column(s)")
}
println(priceLabel(1999))
println(priceLabel(500, "USD"))
}
Output
phone (411 dp): COMPACT, 1 column(s) foldable open (673 dp): MEDIUM, 2 column(s) tablet landscape (1280 dp): EXPANDED, 3 column(s) EUR 19.99 USD 5.00
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Getting modifier order wrong. Padding before
clickableshrinks the touch target; background after padding doesn't fill the padded area. - Fixed widths. Prefer
fillMaxWidth,weightand window size classes over hard-coded pixel sizes. - Touch targets under 48 dp. Material components handle this; custom clickable elements need
Modifier.minimumInteractiveComponentSize()or enough size. - Ignoring insets. With edge-to-edge drawing, use
Scaffold's padding orWindowInsetsso content isn't hidden under system bars.
Key points
Column,RowandBoxwith arrangement, alignment andweightcover most layouts.- Modifier order matters: each modifier wraps the ones after it.
- Material 3 theming gives light, dark and dynamic colour from one
MaterialTheme. - Adapt layouts with window size classes, not device types.
Further reading: Compose layout basics, Window size classes.
Practice questions
Why does modifier order matter in Compose?
Answer
Each modifier wraps the content produced by the modifiers after it, so padding then background colours only the inner area, while background then padding colours the padding too. The same applies to size, clipping and click handling.
How should an Android layout adapt to tablets and foldables?
Answer
Use window size classes (compact, medium, expanded) based on the available window width, not on the device type, and change the layout: more grid columns, a navigation rail instead of a bottom bar, or list and detail side by side.
Open in the app to mark lessons complete, take notes and track your progress.