DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Mastering LazyColumn in Jetpack Compose: Lists Done Right

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    )
}
Enter fullscreen mode Exit fullscreen mode

Performance Tips

  1. Always use key for stable lists. Prevents recomposition cascades.
  2. Extract composables for list items. Recomposing a single BookItem is cheaper than the entire list.
  3. Use contentType for heterogeneous lists. Tells Compose which items are compatible for reuse.
  4. Avoid state in list scope. State in LazyColumn recomposes the entire list on change.
  5. 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)