Lists with LazyColumn
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.
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 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.
rememberinside 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
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:
1LazyColumn {
2 Text(text = "Recent") // does not compile
3}The error is the loud one from Lesson 3.2:
1e: @Composable invocations can only happen from the
2context of a @Composable functionWhich is exactly right, and now you know why: LazyListScope is an ordinary, non-composable builder. Wrap it and it works:
1LazyColumn {
2 item {
3 Text(text = "Recent")
4 }
5 items(notes) { note ->
6 NoteCard(note = note, onClick = { })
7 }
8}Four builders cover nearly everything:
| Builder | Gives 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.
items(list) is an extension function — Lesson 1.18's idea — and it needs its own import:
import androidx.compose.foundation.lazy.itemsMiss 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
1e: Type mismatch: inferred type is List<Note> but Int
2was expectedwhich 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?":
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
rememberstate 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:
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
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.
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}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.
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. Sameitems, same keys,horizontalArrangementinstead of vertical.LazyVerticalGrid— a grid, withcolumns = GridCells.Fixed(2)orGridCells.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:
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:
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.
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:
1if (notes.isEmpty()) {
2 EmptyNotes()
3} else {
4 NoteList(notes, onOpenNote)
5}Lesson 3.12 builds that EmptyNotes screen.
- Open ComposeLab, tap Editor, and open
MainActivity.kt. - Above
setContent, add a list to draw from:val words = List(500) { "Row number $it" }. - Inside
setContent, write aColumnwithmodifier = Modifier.verticalScroll(rememberScrollState())and afor (w in words)loop containing a singleText(text = w). - 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.
- Change
ColumntoLazyColumn, delete themodifierline entirely, and replace theforloop withitems(words) { w -> Text(text = w) }. Accept the import forandroidx.compose.foundation.lazy.items. - Tap Run. Instant, and smooth however hard you flick it. Same 500 rows.
- Now change
List(500)toList(50000)and Run again. It is still instant — that is the whole point of the lesson in one edit. - Add
contentPadding = PaddingValues(16.dp)andverticalArrangement = Arrangement.spacedBy(8.dp)to theLazyColumn. Run, and watch the rows breathe. - Break it on purpose: put the
LazyColumninside aColumn(modifier = Modifier.verticalScroll(rememberScrollState())). Run. Read the crash in Logcat — it is the infinity-height message from this lesson, word for word. - Undo that, then add
item { Text(text = "Everything") }above theitemscall. Run. One header, then the list, in one scrolling surface.
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.item { Text(...) } for one row, or items(list) { ... } for many. Anything you want on screen has to live inside one of those lambdas.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.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.Column that scrolls vertically, or inside another LazyColumn. With no known height it cannot work out how many rows to build.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.key = { index -> index } is honest but gives up the benefits — better to add a real id to your data.remember inside it is thrown away with it.- A
Columnbuilds 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,
rememberinside a row is not storage. Anything that must survive scrolling lives in the data. - The
LazyColumn { }block is aLazyListScope, not a composable body. Useitem { },items(list) { }anditemsIndexed(list) { }. items(list)needsimport androidx.compose.foundation.lazy.items, or you get a confusingInttype mismatch.- Give every row a stable, unique
key. It buys you correct state, correct scroll position andModifier.animateItem(). contentPaddingpads the content and still lets rows scroll to the edges;Arrangement.spacedByputs 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.