LazyX in Jetpack Compose
In the previous article, Scrollable Containers in Jetpack Compose, we explored the possibility of enabling scroll on any composable.
We also mentioned that in situations where we have a long list of items to scroll, it is much more optimal to use a Lazy
container, such as LazyColumn
, LazyRow
, LazyXGrid
etc.
In this article, we'll find out how we can use these containers, and what they can do for us.
Optimization (Recycling)
The reason why we would use a lazy container is that it provides recycling optimization, similar to the old good RecyclerView
, and we won't get into the details about it in this article.
Since all the lazy containers provide more or less the same API and functionality, in this article I'll use LazyColumn
as a reference. Just keep in mind that mostly the same applies to all the Lazy
containers.
The LazyColumn function
LazyColumn
is a composable function that we use to display a vertically scrollable list of items.
The API is super simple to use. All we have to do is call the LazyColumn
composable function.
The only mandatory argument we need to pass is the content
lambda, where we define how the items of the list will look like:
LazyColumn(content = {})
As we know, in Kotlin, we can move the lambda argument outside of the function parentheses, so we'll end up with something like this:
LazyColumn() {}
That's the bare minimum to use this API. The call above, however, won't render anything, because the content lambda is empty.
We'll explore the content
lambda in much more detail below. First, let's look at the optional arguments we can supply to the LazyColumn
function.
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = rememberLazyListState(),
contentPadding = PaddingValues(0.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
reverseLayout = false,
horizontalAlignment = Alignment.Start,
flingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled = true
) { ... }
modifier
(defaulting to Modifier
) - is a common argument in Composable functions, that allows us to enhance and decorate the composable's look and behavior like applying background, making it clickable, etc.
When creating custom and reusable composables on your own, it is a good practice to offer a Modifier as an argument, so that the caller can apply enhancements through it.
Remember that the Modifier is an ordered collection, so the order in which we apply the changes matters.
We will elaborate on the Modifier much more deeply in a standalone article, dedicated to it.
state
(defaulting to rememberLazyListState()
) - the current state of the LazyColumn. It gives us useful information about the current scroll, currently visible items, layout information, etc.
We can leverage this data from the state
for things like loading additional items when the scroll is close to the end of the list, or changing another composable based on the position of the first list item.
I believe this topic deserves an article on its own.
contentPadding
(defaulting to PaddingValues(0.dp)
) - allows us to create padding inside the container, without affecting the size of the container itself (as opposed to using Modifier.padding()
)
Let's see the difference.
In the following LazyColumn
, we apply padding through the Modifier
LazyColumn(
modifier = Modifier.fillMaxSize().padding(bottom = 50.dp),
...
) { ... }
Here is what we will get as a result:
As presented in the GIF above, applying padding through the Modifier
is reducing the container size by the provided amount (in this case 50.dp
), and it makes it feel like the LazyColumn
is being cut from the bottom.
On the other hand, let's see what happens if we use the contentPadding
argument
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 50.dp),
...
) { ... }
In this case, the container remains extended to its full size (in this example edge to edge):
The padding we applied with PaddingValues(bottom = 50.dp)
does not affect the container size. It is applied inside the container, below the last item.
Only when we scroll the list till the end, we will see the padding inside the container itself.
The contentPadding
argument allows us to define behavior as we would have done with the clipToPadding = false
back in the XML world.
It is useful in situations when we have something over the list (think of a FloatingActionButton
) and we want the last item of the list to be fully visible when scrolled to the bottom.
reverseLayout
(defaulting to false) - allows us to reverse the default (top-to-bottom) rendering of the items inside the list, so that they will appear from the bottom upwards.
This is useful when building a chat-like screen where the newest message is effectively shown at the bottom of the list.
verticalArrangement
(defaulting to Arrangement.Bottom
if reverseLayout = true
, or Arrangement.Top
if reverseLayout = false
) - allows us to change the vertical arrangement of the items inside the list.
A common case is when we want to add an equal spacing between the items inside the list.
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp),
...
) { ... }
horizontalAlignment
(defaulting to Alignment.Start
) - allows us to change the horizontal alignments of the list items.
Possible alignment options are: Alignment.Start
, Alignment.CenterHorizontally
, and Alignment.End
flingBehavior
(defaulting to ScrollableDefaults.flingBehavior()
) - allows us to alter the standard scrolling behavior.
This might be useful in cases when we use external devices (like a mouse) and we might potentially change the scroll direction when using the scroll wheel.
userScrollEnabled
(defaulting to true
) - allows us to disable the scrolling by the user input.
This could be useful in cases when we want to disable the scroll unless the user does a required step (like ticking a checkbox), or if we need to perform the scroll only programmatically.
Now that we have a good overview of the arguments available for the LazyColumn
composable, let's focus on its content lambda.
Content Lambda
As mentioned above, the content
argument is the only mandatory argument for the LazyColumn
composable. We use it to define how to render the items of the LazyColumn
.
The content
argument type is a lambda with a receiver, where the receiver type is LazyListScope
@Composable
fun LazyColumn(
...
content: LazyListScope.() -> Unit
) { ... }
The receiver in this case is crucial. It exposes functions that we can use to render the items efficiently.
interface LazyListScope {
fun item( ... )
fun items( ... )
@ExperimentalFoundationApi
fun stickyHeader( ... )
}
Additionally, the Compose SDK comes with a bunch of overloading extension functions on the LazyListScope
inline fun <T> LazyListScope.items(
items: List<T>,
...
)
inline fun <T> LazyListScope.itemsIndexed(
items: List<T>,
...
)
inline fun <T> LazyListScope.items(
items: Array<T>,
...
)
inline fun <T> LazyListScope.itemsIndexed(
items: Array<T>,
...
)
which provides us with convenience with the type that holds the items (Array
, List
) we are about to render.
As we established, since the content
argument of the LazyList
is a lambda with LazyListScope
receiver type, it means that inside this lambda we can call only the functions that the LazyListScope
exposes.
LazyColumn(
...
) {
item {}
items(...) {}
stickyHeader {}
}
Since the LazyListScope
receiver type is not composable type, we couldn't call other composable functions from inside the content
lambda.
If we try, the compiler will scream at us: @Composable invocations can only happen from the context of a @Composable function.
Now, let's look at each function we can call inside the content
lambda, and see how we can use them.
item
We use the item
function when we want to render a single item inside the list or an item different from the rest.
Think of a list header or a special section inside the list.
The only mandatory argument for this function is the content
. This argument is also a lambda with a receiver. But here, the type is composable, allowing us to invoke composable functions inside.
fun item(
key: Any? = null,
contentType: Any? = null,
content: @Composable LazyItemScope.() -> Unit
)
key
(defaulting to null
) - a unique key that separates this item from the rest. If we do not provide any value as a key
, it will default to null
, which internally will take the Int
position of the item inside the list as a key.
We will elaborate a bit more on the key and why it is important a bit below.
contentType
(defaulting to null
) - allows us to specify the type of content that will be rendered inside this item. The composition of items of the same type becomes much more efficient due to reusability.
null
in Kotlin is a valid type, so items of that type are considered compatible.
content
- composable scope that allows us to invoke (and therefore render) any composable function inside it.
The way we would typically use this function would be something like this:
LazyColumn(
...
) {
item {
ProfileHeader(...) //<- calling composable function
}
}
items
We use the items
function to render a collection of items. As mentioned before, there are a bunch of overloads that make usage of it super convenient, but the essence is the same.
fun items(
count: Int,
key: ((index: Int) -> Any)? = null,
contentType: (index: Int) -> Any? = { null },
itemContent: @Composable LazyItemScope.(index: Int) -> Unit
)
count
- is the amount of items we are about to render.
key
(defaulting to null
) - Does the same job as in the item
function. The difference here is that the key is a lambda that gives us the index of the element in question so that we can provide a specific key for it if we want to.
You'll find out below why the key
argument is super important.
contentType
(defaulting to {null}
) - Again, does the same as in the item
function, except the type is lambda which provides the index of the element in question. It is assigned to a lambda that produces null
by default, but we can provide a different type for a given index if we want to.
itemContent
- Once again, does the same as the item
function, with the difference that in this case, the lambda provides an index, so that we can get the correct item for that position from the collection where we hold the items to render.
This form of the items
function is mostly used with a combination of the paging library. From my experience, for the cases when you have the actual collection of items in hand, you will mostly use the items
overload that takes a list of items instead of the count.
inline fun <T> LazyListScope.items(
items: List<T>,
noinline key: ((item: T) -> Any)? = null,
noinline contentType: (item: T) -> Any? = { null },
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
)
Internally, it calls the baseitems
function, passing items.size
as count
. For the rest of the arguments tho, the difference is that the lambdas are providing the item
for the specific position for us, rather than its index.
That is much more convenient to work with compared with the index. There are no performance impacts or any other downsides.
Typical usage of this function might look like this:
LazyColumn(
...
) {
items(posts) { post ->
PostListItem(postItem = post) // <- calling composable function
}
}
The importance of the key argument
As mentioned above, the key
has to be a unique identifier for an element inside the LazyColumn
. Having the same key
value for more than 1 element is not allowed, and if that happens we get a crash.
Internally it is used to differentiate between the items. The key
type must be saveable in a Bundle
. This is required so that the LazyColumn
can store and restore the scroll position when needed.
Therefore, the scroll position of the LazyColumn
is maintained by the key
of its elements.
When we don't pass a specific key
argument, it defaults to null
. Internally, it means that the element position (Int)
will be used as a key.
That is enough and would work just fine when we only display items. However, if we need to do manipulation on the items, like adding, moving, or removing - the positions of the elements are changing, which causes the UI to misbehave.
On top of that, if these manipulations need to be animated, the UI becomes a complete mess.
In those situations, it is recommended that we provide a unique key per element. Something like this:
LazyColumn {
items(
items = posts,
key = { post -> post.id } //<- unique identifier for the element
) { post ->
PostListItem(content = post.content)
}
}
stickyHeader
We use the stickyHeader
function to render a header that will be stuck on top, so that the rest of the items are scrolling underneath it.
Only when another stickyHeader
gets to it - it will be scrolled out and the new stickyHeader
will take its place.
Typical usage of this function would be something like this:
LazyColumn {
stickyHeader {
...
}
items(users) { user ->
...
}
}
In comparison, imagine how hard it would be to achieve the same with the traditional view system.
The arguments that this function takes are the same as the ones for the item
function. They also serve the same purpose.
It is worth mentioning that this function is still marked as experimental.
Bonus: ListItem composable
In my experience, most of the time, people create their custom composables to render plain list items.
Nothing wrong with it, especially since these composables are made reusable.
I want to bring awareness to the built-in ListItem
composable that is part of the material3
This composable is suitable for most common kinds of list items, and instead of creating our own, we can save some time and use what's given to us out of the box.
Happy Composing!
P.S. Check out the Android Devs Skool Community and join us.
Subscribe to my newsletter
Read articles from Jov Mit directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Jov Mit
Jov Mit
Hi, I'm Jov I'm a software craftsman, currently focusing on JVM, Kotlin, and Android I help programmers increase their earning potential. Feel free to DM me if you are interested, or check out: https://linktr.ee/jovmit