DEV Community

Luis Mierez
Luis Mierez

Posted on

10 1

Infinite LazyColumn in Jetpack Compose

Displaying a list of content is one of the most common things that we are required to add to our apps and in my experience the lists are more often than not required to have some sort of pagination. This is a fairly common pattern where we only fetch the items needed at the time. The way we handle that is to show a list with a number of items (say 20) and as the user reaches the end of those first 20 items, we fetch the next 20 items.

In our app we have an implementation of RecyclerView.OnScrollListener where we check if we are close to the end of the list and trigger a loadMore callback. Now that we are moving to compose I wanted to find a way to do the same pattern and came up with this:

/**
* Handler to make any lazy column (or lazy row) infinite. Will notify the [onLoadMore]
* callback once needed
* @param listState state of the list that needs to also be passed to the LazyColumn composable.
* Get it by calling rememberLazyListState()
* @param buffer the number of items before the end of the list to call the onLoadMore callback
* @param onLoadMore will notify when we need to load more
*/
@Composable
fun InfiniteListHandler(
listState: LazyListState,
buffer: Int = 2,
onLoadMore: () -> Unit
) {
val loadMore = remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
lastVisibleItemIndex > (totalItemsNumber - buffer)
}
}
LaunchedEffect(loadMore) {
snapshotFlow { loadMore.value }
.distinctUntilChanged()
.collect {
onLoadMore()
}
}
}

Works similar to our old RecyclerView implementation but now in compose! Let me explain what is going on.

First the parameters:

listState: LazyListState as the name implies is the state of the list, which you can get by calling val listState = rememberLazyListState(). This state has all the info that we need to observe to create our infinite loading list. It can also be used to control the list, but we aren't using that in here. REMEMBER: This same listState needs to be passed to the LazyColumn

buffer: Int = 2 This tells our InfiniteListHandler how many items BEFORE we get to the end of the list to call the onLoadMore function. This is technically not needed, but it makes the infinite loading more seamless, since we can control when to fetch new data and we don't need to show a loading indicator at the end of the list.

onLoadMore: () -> Unit This function will get called once the user reaches the load more threshold.

Now to our logic. It's fairly straightforward: we want to call the onLoadMore function when the last visible item on the list is within the threshold of our total items in the list minus our buffer. For example:
If we have a list with 20 items, we have a buffer of 4, and with our test device we can see 6 items at a time. As the user scrolls the last visible item index will keep getting higher and higher until it reaches 16, which is our threshold of the last item index (20) minus our buffer (4). At which point the onLoadMore will get called and we can fetch and load the next 20 items. Then our list will have 40 items and we will fetch more items once the last visible item index reaches 36, totalItems(40) - buffer(4). And so on until we have no more items to show.

We can write this in code like:

val loadMore = remember {
        derivedStateOf {
            val layoutInfo = listState.layoutInfo
            val totalItemsNumber = layoutInfo.totalItemsCount
            val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1

            lastVisibleItemIndex > (totalItemsNumber - buffer)
        }
    }
Enter fullscreen mode Exit fullscreen mode

We use a remembered derived state to minimize unnecessary compositions. See derivedStateOf for more info.

But inside we use the listState to get the totalItemsNumber and the lastVisibleItemIndex which we add 1 to, to make it match the total count. We then check that lastVisibleItemIndex is greater than our totalItemsNumber minus our buffer.

There is one last thing that we need to get this working, and that is that we need to observe changes to the loadMore State. We do that like so:

LaunchedEffect(loadMore) {
        snapshotFlow { loadMore.value }
            .distinctUntilChanged()
            .collect {
                onLoadMore()
            }
    }
Enter fullscreen mode Exit fullscreen mode

We now have a fully working InfiniteListHandler that will notify you whenever you are at the end of the list, or close to it depending on your buffer. And to use it you can do something like:

@Composable
fun InfiniteList(
listItems: List<UserItem>,
onLoadMore: () -> Unit
) {
val listState = rememberLazyListState()
LazyColumn(
state = listState
) {
items(listItems) { userItem ->
UserRow(userItem)
}
}
InfiniteListHandler(listState = listState) {
onLoadMore()
}
}
view raw InfiniteList.kt hosted with ❤ by GitHub

Happy Composing!

Heroku

This site is built on Heroku

Join the ranks of developers at Salesforce, Airbase, DEV, and more who deploy their mission critical applications on Heroku. Sign up today and launch your first app!

Get Started

Top comments (5)

Collapse
 
akr profile image
UEDA Akira • Edited

This article is very helpful. Thanks.
But in my case, I found that if appended items are small and the loadMore remains true after load, onLoadMore won't be called because the state is not changed. I solved it by modifying the snapshotFlow line as follows:

snapshotFlow { Pair(loadMore.value, listState.layoutInfo.totalItemsCount) }
Enter fullscreen mode Exit fullscreen mode
Collapse
 
kenkyee profile image
Ken Yee • Edited

why isn't
LaunchedEffect(loadMore) {
snapshotFlow { loadMore.value }
.distinctUntilChanged()
.collect {
onLoadMore()
}
}

looking for all true values instead?
i.e. snapshotFlow { loadMore.value }.filter{it}.collect{...}

Collapse
 
quantakt profile image
Quanta

I thought about that too, it looks more explicit and concise to me (assuming it works, about to test it out in a bit)

Collapse
 
gandrewstone profile image
G. Andrew Stone

@akr's solution triggers every time. And @kenkyee's observation appears unnecessary because according to snapshotFlow: "If the result of block is not equal to the previous result, the flow will emit that new result. (This behavior is similar to that of Flow. distinctUntilChanged.)"

But we need onLoadMore to trigger whenever the inputs to loadMore evaluate to true (but never when false), whenever those inputs change (to solve @akr's bug):

val loadMore: State<Pair<Int, Int>> = remember {
...
Pair(lastVisibleItemIndex,(totalItemsNumber - buffer))
}

LaunchedEffect(loadMore) {
        snapshotFlow { loadMore.value }
            .filter { it.first > it.second}
            .collect {
                onLoadMore(readAhead)
            }
    }```


Enter fullscreen mode Exit fullscreen mode
Collapse
 
aeh2153 profile image
AEH

Hi Luis - thanks so much for this. It's really helpful and I am hoping to use it in my app!

I have a controller set up where I call the list. Here, I pass through my instance of UserItems list, but with this added handler I'll also have to pass through something for onLoadMore.

Can you help me understand how to do this? Maybe an example of how you would call InfiniteList.

Thanks so much for your help.

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs