Goals & prerequisites
A collection screen needs more than a loop. Items must retain their identity when users filter or reorder them, controls must update observable state, and the viewport must have a real size. This lesson turns those requirements into a predictable catalog.
First complete state hoisting and the introductory lazy-list lesson. You should be comfortable with Kotlin lists, data classes, lambdas, and Modifier. By the end, you will choose the right collection container, preserve item identity, build an empty state, and explain what survives Activity recreation.
Choose a list or grid
A lazy container composes and lays out items around its viewport as needed; it may also prepare nearby items. It does not promise that only the currently visible pixels have associated composition. A short, fixed settings section can use a normal Column. A growing feed benefits from lazy item management.
| Composable / parameter | Choose it when | Decision to make |
|---|---|---|
LazyColumn | Items flow vertically | One vertical viewport with a bounded height |
LazyRow | Items form a horizontal shelf | Give cards a useful width and the shelf a useful height |
LazyVerticalGrid | Several cards fit across the available width | Fixed column count or adaptive minimum cell width |
contentPadding | Space belongs inside the scrollable content | First and last items need breathing room |
Arrangement.spacedBy | Gaps belong between items | Keep gaps separate from outer content padding |
state | You need scroll position or scroll actions | Create or hoist a LazyListState |
Keys, content types & identity
A position answers “where is this item?” A key answers “which item is this?” Use a stable, unique record ID, such as a database identifier. If a new record is inserted at the beginning, the existing records still have the same keys. Avoid index keys for reorderable data, duplicate titles, and random keys generated during composition.
contentType answers a different question: which item layouts are compatible for reuse? A feed can use types such as “heading”, “lesson”, and “advertisement”. Giving every record its ID as its content type defeats that grouping. String or numeric keys also work with Android's saved-state mechanisms; a custom object key needs additional care.
Own the data and scroll state
Keep durable item state in a state owner, keyed by record ID. A row can leave the composition when scrolled away, so an ordinary row-local remember is a poor place for saved business choices. Replace collections when updating mutableStateOf; mutating an ordinary mutable list in place does not make its internal changes observable.
rememberLazyListState() uses saved-state support for its scroll position. Small UI choices can use rememberSaveable, but neither mechanism is permanent storage. Closing a task or losing application data is different from restoring an Activity. The catalog intentionally keeps a small demonstration list of IDs; a real bookmark library belongs in a repository or database.
Build a saved-lesson catalog
Create SavedLessonCatalog.kt in your app's package, keep its generated package declaration, and paste the imports and declarations below. Call SavedLessonCatalog() inside your generated app theme in setContent. The example needs only Compose Foundation, runtime saveable, and Material 3 from the existing course setup.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
private data class CatalogLesson(val id: String, val title: String)
private val catalogLessons = List(24) { index ->
CatalogLesson("lesson-$index", "Compose lesson ${index + 1}")
}
@Composable
fun SavedLessonCatalog(modifier: Modifier = Modifier) {
var savedIds by rememberSaveable { mutableStateOf(listOf<String>()) }
var onlySaved by rememberSaveable { mutableStateOf(false) }
val listState = rememberLazyListState()
val visibleLessons = catalogLessons.filter { !onlySaved || it.id in savedIds }
Column(modifier.fillMaxSize().padding(16.dp)) {
Text("Your lessons", style = MaterialTheme.typography.headlineSmall)
Button(onClick = { onlySaved = !onlySaved }) {
Text(if (onlySaved) "Show all lessons" else "Show saved lessons")
}
Text("${visibleLessons.size} lessons shown")
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
state = listState,
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (visibleLessons.isEmpty()) {
item(key = "empty", contentType = "message") {
Text("No saved lessons. Choose Show all lessons to save one.")
}
}
items(
items = visibleLessons,
key = { it.id },
contentType = { "lesson" },
) { lesson ->
val saved = lesson.id in savedIds
Column {
Text(lesson.title)
TextButton(onClick = {
savedIds = if (saved) savedIds - lesson.id
else savedIds + lesson.id
}) {
Text(if (saved) "Unsave ${lesson.title}"
else "Save ${lesson.title}")
}
HorizontalDivider()
}
}
}
}
}Save two lessons, switch the filter, then unsave one. The visible collection is derived from the current IDs; no second “filtered list” needs synchronizing. The weighted list receives the remaining height of a screen-sized Column. Rotate the device: small choices and the list position are eligible for saved-state restoration.
Rows, grids & content padding
Adaptive cells are useful when card width matters more than an exact column count. They respond to available width; they do not automatically make a card's internal layout accessible. Test long titles at increased font size. The grid and list DSLs have different items imports, so let Android Studio resolve the overload for the container you use.
// Focused alternative: add these imports to your existing screen.
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(catalogLessons, key = { it.id }, contentType = { "lesson" }) { lesson ->
Text(lesson.title, modifier = Modifier.padding(16.dp))
}
}
// Horizontal alternative: import foundation.lazy.LazyRow and lazy.items.
// LazyRow { items(catalogLessons, key = { it.id }) { Text(it.title) } }Accessible collection items
Give repeated actions enough context: “Save Compose lesson 4” is more useful in an accessibility action list than twenty identical “Save” buttons. Keep text flexible in height, preserve Material touch targets, and avoid making the entire row clickable when it already contains an unrelated action. Essential instructions should be visible, including the recovery path in an empty collection.
Inspect the collection with TalkBack and keyboard navigation. Verify that each action has one focus target, reading order follows the visual order, and changing the saved state does not move focus to an unrelated record.
Diagnose common mistakes
| Symptom | Likely cause | Repair |
|---|---|---|
| A checkmark follows a position after sorting | Position-based identity or incorrectly owned state | Use record IDs for keys and saved values |
| Infinite-height measurement exception | An unbounded vertical list inside vertical scrolling content | Use one LazyColumn with header/footer items, or explicitly bound the child |
| Changing data does not update the screen | An ordinary mutable collection was changed in place | Assign a new immutable list or use observable snapshot collections |
| Large blank inset above the first record | The same screen padding was applied twice | Choose one owner for Scaffold padding and system insets |
Tip
Practice: saved lessons first
- Add sorting so saved records appear before unsaved records in the all-lessons view.
- Keep record IDs as keys and preserve filtering.
- Verify saving, unsaving, rotation, scrolling, and an empty saved list.
Expected result: saving a record moves that record; no other record inherits its state. With no saved records, the filter shows the recovery message. The operation changes ordering without manufacturing new identities.
Open the reference solution
// Inside SavedLessonCatalog, replace the visibleLessons expression.
// id remains the key even when positions change.
val visibleLessons = catalogLessons
.filter { !onlySaved || it.id in savedIds }
.sortedWith(
compareByDescending<CatalogLesson> { it.id in savedIds }
.thenBy { it.title }
)
// For business data, hoist saved IDs and this transformation to a state owner.
// Keep rememberLazyListState() in the screen that owns the scroll position.The secondary comparison is alphabetical, so “lesson 10” appears before “lesson 2”. Add a numeric sequence field if the product requires numeric lesson ordering.
Recap & knowledge check
Key takeaways
- Choose a bounded lazy viewport for growing collections; use one owner for vertical scrolling.
- Keys identify records; content types group compatible item layouts.
- Own important per-record state outside disposable rows and update observable data.
- Saved scroll and small UI state support recreation; repositories provide durable persistence.
Quick check
1. A sorted list uses the item index as its key. What should change?
2. What is contentType intended to describe?
3. Where should a bookmark that must survive app restarts live?