androidkotlinnotifydatasetchangedandroid-filterable

How to avoid notifyDataSetChanged on a Filterable Adapter?


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?


Solution

  • 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)
    }