Every app has lists. A chat app has a list of messages. A store app has a list of products. A settings app has a list of options.

In the old Android world, you used RecyclerView — which needed an Adapter, a ViewHolder, a LayoutManager, and a lot of boilerplate code. In Compose, you just use LazyColumn. That’s it.

LazyColumn vs Column — Why Not Just Use Column?

You already know Column from Tutorial #2. Why not use it for lists?

// This works for 5 items
Column {
    Text("Item 1")
    Text("Item 2")
    Text("Item 3")
    Text("Item 4")
    Text("Item 5")
}

But what if you have 500 items? Column creates ALL 500 items at once, even the ones you can’t see. This is slow and wastes memory.

LazyColumn only creates the items that are visible on screen. When you scroll, it creates new items and destroys the ones that scrolled away. This is the same idea as RecyclerView, but much simpler to use.

Rule: Use Column for small, fixed lists (under 20 items). Use LazyColumn for anything bigger or dynamic.

LazyColumn — Vertical Scrolling List

Basic LazyColumn

@Composable
fun SimpleList() {
    LazyColumn {
        items(100) { index ->
            Text(
                text = "Item $index",
                modifier = Modifier.padding(16.dp)
            )
        }
    }
}

This creates a scrollable list with 100 items. Only the visible items exist in memory.

List from Data

Most of the time, you have a list of data objects:

data class Contact(val name: String, val email: String)

@Composable
fun ContactList(contacts: List<Contact>) {
    LazyColumn {
        items(contacts) { contact ->
            ContactRow(contact)
        }
    }
}

items vs itemsIndexed

Use items when you only need the data. Use itemsIndexed when you also need the position:

LazyColumn {
    // Just the item
    items(contacts) { contact ->
        Text(contact.name)
    }

    // Item + index
    itemsIndexed(contacts) { index, contact ->
        Text("${index + 1}. ${contact.name}")
    }
}

Adding Headers and Footers

You can mix single items and list items:

LazyColumn {
    // Header (single item)
    item {
        Text(
            "My Contacts",
            fontSize = 24.sp,
            fontWeight = FontWeight.Bold,
            modifier = Modifier.padding(16.dp)
        )
    }

    // List of contacts
    items(contacts) { contact ->
        ContactRow(contact)
    }

    // Footer (single item)
    item {
        Text(
            "${contacts.size} contacts",
            modifier = Modifier.padding(16.dp),
            color = Color.Gray
        )
    }
}

Sticky Headers

When you have a long list grouped by categories (contacts by letter, products by type), sticky headers help users know where they are while scrolling. The header stays at the top until the next group starts.

Note: stickyHeader may require @OptIn(ExperimentalFoundationApi::class) depending on your Compose version.

Group items with sticky headers:

LazyColumn {
    // Group contacts by first letter
    val grouped = contacts.groupBy { it.name.first() }

    grouped.forEach { (letter, contactsInGroup) ->
        stickyHeader {
            Text(
                text = letter.toString(),
                modifier = Modifier
                    .fillMaxWidth()
                    .background(MaterialTheme.colorScheme.surfaceVariant)
                    .padding(horizontal = 16.dp, vertical = 8.dp),
                fontWeight = FontWeight.Bold
            )
        }

        items(contactsInGroup) { contact ->
            ContactRow(contact)
        }
    }
}

Spacing Between Items

Two ways to add space between items:

// Option 1: verticalArrangement
LazyColumn(
    verticalArrangement = Arrangement.spacedBy(8.dp)
) {
    items(contacts) { contact ->
        ContactRow(contact)
    }
}

// Option 2: contentPadding (adds padding around the entire list)
LazyColumn(
    contentPadding = PaddingValues(16.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp)
) {
    items(contacts) { contact ->
        ContactRow(contact)
    }
}

Use contentPadding instead of Modifier.padding() on the LazyColumn itself. This way, the padding scrolls with the content instead of cutting off items at the edges.

LazyRow — Horizontal Scrolling List

LazyRow works exactly like LazyColumn, but scrolls horizontally:

@Composable
fun CategoryChips(categories: List<String>) {
    LazyRow(
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        contentPadding = PaddingValues(horizontal = 16.dp)
    ) {
        items(categories) { category ->
            FilterChip(category)
        }
    }
}

