Pocket Studio Academy
HomePart 33.10

Lists with LazyColumn

Full course12 min read·4 questions

A Column with a thousand rows builds a thousand rows, even though eight of them fit on the screen. LazyColumn builds only what you can see — and this lesson covers the keys, padding and spacing that separate a list that works from a list that feels right.

The screen that takes four seconds to appear

Here is a list of notes, built the obvious way. It is correct, it compiles, and it is the version almost everybody writes first.

kotlin
1Column(
2    modifier = Modifier.verticalScroll(
3        rememberScrollState()
4    )
5) {
6    for (note in notes) {
7        NoteCard(note = note, onClick = { })
8    }
9}

With eleven notes it is perfect. Ship it.

With eleven hundred notes, tapping the icon gives you a white screen for four seconds, then a list that stutters every time your thumb moves. Nothing is broken. You asked for eleven hundred cards, and Compose built eleven hundred cards — measured them, laid them out, and kept them all in memory. Eight of them are on the glass. The other 1,092 are stacked up somewhere below the bottom edge, costing you memory and time, doing nothing.

The fix is one word longer than the problem, and it is the composable you will reach for more than any other in real apps.

Think of it like this

Think about a school play with two hundred scenes.

You could build two hundred sets: two hundred painted flats, all standing backstage, all built before the curtain goes up. The play would work. It would also take a term to build, and the school does not have a backstage that big.

Real theatres do something else. They own about six flats. While scene four is on, stagehands are repainting the boards from scene two into scene six. Whatever has just gone off is wiped and becomes whatever is about to come on.

The audience sees two hundred rooms. The theatre only ever owns six boards.

A on a phone is that play. Column builds all two hundred flats. hires the stagehands.

What "lazy" actually means

Lazy is a real word in computing, not a joke. It means do the work at the last possible moment, and only if somebody actually asks for it.

A LazyColumn is handed a description of the rows — "there are 1,100 notes, and here is how to draw any one of them" — rather than the rows themselves. When it is measured, it works out that about nine rows fit, composes those nine, and stops.

Scroll down and it composes row ten as row ten approaches the bottom edge, and disposes row one as it leaves the top. Disposing means the is torn down: its remember slots are thrown away, its s are cancelled. Scroll back up and row one is built again, from scratch, from your data.

Two consequences follow, and the second one catches people out:

  • Memory stays flat. A list of ten items and a list of ten thousand cost about the same.
  • A row is not a safe place to keep anything. remember inside a row survives only as long as the row is on screen. If a row holds a half-typed text field, scrolling it out of view and back loses the typing. Anything that must survive belongs in the list you are drawing from, not inside the row.

The shape of it

kotlin
1LazyColumn {
2    items(notes) { note ->
3        NoteCard(note = note, onClick = { })
4    }
5}

Two things changed from the Column version: the word LazyColumn, and for became items.

That second change is bigger than it looks.

The block inside is not a composable body

Everywhere else in Compose, the braces after a composable are a place to call other composables. Column { Text(...) } calls Text.

LazyColumn is different. Its braces are a LazyListScope — a place to describe rows, not to draw them. You do not call composables there; you call item and items, and hand each of them a lambda that will be called later, if and when that row is needed.

Write this and it will not compile:

kotlin
1LazyColumn {
2    Text(text = "Recent")     // does not compile
3}

The error is the loud one from Lesson 3.2:

text
1e: @Composable invocations can only happen from the
2context of a @Composable function

Which is exactly right, and now you know why: LazyListScope is an ordinary, non-composable builder. Wrap it and it works:

kotlin
1LazyColumn {
2    item {
3        Text(text = "Recent")
4    }
5    items(notes) { note ->
6        NoteCard(note = note, onClick = { })
7    }
8}

Four builders cover nearly everything:

BuilderGives you
item { }Exactly one row — a header, a footer, a banner
items(list) { x -> }One row per thing in the list
itemsIndexed(list) { i, x -> }The same, plus the position
items(20) { i -> }A fixed number of rows, numbered from 0

You can mix them freely, in any order. A header item, then items, then a footer item, is an extremely common shape.

The import that bites everybody

items(list) is an extension function — Lesson 1.18's idea — and it needs its own import:

kotlin
import androidx.compose.foundation.lazy.items

Miss it and the code still compiles against a different items: the built-in one that takes a count. So items(notes) is read as "give me a List<Note> number of rows", and you get

text
1e: Type mismatch: inferred type is List<Note> but Int
2was expected

which says nothing at all about imports. Add the import and it goes away. For itemsIndexed, import that name too.

Keys: the difference between a list and a good list

This is the parameter people skip, and then spend an evening on.

By default, Compose identifies a row by its position. Row 0, row 1, row 2. Delete the note at the top and every row shifts up one — so as far as Compose is concerned, row 0 did not disappear, it changed its contents, and so did row 1, and row 2, and every row after it.

Give each row a key and you change the question from "which position is this?" to "which note is this?":

kotlin
1items(
2    items = notes,
3    key = { note -> note.id }
4) { note ->
5    NoteCard(note = note, onClick = { })
6}

