Dynamic vertical RecyclerView inside viewHolder of vertical recyclerview causes outer RecyclerView to lag on first scroll

241 Views Asked by At

I'm working on an app which has social media like feed.
Feed consists of regular news like posts (cover image, likes, comments, author, text content etc.)
and
poll like posts - its just like news post but instead of cover image you are presented with several options for a question to choose from.
Example that come to my mind is youtube, there when scrolling you see videos, just pictures with text, and surveys with options sometimes. I'll add screenshot for better understanding.

RV - RecyclerView, VH - ViewHolder

GOAL: Vertical RV with VH containing dynamic number of vertical views, lets say the number of views ranges from 1 to 10.

IMPLEMENTAION: Put inside VH of outer RV inner vertical RV and submit new list to inner RV adapter inside of onBindViewHolder() of outer RV.

PROBLEM: Lag on scroll of outer RV caused by inflation of VHs for inner RV. It's because RV creates VHs on demand as user scrolls and when scroll happens VH for outer RV created which in turn triggers inflation of VHs for inner RV and its the main cause of lag. The culprit is the inflation for sure because lag occurs only on first scroll of 5-7 VHs and it's gone, which means rebinding is lightweight task.

SOLUTION ATTEMPTS SO FAR:

  • (Failed) Make inner RVs use shared RecycledViewPool. Because it's needed to inflate about 15 inner VHs before even starting to reuse views in shared pool.

  • (Failed) Experimented with ditching inner RV completely and instead have a custom view with 10 views added in xml layout and make then gone/visible based on required number.=They still need to be inflated, duh..

  • (Semi working) Prepopulate RecycledViewPool used by inner RVs the moment adapter of outer RV gets attached and clear when gets detached. This allowed inner RVs to start using VHs from shared pool right away. The horrible lag seems to be almost completely gone, almost. But it feels really wrong for some reason, plus I have to set my inner VH's itemViewType to -1 for it to work.

       private val recycledViewPool: RecycledViewPool by lazy {
            RecycledViewPool().apply {
                setMaxRecycledViews(-1, 30)
            }
        }

        override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
            recycledViewPool.clear()
            for (i in 0..30) {
                recycledViewPool.putRecycledView(
                    PollResultOptionVH.create(recyclerView)
                )
            }
            super.onAttachedToRecyclerView(recyclerView)
        }

        override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
            super.onDetachedFromRecyclerView(recyclerView)
            recycledViewPool.clear()
        }

REMAINING PROBLEM: Although big chunk of lagging is gone there is another problem now - when scrolling outer RV you can feel that its still not completely smooth, as if some operation is till taking longer than 16ms, like micro jitters. As if taking pre inflated views from shared RecycledViewPool is still taking some time and effort. The problem is only on first scroll..

QUESTION: Can anyone suggest alternative to my solution?
What could be the reason that there is still some jitter?
Any other optimization suggestions, for my outer RV to scroll smoothly?
I know there is solution out there because there are many social media apps with muck more complex VHs that work really smooth, but I couldn't find any.

Youtube example

1

There are 1 best solutions below

6
4gus71n On

It might be difficult to diagnose your issue without the actual code. I'll share a few solutions and ideas about what might be happening. I want you to go through these and see if anything helps you figure out your issue.

#1 "Lagging" when scrolling or "lagging" when loading

I'm not sure if you mean that the lagging appears when the RV adapter loads data for the first time or if it appears when you start scrolling. If it appears when you start scrolling, I'd recommend you double-check a few things:

  • The data doesn't come from a computed property. Using a computed property that generates the data every time you fetch an item might be the source of the lag. For example:
class MyRecyclerViewAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
  private val myRawListData = mutableListOf<Data>()
  
  fun setData(list: List<Data>) {
    myRawListData.clear()
    myRawListData.addAll(list)
    notifyDataSetChanged()
  }
  
  private val myListData : List<SomeOtherDataStructure>
    get() {
      return myRawListData.groupBy { 
        // Transform your data structure into something else
      }
    }
  
  
  override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
    val dataItem = myListData[position] // This call will trigger the computation above everytime
  }
  //...

If you have something like the above, I'd recommend you to process the data only once instead of relying on computed properties.

