Hacking LazyList in Android Jetpack Compose
LazyColumn and LazyRow are powerful, but they have a big flaw that affects list reordering
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!
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.
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. LazyListScrollPosition
has 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.
- gain access to the
scrollPosition
property ofLazyListState
- gain access to the
lastKnownFirstItemKey
property in thisLazyListScrollPosition
instance - create a function that sets the value of this
lastKnownFirstItemKey
reference tonull
when called - gain access to the
index
property and set its value to the customMutableIntState
- ???
- 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.
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:
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!