@Composable
fun FilterChip(label: String) {
    Text(
        text = label,
        modifier = Modifier
            .clip(RoundedCornerShape(50))
            .background(MaterialTheme.colorScheme.secondaryContainer)
            .padding(horizontal = 16.dp, vertical = 8.dp),
        color = MaterialTheme.colorScheme.onSecondaryContainer
    )
}

Common uses for LazyRow:

  • Category filters at the top of a screen
  • Horizontal image galleries
  • Story/status bars (like Instagram)
  • Product recommendations

LazyVerticalGrid — Grid Layout

For grid layouts (like a photo gallery or product grid):

@Composable
fun PhotoGrid(photos: List<String>) {
    LazyVerticalGrid(
        columns = GridCells.Fixed(2), // 2 columns
        contentPadding = PaddingValues(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(photos) { photo ->
            PhotoCard(photo)
        }
    }
}

Grid Column Options

// Fixed number of columns
columns = GridCells.Fixed(2)     // Always 2 columns
columns = GridCells.Fixed(3)     // Always 3 columns

// Adaptive — adjusts based on screen width
columns = GridCells.Adaptive(150.dp) // Each column is at least 150dp wide

GridCells.Adaptive is great for supporting different screen sizes. On a phone you might get 2 columns, on a tablet you get 4.

Click Handling in Lists

Simple Click

LazyColumn {
    items(contacts) { contact ->
        ContactRow(
            contact = contact,
            onClick = { println("Clicked: ${contact.name}") }
        )
    }
}

@Composable
fun ContactRow(contact: Contact, onClick: () -> Unit) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .clickable { onClick() }
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        // Avatar
        Box(
            modifier = Modifier
                .size(40.dp)
                .clip(CircleShape)
                .background(MaterialTheme.colorScheme.primaryContainer),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = contact.name.firstOrNull()?.toString() ?: "?",
                fontWeight = FontWeight.Bold
            )
        }

        Spacer(modifier = Modifier.width(12.dp))

        // Info
        Column {
            Text(contact.name, fontWeight = FontWeight.Medium)
            Text(contact.email, fontSize = 14.sp, color = Color.Gray)
        }
    }
}

Selectable List

@Composable
fun SelectableList(items: List<String>) {
    var selectedIndex by remember { mutableStateOf(-1) }

    LazyColumn {
        itemsIndexed(items) { index, item ->
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .background(
                        if (index == selectedIndex)
                            MaterialTheme.colorScheme.primaryContainer
                        else
                            Color.Transparent
                    )
                    .clickable { selectedIndex = index }
                    .padding(16.dp)
            ) {
                Text(item)
            }
        }
    }
}

Performance: Using Keys

By default, Compose identifies list items by their position. If you add or remove items, Compose might redraw more than necessary.

Use key to tell Compose how to identify each item:

LazyColumn {
    items(
        items = contacts,
        key = { contact -> contact.id }  // Unique identifier
    ) { contact ->
        ContactRow(contact)
    }
}

With keys, when you add an item at the top of the list, Compose knows the existing items didn’t change — it only creates the new one. Without keys, it might redraw the entire list.

Always use keys when:

  • Items can be added, removed, or reordered
  • Items have a unique ID (like a database ID)

Tip: You can also use Modifier.animateItem() to animate items when they are added, removed, or reordered in the list.

Note: The items() function also accepts a contentType parameter. When your list has different types of items (headers, cards, footers), setting contentType helps Compose reuse compositions more efficiently.

Practical Example: Contact List App

Let’s build a complete contact list screen with all the features:

data class Contact(
    val id: Int,
    val name: String,
    val email: String,
    val isOnline: Boolean
)

