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:
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.
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)
}
}
}