Row, Column and Box
Module 3's PriceCard works, but parts of it work by accident. A Spacer makes the gap before the
price, "₹2,450 / quintal" is one string in one style, so the unit shouts as loudly as the number,
and the range is a sentence the eye has to parse. Compose has three basic layouts, Column, Row
and Box, and almost every screen you will build is those three nested. This lesson explains how
they measure their children, runs the one Row bug everybody meets, and rebuilds the card on
purpose.
How Compose measures a layout
Every layout goes through the same three steps, once per frame that needs it:
- The parent passes constraints down: a minimum and maximum width and height. "You may be anything from 0 to 379 dp wide."
- Each child measures itself within those constraints and reports its size.
- The parent places each child at an x and y position.
Each child is measured once. A layout that tries to measure the same child twice fails at run
time, which is why Compose can nest Row inside Column inside Row without the cost that made
XML developers flatten everything into one ConstraintLayout. Deep nesting in Compose is normal and
cheap. That changes how you design: you reach for a small nested layout first, and lesson 3 shows
when anything heavier earns its place.
Column and Row
A Column places children top to bottom, a Row left to right. Each has two settings: how
children are spread along its direction (arrangement), and how they line up across it
(alignment).
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.Start,
) { /* ... */ }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) { /* ... */ }
| Arrangement | What it does with spare space |
|---|---|
Arrangement.Top / Start (default) |
Packs children at the start; spare space at the end |
Arrangement.Bottom / End |
Packs children at the end |
Arrangement.Center |
Packs children in the middle |
Arrangement.SpaceBetween |
First at the start, last at the end, equal gaps between |
Arrangement.SpaceAround |
Equal space round each child, so half-gaps at the edges |
Arrangement.SpaceEvenly |
Equal gaps everywhere, edges included |
Arrangement.spacedBy(8.dp) |
A fixed gap between children, none at the edges |
| Layout | Alignment across | Values |
|---|---|---|
Column |
horizontalAlignment |
Alignment.Start, CenterHorizontally, End |
Row |
verticalAlignment |
Alignment.Top, CenterVertically, Bottom |
A single child can override the alignment with Modifier.align(…), but only with the type its
parent accepts. In a Column, Modifier.align(Alignment.TopEnd) does not compile:
Argument type mismatch: actual type is 'Alignment', but 'Alignment.Horizontal' was expected.
A Column only aligns horizontally, so it only accepts a horizontal alignment.
spacedBy is the one you will use most. It replaces a Spacer between every pair of children,
and unlike padding on each child it puts no gap before the first or after the last.
Baselines
When two texts of different sizes sit in a Row, centring them looks wrong: the small one floats
above the bottom of the large one. What the eye wants is for the letters to sit on the same line,
the baseline. Modifier.alignByBaseline() on each child does that. It exists only inside a
Row, for the same scope reason as weight.
The trap: a Row never wraps
weight(1f) from module 3 shares out the space left over once the other children are measured.
What happens without it is worth seeing once. On the emulator, in a Row 240 dp wide:
Row(modifier = Modifier.width(240.dp).border(1.dp, MaterialTheme.colorScheme.outline)) {
Text(text = "Vashi APMC, Navi Mumbai, Sector 19")
Text(text = "₹2,100")
}
The market name filled all 240 dp and wrapped onto two lines inside the border. The price was
printed down the right-hand side, outside the border, one character per line: ₹, 2, ,, 1,
0, 0. The accessibility tree did not list the price at all.
Read it with the three steps. The Row measured the market name first and offered it the full
240 dp; the name was longer than that, so it took everything. The price was measured second with
no width left, and a Text squeezed that hard still draws its characters, one per line, wherever
they land. A Row never moves a child to the next line. Nothing crashed, no warning appeared, and
with a short market name such as "Nashik APMC" it looks fine, which is how it ships.
The fix is one modifier:
Row(modifier = Modifier.width(240.dp).border(1.dp, MaterialTheme.colorScheme.outline)) {
Text(text = "Vashi APMC, Navi Mumbai, Sector 19", modifier = Modifier.weight(1f))
Text(text = "₹2,100")
}
Weighted children are measured last. The price is measured first and takes its natural
51 dp; the name gets the 189 dp left and wraps inside it. The rule that comes out of this: in a
Row, the child that can wrap gets the weight; the child that must stay whole, such as a price,
does not.
Box
A Box stacks its children on top of each other, the last one drawn on top. Use it for overlays:
a badge on a card, a label on an image, a loading spinner over content.
Box(modifier = Modifier.fillMaxWidth()) {
PriceCard(price = SamplePrices.puneOnion, modifier = Modifier.fillMaxWidth())
Text(
text = "Lowest",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(12.dp)
.background(MaterialTheme.colorScheme.tertiaryContainer, RoundedCornerShape(50))
.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
Inside a Box, Modifier.align takes a full two-way Alignment: TopStart, TopCenter,
TopEnd, CenterStart, Center, CenterEnd, BottomStart, BottomCenter, BottomEnd.
contentAlignment on the Box sets the default for every child. Modifier.matchParentSize()
makes a child exactly the Box's size without affecting it, which is how you put a tinted scrim
over content.
What a Box is not for: placing things side by side. The children do not know about each
other. On the emulator the "Lowest" badge sat neatly in the corner of the Pune onion card. Give
that card a commodity of "Onion (red, Nashik variety)" and the title runs underneath the badge,
because the Box never told the title the badge was there. If two things must not overlap, they
belong in a Row.
Rebuilding PriceCard on purpose
Replace the body of PriceCard in ui/PriceCard.kt (the previews stay as they are):
@Composable
fun PriceCard(price: CommodityPrice, modifier: Modifier = Modifier) {
Card(modifier = modifier) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = price.commodity,
style = MaterialTheme.typography.titleLarge,
)
Text(
text = "${price.market} · ${priceDateFormat.format(price.date)}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = formatRupees(price.modalPrice),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.alignByBaseline(),
)
Text(
text = "per quintal",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alignByBaseline(),
)
}
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Min ${formatRupees(price.minPrice)}",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Text(
text = "Max ${formatRupees(price.maxPrice)}",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
Fix the imports: add Arrangement, Row and fillMaxWidth from androidx.compose.foundation.layout,
and remove Spacer and height, which are no longer used. Every decision is one from this lesson:
spacedBy(4.dp)puts the same small gap between every line, andpadding(top = 8.dp)on the price row adds a larger one where the card changes from "what and where" to "how much".- The modal price and its unit are two texts in a
Row, baseline-aligned, so the number is large and the unit is quiet.spacedBy(6.dp)separates them. - Min and max are a
Rowwithweight(1f)on the first, so min sits at the left edge and max at the right, whatever their widths. The weight goes on the one that can give way.
The market and date are still one line. Module 3 found that this line breaks badly at a large font size; lesson 7 reproduces that on the emulator and fixes it, so leave it for now.
Run the app. Each card now reads top to bottom as commodity, place and date, then a large
₹2,450 with a smaller "per quintal" sitting on the same baseline, then "Min ₹1,200" at the left
and "Max ₹2,851" at the right.
What the rework costs: the card is now six composables where it was five, and two Rows
deeper. In Compose that is nothing measurable. What it does cost is a little reading time for the
next person; the structure pays for itself because each line says what it is for.
Check your work
The three steps of a Compose layout. Constraints go down, sizes come up, the parent places.
Why deep nesting is fine in Compose. Each child is measured once per layout pass, so nesting does not multiply measurement the way nested XML layouts could.
The difference between spacedBy(8.dp) and SpaceEvenly. spacedBy puts a fixed gap only
between children; SpaceEvenly divides all the spare space equally, edges included.
Why the price printed one character per line. The Row measured the long name first and it
took the full width, leaving the price no width at all.
Where the weight goes in a label-and-value row. On the child that can wrap or shrink, never on the value that must stay whole.
Why Modifier.align(Alignment.TopEnd) fails in a Column. A Column only aligns children
horizontally, so its align takes an Alignment.Horizontal.
What a Box does not do. It does not keep its children from overlapping.
Practice 2. With verticalAlignment = Alignment.CenterVertically the two texts are centred
on each other, so the small "per quintal" sits higher than the bottom of the large price and the
letters no longer share a line. Baseline alignment is what text needs.
Practice 4. Put the badge and the title in a Row instead: the title with weight(1f), the
badge after it. The title then wraps before it reaches the badge.
Practice 6. One answer:
@Composable
fun PriceStat(label: String, amount: Long, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(text = label, style = MaterialTheme.typography.labelMedium)
Text(text = formatRupees(amount), style = MaterialTheme.typography.titleMedium)
}
}
Row(modifier = Modifier.fillMaxWidth()) {
PriceStat("Min", price.minPrice, Modifier.weight(1f))
PriceStat("Modal", price.modalPrice, Modifier.weight(1f))
PriceStat("Max", price.maxPrice, Modifier.weight(1f))
}
Equal weights split the whole width into three equal columns, because nothing in the Row is
unweighted; content width does not matter. On a 320 dp screen each column gets under 90 dp once
the screen and card padding are taken off. At 1.5× a value too wide for its column wraps inside
that column; it never pushes the other two aside. That is the opposite of the trap above, and the
reason weights are the right tool here.
Practice
- Rebuild
PriceCardas above and run it. Compare it with a screenshot of the module 3 card. - Replace the two
alignByBaseline()calls withverticalAlignment = Alignment.CenterVerticallyon theRow. Describe what changed, then put the baselines back. - Reproduce the
Rowtrap in a preview withwidthDp = 240, then fix it withweight. - Add the "Lowest" badge from the
Boxexample over one card. Then change that card's commodity to "Onion (red, Nashik variety)" in the preview and watch the overlap. Fix it without aBox. - Break it on purpose: put
Modifier.align(Alignment.TopEnd)on aTextinside aColumnand read the compiler's message. Say what type it wanted and why. - Harder: write
PriceStat(label: String, amount: Long, modifier: Modifier = Modifier)and use three of them in aRowso min, modal and max get equal columns. Check it in the large-text preview and say what happens to a column whose content is too wide.
Official documentation
- Compose layout basics — The measurement model, the standard layouts and the single-measurement rule.
- Alignment lines in Compose — How baselines work, and how to define your own alignment line.
- Box — Every
Boxoverload andBoxScopemodifier, includingmatchParentSize.
Next: edge-to-edge, and what the app must do now that it draws under the system bars.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship