LazyX in Jetpack Compose

Jov MitJov Mit
11 min read

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:

modifier_padding.gif

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

Untitled design.gif

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),
  ...
) { ... }

Screenshot 2024-10-15 at 20.22.55.png


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.

sticky header.gif

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.

0
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