I have created my own custom filter view, much like the autocomplete text view.
So to explain, I pass in the full list to the adapter, after the user enters 3 or more characters I then filter the list and display using diff util. This is all done in my adapter (see below)
import android.os.Handler
import android.support.v7.recyclerview.extensions.ListAdapter
import android.support.v7.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Filter
import android.widget.Filterable
import kotlinx.android.synthetic.main.autocomplete_item_layout.view.*
private const val EXACT_MATCH = 1
private const val STARTS_WITH_AND_CONTAINED_ONCE = 2
private const val STARTS_WITH_AND_CONTAINED_MORE_THAN_ONCE = 3
private const val SEARCH_TERM_IS_CONTAINED_MORE_THAN_ONCE = 4
private const val SEARCH_TERM_IS_CONTAINED_ONCE = 5
class AutoCompleteAdapter (private val locations: MutableList<String>, private val filteredlistener: FilteredResultsListener, private val suggestedLocationClickListener : (AutoCompleteLocationSuggestion) -> Unit) :
ListAdapter<String, AutoCompleteAdapter.AutoCompleteViewHolder>(SuggestedLocationDiffCallback()), Filterable {
var initalFilteredLocation = ArrayList<String>()
var filteredLocationSuggestions = ArrayList<String>()
private var currentSearchTerm = ""
override fun getFilter(): Filter = autoSuggestionFilter
private val locationComparatorForSorting = Comparator {
s1: String, s2: String -> calculateRank(s1, currentSearchTerm) - calculateRank(s2, currentSearchTerm)
}
private fun calculateRank(value: String, searchTerm: String): Int {
val cleanedValue = value.trim().toLowerCase()
val startsWithSearchTerm = cleanedValue.startsWith(searchTerm)
val searchTermCount = cleanedValue.countSubString(searchTerm)
// rule 1
if (searchTerm == cleanedValue) {
return EXACT_MATCH
}
// rule 2
if (startsWithSearchTerm && searchTermCount == 1) {
return STARTS_WITH_AND_CONTAINED_ONCE
}
// rule 3
if (startsWithSearchTerm && searchTermCount > 1) {
return STARTS_WITH_AND_CONTAINED_MORE_THAN_ONCE
}
// rule 4
if (searchTermCount > 1) {
return SEARCH_TERM_IS_CONTAINED_MORE_THAN_ONCE
}
// rule 5
return SEARCH_TERM_IS_CONTAINED_ONCE
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AutoCompleteViewHolder
= AutoCompleteViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.autocomplete_item_layout, parent, false))
override fun onBindViewHolder(holder: AutoCompleteViewHolder, position: Int) {
val location = getItem(position)
holder.bind(location, suggestedLocationClickListener)
}
private fun handleRecyclerViewVisibility() {
filteredlistener.hasFilteredResults(filteredLocationSuggestions.isNotEmpty())
}
fun addSuggestedLocation(suggestion: AutoCompleteLocationSuggestion) {
val newList = ArrayList<String>()
newList.addAll(filteredLocationSuggestions)
newList.add(suggestion.position, suggestion.location)
submitList(newList)
filteredLocationSuggestions = newList
}
fun removeSuggestedLocation(suggestion: AutoCompleteLocationSuggestion) {
val newList = ArrayList<String>()
newList.addAll(filteredLocationSuggestions)
newList.removeAt(suggestion.position)
submitList(newList)
filteredLocationSuggestions = newList
}
fun clearSuggestedLocations() {
filteredLocationSuggestions.clear()
val newList = ArrayList<String>()
submitList(newList)
}
fun addAll(selectedItems : ArrayList<AutoCompleteLocationSuggestion>) {
val newList = ArrayList<String>()
newList.addAll(filteredLocationSuggestions)
selectedItems.forEach {
newList.add(it.position, it.location)
}
submitList(newList)
}
class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(locationName: String, clickListener: (AutoCompleteLocationSuggestion) -> Unit) {
itemView.auto_complete_suggestion_text.text = locationName
if (itemView.location_check_box.isChecked) {
itemView.location_check_box.isChecked = false
}
itemView.setOnClickListener {
itemView.location_check_box.isChecked = true
Handler().postDelayed({
clickListener(AutoCompleteLocationSuggestion(adapterPosition, locationName))
}, 200)
}
}
}
private var autoSuggestionFilter: Filter = object : Filter() {
override fun performFiltering(constraint: CharSequence?): FilterResults {
val searchConstraint = cleanSearchTerm(constraint)
currentSearchTerm = searchConstraint
val filteredList = locations.filter { it.toLowerCase().contains(searchConstraint) }
val filterResults = FilterResults()
filterResults.values = filteredList
filterResults.count = filteredList.size
return filterResults
}
override fun publishResults(constraint: CharSequence?, results: Filter.FilterResults) {
if (results.count == 0) {
return
}
filteredLocationSuggestions = results.values as ArrayList<String>
initalFilteredLocation = filteredLocationSuggestions
filteredLocationSuggestions.sortWith(locationComparatorForSorting)
submitList(filteredLocationSuggestions)
handleRecyclerViewVisibility()
}
}
private fun cleanSearchTerm(constraint: CharSequence?): String = constraint.toString().trim().toLowerCase()
}
And my SuggestedLocationDiffCallback() :
import android.support.v7.util.DiffUtil
class SuggestedLocationDiffCallback : DiffUtil.ItemCallback<String>() {
override fun areItemsTheSame(oldItem: String?, newItem: String?): Boolean = oldItem == newItem
override fun areContentsTheSame(oldItem: String?, newItem: String?): Boolean = oldItem == newItem
}
So this worked fine, the filtering displayed fine and if I removed or added back in an item. The problem occurs if the list is any way large (350+ items). I had read in the docs that best do the processing on a background thread so I did that. I changed my diff util call back to:
import android.support.v7.util.DiffUtil
class SuggestedDiffCallback(private val newList: MutableList<String>, private val oldList: MutableList<String>): DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = oldList[oldItemPosition] == newList[newItemPosition]
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = oldList[oldItemPosition] == newList[newItemPosition]
}
Updated my adapter to extend RecyclerView.Adapter
and ran on a background thread e.g.
if (handler == null) handler = Handler()
Thread(Runnable {
val diffResult = DiffUtil.calculateDiff(callback)
handler!!.post(Runnable {
diffResult.dispatchUpdatesTo(this@AutoCompleteAdapter)
})
}).start()
This unfortunately didn't work, so I then attempted to use coroutines (I have little or no experience so I can't say for sure they worked)
private suspend fun update(callback: SuggestedDiffCallback) {
withContext(Dispatchers.Default) {
val diffResult = DiffUtil.calculateDiff(callback)
diffResult.dispatchUpdatesTo(this@AutoCompleteAdapter1)
}
handleRecyclerViewVisibility()
}
Calling the above like so: CoroutineScope(Dispatchers.Default).launch {update(callback)}
This however didn't work either, the UI blocking was still occurring. So I reverted the adapter back to extending a ListAdapter
and use AsyncListDiffer
initialising as follows val differ = AsyncListDiffer(this, SuggestedLocationDiffCallback())
. and using it like differ.submitList(newList)
Unfortunately this did not work either and the UI blocking continues. I started to look down the road of pagination, which I hope may solve the issue but can anyone look at the above and see if I'm doing anything wrong?
Also here is the encapsulating view class:
import android.arch.paging.PagedList
import android.arch.paging.PagedListAdapter
import android.content.Context
import android.support.constraint.ConstraintLayout
import android.support.v4.widget.NestedScrollView
import android.util.AttributeSet
import android.view.inputmethod.InputMethodManager
import io.reactivex.disposables.CompositeDisposable
import kotlinx.android.synthetic.main.auto_complete_component_layout.view.*
import java.util.*
private const val MAX_TAGS = 10
private const val FILTER_CHAR_COUNT = 3
class AutocompleteLocationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0): ConstraintLayout (context, attrs, defStyleAttr), SuggestedLocationUpdateListener, FilteredResultsListener {
private var maxLocationsAddedListener: MaxLocationsAddedListener? = null
private var adapter: AutoCompleteAdapter1? = null
private var pagedListAdapter: LocationPagedListAdapter? = null
private val selectedItems : ArrayList<AutoCompleteLocationSuggestion> = ArrayList()
private var applyButtonVisibilityListener: ApplyButtonVisibilityListener? = null
private var suggestionListVisibleCallback: SuggestionListVisibleCallback? = null
private val disposables = CompositeDisposable()
init {
inflate(context, R.layout.auto_complete_component_layout, this)
observeClearSearchTerm()
}
fun setLocations(locations: MutableList<String>) {
pagedListAdapter = LocationPagedListAdapter(locations, SuggestedLocationDiffCallback(), this, this::suggestedLocationClicked)
//adapter = AutoCompleteAdapter(locations, this, this::suggestedLocationClicked)
location_suggestion_list.adapter = pagedListAdapter
location_search_box.onChange { handleSearchTerm(it)}
scrollview.setOnScrollChangeListener(onScrollChangeListener)
}
private fun handleSearchTerm(searchTerm: String) {
if(searchTerm.length >= FILTER_CHAR_COUNT) {
pagedListAdapter?.filter?.filter(searchTerm)
} else {
location_suggestion_list.visible(false)
suggestionListVisibleCallback?.areSuggestionsVisible(false)
adapter?.clearSuggestedLocations()
}
}
private fun observeClearSearchTerm() {
disposables + clear_search_term
.throttleClicks()
.subscribe {
handleClearLocationSearchBox()
}
}
private fun handleClearLocationSearchBox() {
location_search_box.text.clear()
if (!selectedItems.isNullOrEmpty()) {
location_search_box.hint = context.getString(R.string.add_another_location)
}
}
fun setSuggestionListVisibleCallback(suggestionListVisibleCallback: SuggestionListVisibleCallback) {
this.suggestionListVisibleCallback = suggestionListVisibleCallback
}
fun clearAreas() {
adapter?.addAll(selectedItems)
selectedItems.clear()
selected_location_container.removeAllViews()
}
fun setMaxLocationsAddedListener(maxLocationsAddedListener: MaxLocationsAddedListener) {
this.maxLocationsAddedListener = maxLocationsAddedListener
}
fun setApplyButtonListener(applyButtonVisibilityListener: ApplyButtonVisibilityListener) {
this.applyButtonVisibilityListener = applyButtonVisibilityListener
}
private fun suggestedLocationClicked(suggestedLocation: AutoCompleteLocationSuggestion) {
val selectedItemView = SelectedSuggestionView(context, null, 0, this)
selectedItemView.setAutoCompleteSuggestion(suggestedLocation)
if (selected_location_container.childCount == MAX_TAGS) {
maxLocationsAddedListener?.maxLocationsAdded()
} else {
selected_location_container.addView(selectedItemView, 0)
selectedItems.add(suggestedLocation)
adapter?.removeSuggestedLocation(suggestedLocation)
applyButtonVisibilityListener?.isApplyButtonVisible(selectedItems.isNotEmpty())
hideKeyboard()
}
}
private fun updateFilteredResults() {
selectedItems.forEach {
if (adapter?.filteredLocationSuggestions?.contains(it.location)!!) {
adapter?.filteredLocationSuggestions?.remove(it.location)
}
}
}
fun submitList(suggestedLocations: PagedList<String>?) {
pagedListAdapter?.submitList(suggestedLocations)
}
fun getSelectedItems() : ArrayList<String> = selectedItems.map { it.location } as ArrayList<String>
override fun hasFilteredResults(results: Boolean) {
location_suggestion_list.visible(results)
suggestionListVisibleCallback?.areSuggestionsVisible(results)
}
override fun selectedLocationDeselected(suggestedLocation: AutoCompleteLocationSuggestion) {
if (adapter?.filteredLocationSuggestions?.isNotEmpty()!!) {
selectedItems.remove(suggestedLocation)
adapter?.addSuggestedLocation(suggestedLocation)
applyButtonVisibilityListener?.isApplyButtonVisible(selectedItems.isNotEmpty())
}
}
fun addSelectedItemsToCloud(suggestedLocations: ArrayList<String>) {
suggestedLocations.forEach {
val selectedItemView = SelectedSuggestionView(context, null, 0, this)
selectedItemView.setAutoCompleteSuggestionText(it)
selected_location_container.addView(selectedItemView)
}
}
interface MaxLocationsAddedListener {
fun maxLocationsAdded()
}
private var onScrollChangeListener: NestedScrollView.OnScrollChangeListener = NestedScrollView.OnScrollChangeListener { _, _, _, _, _ -> hideKeyboard() }
private fun hideKeyboard() {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(windowToken, 0)
}
}
any help is appreciated on this as I'm pulling my hair out.
EDIT
So after more investigation, it may seem that it's not the diffcallback that is causing the issue. I added a log in the bind method, it is called x
amount of times, and there is a lengthy delay from the last bind
to being displayed on screen and the ui is blocked for this duration.