Hacking LazyList in Android Jetpack Compose

LazyColumn and LazyRow are powerful, but they have a big flaw that affects list reordering

Gergely Kőrössy
5 min readOct 5, 2023

LazyColumn and LazyRow are very useful components of the compose ecosystem. They are, a the somewhat equivalent of RecyclerView from the view / XML era. However, the Lazy* world has a huge problem when it comes to changing the order of the list items, which was reported almost 2 years ago first and it’s yet to be fixed.

This article will show you the problem in detail, and also provides a solution that is easy to use thanks to a published library.

Update (February 29, 2024): The library has been updated to v1.1.0 to include implementation for LazyGrid, too.

The problem

Imagine that you’re creating a to-do app, which is one of the unofficial starter projects of most Android beginner tutorials. You follow the article to the letter and everything looks great, you have a working to-do app. Great job!

A simple to-do app, favorite for beginners

Usually these apps don’t do much: you can add new items to the list and tick them off, so you see what’s left to do. But what if you want to make it work similar to, for example Google Keep, where ticking off an item doesn’t just simply change the checkbox state to checked, but it also moves the item to the end of the list, so you can focus on the remaining things to do? Shouldn’t be that difficult, right? You just move the item to the end of the list and you are good to go.

Checking the first list item scrolls the list down to the bottom automatically
Checking the first list item scrolls the list down to the bottom automatically — not great

Uh-oh. Did you see that? The whole list scrolled to the bottom where the item was moved to, which is not what we want.

The explanation

Internally LazyColumn (and all of the Lazy* UI elements) uses LazyListState to keep the track of the list state. This state object contains an instance of LazyListScrollPosition which is responsible for keeping track of, amongst other things, the first visible item index, the scroll offset of the said item, and the last known key of the first item.

/** The last know key of the item at [index] position. */
private var lastKnownFirstItemKey: Any? = null

The key — which is set every time the list is measured — is used to update the scroll position of the list if the first item was moved. LazyListScrollPositionhas a function updateScrollPositionIfTheFirstItemWasMoved which is called from the LazyListState instance when it’s time to update the position, and the description says:

In addition to keeping the first visible item index we also store the key of this item. When the user provided custom keys for the items this mechanism allows us to detect when there were items added or removed before our current first visible item and keep this item as the first visible one even given that its index has been changed.

What it essentially tells us is the lazy list scrolls to the first item if the list content changes and the items move around. This is seemingly for UX purposes, however, it causes the issue in our case as we don’t want the list to scroll to the first items new position.

The solution

To stop the scrolling mechanism in case of the list reordering, we need to disable this functionality by setting the last known key to null as it would render the updateScrollPositionIfTheFirstItemWasMoved call no-op.
Unfortunately, since the aforementioned class LazyListScrollPosition is internal, there’s no way to simply override it. Thankfully we have a very powerful, yet usually frowned upon solution: reflection! Using reflection we can gain access to even the private variables.

Analyzing the source code, it becomes obvious that the call to the update method likely happens after the index of the first element is accessed. The index is a delegated property: when it changes, the composer is notified so the app could react appropriately. The type of index is Int which is the delegate type of mutableIntStateOf, but under the hood it’s a MutableIntState.

var index by mutableIntStateOf(initialIndex)

This also means if we replaced index with a custom MutableIntState, we could get notified of any read accesses and react by setting the lastKnownFirstItemKey to null. For this, we will use reflection heavily.

  1. gain access to the scrollPosition property of LazyListState
  2. gain access to the lastKnownFirstItemKey property in this LazyListScrollPosition instance
  3. create a function that sets the value of this lastKnownFirstItemKey reference to null when called
  4. gain access to the index property and set its value to the custom MutableIntState
  5. ???
  6. profit

Easy, isn’t it? Let’s see the code!

One interesting bit is how we can access the index delegated property via reflection: we need to address index$delegate instead of simply just index. This is because it is in fact a delegated property where the delegate is the one providing the value of the field.

The code for the custom MutableIntState is pretty simple, it calls the key remover function whenever the value or intValue properties are read.

Calling the key remover when the properties are read

Note how we used delegation for providing the implementation of IntStateHijacker.

Now what remains is to instantiate the LazyListHijacker with the LazyListState instance we use for the LazyColumn and we are ready to roll! Just don’t forget to remember, so it doesn’t get recreated on every recomposition:

And the result is just magnificent:

Checking the first list item doesn’t scroll the list down anymore
Checking the first list item doesn’t scroll the list down anymore

This might not be the most elegant solution, and it could lead to crashes if the underlying implementation of LazyList changes, but it’s a small price to pay for the improved user experience.

The entire repository including the library and a sample app is available at
https://github.com/gregkorossy/lazylist-state-hijack

Also, if you just want to use it as a library, add the following dependency to your module build.gradle.kts file:

implementation("me.gingerninja.lazylist:hijacker:1.1.0")

Sample usage:

In the next part, we will implement the drag and drop reordering feature.
Stay tuned!

--

--

Gergely Kőrössy

Android dev with almost 10 years of experience in creating apps (and sometimes web stuff too)