Jacques Krause Jacques Krause - 5 months ago 23
Android Question

How to filter a RecyclerView with a SearchView

I am trying to implement the

SearchView
from the support library. I want the user to be to use the
SearchView
to filter a
List
of movies in a
RecyclerView
.

I have followed a few tutorials so far and I have added the
SearchView
to the
ActionBar
, but I am not really sure where to go from here. I have seen a few examples but none of them show results as you start typing.

This is my
MainActivity
:

public class MainActivity extends ActionBarActivity {

RecyclerView mRecyclerView;
RecyclerView.LayoutManager mLayoutManager;
RecyclerView.Adapter mAdapter;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_recycler_view);

mRecyclerView = (RecyclerView) findViewById(R.id.recycler_view);
mRecyclerView.setHasFixedSize(true);

mLayoutManager = new LinearLayoutManager(this);
mRecyclerView.setLayoutManager(mLayoutManager);

mAdapter = new CardAdapter() {
@Override
public Filter getFilter() {
return null;
}
};
mRecyclerView.setAdapter(mAdapter);
}

@Override
public boolean onCreateOptionsMenu(Menu menu) {
// Inflate the menu; this adds items to the action bar if it is present.
getMenuInflater().inflate(R.menu.menu_main, menu);
SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE);
SearchView searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView();
searchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName()));
return true;
}

@Override
public boolean onOptionsItemSelected(MenuItem item) {
// Handle action bar item clicks here. The action bar will
// automatically handle clicks on the Home/Up button, so long
// as you specify a parent activity in AndroidManifest.xml.
int id = item.getItemId();

//noinspection SimplifiableIfStatement
if (id == R.id.action_settings) {
return true;
}

return super.onOptionsItemSelected(item);
}
}


And this is my
Adapter
:

public abstract class CardAdapter extends RecyclerView.Adapter<CardAdapter.ViewHolder> implements Filterable {

List<Movie> mItems;

public CardAdapter() {
super();
mItems = new ArrayList<Movie>();
Movie movie = new Movie();
movie.setName("Spiderman");
movie.setRating("92");
mItems.add(movie);

movie = new Movie();
movie.setName("Doom 3");
movie.setRating("91");
mItems.add(movie);

movie = new Movie();
movie.setName("Transformers");
movie.setRating("88");
mItems.add(movie);

movie = new Movie();
movie.setName("Transformers 2");
movie.setRating("87");
mItems.add(movie);

movie = new Movie();
movie.setName("Transformers 3");
movie.setRating("86");
mItems.add(movie);

movie = new Movie();
movie.setName("Noah");
movie.setRating("86");
mItems.add(movie);

movie = new Movie();
movie.setName("Ironman");
movie.setRating("86");
mItems.add(movie);

movie = new Movie();
movie.setName("Ironman 2");
movie.setRating("86");
mItems.add(movie);

movie = new Movie();
movie.setName("Ironman 3");
movie.setRating("86");
mItems.add(movie);
}

@Override
public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
View v = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.recycler_view_card_item, viewGroup, false);
return new ViewHolder(v);
}

@Override
public void onBindViewHolder(ViewHolder viewHolder, int i) {
Movie movie = mItems.get(i);
viewHolder.tvMovie.setText(movie.getName());
viewHolder.tvMovieRating.setText(movie.getRating());
}

@Override
public int getItemCount() {
return mItems.size();
}

class ViewHolder extends RecyclerView.ViewHolder{

public TextView tvMovie;
public TextView tvMovieRating;

public ViewHolder(View itemView) {
super(itemView);
tvMovie = (TextView)itemView.findViewById(R.id.movieName);
tvMovieRating = (TextView)itemView.findViewById(R.id.movieRating);
}
}
}

Answer

Introduction

Since it is not really clear form your question with what exactly you are having trouble I wrote up this quick walkthrough about how to implement this feature, if you still have questions feel free to ask. I have a working example of everything I am talking about here in this GitHub Repository.

The result should looks something like this:

enter image description here


1) Setting up the SearchView

In your menu xml just add an item and set the actionViewClass to android.support.v7.widget.SearchView. Since you are using the support library you have to use the namespace of the support library to set the actionViewClass attribute. Your menu xml should look something like this:

<menu xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:app="http://schemas.android.com/apk/res-auto">

    <item android:id="@+id/action_search"
          android:title="@string/action_search"
          app:actionViewClass="android.support.v7.widget.SearchView"
          app:showAsAction="always"/>

</menu>

In your Fragment or Activity you have to inflate this menu xml like usual, then you can look for the MenuItem which contains the SearchView and implement the OnQueryTextListener which we are going to use to listen for changes to the text entered into the SearchView:

@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
    inflater.inflate(R.menu.menu_main, menu);

    final MenuItem item = menu.findItem(R.id.action_search);
    final SearchView searchView = (SearchView) MenuItemCompat.getActionView(item);
    searchView.setOnQueryTextListener(this);
}

@Override
public boolean onQueryTextChange(String query) {
    // Here is where we are going to implement our filter logic
    return false;
}

@Override
public boolean onQueryTextSubmit(String query) {
    return false;
}

And now the SearchView is ready to be used. We will implement the filter logic later on in onQueryTextChange() once we are finished implementing the Adapter.


2) Setting up the Adapter

First and foremost this is the model class I am going to use for this example:

public class ExampleModel {

    private final String mText;

    public ExampleModel(String text) {
        mText = text;
    }

    public String getText() {
        return mText;
    }
}

It's just your basic model which will display a text in the RecyclerView. This is the layout I am going to use to display the text:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:clickable="true"
    android:background="?attr/selectableItemBackground">

    <TextView
        android:id="@+id/tvText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="8dp"/>

</FrameLayout>

And the ViewHolder I am using looks like this:

public class ExampleViewHolder extends RecyclerView.ViewHolder {

    private final TextView tvText;

    public ExampleViewHolder(View itemView) {
        super(itemView);

        tvText = (TextView) itemView.findViewById(R.id.tvText);
    }

    public void bind(ExampleModel model) {
        tvText.setText(model.getText());
    }
}

Again nothing special. It just sets the text from the ExampleModel to the TextView in our layout.
Now we can finally come to the really interesting part: Writing the Adapter. I am going to skip over the basic implementation of the Adapter and am instead going to concentrate on the parts which are relevant for the SearchView. This is the basic implementation of the Adapter I started out with:

public class ExampleAdapter extends RecyclerView.Adapter<ExampleViewHolder> {

    private final LayoutInflater mInflater;
    private List<ExampleModel> mModels;

    public ExampleAdapter(Context context, List<ExampleModel> models) {
        mInflater = LayoutInflater.from(context);
        mModels = new ArrayList<>(models);
    }

    @Override
    public ExampleViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        final View itemView = mInflater.inflate(R.layout.item_example, parent, false);
        return new ExampleViewHolder(itemView);
    }

    @Override
    public void onBindViewHolder(ExampleViewHolder holder, int position) {
        final ExampleModel model = mModels.get(position);
        holder.bind(model);
    }

    @Override
    public int getItemCount() {
        return mModels.size();
    }

    public void setModels(List<ExampleModel> models) {
        mModels = new ArrayList<>(models);
    }
}

Basically this is all you need to make this work. You can filter items in onQueryTextChange() and use the setModels() method to change the models in the Adapter and then call notifyDatasetChanged() on the Adapter to update the RecyclerView. Or at least that would have been the way to do this with a ListView. But the RecyclerView brings a whole new element to the table which we can take advantage off: out-of-the-box support for item animations!


3) Getting the Adapter ready for item animations

The first thing we have to do is define three helper methods which enable us to add, remove or move items around in the Adapter. Those methods will automatically call the required notify... method to trigger the item animation that goes along with it.

public ExampleModel removeItem(int position) {
    final ExampleModel model = mModels.remove(position);
    notifyItemRemoved(position);
    return model;
}

