TL;DR: This article solves the problem of horizontal scroll position lost when scrolling vertically and horizontal scroll gestures being registered as vertical. Refer to this GitHub repo with sample app to see solution.
Many apps including Netflix and Play Store use the nested recycler pattern of having multiple horizontal scrollable views embedded inside a vertical one. Implementing such structure in Android with RecyclerView seemed quite straightforward. And it is!
BUT… there are a couple of things that need to be tweaked for everything to be smooth and behave as expected. Let’s jump straight into it.
Let’s create an app that shows sections with animals. Each section has a title and each animal has a name and an image. The result should look something like this:
The data structure is following:
We need a layout for the individual animal. The layout item_animal.xml contains a CardView with an ImageView, a TextView and a gradient to make the text pop.
Next, we need the layout for the section with the title and the nested RecyclerView: item_animal_section.xml
The activity_main.xml contains just a recycler.
Putting it all together
For the parent recycler (vertical), we’ll be using a ConcatAdapter. This is technically not necessary, but is a good habit as it will make your life easier if you decide to build on this. The structure is illustrated in the following image (MS Paint skills: 10/10):
All we need to do now is create some fake data (this is not really the point of this tutorial; check out my DataSource at the GitHub repo to see how I came around creating the lists, or create your own logic) and populate the adapters.
And voilà, we’ve created working nested RecyclerViews!
But have we really?
1. Recycling the recyclers
The first issue is caused by the fact that the individual views in our AnimalSectionAdapter (meaning the whole rows) are being recycled. This results in the horizontal scroll position being lost when scrolling vertically:
To resolve this, we need to manually save and restore horizontal scroll state of each row when it gets recycled or bound respectively.
To achieve this, we need to to save the state in the onViewRecycled method inside of our AnimalSectionAdapter and restostore the state in the onBindViewHolder method.
To persist the states, will use a MutableMap with the key being the ID of the corresponding row.
2. Horizontal swipes registered as vertical
The second issue comes from the fact that we have two views with opposite scroll directions inside of each other. This has already been tackled by Christophe Beyls in his post (read it!) and instead of reinventing the wheel, we’ll use his brilliant solution.
Based on Christophe’s solution, we’ll create a Kotlin extension to solve this issue.
And call it in our MainActivity.kt’s initViews method:
Each nested RecyclerView will by default have its own pool of views to recycle. This is not optimal, as we know the views are always identical so one pool would be enough for all of them.
We can easily achieve this by creating one pool inside of our AnimalSectionAdapter and setting it to each of the nested RecyclerViews.
Initial prefetch item count
As pointed out by Christophe Beyls himself:
On the nested RecyclerView’s
LinearLayoutManager, you should call
setInitialPrefetechItemCount()with the estimated number of horizontal items that will be visible. When prefetching the
ViewHoldercontaining the horizontal RecyclerView, the parent RecyclerView will ask the child RecyclerView to pre-bind a full row of items and since the child RecyclerView can't know how many items will be displayed until it's laid out, you need to provide that information yourself. If you don't, only 2 items will be pre-bound by default, and the rest will be bound after prefetch when the row becomes laid out and visible which causes slower performance.
We will do exactly that inside the AnimalSectionAdapter’s onBindViewHolder:
Full code available at the this GitHub repo.
Building a fairly complex nested RecyclerView layout is such a common task that one would expect it to be working perfectly out-of-the box. In the Android world, however, this is unfortunately still not the case. I tackled the issue in this post, creating a sample app you can use to solve a similar issue of your own. Good luck!