Compete in the Jetpack Compose #AndroidDevChallenge for a chance to win one of over 1,000 prizes, including a Google Pixel 5. Learn more

Lists

Many apps need to display collections of items. This document explains how you can efficiently do this in Jetpack Compose.

If you know that your use case does not require any scrolling, you may wish to use a simple Column or Row (depending on the direction), and emit each item’s content by iterating over a list like so:

@Composable
fun MessageList(messages: List<Message>) {
    Column {
        messages.forEach { message ->
            MessageRow(message)
        }
    }
}

We can make the Column scrollable by using the verticalScroll() modifier. See the Gestures documentation for more information.

Lazy composables

If you need to display a large number of items (or a list of an unknown length), using a layout such as Column can cause performance issues, since all the items will be composed and laid out whether or not they are visible.

Compose provides a set of components which only compose and lay out items which are visible in the component’s viewport. These components include LazyColumn and LazyRow.

As the name suggests, the difference between LazyColumn and LazyRow is the orientation in which they lay out their items and scroll. LazyColumn produces a vertically scrolling list, and LazyRow produces a horizontally scrolling list.

The lazy components are different to most layouts in Compose. Instead of accepting a @Composable content block parameter, allowing apps to directly emit composables, the lazy components provide a LazyListScope.() block. This LazyListScope block offers a DSL which allows apps to describe the item contents. The lazy component is then responsible for adding the each item’s content as required by the layout and scroll position.

LazyListScope DSL

The DSL of LazyListScope provides a number of functions for describing items in the layout. At the most basic, item() adds a single item, and items(Int) adds multiple items:

LazyColumn {
    // Add a single item
    item {
        Text(text = "First item")
    }

    // Add 5 items
    items(5) { index ->
        Text(text = "Item: $index")
    }

    // Add another single item
    item {
        Text(text = "Last item")
    }
}

There are also a number of extension functions which allow you to add collections of items, such as a List. These extensions allow us to easily migrate our Column example from above:

import androidx.compose.foundation.lazy.items

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(messages) { message ->
            MessageRow(message)
        }
    }
}

There is also a variant of the items() extension function called itemsIndexed(), which provides the index. Please see the LazyListScope reference for more details.

Content padding

Sometimes you'll need to add padding around the edges of the content. The lazy components allow you to pass some PaddingValues to the contentPadding parameter to support this:

LazyColumn(
    contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
    // ...
}

In this example, we add 16.dp of padding to the horizontal edges (left and right), and then 8.dp to the top and bottom of the content.

Please note that this padding is applied to the content, not to the LazyColumn itself. In the example above, the first item will add 8.dp padding to it’s top, the last item will add 8.dp to its bottom, and all items will have 16.dp padding on the left and the right.

Content spacing

To add spacing in-between items, you can use Arrangement.spacedBy(). The example below adds 4.dp of space in-between each item:

