
Navigating lists in Jetpack Compose with LazyListState
While Jetpack Compose was still in alpha stages, I was immediately won over by a single demo: replacing a RecyclerView with a LazyColumn. What takes RecyclerView 5 files, LazyColumn can accomplish with 5 lines.
LazyColumn {
items(data) {
ListItem(it)
}
}
The productivity and developer experience gains of LazyColumn are phenomenal, and it is incredibly simple to use for basic use cases. LazyListState takes it to the next level. In this article I’ll introduce you to several of LazyListState’s most helpful features.
Getting Set Up
Using LazyListState is as simple as creating a stateful LazyListState instance with rememberLazyListState()
and passing it to your LazyColumn.
val listState = rememberLazyListState()
LazyColumn(listState = listState)
That’s it! Now we’re ready to start using our LazyListState.
Properties
LazyListState has 3 very useful properties: firstVisibleItemIndex
, firstVisibleItemScrollOffset
, and isScrollInProgress
.
As its name suggests, firstVisibleItemIndex
returns the index of the first item that is visible on the screen. This is similar to RecyclerView’s LayoutManager’s findFirstVisibleItemPosition()
but with the added Compose superpower that it reflects state at any given time and will thus force a recomposition when the underlying state changes. So for example in the following code snippet our Text composable will always display the index of the first visible item, updating automatically as the user scrolls LazyColumn.
val listState = rememberLazyListState()
...
Text(text = "First index: ${listState.firstVisibleItemIndex}")
No scroll listeners, no calls to update text, no invalidating views. Thanks to Compose’s declarative paradigm, reading firstVisibleItemIndex
ensures that your UI always reflects the latest data.
Similarly, firstVisibleItemScrollOffset
reflects the distance in pixels between the top of the first visible item and the first visible pixel. So for example if you jump to the item at index 3 in the list and then scroll down 135 pixels, firstVisibleItemScrollOffset
will be 135. This property automatically resets as you scroll across item boundaries. So in the previous example, if the item at index 3 was only 100 pixels tall, then after we scrolled 135 pixels firstVisibleItemIndex
would be 4 and firstVisibleItemScrollOffset
would be 35. This is a significant improvement over RecyclerView, which bewilderingly does not maintain its own scroll offset and is a nightmare to track manually.
A third useful property is isScrollInProgress
which returns true or false depending on whether there is currently a scroll in progress. And again since Compose is declarative and LazyListState is stateful, this always reflects the latest data. A handy use case for this might be to display a header banner of the current scroll position whenever the user is scrolling.
if (listState.isScrollInProgress) {
val currentItem = items[listState.firstVisibleItemIndex]
HeaderBanner(currentItem)
}
Scrolling
LazyListState also lets you scroll the LazyColumn to a specific position with scrollToItem()
. scrollToItem()
requires a single parameter index
and allows an optional second parameter scrollOffset
. As you have probably guessed, combining this function with the properties we looked at above allows us to save and recall our exact scroll position.
// Save the current scroll position to a Datastore, database, etc.saveScrollPosition(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset
)...// And recall the previous scroll positionval index = ...
val offset = ...listState.scrollToItem(index, offset)
It is important to note that scrollToItem()
is a suspend function and so must be called from a coroutine scope. This is easy in Compose with rememberCoroutineScope()
. So a real example would look more like this:
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()...coroutineScope.launch {
listState.scrollToItem(index, offset)
}
scrollToItem()
could be called in response to a user interaction like clicking a Button or dragging a Slider, or it could be called upon launch. There are two important details to note if you are calling it upon launch. First, composables should be side-effect free and so your coroutine should be launched from a LaunchedEffect. Second, rememberLazyListState()
accepts two optional parameters for the initial index and scroll offset. So in our example above where we are loading a previous scroll position at launch, it might make more sense to load our index and offset from storage and just pass them to our remember function when we create our LazyListState:
val savedIndex = ...
val savedOffset = ...
val listState = rememberLazyListState(savedIndex, savedOffset)
One final thing to note is that LazyListState also provides a function animateScrollToItem()
. This behaves exactly like scrollToItem()
except that it will animate the scroll event rather than immediately jumping to the new position.
Follow for more on best practices in Kotlin and Android development.