Every time I start a new screen in Compose, I ask myself the same question: Row, Column, Box, or ConstraintLayout?
After building 40+ production screens across multiple apps, I've developed a pretty clear mental model for which layout to use when. The short version: Row, Column, and Box handle about 90% of what you'll ever need. ConstraintLayout fills the remaining 10% — but that 10% would be miserable without it.
Here's what I've learned.
Row, Column, Box — When to Use Each
These three are your workhorses. If you're coming from XML, think of them as replacements for LinearLayout(horizontal), LinearLayout(vertical), and FrameLayout.
Column — items stacked vertically. Use it for forms, lists of settings, card content, basically anything that reads top to bottom.
Column(modifier = Modifier.padding(16.dp)) {
Text("Order #1234", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text("Placed on March 15, 2026", style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(12.dp))
Text("3 items · $47.99", style = MaterialTheme.typography.bodyMedium)
}
Row — items laid out horizontally. Profile headers, action button groups, icon + text combos.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = user.avatarUrl,
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(user.displayName, fontWeight = FontWeight.SemiBold)
Text(user.role, style = MaterialTheme.typography.bodySmall)
}
}
Box — items stacked on top of each other (z-axis). Overlays, badges, loading states on top of content.
Box(modifier = Modifier.fillMaxSize()) {
// Content underneath
LazyColumn { /* ... */ }
// FAB on top
FloatingActionButton(
onClick = { /* ... */ },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
) {
Icon(Icons.Default.Add, contentDescription = "Add")
}
}
My rule: if items go down the screen, Column. Side by side, Row. On top of each other, Box. You'd be surprised how far this gets you.
ConstraintLayout in Compose — When You Actually Need It
Here's the thing most tutorials don't tell you: you probably don't need ConstraintLayout in Compose.
In XML, ConstraintLayout solved a real problem — deeply nested LinearLayout and RelativeLayout trees that killed performance. But Compose's layout system is fundamentally different. Nesting Row inside Column inside Box doesn't create the same performance overhead because Compose uses a single-pass measurement system.
So when do I actually reach for ConstraintLayout in Compose?
1. Complex cross-references between siblings. When View A's position depends on View B's size, and View C needs to align with both. Chains of Row and Column start to feel like you're fighting the framework.
2. Overlapping views with constraint-based positioning. Box handles simple overlaps, but when you need "this view's bottom aligns with that view's center, offset by 8dp" — that's constraint territory.
3. Barrier-based layouts. When you need a dynamic boundary that adjusts based on multiple views. There's no Row/Column equivalent for barriers.
4. Percentage-based positioning. Guidelines at 30% from the left, for example. You can do math with fillMaxWidth(0.3f), but guidelines are cleaner for complex cases.
For everything else — and I mean the vast majority of screens — Row, Column, and Box are simpler, more readable, and perform the same.
If you're working with XML layouts and wondering when ConstraintLayout makes sense there, I wrote about ConstraintLayout decision-making for traditional Android views.
Arrangement & Alignment
This confused me for weeks when I started with Compose. Here's the simple version:
Arrangement = how items are distributed along the main axis (horizontal for Row, vertical for Column).
Alignment = how items are positioned on the cross axis.
// Items spaced evenly across the row, centered vertically
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Home, contentDescription = "Home")
Icon(Icons.Default.Search, contentDescription = "Search")
Icon(Icons.Default.Person, contentDescription = "Profile")
}
The arrangements I use most:
| Arrangement | What it does | When I use it |
|---|---|---|
SpaceBetween |
First and last items touch edges, equal space between | Tab bars, toolbars |
SpaceEvenly |
Equal space everywhere including edges | Icon rows, button groups |
Center |
All items centered together | Login forms, empty states |
End |
Items pushed to the end | Action buttons in dialogs |
spacedBy(8.dp) |
Fixed gap between items | Most common — lists, cards |
Arrangement.spacedBy() is the one I use 80% of the time. Fixed spacing between items. Clean and predictable.
Weight & Sizing
Weight in Compose works like layout_weight in XML's LinearLayout, but the API is cleaner:
Row(modifier = Modifier.fillMaxWidth()) {
// Takes 70% of the width
Text(
text = product.name,
modifier = Modifier.weight(0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Takes 30% of the width
Text(
text = product.price,
modifier = Modifier.weight(0.3f),
textAlign = TextAlign.End
)
}
The gotcha I hit early on: weight is only available inside Row and Column scope. You can't use it inside Box. This makes sense — Box stacks things on top of each other, so distributing width doesn't apply.
Another pattern I use constantly — one item takes remaining space, others wrap their content:
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Settings",
modifier = Modifier.weight(1f) // Takes all remaining space
)
Switch(
checked = isEnabled,
onCheckedChange = { isEnabled = it }
)
}
The weight(1f) on the text pushes the switch to the far right. No spacers needed.
Advanced Layouts with ConstraintLayout in Compose
When you do need ConstraintLayout, here's how the XML concepts translate.
Setup
dependencies {
implementation("androidx.constraintlayout:constraintlayout-compose:1.1.1")
}
Chains
Link multiple composables for distribution — like SpaceEvenly in Row, but with more control:
ConstraintLayout(modifier = Modifier.fillMaxWidth()) {
val (btn1, btn2, btn3) = createRefs()
createHorizontalChain(btn1, btn2, btn3, chainStyle = ChainStyle.SpreadInside)
Button(
onClick = { },
modifier = Modifier.constrainAs(btn1) {
top.linkTo(parent.top)
}
) { Text("Save") }
Button(
onClick = { },
modifier = Modifier.constrainAs(btn2) {
top.linkTo(parent.top)
}
) { Text("Edit") }
Button(
onClick = { },
modifier = Modifier.constrainAs(btn3) {
top.linkTo(parent.top)
}
) { Text("Delete") }
}
Chain styles: Spread (equal gaps), SpreadInside (edges pinned, gaps between), Packed (grouped together).
Honestly, for most chain use cases in Compose, Row with Arrangement.SpaceEvenly or SpaceBetween does the same thing with less code.
Guidelines
Invisible anchor lines at a percentage position:
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
val guideline = createGuidelineFromStart(fraction = 0.35f)
val (image, content) = createRefs()
Image(
painter = painterResource(R.drawable.product),
contentDescription = null,
modifier = Modifier.constrainAs(image) {
start.linkTo(parent.start)
end.linkTo(guideline)
width = Dimension.fillToConstraints
}
)
Column(
modifier = Modifier.constrainAs(content) {
start.linkTo(guideline, margin = 12.dp)
end.linkTo(parent.end)
top.linkTo(parent.top)
width = Dimension.fillToConstraints
}
) {
Text("Product Title", fontWeight = FontWeight.Bold)
Text("Description goes here")
}
}
The image gets 35% of the width, content gets the rest. Resizes on any screen without breakpoints.
Barriers
This is where ConstraintLayout genuinely shines — no Row/Column equivalent exists:
ConstraintLayout(modifier = Modifier.fillMaxWidth()) {
val (label1, label2, input1, input2) = createRefs()
val barrier = createEndBarrier(label1, label2)
Text(
text = "Name",
modifier = Modifier.constrainAs(label1) {
start.linkTo(parent.start)
top.linkTo(parent.top)
}
)
Text(
text = "Email Address",
modifier = Modifier.constrainAs(label2) {
start.linkTo(parent.start)
top.linkTo(label1.bottom, margin = 16.dp)
}
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.constrainAs(input1) {
start.linkTo(barrier, margin = 12.dp)
end.linkTo(parent.end)
top.linkTo(label1.top)
width = Dimension.fillToConstraints
}
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
modifier = Modifier.constrainAs(input2) {
start.linkTo(barrier, margin = 12.dp)
end.linkTo(parent.end)
top.linkTo(label2.top)
width = Dimension.fillToConstraints
}
)
}
The barrier sits after whichever label is wider. Both input fields align perfectly regardless of label text length. Try doing this cleanly with just Row and Column — it's ugly.
Real Example: Product Card
Here's a product card I built for an e-commerce app. It uses ConstraintLayout because the price, discount badge, and cart button have cross-references that nested Row/Column couldn't handle cleanly:
@Composable
fun ProductCard(product: Product, onAddToCart: () -> Unit) {
ConstraintLayout(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
val (image, title, price, originalPrice, badge, cartBtn) = createRefs()
// Square product image
AsyncImage(
model = product.imageUrl,
contentDescription = product.name,
modifier = Modifier
.constrainAs(image) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
}
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
// Discount badge overlapping image corner
if (product.discountPercent > 0) {
Text(
text = "-${product.discountPercent}%",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.constrainAs(badge) {
top.linkTo(image.top, margin = 8.dp)
end.linkTo(image.end, margin = 8.dp)
}
.background(Color(0xFFE53935), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
// Product title
Text(
text = product.name,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.constrainAs(title) {
top.linkTo(image.bottom, margin = 8.dp)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
}
)
// Sale price
Text(
text = "$${product.salePrice}",
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
modifier = Modifier.constrainAs(price) {
top.linkTo(title.bottom, margin = 6.dp)
start.linkTo(parent.start)
}
)
// Original price with strikethrough
Text(
text = "$${product.originalPrice}",
textDecoration = TextDecoration.LineThrough,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
modifier = Modifier.constrainAs(originalPrice) {
baseline.linkTo(price.baseline)
start.linkTo(price.end, margin = 8.dp)
}
)
// Add to cart
IconButton(
onClick = onAddToCart,
modifier = Modifier.constrainAs(cartBtn) {
top.linkTo(price.top)
bottom.linkTo(price.bottom)
end.linkTo(parent.end)
}
) {
Icon(Icons.Default.ShoppingCart, contentDescription = "Add to cart")
}
}
}
The baseline alignment between sale price and original price, the badge overlapping the image corner, and the cart button vertically centered against the price — all of that would need multiple nested Box/Row/Column combos. ConstraintLayout keeps it flat and readable.
Common Mistakes
1. Using ConstraintLayout when Row/Column works fine
I see this constantly in code reviews. A simple vertical form doesn't need constraints:
// Overkill — just use Column
ConstraintLayout {
val (title, subtitle, button) = createRefs()
// ...12 lines of constraint wiring for 3 views in a column
}
// Better
Column(modifier = Modifier.padding(16.dp)) {
Text("Title")
Text("Subtitle")
Button(onClick = { }) { Text("Submit") }
}
2. Forgetting Dimension.fillToConstraints
In ConstraintLayout, if you want a view to fill the space between its start and end constraints, you need width = Dimension.fillToConstraints. Without it, the view wraps its content and your constraints for positioning still apply, but the sizing doesn't.
// Won't fill the width between constraints
modifier = Modifier.constrainAs(input) {
start.linkTo(barrier)
end.linkTo(parent.end)
}
// Will fill — add this
modifier = Modifier.constrainAs(input) {
start.linkTo(barrier)
end.linkTo(parent.end)
width = Dimension.fillToConstraints // This is the fix
}
3. Nesting Row inside Row inside Column
Three levels of nesting in Compose is a code smell. If you're there, step back and consider:
- Can you extract a composable function?
- Would ConstraintLayout be cleaner?
- Are you overcomplicating the design?
4. Hardcoding sizes instead of using weight
// Fragile — breaks on different screen sizes
Row {
Text(modifier = Modifier.width(250.dp))
Button(modifier = Modifier.width(100.dp))
}
// Responsive — works everywhere
Row {
Text(modifier = Modifier.weight(1f))
Button(onClick = { }) { Text("Go") }
}
5. Using fillMaxWidth() inside a weighted Row
If a child already has weight, adding fillMaxWidth() does nothing — weight already determines the width. I've seen both applied together too many times.
Performance: Nested vs Flat in Compose
Here's the thing XML developers need to unlearn: nesting in Compose is not the same performance problem as nesting in XML.
XML's LinearLayout inside LinearLayout caused exponential measure/layout passes. Each level could trigger multiple measurements of its children. A 5-level deep hierarchy could mean dozens of measure passes.
Compose's layout system enforces a single-pass measurement. Each composable is measured exactly once. Nesting Row inside Column inside Box doesn't multiply measurements.
That said, unnecessary nesting still has costs:
- More composable nodes in the tree
- More recomposition scope boundaries
- Harder to read and maintain
My guidelines:
- 1-2 levels of nesting: totally fine, don't even think about it
- 3 levels: probably fine, but consider extracting composables
- 4+ levels: refactor — either extract or consider ConstraintLayout
I've never profiled a Compose app and found that layout nesting was the bottleneck. If your app is slow, look at recomposition count, image loading, and list performance first.
Quick Decision Guide
Need to stack items vertically? → Column
Need items side by side? → Row
Need to overlay items? → Box
Need cross-references between siblings? → ConstraintLayout
Need barrier-based alignment? → ConstraintLayout
Need percentage positioning? → ConstraintLayout (or fillMaxWidth fraction)
Building a form with aligned labels? → ConstraintLayout
Everything else? → Row + Column + Box
Start simple. Add complexity only when the simpler approach gets awkward. I've refactored dozens of ConstraintLayouts back to Row/Column when I realized the constraints were overkill.
For setting up navigation between your layout screens, check out the Jetpack Compose Navigation tutorial with type-safe routes and popUpTo. And if you're structuring your ViewModels behind these screens, the MVVM Architecture guide covers the state management patterns I use in production.