LazyColumn(
    verticalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

Similarly for LazyRow:

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

Item animations

If you’ve used the RecyclerView widget, you’ll know that it animates item changes automatically. The Lazy layouts do not yet provide that functionality, which means that item changes cause an instant ‘snap’. You can follow this bug to track any changes for this feature.

Sticky headers (experimental)

The ‘sticky header’ pattern is helpful when displaying lists of grouped data. Below you can see an example of a ‘contacts list’, grouped by each contact’s initial:

Video of a phone scrolling up and down through a contacts list

To achieve a sticky header with LazyColumn, you can use the experimental stickyHeader() function, providing the header content:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ListWithHeader(items: List<Item>) {
    LazyColumn {
        stickyHeader {
            Header()
        }

        items(items) { item ->
            ItemRow(item)
        }
    }
}

To achieve a list with multiple headers, like the ‘contacts list’ example above, you could do:

// TODO: This ideally would be done in the ViewModel
val grouped = contacts.groupBy { it.firstName[0] }

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(grouped: Map<Char, List<Contact>>) {
    LazyColumn {
        grouped.forEach { (initial, contactsForInitial) ->
            stickyHeader {
                CharacterHeader(initial)
            }

            items(contactsForInitial) { contact ->
                ContactListItem(contact)
            }
        }
    }
}

Grids (experimental)

The LazyVerticalGrid composable provides experimental support for displaying items in a grid.

Screenshot of a phone showing a grid of photos

The cells parameter controls how cells are formed into columns. The following example displays items in a grid, using GridCells.Adaptive to set each column to be at least 128.dp wide:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PhotoGrid(photos: List<Photo>) {
    LazyVerticalGrid(
        cells = GridCells.Adaptive(minSize = 128.dp)
    ) {
        items(photos) { photo ->
            PhotoItem(photo)
        }
    }
}

If you know the exact amount of columns to be used, you can instead provide an instance of GridCells.Fixed containing the number of required columns.

Reacting to scroll position

Many apps need to react and listen to scroll position and item layout changes. The lazy components support this use-case by hoisting the LazyListState:

@Composable
fun MessageList(messages: List<Message>) {
    // Remember our own LazyListState
    val listState = rememberLazyListState()

    // Provide it to LazyColumn
    LazyColumn(state = listState) {
        // ...
    }
}

For simple use-cases, apps commonly only need to know information about the first visible item. For this LazyListState provides the firstVisibleItemIndex and firstVisibleItemScrollOffset properties.

If we use the example of a showing and hiding a button based on if the user has scrolled past the first item:

@OptIn(ExperimentalAnimationApi::class) // AnimatedVisibility
@Composable
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

Reading the state directly in composition is useful when you need to update other UI composables, but there are also scenarios where the event does not need to be handled in the same composition. A common example of this is sending an analytics event once the user has scrolled past a certain point. To handle this efficiently, we can use a snapshotFlow():

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

LazyListState also provides information about all of the items currently being displayed and their bounds on screen, via the layoutInfo property. See the LazyListLayoutInfo class for more information.

Controlling the scroll position

As well as reacting to scroll position, it’s also useful for apps to be able to control the scroll position too. LazyListState supports this via the scrollToItem() function, which ‘immediately’ snaps the scroll position, and animateScrollToItem() which scrolls using an animation (also known as a smooth scroll):

@Composable
fun MessageList(messages: List<Message>) {
    val listState = rememberLazyListState()
    // Remember a CoroutineScope to be able to launch
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(state = listState) {
        // ...
    }

    ScrollToTopButton(
        onClick = {
            coroutineScope.launch {
                // Animate scroll to the first item
                listState.animateScrollToItem(index = 0)
            }
        }
    )
}

Large data-sets (paging)

The Paging library enables apps to support large lists of items, loading and displaying small chunks of the list as necessary. Paging 3.0 and later provides Compose support through the androidx.paging:paging-compose library.

To display a list of paged content, we can use the collectAsLazyPagingItems() extension function, and then pass in the returned LazyPagingItems to items() in our LazyColumn. Similar to Paging support in views, you can display placeholders while data loads by checking if the item is null:

import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items

@Composable
fun MessageList(pager: Pager<Int, Message>) {
    val lazyPagingItems = pager.flow.collectAsLazyPagingItems()

    LazyColumn {
        items(lazyPagingItems) { message ->
            if (message != null) {
                MessageRow(message)
            } else {
                MessagePlaceholder()
            }
        }
    }
}

Item keys

By default, each item's state is keyed against the position of the item in the list. However, this can cause issues if the data set changes, since items which change position effectively lose any remembered state. If you imagine the scenario of LazyRow within a LazyColumn, if the row changes item position, the user would then lose their scroll position within the row.

To combat this, you can provide a stable and unique key for each item, providing a block to the key parameter. Providing a stable key enables item state to be consistent across data-set changes:

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(
            items = messages,
            key = { message ->
                // Return a stable + unique key for the item
                message.id
            }
        ) { message ->
            MessageRow(message)
        }
    }
}