Glide'ing your way into RecyclerView state invalidation
And Glide'ing your way out too!
A few days ago, I worked on a sample project for a tutorial.
It was a very simple app: a list of images, where you could
click any image to view it in full screen. The code was
simple, well structured, did what it had to do, and did it
well. There was only one problem: the
RecyclerView
responsible for showing the image
list was resetting its state, i.e., its scroll position, when
you came back to it after checking an image in full screen.
A few things about RecyclerViews
Well, this can’t be right… I know I’m doing everything I’m
supposed to do in order for the RecyclerView
to
be able to retain its scroll position!
Given the huge amount of code samples online explaining how to
manually save and restore RecyclerView
state, it
seems that a lot of people think that this isn’t supposed to
happen automatically. Well, it is, and it’s actually quite
simple to do. You just have to make sure that:
- The
RecyclerView
has an ID. -
You setup the
Adapter
with all the data before theRecyclerView
goes through its first layout pass.
The first one is simple: you just go to the layout and add an
ID to the RecyclerView
. By default, if a
View
doesn’t have an ID, its state
won’t be stored. This one’s actually hard to miss since you’re probably
using an ID to access the view in the code.
The second one is trickier. It’s not only a matter of setting
up the Adapter
before the
RecyclerView
. You need to make sure that when the
RecyclerView
is about to go through that first
layout pass, it already has all the data it
needs. If the layout pass starts and the
Adapter
doesn’t have the same data or is empty,
the RecyclerView
’s scroll position will get
reset, as its state will be invalidated. So, for instance, if
an app displaying a RecyclerView
undergoes a
config change and has to send an API request for data, it’ll
be next to impossible for the data to arrive in time for the
layout pass, which means that the RecyclerView
’s
scrolling position will inevitably be reset to the initial
position.
The solution here is simple: just cache the data. For example,
if you have all the data cached in a LiveData
,
something like this will work:
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_list, container, false)
val myAdapter = createAdapter()
setupRecyclerView(view, myAdapter)
observeViewModel(myAdapter)
return view
}
private fun setupRecyclerView(view: View, myAdapter: MyAdapter) {
view.recyclerView.adapter = myAdapter
// Other settings like listeners, setHasFixedSize, etc
}
private fun observeViewModel(myAdapter: MyAdapter) {
viewModel.myLiveData.observe(viewLifecycleOwner) {
myAdapter.submitList(it)
}
}
By the time the RecyclerView
starts getting
drawn, the data is more than ready.
Hello darkness my old friend
“What am I missing?!”
This was the question I asked myself for three days. My
RecyclerView
had an ID, and my data was cached
and ready on time, so what could be wrong?
I tried everything I could think of. Removing
setHasFixedSize(true)
from the
RecyclerView
setup, removing animations, using
RecyclerView.Adapter
instead of
ListAdapter
,
StaggeredGridLayout
instead of
GridLayout
, setting things up in different
lifecycle methods and in different combinations, persisting
everything… I even saved and restored the state manually at
one point, but was not happy at all with the result (UI
flickering). Going through the RecyclerView
’s
code, I could see that its state was indeed being saved and
correctly retrieved, but later invalidated. I hadn’t felt this
mad at Android for years!
As I was close to give up on fixing the bug and on my software
engineering career in general, I began browsing Slack
channels. In one specific channel, I found something that
Jon F Hancock
said when trying to help someone else with a different
RecyclerView
problem:
If the size of your RecyclerView depends on its children, you shouldn’t set that to true.
The “that” in the quote refers to
setHasFixedSize(true)
. But the bit that actually
caught my attention was the first part: “If the size of your RecyclerView depends on its children
(…)”.
Holy crap. Could it be?
I can see clearly now, the rain is gone
What Jon said was related to the Recyclerview
’s
size. However, it got me thinking about the size of the
RecyclerView
’s children.
So, here’s the layout for the RecyclerView
items:
<?xml version="1.0" encoding="utf-8"?>
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/image_view_image"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:contentDescription="@null"
tools:src="@tools:sample/backgrounds/scenic" />
At a first glance, you probably won’t see anything unusual.
And there isn’t! It’s a pretty standard setup for an
ImageView
. However, this innocent code was
masking a nasty bug.
The images that feed the RecyclerView
come from
an image API. The images are random, and are loaded by
Glide. Here’s the extension function for
image loading:
fun ImageView.load(imageAddress: String) {
Glide.with(this)
.load(imageAddress)
.into(this)
}
Glide is smart enough to cache unmodified data, so it won’t
keep requesting the images from the API. However, Glide caches
the images with their original size, so it will still have to
resize them to fit in the ImageView
. Not only
that, but since the xml layout is setting the height for the
ImageView
as wrap_content
, and Glide
is not specifying any default size either, the latter will
have to calculate the height of each image it
loads.
This calculation takes its time, and while Glide is busy with
it, the RecyclerView
is setting up its layout.
When it’s ready to layout its children, it gets the height of
each item so that it knows the space each of them will occupy,
i.e., how many ViewHolder
instances it needs. The
RecyclerView
reaches this step in a lot less time
than Glide takes to be done with the image size calculations.
As such, by having the height of the
ImageView
declared as wrap_content
,
its actual measured height
will default to zero until it’s finally
displaying an image.
This effectively messes up the whole logic that comes afterwards:
-
The
RecyclerView
uses the scrolling position that comes from the previous state to know from which view it should start displaying the items. -
Using the current position and the size of the items, the
RecyclerView
figures out how many more items it can show. It first goes from the current position to the last item, and then from the current position to the first item. -
Since the item size is coming out as zero, it’ll basically
try to set every item in the item list as a
visible item. This will then make it so that the previous
state is invalidated, forcing the
RecyclerView
to reset everything and discard the scrolling position, drawing the items from the beginning.
Uff! How can you solve this then? There are a few options. You can:
- Hardcode the height in the layout
<?xml version="1.0" encoding="utf-8"?>
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/image_view_doggo"
android:layout_width="match_parent"
android:layout_height="200dp"
android:adjustViewBounds="true"
android:contentDescription="@null"
tools:src="@tools:sample/backgrounds/scenic" />
- Override the view size with Glide
fun ImageView.load(imageAddress: String) {
Glide.with(this)
.load(imageAddress)
.override(Target.SIZE_ORIGINAL, 200)
.into(this)
}
-
Add a placeholder with Glide, so that the
RecyclerView
uses its height
fun ImageView.load(imageAddress: String) {
Glide.with(this)
.load(imageAddress)
.placeholder(R.drawable.placeholder)
.into(this)
}
The main point here is that, as long as you set the height of
the view before the
RecyclerView
tries to layout its children, you’re
good!
Final thoughts
That’s it for this article. Congratulations on your retained
RecyclerView
state! I hope this was helpful, or
that you at least learned something new. Feel free to talk
about it either in the comment section down below or at
Twitter.
Until next time!