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()) {
LazyColumn { /* ... */ }
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.
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.
For everything else — and I mean the vast majority of screens — Row, Column, and Box are simpler, more readable, and perform the same.
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.
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")
}
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()) {
Text(
text = product.name,
modifier = Modifier.weight(0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
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.
Another pattern I use constantly — one item takes remaining space:
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Settings",
modifier = Modifier.weight(1f)
)
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:
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") }
}
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")
}
}
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.
Real Example: Product Card
Here's a product card I built for an e-commerce app:
@Composable
fun ProductCard(product: Product, onAddToCart: () -> Unit) {
ConstraintLayout(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
val (image, title, price, originalPrice, badge, cartBtn) = createRefs()
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
)
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)
)
}
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
}
)
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)
}
)
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)
}
)
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.
Common Mistakes
1. Using ConstraintLayout when Row/Column works fine
A simple vertical form doesn't need constraints:
// Overkill
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.
3. Nesting Row inside Row inside Column
Three levels of nesting in Compose is a code smell. Step back and consider extracting a composable function or using ConstraintLayout.
4. Hardcoding sizes instead of using weight
// Fragile
Row {
Text(modifier = Modifier.width(250.dp))
Button(modifier = Modifier.width(100.dp))
}
// Responsive
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.
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. Compose's layout system enforces a single-pass measurement. Each composable is measured exactly once.
My guidelines:
- 1-2 levels of nesting: totally fine
- 3 levels: 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
Building a form with aligned labels? → ConstraintLayout
Everything else? → Row + Column + Box
Start simple. Add complexity only when the simpler approach gets awkward.
For setting up navigation between your layout screens, check out the Jetpack Compose Navigation tutorial at https://www.suridevs.com/blog/posts/navigation-component-jetpack-compose-complete-guide/. And if you're structuring ViewModels behind these screens, the MVVM Architecture guide at https://www.suridevs.com/blog/posts/mvvm-jetpack-compose-authentication-guide/ covers the patterns I use in production.
Originally published at SuriDevs
Top comments (0)