Lazy lists are essential in Android app development, allowing users to navigate large datasets efficiently without compromising performance. Jetpack Compose offers powerful components for implementing lazy lists in your app.
In this topic, you'll learn how to use LazyColumn and LazyRow to efficiently create vertical and horizontal lists, and handle scenarios that require reacting to and controlling scroll positions.
Lazy lists
As previously mentioned, the primary components for implementing lazy lists are LazyColumn and LazyRow. While there are other lazy components, such as lazy grids, this topic will mainly focus on these two.
Why Use lazy lists?
Using a regular Column or Row composable with a scroll modifier may seem straightforward, but it leads to performance issues with large datasets. In such cases, all items in the list are composed and laid out regardless of whether they are visible on the screen or not. This can cause unnecessary memory usage and slow down your app. In contrast, lazy lists ensure that only the items currently visible are composed. This "lazy" approach optimizes resource usage and improves the performance of your app, making scrolling smoother and more efficient. Beyond composing only visible items, lazy lists also provide mechanisms to efficiently handle dynamic data, ensuring that changes in the dataset are efficiently reflected in the UI without unnecessary recompositions. A good rule of thumb is to use lazy lists whenever you are dealing with large or dynamically changing datasets where performance and memory usage are a concern.
Here is a quick overview and code snippet of both:
LazyColumn {
items(count = 100) { index ->
Text("Item #$index")
}
}LazyRow {
items(count = 100) { index ->
Text("Item #$index")
}
}Both LazyColumn and LazyRow are used to create vertically and horizontally scrollable lists respectively. Both take one required argument, content: LazyListScope.() -> Unit, which is a lambda receiver. This lambda defines the items to be displayed in the list. This is the last argument, allowing it to be passed as a trailing lambda.
Note that the lambda is not annotated with @Composable, meaning it is not a composable function itself. As a result, you cannot directly call other composable functions within this lambda.
The LazyListScope provided by both the LazyColumn and LazyRow composables offers functions such as items to define the content of the list. In our code snippet above, items(100) adds 100 items to the list, each represented by a Text composable displaying "Item #$index". More details about LazyListScope and its provided functions will be discussed in the next section.
Since LazyColumn and LazyRow are nearly identical in their usage and behavior, we'll focus on LazyColumn for the remainder of this topic. Keep in mind that the concepts and code snippets can easily be adapted for LazyRow.
LazyListScope and item definitions
Jetpack Compose uses the concept of a receiver scope to allow certain functions to be called directly in the content lambda without a qualifying receiver. In the context of lazy lists, the LazyListScope provides functions for defining items within LazyColumn and LazyRow.
We have already used one of the provided functions in our previous code snippet:
// Inside `LazyListScope`
fun items(
count: Int,
key: ((index: Int) -> Any)? = null,
contentType: (index: Int) -> Any? = { null },
itemContent: @Composable LazyItemScope.(index: Int) -> Unit
)Let's take a closer look at its parameters:
count: Int:This specifies the number of items in the list. As demonstrated in our earlier code snippet, we set the count to 100, indicating that our list contained exactly 100 items.
key: ((index: Int) -> Any)? = null:This is a nullable function that you can use to provide a stable and unique key for each item. In a moment we'll see what this means and why it matters.
contentType: (index: Int) -> Any? = { null }:This function maps each item to a content type, useful when your list contains multiple item types, such as header items and content items. We'll soon explore its significance and relevance.
itemContent: @Composable LazyItemScope.(index: Int) -> Unit:This is a composable lambda that defines the content of each item in the list. For instance, in our previous example, each item was a
Textcomposable. Note that the lambda receives an index.
The LazyListScope also provides the item function, which allows you to define a single item within the lazy list:
fun item(
key: Any? = null,
contentType: Any? = null,
content: @Composable LazyItemScope.() -> Unit
)The item function is useful when you want to add individual items to the list that don't necessarily come from a collection or follow a specific pattern.
In addition to the items function we discussed earlier, which takes a count parameter, there are several other variations of the items function that accept different types of collections:
// Note that these are extenstion functions on `LazyListScope`
// You can ignore the inline, noinline and crossinline keywords if you are not familiar.
inline fun <T> LazyListScope.items(
items: List<T> || items: Array<T>,
noinline key: ((item: T) -> Any)? = null,
noinline contentType: (item: T) -> Any? = { null },
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
)These functions allow you to directly pass a generic list or array as the items parameter (we joined the two functions since all the other parameters are identical), and the lazy list will create an item for each element in the collection. In most cases, you will be using these functions. The itemContent lambda now receives the individual item (of type T) instead of the index.
Since these are extension functions and not directly part of LazyListScope, you have to import them from androidx.compose.foundation.lazy.items.
Furthermore, there are itemsIndexed extension functions that are similar to the two items functions that take collections but provide an additional index parameter in the key, contentType, and itemContent lambdas, allowing you to access both the index and the item when providing the lambdas. Similar to the two previously mentioned items variations, you have to import itemsIndexed from androidx.compose.foundation.lazy.itemsIndexed.
Note that you can add multiple item(s) functions in a lazy list:
LazyColumn {
item {
Header()
}
items(data) { item ->
ItemCard(item)
}
item {
Footer()
}
}Keys in lazy lists:
The key parameter in the lazy list functions serves as a unique identifier for each item in the list. But why do items need to be uniquely identified? By default (when null is passed as an argument to the key parameter), each item's state in a lazy list is keyed against its position. However, this can lead to issues and unexpected behaviors (when handled incorrectly) if the data set changes. Let's consider the following scenario:
// data class ItemData(val id: Int, val name: String)
@Composable
fun MyLazyList(data: List<ItemData>, deleteItem: (Int) -> Unit) {
LazyColumn {
items(items = data) { item ->
var bgColor by remember { mutableStateOf(Color.Red) }
Text(
text = item.name,
modifier = Modifier
.combinedClickable(
onLongClick = {
deleteItem(item.id)
},
onClick = {
bgColor = if (bgColor == Color.Red) Color.Yellow else Color.Red
}
)
.fillMaxWidth()
.background(bgColor)
.padding(16.dp)
)
}
}
}If an item is deleted from the list by long-clicking the item, the state of the remaining items changes "unexpectedly". If we have three items in the list: "Item 1", "Item 2", and "Item 3". If the user clicks on "Item 2" to change its background color to yellow and then deletes "Item 1" from the list, the state of "Item 2" will be lost. Instead, "Item 3" will occupy the position previously held by "Item 2" and will inherit its yellow background color. This is because the state of an item is tied to the key of the item which in turn (by default) is keyed against its position.
Another great example is if you have a LazyRow within a LazyColumn, and the row changes item position, the user would lose their scroll position within the row.
To address this, you can provide a unique key for each item using the key parameter. This will ensure that the item state remains consistent across data-set changes:
@Composable
fun MyLazyList(data: List<ItemData>, deleteItem: (Int) -> Unit) {
LazyColumn {
// Here the `id` property of the item is provided as the key
// It's common to use the id of an object (if available) as the key as it is already unique!
items(items = data, key = { it.id }) { item ->
...
}
}
}By providing keys, you help Compose handle reorderings correctly. If your item contains remembered state, setting keys allows Compose to move this state together with the item when its position changes. However, there is a limitation on the types you can use as item keys. The key's type must be supported by Bundle. Bundle supports types like primitives, enums, or Parcelables.
It's worth noting that when items are scrolled off-screen in a LazyColumn, the remember function does not retain the state of those items. If you need to persist the state across configuration changes or when items are scrolled off-screen and back on-screen, you should use rememberSaveable instead of remember. rememberSaveable automatically saves and restores the state of the composable, ensuring that the state is preserved even when the composable is destroyed and recreated.
Parcelable is an Android interface that allows for efficient serialization and deserialization of objects so they can be passed between components via Bundles.
Content Types in Lazy Lists:
The contentType parameter in lazy list functions allows you to specify the type of content for each item in the list. It is particularly useful when your list contains multiple item types, such as header items and content items. By providing a contentType for each item, Compose can optimize the rendering process by reusing the same composables for items of the same type:
LazyColumn {
items(
items = items,
contentType = { item ->
when (item) {
is HeaderItem -> "header"
is ContentItem -> "content"
else -> throw IllegalArgumentException("Unknown item type")
}
}
) { item ->
when (item) {
is HeaderItem -> HeaderComposable(item)
is ContentItem -> ContentComposable(item)
}
}
}Content padding and spacing
In most cases, you'll need to add padding around the content. LazyColumn and LazyRow allow you to pass PaddingValues to the contentPadding parameter:
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
// ...
}Note that padding is applied to the content and not the LazyColumn itself.
You can use Arrangement.spacedBy() to add spacing in-between items:
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// ...
}LazyRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// ...
}LazyListState
LazyListState is a key component in managing the state of lazy lists in Jetpack Compose. It provides useful properties and methods for observing and controlling the scroll position of LazyColumn and LazyRow. We can use the rememberLazyListState function to hoist LazyListState and pass it to LazyColumn or LazyRow:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
...
}For most use cases, you commonly need to know information about the first visible item. LazyListState provides the firstVisibleItemIndex and firstVisibleItemScrollOffset properties. For example, to show a "scroll to top" button when a list is scrolled past the first item:
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun MyLazyList(items: List<Item>) {
Box {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
...
}
// The button is displayed if the first item visible
// is past the first item.
// Remembered derived state is used to minimize unnecessary compositions
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton(onClick = { ??? })
}
}
}But how do we scroll to the top? We can do so by using the scrollToItem function or animateScrollToItem function. The scrollToItem function snaps the scroll position whereas animateScrollToItem scrolls using an animation:
listState.animateScrollToItem(index = 0)Conclusion
In conclusion, lazy lists are crucial for efficient navigation of large datasets in Android app development, and Jetpack Compose provides robust components like LazyColumn and LazyRow to create vertical and horizontal lists seamlessly. These components enable developers to handle scrolling effectively, ensuring optimal performance and a smooth user experience. Here are some tips when working with lazy lists.
On debug builds, Lazy layout scrolling may appear slower. To reliably measure the performance of a Lazy layout, run the app in release mode with R8 optimisation enabled.