public void addItem(int position, ExampleModel model) {
    mModels.add(position, model);
    notifyItemInserted(position);
}

public void moveItem(int fromPosition, int toPosition) {
    final ExampleModel model = mModels.remove(fromPosition);
    mModels.add(toPosition, model);
    notifyItemMoved(fromPosition, toPosition);
}

Again nothing special. We just modify the internal list of objects in the Adapter by either removing, adding or moving objects and once we are done call a notify... method.

Now we are going to implement a method which will animate between the List of objects currently displayed in the Adapter to the filtered List we are going to supply to the method.

public void animateTo(List<ExampleModel> models) {
    applyAndAnimateRemovals(models);
    applyAndAnimateAdditions(models);
    applyAndAnimateMovedItems(models);
}

The three methods contained in animateTo() do all the work here, but the order is important! The most difficult part about animating multiple items like this is keeping track of indexes. What I mean by that is that if for example you add an item then all items below the item you added are moved down. Equally if you remove an item all items below it are moved up. This is a big problem because all notify...() methods which trigger the item animations require the index of an item. If you are not careful and add or remove items and then try to call the notify... methods you are going to end up with weird glitchy animations or even an ArrayIndexOutOfBoundsException.

We do two things to greatly simplify this problem for us:

  1. We have our 3 three helper methods from above which we can use to add, remove or move items and they automatically call the correct notify... method. So we don't have to worry about the animations anymore. We can purely concentrate on modifying the internal List of the Adapter.
  2. We define a specific order of operations when modifying the internal List of the Adapter.

As I mentioned above we supply the filtered List to the Adapter by passing it into the animateTo() method. The first step is to remove all items which do not exist in the filtered List anymore. The next step is to add all items which did not exist in the original List but do in the filtered List. The final step is to move all items which exist in both Lists. This is exactly what the three methods in animateTo() do.

Now we are going to look at the implementation of those three methods.

Lets start in order with applyAndAnimateRemovals():

private void applyAndAnimateRemovals(List<ExampleModel> newModels) {
    for (int i = mModels.size() - 1; i >= 0; i--) {
        final ExampleModel model = mModels.get(i);
        if (!newModels.contains(model)) {
            removeItem(i);
        }
    }
}

As you can see this method iterates through the internal List of the Adapter backwards and checks if each item is contained in the new filtered List. If it is not it calls removeItem(). The reason we iterate backwards is to avoid having to keep track of an offset. If you remove an item all items below it move up. If you iterate through to the List from the bottom up then only items which you have already iterated over are moved.

Now lets look at applyAndAnimateAdditions():

private void applyAndAnimateAdditions(List<ExampleModel> newModels) {
    for (int i = 0, count = newModels.size(); i < count; i++) {
        final ExampleModel model = newModels.get(i);
        if (!mModels.contains(model)) {
            addItem(i, model);
        }
    }
}

It basically does the same thing as applyAndAnimateRemovals() but instead of iterating through the internal List of the Adapter it iterates through the filtered List and checks if the item exists in the internal List. If it does not it calls addItem().

And finally lets look at applyAndAnimateMovedItems():

private void applyAndAnimateMovedItems(List<ExampleModel> newModels) {
    for (int toPosition = newModels.size() - 1; toPosition >= 0; toPosition--) {
        final ExampleModel model = newModels.get(toPosition);
        final int fromPosition = mModels.indexOf(model);
        if (fromPosition >= 0 && fromPosition != toPosition) {
            moveItem(fromPosition, toPosition);
        }
    }
}

This method implements a more complicated logic than the previous two. It is essentially a combination of applyAndAnimateRemovals() and applyAndAnimateAdditions() but with a twist. You have to realize that at this point applyAndAnimateRemovals() and applyAndAnimateAdditions() have already been called. So we have removed all the items that need to be removed and we added all new items which need to be added. So the internal List of the Adapter and the filtered List contain the exactly same items, but they may be in a different order. What applyAndAnimateMovedItems() now does is it iterates through the filtered List backwards and looks up the index of each item in the internal List. If it detects a difference in the index it calls moveItem() to bring the internal List of the Adapter in line with the filtered List.

