Android has an excellent Fitler
class that helps us filter data from an entire data set based on some pattern (for example a string) provided. Filters are usually created by classes implementing the Filterable
interface. It makes sense to use filters (implement the Filterable
interface) mostly with adapters, which means Filterable
classes are usually Adapter
implementations.
The Filterable
interface has just one job which is to return a filter that can be used to constrain data based on some filtering pattern. It has a getFilter()
method that must be overridden by the adapter returning a filter. Here’s what I mean in terms of coding:
What's the one thing every developer wants? More screens! Enhance your coding experience with an external monitor to increase screen real estate.
class CustomAdapter extends ArrayAdapter implements Filterable { public CustomAdapter(Context context, int resource) { super(context, resource); } @Override public Filter getFilter() { // return a filter that filters data based on a constraint return new Filter() { @Override protected FilterResults performFiltering(CharSequence constraint) { return null; } @Override protected void publishResults(CharSequence constraint, FilterResults results) { } }; } }
Don’t sweat what’s going on with implements Filterable
and new Filter() { ... }
. I’ll explain everything as we proceed gradually.
Implementing a Custom Filter
Let’s see how to create a custom filter by extending the Filter
class. Here’s the basic blueprint:
private class ContactsFilter extends Filter { @Override protected FilterResults performFiltering(CharSequence constraint) { return null; } @Override protected void publishResults(CharSequence constraint, FilterResults results) { } }
The Filter
class has 2 abstract methods that must be overridden:
-
performFiltering()
– Filter the data based on a pattern in a worker thread. It must return aFilterResults
object which holds the results of the filtering operation. AFilterResults
object has 2 properties,count
that holds the count of results computed by the operation andvalues
that contains the values returned by the same filtering operation. -
publishResults()
– Once the filtering is completed, the results are passed through this method to publish them in the user interface on the UI thread.
The entire filter operation is performed asynchronously.
We’ll now see how to code our performFiltering()
and publishResults()
to filter our phone’s contacts. Let’s say I’ve a class like this to represent my phonebook’s contacts:
public class Contact { public String mName; public String mNumber; public boolean mIsSeparator; public Contact(String name, String number, boolean isSeparator) { mName = name; mNumber = number; mIsSeparator = isSeparator; } public void setName(String name) { mName = name; } public void setNumber(String number) { mNumber = number; } public void setIsSection(boolean isSection) { mIsSeparator = isSection; } }
Here’s how my performFiltering()
method will look like:
protected FilterResults performFiltering(CharSequence constraint) { // Create a FilterResults object FilterResults results = new FilterResults(); // If the constraint (search string/pattern) is null // or its length is 0, i.e., its empty then // we just set the `values` property to the // original contacts list which contains all of them if (constraint == null || constraint.length() == 0) { results.values = mContacts; results.count = mContacts.size(); } else { // Some search copnstraint has been passed // so let's filter accordingly ArrayList<Contact> filteredContacts = new ArrayList<Contact>(); // We'll go through all the contacts and see // if they contain the supplied string for (Contact c : mContacts) { if (c.mName.toUpperCase().contains( constraint.toString().toUpperCase() )) { // if `contains` == true then add it // to our filtered list filteredContacts.add(c); } } // Finally set the filtered values and size/count results.values = filteredContacts; results.count = filteredContacts.size(); } // Return our FilterResults object return results; }
I’ve tried to explain everything in the comments inside the code itself. Basically we’ve a mContacts
object that contains all the contacts of our phone. Then if some constraint
data is passed, we go through each and every contact to see if the name contains the string or not. If it does, then it’s stored in our filtered set. If the constraint
is empty then we just set the value
and count
properties to the entire contacts data set.
Once all the filtering is done, the result is passed to the publishResults()
method that publishes the results to the user interface. Here’s the code it should contain:
protected void publishResults(CharSequence constraint, FilterResults results) { mList = (ArrayList<Contact>) results.values; notifyDataSetChanged(); }
We just reset the data set to the filtered list. The mList
variable is used to get data for each view in getView()
method of the adapter. As soon as the data set is changed, we call notifyDataSetChanged()
to refresh the Views.
We’ll have to do 1 final thing, which is to add the getFilter()
method to our adapter implementing the Filterable
interface, that should create and return the filter instance:
ContactsFilter mContactsFilter; public Filter getFilter() { if (mContactsFilter == null) mContactsFilter = new ContactsFilter(); return mContactsFilter; }
I’ll just paste the entire code of my Activity that contains code to fetch all the contacts from the Contacts Provider, then display them in a list and finally add a searchbox to do the search using Custom Filters. This code is based off two of my earlier posts which are:
public class ContactsActivity extends Activity { String[] mProjection; CustomAdapter mAdapter; ArrayList<Contact> mContacts; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_contacts); mProjection = new String[] { ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER }; final Cursor cursor = getContentResolver().query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, mProjection, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" ); int nameIndex = cursor.getColumnIndex(mProjection[0]); int numberIndex = cursor.getColumnIndex(mProjection[1]); mContacts = new ArrayList(); int position = 0; boolean isSeparator = false; while(cursor.moveToNext()) { isSeparator = false; String name = cursor.getString(nameIndex); String number = cursor.getString(numberIndex); char[] nameArray; // If it is the first item then need a separator if (position == 0) { isSeparator = true; nameArray = name.toCharArray(); } else { // Move to previous cursor.moveToPrevious(); // Get the previous contact's name String previousName = cursor.getString(nameIndex); // Convert the previous and current contact names // into char arrays char[] previousNameArray = previousName.toCharArray(); nameArray = name.toCharArray(); // Compare the first character of previous and current contact names if (nameArray[0] != previousNameArray[0]) { isSeparator = true; } // Don't forget to move to next // which is basically the current item cursor.moveToNext(); } // Need a separator? Then create a Contact // object and save it's name as the section // header while pass null as the phone number if (isSeparator) { Contact contact = new Contact(String.valueOf(nameArray[0]), null, isSeparator); mContacts.add( contact ); } // Create a Contact object to store the name/number details Contact contact = new Contact(name, number, false); mContacts.add( contact ); position++; } // Creating our custom adapter mAdapter = new CustomAdapter(this, mContacts); // Create the list view and bind the adapter ListView listView = (ListView) findViewById(R.id.listview); listView.setAdapter(mAdapter); } public class Contact { public String mName; public String mNumber; public boolean mIsSeparator; public Contact(String name, String number, boolean isSeparator) { mName = name; mNumber = number; mIsSeparator = isSeparator; } public void setName(String name) { mName = name; } public void setNumber(String number) { mNumber = number; } public void setIsSection(boolean isSection) { mIsSeparator = isSection; } } public class CustomAdapter extends BaseAdapter implements Filterable { private Context mContext; private ArrayList<Contact> mList; // View Type for Separators private static final int ITEM_VIEW_TYPE_SEPARATOR = 0; // View Type for Regular rows private static final int ITEM_VIEW_TYPE_REGULAR = 1; // Types of Views that need to be handled // -- Separators and Regular rows -- private static final int ITEM_VIEW_TYPE_COUNT = 2; ContactsFilter mContactsFilter; public CustomAdapter(Context context, ArrayList list) { mContext = context; mList = list; } @Override public int getCount() { return mList.size(); } @Override public Object getItem(int position) { return null; } @Override public long getItemId(int position) { return 0; } @Override public int getViewTypeCount() { return ITEM_VIEW_TYPE_COUNT; } @Override public int getItemViewType(int position) { boolean isSeparator = mList.get(position).mIsSeparator; if (isSeparator) { return ITEM_VIEW_TYPE_SEPARATOR; } else { return ITEM_VIEW_TYPE_REGULAR; } } @Override public boolean isEnabled(int position) { return getItemViewType(position) != ITEM_VIEW_TYPE_SEPARATOR; } @Override public View getView(int position, View convertView, ViewGroup parent) { View view; Contact contact = mList.get(position); int itemViewType = getItemViewType(position); if (convertView == null) { LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); if (itemViewType == ITEM_VIEW_TYPE_SEPARATOR) { // If its a section ? view = inflater.inflate(R.layout.contact_section_header, null); } else { // Regular row view = inflater.inflate(R.layout.contact_item, null); } } else { view = convertView; } if (itemViewType == ITEM_VIEW_TYPE_SEPARATOR) { // If separator TextView separatorView = (TextView) view.findViewById(R.id.separator); separatorView.setText(contact.mName); } else { // If regular // Set contact name and number TextView contactNameView = (TextView) view.findViewById(R.id.contact_name); TextView phoneNumberView = (TextView) view.findViewById(R.id.phone_number); contactNameView.setText( contact.mName ); phoneNumberView.setText( contact.mNumber ); } return view; } @Override public Filter getFilter() { if (mContactsFilter == null) mContactsFilter = new ContactsFilter(); return mContactsFilter; } // Filter private class ContactsFilter extends Filter { @Override protected FilterResults performFiltering(CharSequence constraint) { // Create a FilterResults object FilterResults results = new FilterResults(); // If the constraint (search string/pattern) is null // or its length is 0, i.e., its empty then // we just set the `values` property to the // original contacts list which contains all of them if (constraint == null || constraint.length() == 0) { results.values = mContacts; results.count = mContacts.size(); } else { // Some search copnstraint has been passed // so let's filter accordingly ArrayList<Contact> filteredContacts = new ArrayList<Contact>(); // We'll go through all the contacts and see // if they contain the supplied string for (Contact c : mContacts) { if (c.mName.toUpperCase().contains( constraint.toString().toUpperCase() )) { // if `contains` == true then add it // to our filtered list filteredContacts.add(c); } } // Finally set the filtered values and size/count results.values = filteredContacts; results.count = filteredContacts.size(); } // Return our FilterResults object return results; } @Override protected void publishResults(CharSequence constraint, FilterResults results) { mList = (ArrayList<Contact>) results.values; notifyDataSetChanged(); } } } @Override public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.contacts, menu); SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE); SearchView searchView = (SearchView) menu.findItem(R.id.search).getActionView(); searchView.setSearchableInfo( searchManager.getSearchableInfo(getComponentName()) ); searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @Override public boolean onQueryTextSubmit(String query) { return true; // handled } @Override public boolean onQueryTextChange(String newText) { mAdapter.getFilter().filter(newText); return true; } }); 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(); if (id == R.id.action_settings) { return true; } return super.onOptionsItemSelected(item); } }
You’ll notice that the code to do the actual search in realtime is in the onCreateOptionsMenu()
:
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @Override public boolean onQueryTextSubmit(String query) { return true; // handled } @Override public boolean onQueryTextChange(String newText) { mAdapter.getFilter().filter(newText); return true; } });
onQueryTextChange()
is the callback listener that calls getFilter().filter(newText)
on the Adapter object to do the filtering. Instead of using a SearchView
(in the action bar) you could also use a TextView
like EditText
in your activity layout if you wish to. The addTextChangedListener()
method can be used to add a TextWatcher
whose methods will be called whenever the view’s text changes. Here’s how it’s going to be like:
EditText editText = (EditText) findViewById(R.id.editText); editText.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { mAdapter.getFilter().filter(s.toString()); } @Override public void afterTextChanged(Editable s) { } });
Using the inbuilt ArrayAdapter Filter
You can go through the subclasses list here to get to know which of the adapter implementations are Filterable
. One of them is the most common ArrayAdapter
adapter. Let’s see how to use the inbuilt filter of it. Here’s the onCreate()
method from an Activity:
protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_search_results); // List of items String[] items = { "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6", "Item 7", "Item 8", "Item 9", "Item 10", "Item 11", "Item 12", "Item 13", "Item 14", "Item 15" }; // Create an array adapter final ArrayAdapter adapter = new ArrayAdapter(this, android.R.layout.simple_list_item_1, items); // set the adapter to the view setListAdapter(adapter); EditText editText = (EditText) findViewById(R.id.editText); editText.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { adapter.getFilter().filter(s); } @Override public void afterTextChanged(Editable s) { } }); }
Pretty simple, adapter.getFilter().filter(s)
does the job for us. Just incase you’re wondering what the view would look like, then here it is:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="match_parent" android:orientation="vertical"> <EditText android:layout_width="fill_parent" android:layout_height="wrap_content" android:id="@+id/editText" /> <ListView android:layout_height="wrap_content" android:layout_width="fill_parent" android:id="@android:id/list"> </ListView> </LinearLayout>
There are certain limitations of this though (that I came across), like:
- It does a “startsWith” check rather than “contains” with the constraint (search string) on the data set.
- It is case insensitive.
To overcome these, we’ll have to write our own Filter implementation as described above in the “Implementing a Custom Filter” section.
Using the inbuilt SimpleCursorAdapter Filter
When working with cursor adapters like say SimpleCursorAdapter
, we’ll need to do a few extra things.
- First, we’ll have to define the index of the column in the cursor that can be used to get a string representation of the cursor. For this we just have to call the
setStringConversionColumn()
method on the adapter and pass it thecursor.getColumnIndex(COLUMN_NAME)
value. - Secondly, the inbuilt filter does not implement any logic so we need to do that by ourselves. This has to be done by passing a
FilterQueryProvider
object tosetFilterQueryProvider
. TherunQuery
method will contain the logic to filter the current cursor. Generally it should run a cursor query by itself with the given constraint to return a new cursor that contains the filtered set.
Let’s work with our phone contacts again to see how we can filter it using SimpleCursorAdapter
with the built-in CursorFilter
being used as the filter class.
protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_search_results); // Columns from DB to map into the view file String[] fromColumns = { ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER }; // View IDs to map the columns (fetched above) into int[] toViews = { R.id.contact_name, R.id.phone_number }; final Cursor cursor = getContentResolver().query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" ); final SimpleCursorAdapter adapter = new SimpleCursorAdapter( this, // context R.layout.contact_item, // layout file cursor, // DB cursor fromColumns, // data to bind to the UI toViews, // views that'll represent the data from `fromColumns` 0 ); // Bind the adapter setListAdapter(adapter); adapter.setStringConversionColumn( cursor.getColumnIndex(fromColumns[0]) ); adapter.setFilterQueryProvider(new FilterQueryProvider() { @Override public Cursor runQuery(CharSequence constraint) { if (constraint == null || constraint.length() == 0) { return getContentResolver().query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" ); } else { return getContentResolver().query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " LIKE ?", new String[] { "%"+constraint+"%" }, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" ); } } }); EditText editText = (EditText) findViewById(R.id.editText); editText.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { adapter.getFilter().filter(s); } @Override public void afterTextChanged(Editable s) { } }); }
The most significant portion is this (which is what I explained above):
adapter.setStringConversionColumn( cursor.getColumnIndex(fromColumns[0]) ); adapter.setFilterQueryProvider(new FilterQueryProvider() { @Override public Cursor runQuery(CharSequence constraint) { if (constraint == null || constraint.length() == 0) { return getContentResolver().query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" ); } else { return getContentResolver().query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " LIKE ?", new String[] { "%"+constraint+"%" }, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" ); } } });
Basically behind the scenes when adapter.getFilter().filter(s)
is called, the performFiltering()
method of the CursorFilter
class executes runQueryOnBackgroundThread(constraint)
method which executes the runQuery()
method that we specified.
Note: If you’re working with CursorAdapter
and not SimpleCursorAdapter
then using setStringConversionColumn()
might not be an option for you, since CursorAdapter
doesn’t has that method. Instead you’ll have to implement the convertToString()
method and work with that.
Here’s the performFiltering()
and publishResults()
pieces from the system’s CursorFilter
class:
@Override protected FilterResults performFiltering(CharSequence constraint) { Cursor cursor = mClient.runQueryOnBackgroundThread(constraint); FilterResults results = new FilterResults(); if (cursor != null) { results.count = cursor.getCount(); results.values = cursor; } else { results.count = 0; results.values = null; } return results; } @Override protected void publishResults(CharSequence constraint, FilterResults results) { Cursor oldCursor = mClient.getCursor(); if (results.values != null && results.values != oldCursor) { mClient.changeCursor((Cursor) results.values); } }
The runQueryOnBackgroundThread()
in android’s CursorAdapter.java
looks something like this:
public Cursor runQueryOnBackgroundThread(CharSequence constraint) { if (mFilterQueryProvider != null) { return mFilterQueryProvider.runQuery(constraint); } return mCursor; }
Notice the if (constraint == null || constraint.length() == 0) {
condition in our runQuery()
. It checks for whether the constraint is empty or not, if yes then return the entire list of contacts. In this if
block you might be tempted to do something like return adapter.getCursor()
to simply return the cursor attached to the adapter previously, but then you might bump into an exception with the message attempted to access a cursor after it has been closed
. This is because publishResults()
calls changeCursor()
that closes the previous/old cursor. If we keep on returning the same old cursor then it can’t be further used as it is closed, hence the exception is thrown.
Conclusion
Using filters, implementing functionalities like search becomes really easy on Android. Hopefully I was able to touch on various portions of the concept that’s really helpful as there isn’t much documentation available on the usage. Let me know if you have any questions in the comments.