Now deleting the top note tells Compose that one specific row is gone and the rest are unchanged. Three things you get for free:

  • Rows keep their own remember state when the list is reordered or filtered.
  • Modifier.animateItem() can slide the survivors into their new places, instead of the whole list snapping up by one row.
  • Scroll position survives an item being inserted above where you are looking.

The key must be stable (the same note gets the same key every time) and unique within the list. A database id is perfect. The note's title is not — two notes can share a title, and you get a real crash:

text
1java.lang.IllegalArgumentException: Key was already
2used. If you are using LazyColumn/LazyRow please make
3sure you provide a unique key for each item.

Padding, and why there are two kinds

kotlin
1LazyColumn(
2    contentPadding = PaddingValues(
3        start = 16.dp,
4        end = 16.dp,
5        top = 4.dp,
6        bottom = 96.dp
7    ),
8    verticalArrangement =
9        Arrangement.spacedBy(10.dp)
10) { }

Modifier.padding pads the scrolling window. The list gets smaller, and rows are chopped off at the padded edge — they never travel under the app bar, which looks cheap on a modern Android screen.

contentPadding pads the content inside the window. The window still fills the screen, so rows scroll all the way to the physical edges, but the first row starts below the top and the last row can scroll clear of whatever is floating over the bottom. That bottom = 96.dp in Pocket Notes is there so the final note can rise above the floating action button instead of hiding behind it forever.

verticalArrangement = Arrangement.spacedBy(10.dp) puts 10dp between rows and nothing before the first or after the last — which is what you want, and what padding on each card would get wrong at both ends.

The real thing

This is Pocket Notes' list, with one wrapper removed that Lesson 5.10 puts back.

NoteList.ktkotlin
1@Composable
2private fun NoteList(
3    notes: List<Note>,
4    onOpenNote: (Long) -> Unit
5) {
6    LazyColumn(
7        modifier = Modifier.fillMaxSize(),
8        contentPadding = PaddingValues(
9            start = 16.dp,
10            end = 16.dp,
11            top = 4.dp,
12            bottom = 96.dp
13        ),
14        verticalArrangement =
15            Arrangement.spacedBy(10.dp)
16    ) {
17        items(
18            items = notes,
19            key = { note -> note.id }
20        ) { note ->
21            NoteCard(
22                note = note,
23                onClick = { onOpenNote(note.id) },
24                modifier = Modifier.animateItem()
25            )
26        }
27    }
28}
9:41▲ ▮
Pocket Notes
Shopping
Oats, tinned tomatoes, a new sponge for the
2 minutes ago
Song ideas
Something in 6/8. Chorus goes up, verse stays
Yesterday
Untitled
bike lock code is the year gran was born plus
Tuesday
Books to find
The one about the lighthouse keeper, and the
Last week
+

Pocket Notes' NoteList in light mode. Four cards, 10dp apart, the last one clear of the button.

Scrolling it yourself

Sometimes you need to move the list from code — jump to the top after a search, or scroll to the note that was just added.

kotlin
1val listState = rememberLazyListState()
2val scope = rememberCoroutineScope()
3
4LazyColumn(state = listState) {
5    items(notes, key = { it.id }) { note ->
6        NoteCard(note = note, onClick = { })
7    }
8}
9
10Button(
11    onClick = {
12        scope.launch {
13            listState.animateScrollToItem(0)
14        }
15    }
16) {
17    Text(text = "Top")
18}

rememberLazyListState() hands you the list's own memory: where it is scrolled to, and the methods to move it. Scrolling is a function — it takes time and can be cancelled mid-flight — so it must be called from a , which is what rememberCoroutineScope() plus launch is for. Lesson 1.19 built exactly this machinery.

Use scrollToItem(0) instead of animateScrollToItem(0) to jump with no animation.

The state is also readable: listState.firstVisibleItemIndex tells you where the user is, which is how apps decide when to show a "back to top" button.

The rest of the family

  • LazyRow — identical, sideways. Same items, same keys, horizontalArrangement instead of vertical.
  • LazyVerticalGrid — a grid, with columns = GridCells.Fixed(2) or GridCells.Adaptive(120.dp). Focus Flow uses one for its stats.
  • LazyHorizontalGrid — the same, on its side.

Everything you have just learned about items, keys and contentPadding applies to all of them unchanged.

The one rule you must not break

Never put a LazyColumn inside a Column that scrolls vertically, and never give a vertical lazy list an unbounded height. Do it and the app crashes on the spot:

text
1java.lang.IllegalStateException: Vertically scrollable
2component was measured with an infinity maximum height
3constraints, which is disallowed.

The reason is honest, once you see it. LazyColumn needs to know how tall its window is, so it can work out how many rows to build. A vertically scrolling Column says "you may be as tall as you like" — infinity. Asked to fill infinity, the lazy list would have to build every row, which is the exact thing it exists to avoid. So it refuses.

The fix is almost never a nested list. It is one lazy list, with the other content added as item { } blocks:

