Skip to main content

Lists & Lazy Layouts

Lazy layouts are how Compose renders thousands of items without choking. Unlike Column, they compose only visible items (plus a small buffer), recycle slots as content scrolls, and respond to scroll-driven state. This chapter covers every lazy API, scroll state, sticky headers, pull-to- refresh, and Paging 3 integration.

The lazy family

ComposableWhat it renders
LazyColumnVertical list
LazyRowHorizontal list
LazyVerticalGridFixed-column-count grid
LazyHorizontalGridFixed-row-count horizontal grid
LazyVerticalStaggeredGridPinterest-style staggered vertical grid
LazyHorizontalStaggeredGridStaggered horizontal grid
HorizontalPager / VerticalPagerSnapping paged list

All share the same DSL: items(), item(), stickyHeader(), and scroll state management.


LazyColumn — the core

@Composable
fun MessageList(
messages: List<Message>,
onMessageClick: (Message) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = 16.dp, bottom = 96.dp, // extra bottom for FAB
start = 16.dp, end = 16.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { Text("Today", style = MaterialTheme.typography.titleMedium) }

items(
items = messages,
key = { it.id }, // stable identity
contentType = { "message" } // type hint for reuse
) { message ->
MessageRow(message, onClick = { onMessageClick(message) })
}

item {
TextButton(onClick = { /* load older */ }) {
Text("Load older messages")
}
}
}
}

itemsIndexed — when position matters

itemsIndexed(
items = messages,
key = { _, msg -> msg.id }
) { index, message ->
if (index == 0) TopBadge()
MessageRow(message)
if (index % 10 == 9) Banner() // every 10th row
}

Multiple item types — section headers + items

sealed interface ListItem {
data class Header(val title: String) : ListItem
data class Row(val message: Message) : ListItem
}

LazyColumn {
items(
items = items,
key = {
when (it) {
is ListItem.Header -> "header-${it.title}"
is ListItem.Row -> "row-${it.message.id}"
}
},
contentType = {
when (it) {
is ListItem.Header -> "header"
is ListItem.Row -> "row"
}
}
) { item ->
when (item) {
is ListItem.Header -> SectionHeader(item.title)
is ListItem.Row -> MessageRow(item.message)
}
}
}

Sticky headers

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(contacts: List<Contact>) {
val grouped = contacts.groupBy { it.name.first().uppercaseChar() }

LazyColumn {
grouped.forEach { (initial, list) ->
stickyHeader(key = "header-$initial") {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Text(
text = "$initial",
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.titleMedium
)
}
}
items(list, key = { it.id }) { contact -> ContactRow(contact) }
}
}
}

Scroll state

rememberLazyListState() exposes scroll position, animations, and drives effects:

@Composable
fun TrackedList(items: List<Item>) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()

Column {
TopBar(
title = "Items",
onScrollToTop = { scope.launch { listState.animateScrollToItem(0) } }
)
LazyColumn(state = listState) {
items(items, key = { it.id }) { ItemRow(it) }
}
}

// Show a "scroll to top" FAB after scrolling past item 10
val showFab by remember {
derivedStateOf { listState.firstVisibleItemIndex > 10 }
}
AnimatedVisibility(showFab) {
FloatingActionButton(onClick = { scope.launch { listState.animateScrollToItem(0) } }) {
Icon(Icons.Default.KeyboardArrowUp, null)
}
}
}

Scroll-aware effects

// Log scroll depth for analytics without triggering recompositions
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.debounce(1_000)
.collect { analytics.logScrollDepth(it) }
}

// Load more when approaching the end
LaunchedEffect(listState) {
snapshotFlow {
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
val lastVisible = visibleItems.lastOrNull()?.index ?: 0
val total = layoutInfo.totalItemsCount
lastVisible >= total - 5
}.filter { it }
.collect { viewModel.loadMore() }
}

Restore scroll position across navigation

LazyListState is Saveable by default — scroll position survives configuration changes and process death automatically.


Grids

Fixed grid

LazyVerticalGrid(
columns = GridCells.Fixed(3), // 3 columns
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(photos, key = { it.id }) { photo ->
AsyncImage(
model = photo.url,
contentDescription = photo.caption,
modifier = Modifier
.aspectRatio(1f)
.clip(MaterialTheme.shapes.small)
)
}
}

Adaptive grid (responsive)

LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp), // as many columns as fit
/* ... */
)

Grid item spans

LazyVerticalGrid(columns = GridCells.Fixed(3)) {
item(span = { GridItemSpan(maxLineSpan) }) { // full-width header
SectionHeader("Featured")
}
items(photos, span = { if (it.isFeatured) GridItemSpan(2) else GridItemSpan(1) }) { photo ->
PhotoCard(photo)
}
}

Staggered grid (Pinterest-style)

LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(8.dp)
) {
items(items, key = { it.id }) { item ->
Card { /* variable-height content */ }
}
}