#2 The poll-like view

I think this is most likely what is causing the issue. I'd recommend not using an inner RV no matter what, it might not just cause issues with the scrolling, but you might get issues when the VH gets recycled. If you disable the recycling pool so none of the items actually gets recycled, you are basically using a LinearLayout with that in mind, why don't we just use a custom view? For example:

class CustomStylingActivity : AppCompatActivity() {
    private lateinit var binding: ActivityCustomStylingBinding
    private val adapter by lazy {
        MyNewsAdapter(this)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityCustomStylingBinding.inflate(LayoutInflater.from(this))
        setContentView(binding.root)
        binding.newsRv.adapter = adapter
        adapter.setNewsItems(generateRandomData())
    }

    private fun generateRandomData(): List<Any> {
        return mutableListOf<Any>().apply {
           repeat(3000) {
               add(News(title = "Title #$it", body = "#$it Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum"))
               add(PollPost(title = "Poll #$it", options = mutableListOf<String>().apply {
                    repeat((0..10).random()) {
                        add("Random Option #$it")
                    }
               }))
           }
        }
    }
}

class MyNewsAdapter(
    private val context: Context,
    private val layoutInflater: LayoutInflater = LayoutInflater.from(context)
    // Ideally if you have some sort of unique id, go with a RecyclerView.ListAdapter
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    // Using Any might look like an anti-pattern but in my experience when you have adapters
    // like this with so many different viewTypes, it makes things much more flexible.
    private val newsItems = mutableListOf<Any>()

    private class PollViewHolder(val binding: ViewHolderPollBinding) : RecyclerView.ViewHolder(binding.root)
    private class NewsViewHolder(val binding: ViewHolderNewsBinding) : RecyclerView.ViewHolder(binding.root)

    override fun getItemViewType(position: Int): Int {
        return when (newsItems[position]) {
            is News -> VIEW_TYPE_NEWS
            is PollPost -> VIEW_TYPE_POLL
            else -> throw RuntimeException("Unknown item")
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            VIEW_TYPE_POLL -> {
                PollViewHolder(
                    ViewHolderPollBinding.inflate(layoutInflater, parent, false)
                )
            }
            VIEW_TYPE_NEWS -> {
                NewsViewHolder(
                    ViewHolderNewsBinding.inflate(layoutInflater, parent, false)
                )
            }
            else -> throw RuntimeException("Unknown view")
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val item = newsItems[position]
        when (holder) {
            is NewsViewHolder -> onBindNewsViewHolder(holder, item as News, position)
            is PollViewHolder -> onBindPollViewHolder(holder, item as PollPost, position)
        }
    }

    private fun onBindPollViewHolder(
        holder: PollViewHolder,
        pollPost: PollPost,
        position: Int
    ) {
        holder.binding.titleTv.text = pollPost.title
        holder.binding.poll.setItems(pollPost.options)
        holder.binding.poll.setOnPollOptionClicked { index, str ->
            // You can expose this back to the activity through another callback interface
            Toast.makeText(context, "Option #$index Clicked -> $str", Toast.LENGTH_LONG).show()
        }
    }

    private fun onBindNewsViewHolder(
        holder: NewsViewHolder,
        news: News,
        position: Int
    ) {
        holder.binding.titleTv.text = news.title
        holder.binding.bodyTv.text = news.body
    }

    override fun getItemCount() = newsItems.size
    fun setNewsItems(data: List<Any>) {
        newsItems.clear()
        newsItems.addAll(data)
        // Again, if you can use ListAdapter even better!
        notifyDataSetChanged()
    }

    companion object {
        private const val VIEW_TYPE_NEWS = 100
        private const val VIEW_TYPE_POLL = 200
    }
}


data class News(
    val title: String,
    val body: String,
)

data class PollPost(
    val title: String,
    val options: List<String>
)

The PollView looks like this:

package com.appsamurai.storylydemo

import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import com.appsamurai.storylydemo.databinding.ViewPollBinding

open class PollView : LinearLayout {

    constructor(context: Context) : super(context) {
        onInflate(context)
    }

    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
        onInflate(context)
    }

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        onInflate(context)
    }

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
        onInflate(context)
    }

    private val bindings by lazy {
        ViewPollBinding.inflate(LayoutInflater.from(context), this, true)
    }

    private val items = mutableListOf<String>()

    private val allOptTextViews : List<TextView>
        get() = listOf(bindings.opt1, bindings.opt2, bindings.opt3, bindings.opt4, bindings.opt5,
            bindings.opt6, bindings.opt7, bindings.opt8, bindings.opt9, bindings.opt10)

    private fun onInflate(context: Context) {
        allOptTextViews.forEachIndexed { index, textView ->
            textView.setOnClickListener {
                callback(index, items[index])
            }
        }
    }

    private var callback : ((Int, String) -> Unit) = {_, _ -> } // Default init

    fun setOnPollOptionClicked(func: ((Int, String) -> Unit)) {
        callback = func
    }

    fun setItems(options: List<String>) {
        items.clear()
        items.addAll(options)
        allOptTextViews.forEachIndexed { index, textView ->
            val option = items.getOrNull(index)
            if (option.isNullOrBlank()) {
                textView.visibility = View.GONE
            } else {
                textView.visibility = View.VISIBLE
                textView.text = option
            }
        }
    }
}