kotlin
1LazyColumn {
2    item { Header() }
3    items(notes, key = { it.id }) { note ->
4        NoteCard(note = note, onClick = { })
5    }
6    item { Footer() }
7}

One scrolling surface, one scrollbar, one gesture. It is also what the user expected.

Tip

A LazyColumn given an empty list draws nothing at all — a blank screen with no explanation, which reads as a broken app. Check the list first and show an instead. Pocket Notes chooses between them one line before it draws anything:

kotlin
1if (notes.isEmpty()) {
2    EmptyNotes()
3} else {
4    NoteList(notes, onOpenNote)
5}

Lesson 3.12 builds that EmptyNotes screen.

Try it in Pocket Studio
  1. Open ComposeLab, tap Editor, and open MainActivity.kt.
  2. Above setContent, add a list to draw from: val words = List(500) { "Row number $it" }.
  3. Inside setContent, write a Column with modifier = Modifier.verticalScroll(rememberScrollState()) and a for (w in words) loop containing a single Text(text = w).
  4. Tap Run. Count how long the screen takes to appear, then scroll hard with your thumb and watch it stutter. That delay is 500 rows being measured before anything is shown.
  5. Change Column to LazyColumn, delete the modifier line entirely, and replace the for loop with items(words) { w -> Text(text = w) }. Accept the import for androidx.compose.foundation.lazy.items.
  6. Tap Run. Instant, and smooth however hard you flick it. Same 500 rows.
  7. Now change List(500) to List(50000) and Run again. It is still instant — that is the whole point of the lesson in one edit.
  8. Add contentPadding = PaddingValues(16.dp) and verticalArrangement = Arrangement.spacedBy(8.dp) to the LazyColumn. Run, and watch the rows breathe.
  9. Break it on purpose: put the LazyColumn inside a Column(modifier = Modifier.verticalScroll(rememberScrollState())). Run. Read the crash in Logcat — it is the infinity-height message from this lesson, word for word.
  10. Undo that, then add item { Text(text = "Everything") } above the items call. Run. One header, then the list, in one scrolling surface.
Error Doctor5 common errors
e: @Composable invocations can only happen from the context of a @Composable function
MeansYou called a composable — usually Text or a Card — directly inside the LazyColumn { } braces. That block is a LazyListScope, which builds a description of rows and is not itself composable.
FixWrap it: item { Text(...) } for one row, or items(list) { ... } for many. Anything you want on screen has to live inside one of those lambdas.
e: Type mismatch: inferred type is List<Note> but Int was expected
MeansThe items(List) version is an extension function that has not been imported, so Kotlin fell back to the built-in items(count: Int) and you handed it a list.
FixAdd import androidx.compose.foundation.lazy.items. For the indexed version add androidx.compose.foundation.lazy.itemsIndexed. In a grid the import is androidx.compose.foundation.lazy.grid.items instead — a different package for the same word.
java.lang.IllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed. One of the common reasons is nesting layouts like LazyColumn and Column(Modifier.verticalScroll()).
MeansA vertical lazy list has been given unlimited height — almost always because it is inside a Column that scrolls vertically, or inside another LazyColumn. With no known height it cannot work out how many rows to build.
FixUse one lazy list, not two. Move the surrounding content into item { } blocks inside it. If you genuinely need a fixed-height list inside a scrolling parent, give it an explicit Modifier.height(200.dp) — but that is rare, and usually a sign the screen wants restructuring.
java.lang.IllegalArgumentException: Key was already used. If you are using LazyColumn/LazyRow please make sure you provide a unique key for each item.
MeansTwo rows produced the same key. Usually the key is something that only looks unique — a title, a name, a date.
FixKey on a database id, which is unique by definition. If you have no id, key = { index -> index } is honest but gives up the benefits — better to add a real id to your data.
Text I typed into a row disappears when I scroll it off the screen and back
MeansWorking as designed. A scrolled-away row is disposed, and everything held in a remember inside it is thrown away with it.
FixMove that value out of the row and into the list you are drawing from, so the row only ever displays it. This is state hoisting, and Lesson 4.1 is entirely about it.
Recap
  • A Column builds every row you give it. builds only the rows on screen and disposes the rest, so a list of ten and a list of ten thousand cost the same.
  • Because rows are disposed, remember inside a row is not storage. Anything that must survive scrolling lives in the data.
  • The LazyColumn { } block is a LazyListScope, not a composable body. Use item { }, items(list) { } and itemsIndexed(list) { }.
  • items(list) needs import androidx.compose.foundation.lazy.items, or you get a confusing Int type mismatch.
  • Give every row a stable, unique key. It buys you correct state, correct scroll position and Modifier.animateItem().
  • contentPadding pads the content and still lets rows scroll to the edges; Arrangement.spacedBy puts gaps between rows and nowhere else.
  • rememberLazyListState() plus a lets you scroll from code.
  • Never nest a vertical lazy list inside a vertically scrolling parent — one list, with item { } blocks for the extras.
  • Next: theming — how to stop writing Color(0xFF5B3FD6) in forty places, and get a dark mode that is genuinely designed rather than inverted.