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.

Create a free account to save this page, take notes and track your progress. Sign in

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:

  • Column stacks children vertically, Row horizontally, Box on 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:

Window size classes and price formatting
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 clickable shrinks the touch target; background after padding doesn't fill the padded area.
  • Fixed widths. Prefer fillMaxWidth, weight and 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 or WindowInsets so content isn't hidden under system bars.

Key points

  • Column, Row and Box with arrangement, alignment and weight cover 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.

Was this page helpful?