How to avoid notifyDataSetChanged on a Filterable Adapter?
Asked Answered
L

2

7

I am in the process of improving my app stability and performance, but right now I am stuck at a warning from Android Studio. Please consider the following Adapter class:

private class CoinsAdapter(private val fragment: CoinFragment, private val coins: List<Coin>): RecyclerView.Adapter<CoinsAdapter.ViewHolder>(), Filterable {

    private val filter = ArrayList(coins)

    override fun onCreateViewHolder(parent: ViewGroup, position: Int): ViewHolder {
        val binding = ItemCoinBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val coin = filter[position]
        
        holder.binding.coinImage.setImageResource(coin.image)
        holder.binding.coinText.text = builder.toString()
    }

    override fun getItemCount() = filter.size

    override fun getFilter() = object : Filter() {

        override fun performFiltering(constraint: CharSequence): FilterResults {
            if (constraint.length < 2) return fetchResults(coins)
            val pattern = constraint.toString().lowercase().trim()

            val filter = arrayListOf<Coin>()
            for (coin in coins) if (coin.name.lowercase().contains(pattern)) filter.add(coin)

            return fetchResults(filter)
        }

        private fun fetchResults(coins: List<Coin>): FilterResults {
            val results = FilterResults()
            results.values = coins

            return results
        }

        override fun publishResults(constraint: CharSequence, results: FilterResults) {
            filter.clear()
            filter.addAll(results.values as List<Coin>)

            notifyDataSetChanged()
        }
    }

    private inner class ViewHolder(val binding: ItemCoinBinding) : RecyclerView.ViewHolder(binding.root)
}

The adapter and filter work perfectly but notice the publishResults function. Android Studio is warning that regarding the notifyDataSetChanged.

It will always be more efficient to use more specific change events if you can. Rely on notifyDataSetChanged as a last resort.

However, I am clueless on how to use the notifyDataSetChanged in this instance (with a filter). What would be the right method and how to use it in this case?

Laicize answered 11/10, 2021 at 13:20 Comment(0)
J
6

To the best of my knowledge, there's no point in using the Filterable interface with RecyclerView.Adapter. Filterable is intended for use in AdapterView Adapters because there are a few widgets that check if the Adapter is a Filterable and can automatically provide some filtering capability. However, RecyclerView.Adapter has no relation whatsoever to AdapterView's Adapter.

You can still use the Filter interface as a way to organize your code if you like, but to me it seems like needless extra boilerplate. I have seen other old answers on StackOverflow saying to implement Filterable in RecyclerView.Adapter, but I think they are doing it out of habit from working with the old Adapter class.

As for improving the performance of your adapter when filtering, there are a couple of options.

  1. Use SortedList and SortedList.Callback to manage your list. The callback has you implement a bunch of functions to notify changes of specific items or ranges of items instead of the whole list at once. I have not used this, and it seems like there's a lot of room for getting something wrong because there are so many callback functions to implement. It's also a ton of boilerplate. The top answer here describes how to do it, but it's a few years old so I don't know if there's a more up-to-date way.

  2. Extend your adapter from ListAdapter. ListAdapter's constructor takes a DiffUtil.ItemCallback argument. The callback tells it how to compare two items. As long as your model items have unique ID properties, this is very easy to implement. When using ListAdapter, you don't create your own List property in the class, but instead let the superclass handle that. Then instead of setting a new filtered list and calling notifyDataSetChanged(), you call adapter.submitList() with your filtered list, and it uses the DiffUtil to automatically change only the views necessary, and it does it with nice animations, too. Note you don't need to override getItemCount() either since the superclass owns the list.

Since you are filtering items, you might want to keep an extra property to store the original unfiltered list and use that when new filters are applied. So I did create an extra list property in this example. You need to be careful about only using it to pass to submitList() and always using currentList in onBindViewHolder() since currentList is what's actually being used by the Adapter to display.

And I removed the Filterable function and made it so the outside class can simply set the filter property.

class CoinsAdapter : ListAdapter<Coin, CoinsAdapter.ViewHolder>(CoinItemCallback) {
    
    object CoinItemCallback : DiffUtil.ItemCallback<Coin>() {
        override fun areItemsTheSame(oldItem: Coin, newItem: Coin): Boolean = oldItem.id == newItem.id
        override fun areContentsTheSame(oldItem: Coin, newItem: Coin): Boolean = oldItem == newItem
    }
    
    var coins: List<Coin> = emptyList()
        set(value) {
            field = value
            onListOrFilterChange()
        }

    var filter: CharSequence = ""
        set(value) {
            field = value
            onListOrFilterChange()
        }

    override fun onCreateViewHolder(parent: ViewGroup, position: Int): ViewHolder {
        val binding = ItemCoinBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val coin = currentList[position]

        holder.binding.coinImage.setImageResource(coin.image)
        holder.binding.coinText.text = builder.toString()
    }

    private fun onListOrFilterChange() {
        if (filter.length < 2) {
            submitList(coins)
            return
        }
        val pattern = filter.toString().lowercase().trim()
        val filteredList = coins.filter { pattern in it.name.lowercase() }
        submitList(filteredList)
    }

    inner class ViewHolder(val binding: ItemCoinBinding) : RecyclerView.ViewHolder(binding.root)
}
Jonijonie answered 11/10, 2021 at 14:18 Comment(3)
I have just implemented your solution and it is working wonders. Thanks a lot for your descriptive explanation and solution!Laicize
@Jonijonie I have a similar use case with ListAdapter but I have no experience with Kotlin. Can you post your above example using Java/Android code lines?Stricker
Honestly I think it would take you less time to read the first few pages about Kotlin syntax on the Kotlin site so you could understand it than for me to translate it to Java.Jonijonie
C
2

The notifyDataSetChanged redraws the whole view and that's why Android Studio shows a warning.

To get away with this you could use DiffUtil

private class CoinsAdapter(private val fragment: CoinFragment, private val coins: List<Coin>): RecyclerView.Adapter<CoinsAdapter.ViewHolder>(FilterDiffCallBack()), Filterable {
 ....
 ....
  //This check runs on background thread
class FilterDiffCallBack: DiffUtil.ItemCallback<Post>() {
    override fun areItemsTheSame(oldItem: Coin, newItem: Coin): Boolean {
      
        return oldItem.someUniqueId == newItem.someUniqueId
    }

    override fun areContentsTheSame(oldItem: Coin, newItem: Coin): Boolean {
        
        return oldItem == newItem
    }
}
...
...
override fun publishResults(constraint: CharSequence, results: FilterResults) {

        submitList(results)// call the DiffUtil internally
    }
}

If the data in list mostly changes with user interaction then you could use methods like notifyItemChanged(int), notifyItemInserted(int), notifyItemRemoved(int), etc as this is the most efficient way to update your view. More info here

Cramped answered 11/10, 2021 at 14:21 Comment(1)
The whole view is redrawn many times while you're scrolling it. notifyDataSetChanged() is slow because it also causes the entire layout to be remeasured, not just redrawn. It also causes a flash because it doesn't perform animation to show how the contents are changing.Jonijonie

© 2022 - 2024 — McMap. All rights reserved.