Phase 2 · Building Interactive UIsModule 22~50 min read

LazyColumn, LazyRow & Grids: Identity and State

Build efficient lists and grids with stable keys, explicit empty states, immutable updates and a clear owner for scroll and row state.

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 / parameterChoose it whenDecision to make
LazyColumnItems flow verticallyOne vertical viewport with a bounded height
LazyRowItems form a horizontal shelfGive cards a useful width and the shelf a useful height
LazyVerticalGridSeveral cards fit across the available widthFixed column count or adaptive minimum cell width
contentPaddingSpace belongs inside the scrollable contentFirst and last items need breathing room
Arrangement.spacedByGaps belong between itemsKeep gaps separate from outer content padding
stateYou need scroll position or scroll actionsCreate 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.

SavedLessonCatalog.kt
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()
                }
            }
        }
    }
}
Complete screen composable. English labels keep the example self-contained; move user-facing text and counts into string/plural resources in a localized app.

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.

CollectionAlternatives.kt
// 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) } }
Focused alternatives, replacing the catalog's list rather than nesting another vertical scroll container inside it.

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

SymptomLikely causeRepair
A checkmark follows a position after sortingPosition-based identity or incorrectly owned stateUse record IDs for keys and saved values
Infinite-height measurement exceptionAn unbounded vertical list inside vertical scrolling contentUse one LazyColumn with header/footer items, or explicitly bound the child
Changing data does not update the screenAn ordinary mutable collection was changed in placeAssign a new immutable list or use observable snapshot collections
Large blank inset above the first recordThe same screen padding was applied twiceChoose one owner for Scaffold padding and system insets

Tip

A horizontal shelf inside a vertical feed is a useful pattern because their scroll directions differ. That does not remove the need to size the shelf and its cards sensibly.

Practice: saved lessons first

  1. Add sorting so saved records appear before unsaved records in the all-lessons view.
  2. Keep record IDs as keys and preserve filtering.
  3. 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
SavedLessonsFirst.kt
// 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?

Continue with official documentation