@Composable
fun ContactListScreen() {
    val contacts = remember {
        listOf(
            Contact(1, "Alex", "alex@example.com", true),
            Contact(2, "Sam", "sam@example.com", false),
            Contact(3, "Jordan", "jordan@example.com", true),
            Contact(4, "Taylor", "taylor@example.com", false),
            Contact(5, "Morgan", "morgan@example.com", true),
            Contact(6, "Casey", "casey@example.com", false),
            Contact(7, "Riley", "riley@example.com", true),
            Contact(8, "Quinn", "quinn@example.com", true),
        )
    }

    var selectedId by remember { mutableStateOf(-1) }

    Column(modifier = Modifier.fillMaxSize()) {
        // Header
        Text(
            text = "Contacts",
            fontSize = 24.sp,
            fontWeight = FontWeight.Bold,
            modifier = Modifier.padding(16.dp)
        )

        // Category filter (horizontal list)
        LazyRow(
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            contentPadding = PaddingValues(horizontal = 16.dp)
        ) {
            val filters = listOf("All", "Online", "Offline")
            items(filters) { filter ->
                FilterChip(filter)
            }
        }

        Spacer(modifier = Modifier.height(8.dp))

        // Contact list (vertical list)
        LazyColumn(
            verticalArrangement = Arrangement.spacedBy(2.dp),
            contentPadding = PaddingValues(horizontal = 16.dp)
        ) {
            items(
                items = contacts,
                key = { it.id }  // Use ID as key for better performance
            ) { contact ->
                ContactCard(
                    contact = contact,
                    isSelected = contact.id == selectedId,
                    onClick = { selectedId = contact.id }
                )
            }

            // Footer
            item {
                Text(
                    text = "${contacts.size} contacts",
                    modifier = Modifier.padding(vertical = 16.dp),
                    color = Color.Gray,
                    fontSize = 14.sp
                )
            }
        }
    }
}

@Composable
fun ContactCard(
    contact: Contact,
    isSelected: Boolean,
    onClick: () -> Unit
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .background(
                if (isSelected) MaterialTheme.colorScheme.primaryContainer
                else Color.Transparent,
                RoundedCornerShape(8.dp)
            )
            .clickable { onClick() }
            .padding(12.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        // Avatar with online status
        Box {
            Box(
                modifier = Modifier
                    .size(44.dp)
                    .clip(CircleShape)
                    .background(MaterialTheme.colorScheme.secondaryContainer),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = contact.name.firstOrNull()?.toString() ?: "?",
                    fontWeight = FontWeight.Bold
                )
            }
            if (contact.isOnline) {
                Box(
                    modifier = Modifier
                        .size(12.dp)
                        .align(Alignment.BottomEnd)
                        .background(Color(0xFF4CAF50), CircleShape)
                        .border(2.dp, MaterialTheme.colorScheme.surface, CircleShape)
                )
            }
        }

        Spacer(modifier = Modifier.width(12.dp))

        Column(modifier = Modifier.weight(1f)) {
            Text(contact.name, fontWeight = FontWeight.Medium)
            Text(
                contact.email,
                fontSize = 14.sp,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }
}

This contact list has:

  • A header at the top
  • Horizontal filter chips (LazyRow)
  • Vertical contact list (LazyColumn) with keys
  • Click to select (highlighted background)
  • Online status dots
  • Footer showing total count

Common Mistakes

Mistake 1: Using Column for Long Lists

// BAD — creates all 1000 items at once
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
    repeat(1000) { Text("Item $it") }
}

// GOOD — only creates visible items
LazyColumn {
    items(1000) { Text("Item $it") }
}

Mistake 2: Nesting LazyColumn Inside LazyColumn

// BAD — crashes or behaves unpredictably
LazyColumn {
    item {
        LazyColumn { /* ... */ } // Never do this!
    }
}

If you need nested scrolling, use a regular Column for the inner list, or flatten the data into a single LazyColumn with headers.

Mistake 3: Using Modifier.padding on LazyColumn

// BAD — items get cut off at the edges
LazyColumn(modifier = Modifier.padding(16.dp)) {
    items(data) { /* ... */ }
}

// GOOD — padding scrolls with content
LazyColumn(contentPadding = PaddingValues(16.dp)) {
    items(data) { /* ... */ }
}

Mistake 4: Forgetting Keys for Dynamic Lists

// BAD — no keys, Compose redraws everything when list changes
items(contacts) { contact -> ContactRow(contact) }

// GOOD — keys help Compose track individual items
items(items = contacts, key = { it.id }) { contact -> ContactRow(contact) }

Quick Reference

ComponentDirectionUse For
LazyColumnVerticalLong scrollable lists
LazyRowHorizontalCategory chips, galleries
LazyVerticalGridGridPhoto grids, product grids
Column + verticalScrollVerticalShort, fixed content
FunctionWhat It Does
items(list)Add items from a list
items(count)Add a number of items
itemsIndexed(list)Add items with index
item { }Add a single item (header/footer)
stickyHeader { }Header that sticks while scrolling

Result

Here is what the app looks like when you run the code from this tutorial:

Light ModeDark Mode
Tutorial 6 LightTutorial 6 Dark

Source Code

The complete working code for this tutorial is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about Material 3 Theming — custom colors, typography, dark mode, and dynamic colors. This is what makes your app look professional.

See you there.