I have a SearchView in a Toolbar, that sits above a RecyclerView list of CardViews. The SearchView works correctly to filter the list based on text inputs. However, the code in the ListAdapter in "onBindViewHolder()" method to highlight the search text with Color.GREEN on each CardView is not working.
I have set up a boolean "isSearchMatched" that toggles to true when a CardView has text that matches the SearchView text. I then use "isSearchMatched" in onBindViewHolder() plus submitList() with a new ArrayList for ListAdapter to recognize a change to the CardView items so that the search text in the CardView can be highlighted...no luck.
What am I missing here?
MainActivity
mSearchView.setOnQueryTextListener() {
...
filter(newText)
}
private void filter(String searchText) {
ArrayList<Card> searchList = new ArrayList<>();
for (Card cardItem : mCards) {
if (cardItem.getTodo().toLowerCase().contains(searchText.toLowerCase(Locale.US))) {
searchList.add(cardItem);
**cardItem.setSearchMatched(true);**
}
}
if (!searchList.isEmpty()) {
adapter.setFilter(searchList, searchText);
}
Card.java // model item
@Entity(tableName = "cards_table")
public class Card implements Parcelable {
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "cardId")
public int id;
@ColumnInfo(name = "cardTodo")
private String todo;
@ColumnInfo(name = "cardMatchedSearchText")
private boolean isSearchMatched = false;
...
@Override
public void writeToParcel(Parcel parcel, int flags) {
parcel.writeInt(id);
parcel.writeString(todo);
parcel.writeByte((byte) (isSearchMatched ? 1 : 0));
}
private void readFromParcel(Parcel in) {
id = in.readInt();
todo = in.readString();
isSearchMatched = in.readByte() != 0;
}
public int getId() {
return this.id;
}
public String getTodo() {
return this.todo;
}
public boolean isSearchMatched() {
return this.isSearchMatched;
}
public void setId(int id) {
this.id = id;
}
public void setTodo(String todo) {
this.todo = todo;
}
public void setSearchMatched(boolean isSearchMatched) {
this.isSearchMatched = isSearchMatched;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Card card = (Card) o;
return Objects.equals(id, card.id) &&
Objects.equals(todo, card.todo) &&
Objects.equals(isSearchMatched, card.isSearchMatched);
}
@Override
public int hashCode() {
return Objects.hash(id, todo, isSearchMatched);
}
ListAdapter
public class CardRVAdapter extends ListAdapter<Card, CardRVAdapter.ViewHolder> {
private String searchString = "";
public Spannable spannable;
private static final DiffUtil.ItemCallback<Card> DIFF_CALLBACK = new DiffUtil.ItemCallback<Card>() {
@Override
public boolean areItemsTheSame(Card oldItem, Card newItem) {
return oldItem.getId() == newItem.getId();
}
@Override
public boolean areContentsTheSame(Card oldItem, Card newItem) {
return oldItem.getTodo().equals(newItem.getTodo()) &&
oldItem.isSearchMatched() == (newItem.isSearchMatched());
}
};
public void setFilter(List<Card> newSearchList, String adapSearchText) {
if (newSearchList != null && !newSearchList.isEmpty()) {
this.searchString = adapSearchText.toLowerCase(Locale.US);
submitList(new ArrayList<>(newSearchList));
}
}
public class ViewHolder extends RecyclerView.ViewHolder {
TextView cardBlankText2; // displays text in the CardView and is matched against the user's search text input.
}
public ViewHolder(@NonNull final View itemView) {
super(itemView);
cardBlankText2 = itemView.findViewById(R.id.cardBlankText2);
}
void bindData(Card card, final int position) {
// Highlight matches from search characters is Green color.
if (searchString != null && !TextUtils.isEmpty(searchString)) {
String todoSearchHighlightFromVH = cardBlankText2.getText().toString().toLowerCase();
int offsetEnd2 = todoSearchHighlightFromVH.indexOf(searchString.toLowerCase(Locale.US));
final Spannable spannable2 = new SpannableString(cardBlankText2.getText());
spannable2.removeSpan(cardBlankText2);
cardBlankText2.setText(spannable2);
if (card.isSearchMatched()) {
for (int start2 = 0; start2 < todoSearchHighlightFromVH.length() && offsetEnd2 != -1; start2 = offsetEnd2 + 1) {
offsetEnd2 = todoSearchHighlightFromVH.indexOf(searchString.toLowerCase(Locale.US), start2);
if (offsetEnd2 == -1) {
break;
} else {
final ForegroundColorSpan foregroundColorSpan2 = new ForegroundColorSpan(Color.GREEN);
spannable2.setSpan(foregroundColorSpan2, offsetEnd2, offsetEnd2 + searchString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
cardBlankText2.setText(spannable2);
}
}
}
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
final Card card = getItem(position);
if (card != null) {
holder.bindData(card, position);
}
}
}
Edit: You need to think more critically about what is "view" and what is "model" or "data". The fact that you named your persistent class Card
means you are conflating the two.
Now you can call your data class anything you want, but it would make more sense if you called it ToDo
instead of naming it after a UI component. The data exists (is persisted) independently of the view.
This is the whole purpose of the MVVM (Model/View/ViewModel) architectural pattern. You haven't mentioned anything about a ViewModel, so you end up with a poor separation of concerns among your classes.
I was assuming the SearchView was managing a separate autocomplete for selecting a Card; but now I see what you want is for the "Card"s to be displayed so the user can work with what has been filtered using the SearchView.
I have modified my answer accordingly below.
Disclaimer: This is my highly opinionated solution; there are many other valid solutions possible.
Remove isSearchedMatched
property from Card
. This flag only makes sense in the context of the UI. It does not make sense to persist it because it is always changing based on what the user enters as a search query.
You can add transient UI properties to your Card
class, but it might make more sense to have a class like CardUI
to hold properties that are needed for the UI but should not be persisted. I'll proceed with that concept.
Create a CardUI
class that includes your Card
class as a property. It will also have the current search string as another property.
SpannableString
and turn that into a helper method on CardUI
; e.g. example belowpublic class CardUI {
private final String searchText;
private final Card card;
public CardUI(String searchText, Card card) {
this.searchText = searchText;
this.card = card;
}
public Card getCard() {
return card;
}
// SpannableString is used here because it supports equals() comparison needed by adapter
public SpannableString getHighlight() {
final SpannableString spannableString = new SpannableString(card.getTodo());
final String todo = card.getTodo().toLowerCase();
int index = todo.indexOf(searchText.toLowerCase());
while (index != -1) {
final ForegroundColorSpan foregroundColorSpan2 = new ForegroundColorSpan(Color.GREEN);
spannableString.setSpan(foregroundColorSpan2, index, index + searchText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
index = todo.indexOf(searchText.toLowerCase(), index + searchText.length());
}
return spannableString;
}
}
Change your adapter list type to CardUI
.
Now your filter
method could look like this:
ArrayList<CardUI> searchList = new ArrayList<>();
for (Card cardItem : mCards) {
if (cardItem.getTodo().toLowerCase().contains(searchText.toLowerCase(Locale.US))) {
CardUI entry = new CardUI(searchText, cardItem)
searchList.add(entry);
}
}
ViewHolder.bindData()
is simply: final CardUI item = getItem(position)
cardBlankText2.setText(item.getHighlight());
private static final DiffUtil.ItemCallback<CardUI> DIFF_CALLBACK = new DiffUtil.ItemCallback<CardUI>() {
@Override
public boolean areItemsTheSame(CardUI oldItem, CardUI newItem) {
return oldItem == newItem;
}
@Override
public boolean areContentsTheSame(CardUI oldItem, CardUI newItem) {
// you don't want to get id involved in list comparison, otherwise the list won't update correctly as the user is entering search strings
return oldItem.getHighlight().equals(newItem.getHighlight());
}
};
Because you will be rebuilding the highlight strings each time the user types, the RecyclerView
should update properly now.