And the XMLs

The view_holder_poll.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:paddingStart="16dp"
        style="@style/TextAppearance.AppCompat.Title"
        android:id="@+id/title_tv"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <com.appsamurai.storylydemo.PollView
        android:id="@+id/poll"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

</LinearLayout>

The view_poll.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/opt1"
        android:padding="16dp"
        tools:text="Option #1"
        style="@style/TextAppearance.AppCompat.Body2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <TextView
        android:id="@+id/opt2"
        android:padding="16dp"
        tools:text="Option #1"
        style="@style/TextAppearance.AppCompat.Body2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <TextView
        android:id="@+id/opt3"
        android:padding="16dp"
        tools:text="Option #1"
        style="@style/TextAppearance.AppCompat.Body2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <TextView
        android:id="@+id/opt4"
        android:padding="16dp"
        tools:text="Option #1"
        style="@style/TextAppearance.AppCompat.Body2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <TextView
        android:id="@+id/opt5"
        android:padding="16dp"
        tools:text="Option #1"
        style="@style/TextAppearance.AppCompat.Body2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <TextView
        android:id="@+id/opt6"
        android:padding="16dp"
        tools:text="Option #1"
        style="@style/TextAppearance.AppCompat.Body2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <TextView
        android:id="@+id/opt7"
        android:padding="16dp"
        tools:text="Option #1"
        style="@style/TextAppearance.AppCompat.Body2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <TextView
        android:id="@+id/opt8"
        android:padding="16dp"
        tools:text="Option #1"
        style="@style/TextAppearance.AppCompat.Body2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <TextView
        android:id="@+id/opt9"
        android:padding="16dp"
        tools:text="Option #1"
        style="@style/TextAppearance.AppCompat.Body2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <TextView
        android:id="@+id/opt10"
        android:padding="16dp"
        tools:text="Option #1"
        style="@style/TextAppearance.AppCompat.Body2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

</LinearLayout>

The view_holder_news.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:padding="16dp"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        tools:text="Title"
        style="@style/TextAppearance.AppCompat.Title"
        android:id="@+id/title_tv"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <TextView
        tools:text="Body"
        style="@style/TextAppearance.AppCompat.Body1"
        android:id="@+id/body_tv"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

</LinearLayout>

And the activity_custom_styling.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".CustomStylingActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/news_rv"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
    
</androidx.constraintlayout.widget.ConstraintLayout>

It looks like this:

https://i.stack.imgur.com/ejHFF.jpg

Now, notice a few things, I assumed you have a max of 10 options, if that isn't the case, then the only other solution would be to dinamically add the views into the LinearLayout – if it is possible for you to get a fixed amount of options, even better, that would make the implementation much simpler.

But see that the "dynamic" custom view does not cause any lagging. The lagging you are experiencing might also come from other places. Are you loading any images using Glide/Picasso? Do you stop any rendering of those images if the ViewHolder gets scrolled away? Are you loading YouTube videos?

If you have any more info, I'd be happy to drop some more feedback. I hope this is useful.