Jetpack Compose's LazyColumn is the foundation of efficient list rendering in modern Android development. Unlike traditional RecyclerView where you manage viewholders manually, LazyColumn handles composition efficiently by only rendering visible items. Let's explore how to master it.
Why LazyColumn?
Traditional RecyclerView development requires boilerplate—adapters, viewholders, layout inflation. Compose eliminates this. LazyColumn composes only what's visible on screen, garbage collects the rest, and recomposes when state changes. Performance is automatic.
LazyColumn {
items(items.size) { index ->
Text("Item $index")
}
}
That's it. No adapter. No viewholder. Just pure composable logic.
Basic Structure: items() vs itemsIndexed()
items() is for simple iterations. Use it when you don't need the index:
data class Book(val id: Int, val title: String, val author: String)
val books = listOf(
Book(1, "Kotlin in Action", "Jemerov"),
Book(2, "Clean Code", "Martin"),
Book(3, "The Pragmatic Programmer", "Hunt")
)
LazyColumn {
items(books) { book ->
BookItem(book)
}
}
@Composable
fun BookItem(book: Book) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.border(1.dp, Color.Gray)
) {
Text(book.title, fontSize = 18.sp, fontWeight = FontWeight.Bold)
Text(book.author, fontSize = 14.sp, color = Color.Gray)
}
}
Use itemsIndexed() when you need both value and position:
LazyColumn {
itemsIndexed(books) { index, book ->
BookItem(book, index)
}
}
@Composable
fun BookItem(book: Book, index: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text("${index + 1}. ${book.title}", fontSize = 18.sp, fontWeight = FontWeight.Bold)
Text(book.author, fontSize = 14.sp, color = Color.Gray)
}
}
The Key Parameter: Preventing Recomposition Hell
This is critical. Without key, LazyColumn recomposes items when the list order changes, causing animation glitches and state loss.
// BAD: Items recompose when list reorders
LazyColumn {
items(books) { book ->
BookItem(book)
}
}
// GOOD: Stable key prevents unnecessary recomposition
LazyColumn {
items(books, key = { it.id }) { book ->
BookItem(book)
}
}
Why? Without a key, Compose uses position. When you move item 0 to position 2, Compose thinks item 0 was deleted and a new item was inserted. State resets. Animations break.
With key = { it.id }, Compose knows "this Book with id=5 moved positions" and preserves its state.
// State preserved when list reorders
var selectedIds by remember { mutableStateOf(setOf<Int>()) }
LazyColumn {
items(books, key = { it.id }) { book ->
var isSelected by remember { mutableStateOf(book.id in selectedIds) }
BookItem(
book = book,
isSelected = isSelected,
onSelect = {
isSelected = true
selectedIds = selectedIds + book.id
}
)
}
}
Advanced Patterns: stickyHeader
Sticky headers stay at the top while scrolling. Perfect for alphabetical or date-grouped lists:
data class Section(val letter: String, val books: List<Book>)
val booksByAuthor = listOf(
Section("H", listOf(Book(1, "Clean Code", "Hunt"))),
Section("J", listOf(Book(2, "Kotlin in Action", "Jemerov"))),
Section("M", listOf(Book(3, "The Pragmatic Programmer", "Martin")))
)
LazyColumn {
booksByAuthor.forEach { section ->
stickyHeader {
Text(
section.letter,
modifier = Modifier
.fillMaxWidth()
.background(Color.LightGray)
.padding(16.dp),
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
}
items(section.books, key = { it.id }) { book ->
BookItem(book)
}
}
}
LazyRow: Horizontal Lists
Same principles apply horizontally:
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp)
) {
items(books, key = { it.id }) { book ->
BookCard(book)
}
}
@Composable
fun BookCard(book: Book) {
Card(
modifier = Modifier
.width(150.dp)
.height(200.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
Text(book.title, fontSize = 14.sp, fontWeight = FontWeight.Bold)
Text(book.author, fontSize = 12.sp)
}
}
}
Swipe-to-Delete with SwipeToDismiss
Modern UX requires swipe actions. Compose provides SwipeToDismiss:
var books by remember { mutableStateOf(listOf(...)) }
LazyColumn {
items(books, key = { it.id }) { book ->
val dismissState = rememberSwipeToDismissState(
confirmStateChange = { dismissValue ->
if (dismissValue == DismissValue.DismissedToEnd) {
books = books.filter { it.id != book.id }
true
} else {
false
}
}
)
SwipeToDismiss(
state = dismissState,
background = {
val color by animateColorAsState(
when (dismissState.targetValue) {
DismissValue.Default -> Color.LightGray
DismissValue.DismissedToEnd -> Color.Red
DismissValue.DismissedToStart -> Color.Green
}
)
Box(
modifier = Modifier
.fillMaxSize()
.background(color)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterStart
) {
Icon(Icons.Default.Delete, contentDescription = null)
}
},
dismissContent = {
BookItem(book)
}
)
}
}
Pull-to-Refresh with Accompanist
Combine LazyColumn with pull-to-refresh:
var isRefreshing by remember { mutableStateOf(false) }
var books by remember { mutableStateOf(listOf(...)) }
val pullRefreshState = rememberPullRefreshState(
refreshing = isRefreshing,
onRefresh = {
isRefreshing = true
// Simulate API call
LaunchedEffect(Unit) {
delay(2000)
books = books.map { it.copy(title = it.title + " (Updated)") }
isRefreshing = false
}
}
)
Box(modifier = Modifier.pullRefresh(pullRefreshState)) {
LazyColumn {
items(books, key = { it.id }) { book ->
BookItem(book)
}
}
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
Performance Tips
-
Always use
keyfor stable lists. Prevents recomposition cascades. -
Extract composables for list items. Recomposing a single
BookItemis cheaper than the entire list. -
Use
contentTypefor heterogeneous lists. Tells Compose which items are compatible for reuse. - Avoid state in list scope. State in LazyColumn recomposes the entire list on change.
-
Pagination with
lazyListState. Observe scroll position and load more when near the bottom.
val listState = rememberLazyListState()
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
.collect { lastVisibleIndex ->
if (lastVisibleIndex != null && lastVisibleIndex >= books.size - 3) {
// Load more items
}
}
}
LazyColumn(state = listState) {
items(books, key = { it.id }) { book ->
BookItem(book)
}
}
Conclusion
LazyColumn is the modern Android way to build scrollable lists. Master the key parameter, use itemsIndexed() when needed, combine with SwipeToDismiss and pull-to-refresh for a complete experience. Your users will notice the performance boost.
All 8 templates use optimized LazyColumn lists. https://myougatheax.gumroad.com
Top comments (0)