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
rememberor 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"
},
// …
fillMaxWidth()— the card is as wide as the list.clip(RoundedCornerShape(…))— everything after it is clipped to rounded corners. Because it comes beforeclickable, 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.clickable(…)— the tap target is the whole clipped card.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
modifierparameter, defaulting toModifier, applied to the root. It is how a caller addsfillMaxWidth()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.
contentPaddingdefaults to 16dp, so most calls pass nothing. - Content as a trailing lambda.
content: @Composable ColumnScope.() -> Unitis a slot: the caller passes UI, and because it is typed as an extension onColumnScope, the caller's children can useModifier.weightas if they were written directly inside aColumn.
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.