And with that our Adapter is complete. It can now display items and automatically triggers appropriate animations as we filter the List of objects. The only thing missing now is to connect the SearchView to the RecyclerView!

One thing I should still mention is that while this is a very simple approach to animating the items it is by far not the most efficient one. But for most use cases it should be more than enough and once you understand the gist of it you can implement it very quickly.


4) Implementing the filter logic

One thing in your question which caught my eye is that you maintain the list of items you want to display directly in the Adapter. While the Adapter of course has to have a List of items internally you should not completely maintain the List in there. The Adapter - as the name implies - should just turn Objects into Views. You give it a List of Objects and the RecyclerView gets a View representing each Object. What the Adapter is definitely not responsible for is creating those Objects.

To implement the filter logic we first have to define a List of all possible Objects. As I mentioned above we will do that not inside the Adapter, but outside in the Fragment or Activity which contains the RecyclerView. The basic implementation (in my case for a Fragment) looks like this:

private RecyclerView mRecyclerView;
private ExampleAdapter mAdapter;
private List<ExampleModel> mModels;

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    final View view = inflater.inflate(R.layout.fragment_main, container, false);

    mRecyclerView = (RecyclerView) view.findViewById(R.id.recyclerView);

    return view;
}

@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);
    setHasOptionsMenu(true);

    mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));

    mModels = new ArrayList<>();

    for (String movie : MOVIES) {
        mModels.add(new ExampleModel(movie));
    }

    mAdapter = new ExampleAdapter(getActivity(), mModels);
    mRecyclerView.setAdapter(mAdapter);
}

Again nothing special. We setup the RecyclerView and instantiate the Adapter. We create a List of items and store that in a field. This list will be our reference. It contains all possible items which we will filter with the SearchView. In the above example MOVIES is a String[] I defined which contains my test data.

Now we can go back to onQueryTextChange() which we defined earlier and start implementing the filter logic:

@Override
public boolean onQueryTextChange(String query) {
    final List<ExampleModel> filteredModelList = filter(mModels, query);
    mAdapter.animateTo(filteredModelList);
    mRecyclerView.scrollToPosition(0);
    return true;
}

This is again pretty straight forward. We call the method filter() and pass in our reference List of objects and the query string. We then call animateTo() on the Adapter and pass in the filtered List returned by filter(). We also have to call scrollToPosition(0) on the RecyclerView to ensure that the user can always see all items when searching for something. Otherwise the RecyclerView might stay in a scrolled down position while filtering and subsequently hide a few items. Scrolling to the top ensures a better user experience while searching.

The only thing left to do now is to implement filter() itself:

private List<ExampleModel> filter(List<ExampleModel> models, String query) {
    query = query.toLowerCase();

    final List<ExampleModel> filteredModelList = new ArrayList<>();
    for (ExampleModel model : models) {
        final String text = model.getText().toLowerCase();
        if (text.contains(query)) {
            filteredModelList.add(model);
        }
    }
    return filteredModelList;
}

The first thing we do here is call toLowerCase() on the query string. We don't want our search function to be case sensitive and by calling toLowerCase() on all strings we compare we can ensure that we return the same results regardless of case.
filter() basically just iterates through all the models in the List we passed into it and checks if the query string is contained in the text of the model. If it is then the model is added to the filtered List.


And that is pretty much it! If you run your app now you should be able to filter the data in the RecyclerView with the SearchView and this whole thing runs on Froyo (Android 2.2, API level 7) and above! And starting with Honeycomb (Android 3.0, API level 11) all changes to the dataset displayed in the RecyclerView will be animated automatically!
I realize that this is a very detailed description which probably makes this whole thing seem more complicated than it really is. I suggest you look at the working example in the GitHub Repository I linked to above if you are having trouble understanding a specific aspect of this whole thing.

Comments