Common Layout Patterns
Common layout patterns for building UIs with Litho KComponents.
Grid Layout
Build a fixed-column grid by chunking items into rows. Give each cell flex(grow = 1f) so columns share the width equally, and use the gap constructor parameter for even spacing between cells.
gap is a constructor parameter on Row/Column — it is not a Style extension (there is no Style.gap() to import). Put gap on the outer Column for row-to-row spacing and on each Row for cell-to-cell spacing. Prefer this over per-child margin, which easily produces uneven gaps (e.g. spacing between columns that doesn't match the spacing between rows, or extra spacing at the container edges). See "Spacing between children with gap" in Row & Column Containers.
import com.facebook.litho.Column
import com.facebook.litho.Row
import com.facebook.litho.Style
import com.facebook.litho.core.height
import com.facebook.litho.core.padding
import com.facebook.litho.flexbox.flex
import com.facebook.litho.kotlin.widget.Text
import com.facebook.litho.view.backgroundColor
import com.facebook.litho.view.clip
import com.facebook.litho.view.Shape
import com.facebook.rendercore.dp
import com.facebook.rendercore.sp
import com.facebook.yoga.YogaAlign
import com.facebook.yoga.YogaJustify
// 2-column grid with uniform 8.dp gaps between every cell
Column(gap = 8.dp, style = Style.padding(all = 8.dp)) {
val items = listOf("A", "B", "C", "D", "E")
for (rowItems in items.chunked(2)) {
child(Row(gap = 8.dp) {
for (item in rowItems) {
child(Column(
alignItems = YogaAlign.CENTER,
justifyContent = YogaJustify.CENTER,
style = Style.flex(grow = 1f).height(100.dp)
.backgroundColor(0xFFE0E0E0.toInt())
.clip(Shape.RoundedRectangle(8.dp))
) {
child(Text(text = item, textSize = 18.sp))
})
}
// Fill remaining space if odd item count so cells keep equal width
if (rowItems.size < 2) {
child(Column(style = Style.flex(grow = 1f)))
}
})
}
}
For a 3-column grid, use chunked(3) and adjust the filler check to rowItems.size < 3.
Square grid cells
To make cells square (e.g. a numbered tile grid), do not pair a flexible width with a fixed height — flex(grow = 1f).height(100.dp) renders rectangles whose shape changes with screen width. Instead, set flex(grow = 1f) for the shared width and aspectRatio(1f) so the height tracks the width. Add justifyContent = YogaJustify.CENTER on a flex(grow = 1f) outer Column when the grid should sit in the vertical center of the screen.
import com.facebook.litho.Column
import com.facebook.litho.Row
import com.facebook.litho.Style
import com.facebook.litho.core.padding
import com.facebook.litho.flexbox.aspectRatio
import com.facebook.litho.flexbox.flex
import com.facebook.litho.kotlin.widget.Text
import com.facebook.litho.view.backgroundColor
import com.facebook.rendercore.dp
import com.facebook.rendercore.sp
import com.facebook.yoga.YogaAlign
import com.facebook.yoga.YogaJustify
// Vertically-centered 3x3 grid of SQUARE cells (1..9)
Column(
gap = 8.dp,
justifyContent = YogaJustify.CENTER, // center the grid vertically on screen
style = Style.flex(grow = 1f).padding(horizontal = 16.dp)
) {
for (rowItems in (1..9).chunked(3)) {
child(Row(gap = 8.dp) {
for (n in rowItems) {
child(Column(
alignItems = YogaAlign.CENTER,
justifyContent = YogaJustify.CENTER,
// flex(grow=1f) shares width equally; aspectRatio(1f) makes height == width
style = Style.flex(grow = 1f).aspectRatio(1f)
.backgroundColor(0xFFE5E5E7.toInt())
) {
child(Text(text = "$n", textSize = 32.sp))
})
}
})
}
}
Key points:
- Use
gapfor inter-cell spacing, not per-childmargin. - Use
aspectRatio(1f)for square cells; avoidflex(grow = 1f)combined with a fixedheight(...), which yields rectangles. - Center the grid vertically with
justifyContent = YogaJustify.CENTERon aflex(grow = 1f)outerColumn.
Horizontal Scrolling Collection
When a row of cards or items is wider than the screen (e.g. a story tray or media carousel — often shown in a design with the last item partially clipped at the edge), use a horizontal LazyList, not a plain Row. A plain Row does not scroll: its children either overflow past the screen edge or get shrunk to fit, distorting their size. Use LazyList with RecyclerView.HORIZONTAL for horizontal scrolling cards or items:
import androidx.recyclerview.widget.RecyclerView
import com.facebook.litho.widget.collection.LazyList
LazyList(
orientation = RecyclerView.HORIZONTAL,
style = Style.height(120.dp)
) {
for (i in 0 until 10) {
child(id = i, component = Column(
alignItems = YogaAlign.CENTER,
justifyContent = YogaJustify.CENTER,
style = Style.width(100.dp).height(100.dp)
.backgroundColor(0xFFBBDEFB.toInt())
.margin(end = 8.dp)
.clip(Shape.RoundedRectangle(12.dp))
) {
child(Text(text = "Item $i"))
})
}
}
A horizontal LazyList must have bounded height (Style.height(N.dp)) since it scrolls horizontally.
Multi-Section Screen
Compose screens with multiple sections using a mix of headers, horizontal collections, and vertical lists:
Column(style = Style.flex(grow = 1f)) {
// Section header with action
child(Row(
alignItems = YogaAlign.CENTER,
justifyContent = YogaJustify.SPACE_BETWEEN,
style = Style.padding(all = 16.dp)
) {
// Font sizing and weight: textSize controls font size; typeface controls font family / weight (bold here)
child(Text(text = "Section Title", textSize = 20.sp,
typeface = Typeface.DEFAULT_BOLD))
child(Text(text = "See All", textSize = 14.sp,
textColor = 0xFF1877F2.toInt()))
})
// Horizontal scrolling items
child(LazyList(
orientation = RecyclerView.HORIZONTAL,
style = Style.height(120.dp).padding(start = 16.dp)
) {
for (i in 0 until 8) {
child(id = i, component = CardItem(
title = "Card $i",
style = Style.width(140.dp).margin(end = 12.dp)))
}
})
// Vertical list below
child(LazyList(style = Style.height(400.dp)) {
for (i in 0 until 20) {
child(id = i, component = ListRow(title = "Row $i"))
}
})
}
Overlapping Avatars (Composite Avatar / Profile Picture Stack)
Build a composite avatar — also called an avatar stack or overlapping profile pictures — by overlapping circular images with negative margins. The second avatar shifts left with a negative margin(start = ...). The same Shape.Circle clip works for any single profile picture.
import com.facebook.litho.Row
import com.facebook.litho.Style
import com.facebook.litho.core.height
import com.facebook.litho.core.margin
import com.facebook.litho.core.width
import com.facebook.litho.kotlin.widget.Image
import com.facebook.litho.view.Shape
import com.facebook.litho.view.clip
import com.facebook.rendercore.dp
import com.facebook.yoga.YogaAlign
// Dual overlapping circular avatars
Row(alignItems = YogaAlign.CENTER) {
child(Image(
drawable = drawableRes(R.drawable.avatar_1),
scaleType = ImageView.ScaleType.CENTER_CROP,
style = Style.width(48.dp).height(48.dp).clip(Shape.Circle)
))
child(Image(
drawable = drawableRes(R.drawable.avatar_2),
scaleType = ImageView.ScaleType.CENTER_CROP,
style = Style.width(48.dp).height(48.dp)
.clip(Shape.Circle)
.margin(start = (-12).dp) // Negative margin creates overlap
))
}
// Single larger avatar
Image(
drawable = drawableRes(R.drawable.avatar_1),
scaleType = ImageView.ScaleType.CENTER_CROP,
style = Style.width(72.dp).height(72.dp).clip(Shape.Circle)
)
Key points:
- Always use equal
widthandheightfor circular clips - Use
ScaleType.CENTER_CROPto fill the circle without distortion - Negative
margin(start = ...)shifts the element left to overlap the previous sibling - For avatar stacks (3+), apply increasing negative margins or use
StackLayoutwithposition()
Pull-to-Refresh
Use LazyList with onPullToRefresh and useLazyCollectionController to add pull-to-refresh:
import com.facebook.litho.widget.collection.LazyList
import com.facebook.litho.widget.collection.useLazyCollectionController
class RefreshableList(
private val items: List<String>,
private val onRefresh: () -> Unit
) : KComponent() {
override fun ComponentScope.render(): Component {
val controller = useLazyCollectionController()
return LazyList(
lazyCollectionController = controller,
onPullToRefresh = {
onRefresh()
controller.setRefreshing(false)
},
style = Style.height(600.dp)
) {
children(items = items, id = { it }) { item ->
Text(text = item, style = Style.padding(all = 16.dp))
}
}
}
}
Call controller.setRefreshing(false) when the refresh completes to dismiss the spinner. Without it, the loading indicator stays indefinitely.