Chapter 8: Working with Lists and Adapters
Working with Lists and Adapters! This is where your app starts feeling professional — think Instagram feed, WhatsApp chats, to-do apps, or any screen showing multiple items that scroll smoothly even if there are hundreds or thousands.
In the old View system days (pre-Compose), you’d use RecyclerView + Adapter + ViewHolder for efficient lists. In Jetpack Compose (as of January 2026, with foundation 1.10.1 stable and 1.11.0-alpha03 fresh out), we don’t need adapters anymore. Instead, we have LazyColumn (vertical scrolling lists) and LazyRow (horizontal scrolling lists). These are “lazy” because they only compose and lay out items that are visible on screen (plus a bit of buffer), recycling them as you scroll — exactly like RecyclerView but way simpler and more declarative.
Let’s break it down like we’re coding side-by-side in Airoli, with plenty of examples, why-things, performance tips from recent best practices (early 2026), common mistakes, and a full hands-on to-do list app at the end.
1. LazyColumn & LazyRow – The Modern List Primitives
- LazyColumn → Vertical scrolling list (most common – like feed, chat, settings).
- LazyRow → Horizontal scrolling list (e.g., stories carousel, horizontal categories).
Key advantages over old RecyclerView:
- No adapter class, no ViewHolder, no onCreateViewHolder/onBindViewHolder.
- Pure Kotlin + Composables – everything in one place.
- Built-in animations, drag-and-drop support possible with modifiers.
- Better performance in many cases (less boilerplate, Compose optimizes recomposition).
- But: RecyclerView still wins for extremely large lists (>10k items) or complex recycling patterns on low-end devices – for 99% apps in 2026, LazyColumn is the way.
Basic structure (inside a @Composable):
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), // Like RecyclerView padding verticalArrangement = Arrangement.spacedBy(8.dp), // Gap between items state = rememberLazyListState() // Optional: control scroll position ) { // Content scope – like RecyclerView adapter's onBind item { // Single item (header, footer, etc.) Text("Header", style = MaterialTheme.typography.headlineMedium) } items(10) { index -> // Simple indexed loop Text("Item #$index") } itemsIndexed(listOf("Apple", "Banana", "Mango")) { index, fruit -> Text("$index: $fruit") } } |
2. Displaying Dynamic Data (The Real Power)
Use items(items = yourList, key = { … }) for dynamic lists.
Why key? (super important in 2026 best practices)
- Stable, unique identifier per item.
- Prevents unnecessary recompositions when list changes (add/delete/reorder).
- Maintains state (e.g., checkbox checked) when items move.
- Without key: Compose may recompose everything or lose animations/state on reorder.
Example with key:
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
data class TodoItem(val id: Int, val text: String, var isDone: Boolean = false) val todos = listOf( TodoItem(1, "Buy milk"), TodoItem(2, "Call mom"), TodoItem(3, "Finish Compose chapter") ) LazyColumn { items( items = todos, key = { it.id } // Stable unique key – MUST be stable! ) { todo -> TodoRow(todo = todo) } } |
If no natural ID → use index carefully (but avoid if list can reorder!):
|
0 1 2 3 4 5 6 |
items(todos) { todo -> ... } // OK for static lists, risky for mutable |
3. RecyclerView Alternatives & Migration Notes (2026 Context)
From recent guides:
- LazyColumn = direct replacement for LinearLayoutManager vertical.
- LazyRow = horizontal LinearLayoutManager.
- LazyVerticalGrid / LazyHorizontalGrid = GridLayoutManager.
- No need for DiffUtil – Compose handles changes smartly with keys.
- Performance tip: Avoid heavy work inside item lambda (hoist calculations with remember).
- Nested lazy (LazyRow inside LazyColumn) → possible but can cause jank if not careful – flatten if possible or use contentType for better recycling.
4. Hands-on: Build a Simple To-Do List App
Let’s build a real to-do list with:
- Add new task via TextField + Button
- Display tasks in LazyColumn
- Checkbox to mark done
- Delete button
- Save state with rememberSaveable (survives rotation)
- Use key for smooth adds/deletes
Step 1: Data model & state
In a new file or MainActivity.kt:
|
0 1 2 3 4 5 6 7 8 9 10 |
data class Todo( val id: Int, // Unique stable ID val title: String, var isCompleted: Boolean = false ) |
Step 2: Full Composable Screen
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
@Composable fun TodoListScreen() { // Hoist state here – survives config changes with rememberSaveable var todos by rememberSaveable(stateSaver = listSaver( save = { list -> list.map { "${it.id},${it.title},${it.isCompleted}" } }, restore = { list -> list.map { parts -> Todo(parts[0].toInt(), parts[1], parts[2].toBoolean()) } } )) { mutableStateListOf<Todo>() } var newTaskText by remember { mutableStateOf("") } var nextId by remember { mutableIntStateOf(1) } Column(modifier = Modifier.fillMaxSize()) { // Top bar / Input Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { OutlinedTextField( value = newTaskText, onValueChange = { newTaskText = it }, label = { Text("New task") }, modifier = Modifier.weight(1f) ) Spacer(Modifier.width(8.dp)) Button(onClick = { if (newTaskText.isNotBlank()) { todos.add(Todo(nextId++, newTaskText.trim())) newTaskText = "" } }) { Text("Add") } } // List LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (todos.isEmpty()) { item { Text( "No tasks yet! Add one above.", modifier = Modifier .fillMaxWidth() .padding(32.dp), textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } else { items( items = todos, key = { it.id }, // Crucial for smooth add/delete/animations contentType = { "todo" } // Helps Compose optimize recycling ) { todo -> TodoItemRow( todo = todo, onToggle = { todos = todos.toMutableList().apply { find { it.id == todo.id }?.isCompleted = !todo.isCompleted } }, onDelete = { todos.removeIf { it.id == todo.id } } ) } } } } } @Composable fun TodoItemRow( todo: Todo, onToggle: () -> Unit, onDelete: () -> Unit ) { Card( modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(2.dp) ) { Row( modifier = Modifier .padding(16.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Checkbox( checked = todo.isCompleted, onCheckedChange = { onToggle() } ) Spacer(Modifier.width(8.dp)) Text( text = todo.title, textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else null, color = if (todo.isCompleted) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f) ) IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = "Delete") } } } } |
Step 3: Add to MainActivity
|
0 1 2 3 4 5 6 7 8 9 10 11 12 |
setContent { HelloWeblianceTheme { Surface(modifier = Modifier.fillMaxSize()) { TodoListScreen() } } } |
Step 4: Run & Test
- Add tasks → See them appear smoothly.
- Check/uncheck → Line-through effect.
- Delete → Item vanishes without recomposing everything (thanks to key!).
- Rotate screen → Tasks survive (rememberSaveable).
Enhancements to try yourself:
- Add priority color (e.g., background based on text).
- Swipe to delete (use Modifier.pointerInput + drag).
- Persist to DataStore/Room (from Chapter 7).
- Sort by completed first.
Performance Tips (2026 best practices)
- Always use key for dynamic lists.
- Hoist state outside LazyColumn (don’t read mutableList inside items lambda).
- Use contentPadding, spacedBy for clean gaps.
- Avoid nested heavy layouts inside items.
- For very large lists → consider Paging 3 integration.
You’ve built a real list-based app! Questions? Want to add drag-to-reorder? Search/filter? Or fix any bug? Tell me — next chapter: Networking (fetching real data from APIs). You’re flying through this course — amazing work! Keep building! 🚀📋
