Android – Jetpack Compose and Layout

August 11, 20268 min readUpdated 10/11/2026

Jetpack Compose is Android's declarative UI toolkit. Instead of inflating an XML layout and then reaching into it to change things, you write functions that describe the UI for the current state, and Compose works out what to change on screen when the state changes.

Almost everything that surprises people about Compose follows from one fact, so it is worth starting there.

A composable is a function

Not a class, not an object with a lifecycle — a function, marked @Composable, that returns nothing:

@Composable
fun HomeScreen(onOrderNow: () -> Unit, viewModel: MenuViewModel = hiltViewModel()) {
    val catalogue by viewModel.catalogue.collectAsStateWithLifecycle()
    HomeContent(catalogue, onOrderNow)
}

It does not return UI; it emits it, by calling other composables. And Compose may call it again — many times, whenever something it read changes. That re-execution is called recomposition, and the next lesson is about controlling it. For layout, the consequences are simple and strict:

  • A composable must be cheap and side-effect free. No network calls, no writing to a database, no starting a timer in the body. Those go in a ViewModel or an effect.
  • It cannot hold state in local variables. A local is recreated on every call. State lives in remember or above the composable — again, next lesson.
  • Order of execution is not guaranteed. Compose may skip, reorder or run composables in parallel.

If you know SwiftUI: a SwiftUI View is a struct whose body is evaluated, and a composable is the body without the struct. The mental model — describe, don't mutate — is the same.

Column, Row, Box

Three layouts cover nearly every screen. Column stacks children vertically, Row horizontally, Box on top of each other. Here is the home screen's content, the first thing the app shows:

fun HomeContent(catalogue: UiState<Catalogue>, onOrderNow: () -> Unit, modifier: Modifier = Modifier) {
    val pizzas = catalogue.valueOrNull?.pizzas.orEmpty()
    val cheapest = pizzas.mapNotNull { it.cheapestPrice }.minOrNull()

    Column(
        modifier = modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .padding(Spacing.lg),
        verticalArrangement = Arrangement.spacedBy(Spacing.lg),
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .background(PizzaTheme.colors.surfaceInverse, RoundedCornerShape(Radius.lg))
                .padding(Spacing.xl),
            verticalArrangement = Arrangement.spacedBy(Spacing.md),
        ) {
            Text("🍕", fontSize = 48.sp, modifier = Modifier.clearAndSetSemantics { })
            Text(
                "Hot, fresh, and built your way.",
                style = MaterialTheme.typography.displaySmall,
                color = PizzaTheme.colors.onSurfaceInverse,
                modifier = Modifier.semantics { heading() },
            )
            Text(
                if (cheapest != null) {
                    "${pizzas.size} pizzas, your toppings · Starting at ${Money.format(cheapest)}"
                } else {
                    "Pizzas, your toppings, delivered or ready for pickup."
                },
                color = PizzaTheme.colors.onSurfaceInverse.copy(alpha = 0.8f),
            )
            PizzaButton(text = "Order now", onClick = onOrderNow)
        }
        // …

Read it as a tree. An outer Column fills the screen and scrolls; inside it, a second Column draws the dark hero card; inside that, an emoji, a headline, a subtitle and a button, top to bottom.

Arrangement and alignment

A Column distributes its children along its main axis (vertical) with an Arrangement, and positions them across the other axis with an Alignment. Arrangement.spacedBy(Spacing.lg) puts a fixed gap between children, which is almost always what you want instead of padding on each child. For a Row the axes swap: arrangement is horizontal, alignment vertical.

fun PriceRow(label: String, amount: Double, modifier: Modifier = Modifier, emphasized: Boolean = false) {
    val style = if (emphasized) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium
    val color = if (emphasized) PizzaTheme.colors.text else PizzaTheme.colors.textMuted

    Row(
        modifier = modifier
            .fillMaxWidth()
            .padding(vertical = Spacing.xs / 2)
            .semantics(mergeDescendants = true) {},
        horizontalArrangement = Arrangement.SpaceBetween,
    ) {
        Text(label, style = style, color = color)
        Text(
            Money.format(amount),
            style = style,
            color = color,
            fontWeight = if (emphasized) FontWeight.Bold else null,
        )
    }

Arrangement.SpaceBetween pushes the first child to the start and the last to the end — the label on the left, the price on the right, which is every receipt line ever printed.

How layout works

Compose lays out in a single pass, and the rule is short: constraints go down, sizes go up, the parent places. A parent tells each child the minimum and maximum width and height it may have; the child measures itself within those limits and reports a size; the parent decides where to put it.

Most layout bugs are a child asking for something its constraints do not allow, so the common modifiers are worth reading in those terms:

  • fillMaxWidth() — take the maximum width the parent allows.
  • fillMaxSize() — the maximum in both directions; how a screen fills the screen.
  • size(64.dp) — exactly this, if the constraints permit.
  • widthIn(max = 280.dp) — narrow the constraints rather than fix a size.
  • Nothing — wrap the content: be as small as the children need.

Units are dp — density-independent pixels, about 1/160 of an inch — for sizes, and sp for text, which additionally scales with the user's font-size setting. Never mix them up: a text size in dp ignores a partially sighted user's accessibility settings.

Weight

The product card is a Row of a fixed-size emoji tile and a column of text that should take whatever width is left:

Row(horizontalArrangement = Arrangement.spacedBy(Spacing.md), verticalAlignment = Alignment.CenterVertically) {
    Box(
        modifier = Modifier
            .size(64.dp)
            .background(PizzaTheme.colors.primarySoft, RoundedCornerShape(Radius.md))
            .clearAndSetSemantics { },
        contentAlignment = Alignment.Center,
    ) {
        Text(if (product.type == ProductType.PIZZA) "🍕" else "🥤", fontSize = 32.sp)
    }

    Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) {
        Text(product.name, style = MaterialTheme.typography.titleMedium)
        Text(
            product.description,
            style = MaterialTheme.typography.bodySmall,
            color = PizzaTheme.colors.textMuted,
            maxLines = 2,
            overflow = TextOverflow.Ellipsis,
        )
        Spacer(Modifier.padding(top = Spacing.xs))
        Text(
            priceLabel,
            style = MaterialTheme.typography.labelLarge,
            // textMuted, not textSubtle: "Unavailable" is information, and the subtle grey
            // is only 2.3:1 against the card — below WCAG's 4.5:1 for text.
            color = if (price != null) PizzaTheme.colors.primary else PizzaTheme.colors.textMuted,
        )
    }
}

Modifier.weight(1f) is only available inside a Row or Column — it comes from their scope, which is why it is not on plain Modifier. It tells the parent: measure everyone without a weight first, then give me the remaining space. Without it, a long description would push the emoji off screen or be measured at its full, un-wrapped width.

Box, for layering

The emoji tile on the left of that row is a Box: one child, drawn on top of a coloured, rounded background, centred with contentAlignment. A Box stacks its children in the order they are written, so the last one is drawn on top — which is also how you put a badge over an icon or a spinner over a button. It is the only one of the three layouts that lets children overlap, and the cheapest way to centre a single thing in a fixed area.

maxLines = 2 with TextOverflow.Ellipsis caps the description at two lines and ends it with "…" — the one-line fix for a menu written by a copywriter.

Modifier order is the layout

This is the single most important idea in Compose layout, and the one tutorials skip. A modifier chain is not a bag of settings. Each modifier wraps everything after it, so the order changes the result.

// A red box with the padding INSIDE it: the colour fills 16dp past the text.
Modifier.background(Red).padding(16.dp)

// A red box with the padding OUTSIDE it: a 16dp transparent margin, then the colour.
Modifier.padding(16.dp).background(Red)

Here is a chain from the app where the order is load-bearing:

CardContainer(
    modifier = modifier
        .fillMaxWidth()
        .clip(RoundedCornerShape(Radius.md))
        .clickable(enabled = price != null, role = Role.Button, onClick = onClick)
        // One TalkBack stop with one sentence, instead of four fragments.
        .semantics(mergeDescendants = true) {
            contentDescription = "${product.name}. ${product.description}. $priceLabel"
        },
        // …
  1. fillMaxWidth() — the card is as wide as the list.
  2. clip(RoundedCornerShape(…)) — everything after it is clipped to rounded corners. Because it comes before clickable, the ripple drawn when you tap stays inside the corners. Swap the two and the ripple is a sharp-cornered rectangle bleeding past the card.
  3. clickable(…) — the tap target is the whole clipped card.
  4. semantics(…) — what TalkBack reads, covered in the accessibility lesson.

The same reasoning decides where padding goes relative to clickable: padding after clickable is inside the tap target; padding before it is a margin the finger cannot hit.

Building a reusable container

Every list row and section in the app sits on the same white rounded card. Writing that Card setup on every screen would scatter the brand's border colour and corner radius across forty files, so it is a component:

@Composable
fun CardContainer(
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(Spacing.lg),
    content: @Composable ColumnScope.() -> Unit,
) {
    Card(
        modifier = modifier,
        shape = RoundedCornerShape(Radius.md),
        colors = CardDefaults.cardColors(containerColor = PizzaTheme.colors.surface),
        border = BorderStroke(1.dp, PizzaTheme.colors.borderSubtle),
        elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
    ) {
        Column(modifier = Modifier.padding(contentPadding), content = content)
    }
}

Three conventions here that every reusable composable should follow:

  • A modifier parameter, defaulting to Modifier, applied to the root. It is how a caller adds fillMaxWidth() or a click handler without the component having to anticipate it. It should be the first optional parameter, and applied exactly once — to the outermost element.
  • Sensible defaults for everything else. contentPadding defaults to 16dp, so most calls pass nothing.
  • Content as a trailing lambda. content: @Composable ColumnScope.() -> Unit is a slot: the caller passes UI, and because it is typed as an extension on ColumnScope, the caller's children can use Modifier.weight as if they were written directly inside a Column.

Slots are how Material's own components are built — Scaffold takes slots for the top bar, bottom bar and content; Button takes a slot for its label. Accepting UI as a parameter is what lets a small component library cover a large app.

Note also what the card does not take: a product, a price, a click handler. It knows nothing about pizza. That is what lets OrderRow, the address cards on the profile and the order summary at checkout all be built on it.

Shadows, once

The card's elevation is one parameter, and it renders the same on every Android version. It is worth noticing because the React Native sibling of this app needs two sets of properties for one shadow — shadow* for iOS and elevation for Android — and a card that only sets one half is flat on the other platform. Native toolkits do not have that problem.

Text and emoji

Text takes a style from the theme — MaterialTheme.typography.titleMedium — rather than a font size, so changing the type scale is a one-file change. The menu uses emoji as product art, which is just text at a large size:

Text(if (product.type == ProductType.PIZZA) "🍕" else "🥤", fontSize = 32.sp)

A real app would use images loaded from the API, with a library such as Coil doing the caching. The emoji keeps this one dependency-free and keeps the lesson about layout.

Scrolling

A Column does not scroll by itself — content taller than the screen is simply cut off. Modifier.verticalScroll(rememberScrollState()) makes it scroll, as on the home screen. That is right for a screen with a fixed, small amount of content. For a list of items — the menu, the orders — it is wrong, because it composes every row up front. The lists lesson covers LazyColumn, which composes only what is visible.

Next

So far every composable has drawn fixed content. The next lesson adds state: remember, rememberSaveable, hoisting, StateFlow — and what actually causes a composable to run again.