Pull to refresh

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RefreshableFeed(viewModel: FeedViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val pullState = rememberPullToRefreshState()

PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = viewModel::refresh,
state = pullState
) {
LazyColumn(Modifier.fillMaxSize()) {
items(state.items, key = { it.id }) { FeedItem(it) }
}
}
}

HorizontalPager / VerticalPager

@Composable
fun Onboarding(pages: List<OnboardingPage>) {
val pagerState = rememberPagerState(pageCount = { pages.size })
val scope = rememberCoroutineScope()

Column {
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 32.dp),
pageSpacing = 16.dp
) { page ->
OnboardingCard(pages[page])
}

PagerIndicator(
pageCount = pages.size,
currentPage = pagerState.currentPage,
currentPageOffsetFraction = { pagerState.currentPageOffsetFraction }
)

Button(onClick = { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } }) {
Text("Next")
}
}
}

pagerState.currentPageOffsetFraction is the real-time offset between pages — perfect for driving synchronized animations (scale, alpha, indicators).


Integrating Paging 3

Paging 3 is the right answer for lists larger than a few hundred items or backed by a remote API with cursor pagination.

@HiltViewModel
class FeedViewModel @Inject constructor(
repository: ArticleRepository
) : ViewModel() {
val articles: Flow<PagingData<Article>> = repository.pager()
.cachedIn(viewModelScope) // cache across config changes
}

@Composable
fun FeedScreen(viewModel: FeedViewModel = hiltViewModel()) {
val articles = viewModel.articles.collectAsLazyPagingItems()

LazyColumn {
items(
count = articles.itemCount,
key = articles.itemKey { it.id },
contentType = articles.itemContentType { "article" }
) { index ->
val article = articles[index]
if (article != null) ArticleRow(article) else ArticlePlaceholder()
}

// Footer based on load state
when (val append = articles.loadState.append) {
is LoadState.Loading -> item { CircularProgressIndicator(Modifier.fillMaxWidth().padding(16.dp)) }
is LoadState.Error -> item { ErrorItem(append.error) { articles.retry() } }
else -> Unit
}

// Initial load / refresh state
when (val refresh = articles.loadState.refresh) {
is LoadState.Loading -> item { FullScreenLoader() }
is LoadState.Error -> item { FullScreenError(refresh.error) { articles.retry() } }
else -> Unit
}
}
}

Item animations

@OptIn(ExperimentalFoundationApi::class)
LazyColumn {
items(
items = messages,
key = { it.id }
) { message ->
MessageRow(
message = message,
modifier = Modifier.animateItem() // smooth re-order / insert / delete
)
}
}

animateItem() (previously animateItemPlacement()) animates position changes when items are added, removed, or reordered. Requires stable keys.


Performance tips for lazy lists

Anti-patterns

Performance killers

  • No key on items
  • Unstable List<T> in state
  • Expensive computation in item composable
  • Modifier chain allocated per item
  • Nested LazyColumn inside LazyColumn (crashes)
  • Items reading state that changes every scroll pixel
Best practices

Proven patterns

  • Stable key + contentType always
  • @Immutable + ImmutableList for state
  • Precompute display strings in ViewModel
  • remember { } the shared Modifier chain
  • Use `items()` with a flat List and headers via `item()`
  • Pass lambdas (state providers), not raw state

Measure scroll jank

@Test
fun feedScrollJank() = benchmarkRule.measureRepeated(
packageName = "com.myapp",
metrics = listOf(FrameTimingMetric()),
compilationMode = CompilationMode.Partial(),
iterations = 10,
startupMode = StartupMode.COLD,
setupBlock = { startActivityAndWait() }
) {
val list = device.findObject(By.res("feed"))
list.setGestureMargin(device.displayWidth / 5)
repeat(5) {
list.fling(Direction.DOWN)
device.waitForIdle()
}
}

Use FrameTimingMetric from :benchmark module (Module 10) to get the P50/P95/P99 frame time distribution.


Key takeaways

Practice exercises

  1. 01

    Sticky-header list

    Build a contacts list grouped by first letter with stickyHeader{}. Verify headers stick during scroll.

  2. 02

    Scroll-to-top FAB

    Use rememberLazyListState + derivedStateOf to show a FAB after scrolling past item 10. Animate in/out.

  3. 03

    Staggered grid

    Render a LazyVerticalStaggeredGrid of photos with random heights. Observe how staggered arrangement works.

  4. 04

    Paging 3 integration

    Integrate a RemoteMediator-backed Pager into a LazyColumn with load-state footers for loading/error states.

  5. 05

    Item animation

    Add Modifier.animateItem() to a list. Delete an item mid-list and confirm it animates out smoothly.

Next

Continue to Navigation Masterclass for type-safe Nav Compose, nested graphs, deep links, and shared element transitions.