This article gives you an overview of how to use the SearchView widget to: 1) search for text that might not be displayed in the list, and 2) highlight the text that matches the search.
Introduction
With some lists, the information you want to search for is displayed right in the list, whereas other times, it may not be but is instead part of the underlying data "behind" the list. While my example shows a list of states and their associated area codes, the latter was solely for demonstration purposes.
Disclaimer: With ±3 million apps in the Google Play store, I'm confident there are examples that will contradict my assertions here.
Background
From the handful of apps that I've used that provide a way to search/filter a list of items, the text that is being searched for is often times a subset of the text that is being displayed in the list. Depending on the app and how configurable its list is, some items may not be displayed. For example, in my Contacts
app, only the first and last names of the contact are displayed, yet I can search for their name, address, phone number, etc. The underlying search algorithm looks through all of the contact's fields, not just the first and last name.
To visually see why a particular item matches the search, I'm also going to show how to highlight the matching text.
When I started this project, my first thought of something to search for was which Social Security Number (SSN) belongs to which state. That list was easy enough to find, but I did not realize that it was no longer valid. For security purposes, the first three digits of your SSN (i.e., the area number) no longer identifies the state you lived in at the time of assignment. I then decided that area codes would be just as effective for this exercise.
Code
JSON
Not wanting to reinvent the wheel or do a ton of data entry, I did a quick search for a JSON file containing area codes for all 50 states. I found one here but it showed to be incomplete and, while syntactically correct, not in a useable format (at least not that I could tell). Not finding anything better, I decided to use it anyway. I added the several missing area codes, and did a global search/replace so that I could treat the file as an array of states containing an array of area codes. I then sent the file through this formatter to get it all dressed up. The first few states of that file look like:
[
{
"Alabama":[
205,
251,
256,
334,
659,
938
]
},
{
"Alaska":[
907
]
},
{
"American Samoa":[
684
]
},
{
"Arizona":[
480,
520,
602,
623,
928
]
},
...
]
It also contains the one federal district and the five territories. To represent a state with area codes, I created a StateInfo
class that looks like:
public class StateInfo
{
private String name;
private class AreaCodeInfo
{
private String area_code;
private boolean bold;
public AreaCodeInfo(String area_code)
{
this.area_code = area_code;
bold = false;
}
}
private ArrayList<AreaCodeInfo> area_codes = null;
public StateInfo( String name )
{
this.name = name;
area_codes = new ArrayList<AreaCodeInfo>();
}
...
}
The class is fairly straightforward. The inner AreaCodeInfo
class was added so that each individual area code could be displayed in bold when necessary. More on that below.
I've done several Code Project articles on downloading and parsing a JSON file, but this one differs in that the file is not downloaded but is actually packaged with the app. It is contained in the assets folder. All we have to do is assign the JSON file to an InputStream
object and then use the read()
method to read the file into a buffer, like:
InputStream is = getAssets().open("us_area_codes.json");
int bytes = is.available();
if (bytes > 0)
{
byte[] buffer = new byte[bytes];
is.read(buffer, 0, bytes);
is.close();
...
}
The buffer can now be assigned to a JSONArray
object, like:
String str = new String(buffer);
JSONArray states = new JSONArray(str);
At this point, the states
array can be iterated like normal, and the area code(s) belonging to each state can be added to the area_codes
array. That loop looks like:
for (int x = 0; x < states.length(); x++)
{
JSONObject state = states.getJSONObject(x);
Iterator<String> key = state.keys();
String name = key.next();
JSONArray arr = state.getJSONArray(name);
StateInfo si = new StateInfo(name);
for (int i = 0; i < arr.length(); i++)
si.AddAreaCode(String.valueOf(arr.getInt(i)));
arrStates.add(si);
}
Since the AreaCodeInfo
class is private
to StateInfo
, the AddAreaCode()
method is used to add an area code to a state. It looks like:
public void AddAreaCode(String area_code)
{
area_codes.add(new AreaCodeInfo(area_code));
}
As was noted earlier, even though the area code in the JSON file is an integer, the area_code
variable is a String
so that searching is a tad easier. We can convert an integer to a string
once during initialization rather than multiple times during a search/filter.
The JSON file is now loaded into an ArrayList<StateInfo>
object, so the list's adapter can be created and setAdapter()
can be called. While not a requirement, the list of states can be put in alphabetical order, like:
arrStates.sort(new Comparator<StateInfo>()
{
@Override
public int compare( StateInfo o1, StateInfo o2 )
{
return o1.name.compareTo(o2.name);
}
});
I suppose you could do the same thing to each state's list of area codes after the inner for()
loop above.
I don't claim to be a JSON aficionado, so if you see something with the arrays in the JSON file and the code above that parses them that could be made easier, please let me know.
Implementing SearchView
Plenty has already been written about creating a search interface. You can implement it with
- a dialog that appears at the top of the activity window,
- a widget that is part of the layout, or
- an action view in the app bar.
For simplicity, I opted for the last method. The interface itself does no matter; the searching is handled the same for each. A few things will be needed for the magnifying glass search icon to show up in the app bar, like:
First is a special menu item that looks like:
<item android:id="@+id/searchView"
android:title="Search"
android:icon="@android:drawable/ic_menu_search"
app:showAsAction="always"
app:actionViewClass="android.widget.SearchView" />
To render this menu item in the app bar's, we'll need to add some code to the activity's onCreateOptionsMenu()
method. The menuItem.getActionView()
call is what retrieves the menu item shown earlier. The setOnQueryTextListener()
method is called so that when the search icon is clicked, filtering can begin. This looks like:
@Override
public boolean onCreateOptionsMenu( Menu menu)
{
getMenuInflater().inflate(R.menu.search_menu,menu);
MenuItem menuItem = menu.findItem(R.id.searchView);
SearchView searchView = (SearchView) menuItem.getActionView();
try
{
searchView.setInputType(EditorInfo.TYPE_CLASS_NUMBER);
searchView.setQueryHint(getResources().getString(R.string.search_hint));
EditText et = searchView.findViewById
(searchView.getResources().getIdentifier("android:id/search_src_text", null, null));
et.setFilters(new InputFilter[]{new InputFilter.LengthFilter(3)});
}
catch(Exception e)
{
Log.e(TAG, "Error setting SearchView styles: " + e.getMessage());
}
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener()
{
@Override
public boolean onQueryTextSubmit(String query)
{
return false;
}
@Override
public boolean onQueryTextChange(String newText)
{
customAdapter.getFilter().filter(newText);
return true;
}
});
return true;
}
Since we're dealing with area codes, I added some constraints to help in that endeavor. The first is to only allow numbers as the input type. The second is to limit the length of those numbers to three. This looks like a bit more code than expected but since android:maxLength
belongs to TextView
(and its descendants), we have to look inside the SearchView
widget and get its EditText
property to set the maximum length of.
As each number is keyed in the search box, the onQueryTextChange()
method is called so that the list's contents can be filtered appropriately. We're not interested in submitting anything with the 'enter' key or a 'submit' button, so the onQueryTextSubmit()
simply returns false
. At this point, the SearchView
gets inflated to look like:
The last piece of code to look at, albeit probably the most important, is the actual filtering. The adapter is Filterable, which means its contents can be constrained by a filter. Normally, you would construct the list's adapter and pass it the array of items that it will be displaying. No difference with the filterable adapter, except we're going to keep two copies of the items: the original array that will remain untouched, and a filtered array that will change (a bunch). This looks like:
public class CustomAdapter extends BaseAdapter implements Filterable
{
private ArrayList<StateInfo> arrStates;
private ArrayList<StateInfo> arrStatesFiltered;
public CustomAdapter(ArrayList<StateInfo> arrStates)
{
this.arrStates = arrStates;
this.arrStatesFiltered = arrStates;
}
...
}
When getFilter()
gets called from onQueryTextChange()
above, two things happen with each number keyed in the search box. First, a Filter
object is created, and its performFiltering()
method is called to execute the filtering "rules." In other words, when you key in the number 7, what do you want to do with it? In this case, I want to find all area codes that begin with the number 7. This will happen in the performFiltering()
method, which expects us to construct and return a FilterResults
object. This object has two members that must be assigned: count
and values
. If the search box contains nothing, we simply assign to those two members our original, untouched array of items. If, however, the search box contains a number, we take that number and look through each area code of each state for a match. If a matching area code is found, we add the owning state to the temporary array of items. In the end, this temporary array of items is assigned to the count
and values
members like before.
Second, all of the matching area codes are displayed in bold. This is to help highlight which area code(s) satisfied the search criteria. When the adapter's contents are being reset to the original list, the 'bold' flag of each area code is turned off using the ResetAreaCodes()
method. Similarly, when a number is keyed in to the search box, each state is iterated. For each state, its area codes will all have their 'bold' flags turned off using the ResetAreaCodes()
method, then for each matching area code found, its 'bold' flag will be turned on.
@Override
protected FilterResults performFiltering(CharSequence constraint)
{
FilterResults filterResults = new FilterResults();
if (constraint == null || constraint.length() == 0)
{
filterResults.count = arrStates.size();
filterResults.values = arrStates;
for (StateInfo si : arrStates)
si.ResetAreaCodes();
}
else
{
ArrayList<StateInfo> statesTemp = new ArrayList<StateInfo>();
String search = constraint.toString();
for (StateInfo si : arrStates)
{
si.ResetAreaCodes();
boolean bAdded = false;
for (StateInfo.AreaCodeInfo aci : si.area_codes)
{
if (aci.area_code.startsWith(search))
{
aci.bold = true;
if (! bAdded)
{
statesTemp.add(si);
bAdded = true;
}
}
}
}
filterResults.count = statesTemp.size();
filterResults.values = statesTemp;
}
return filterResults;
}
You'll notice in the inner for()
loop that there is no break
statement when a match is found. Instead, the search continues so that all matching area codes for a given state can be marked as bold. The state itself, however, is only added to the temporary array once. So, for example, if I keyed in the number 51, there are six states that have an area code that begins with 51, with New York having two. The result of this search looks like:
So how is an item in the list displayed in bold? That all happens in the adapter's getView()
method. For each state in the (filtered) list, its area codes are joined and comma-separated with a StringJoiner
object. If an area code's bold flag is turned on, the area code is surrounded by a <b>
HTML tag. The string
is then sent to Html.fromHtml()
which returns a Spanned
object that setText()
can use. This all looks like:
try
{
viewHolder.tvState.setText(arrStatesFiltered.get(position).name);
StringJoiner sj = new StringJoiner(", ");
for (int x = 0; x < arrStatesFiltered.get(position).area_codes.size(); x++)
{
StateInfo.AreaCodeInfo aci = arrStatesFiltered.get(position).area_codes.get(x);
if (aci.bold)
sj.add("<b>" + aci.area_code + "</b>");
else
sj.add(aci.area_code);
}
viewHolder.tvAreaCodes.setText(Html.fromHtml(sj.toString()));
}
catch(Exception e)
{
Log.e(TAG, "Error displaying state and/or area code(s): " + e.getMessage());
}
Points of Interest
I mentioned earlier that the area_code
member of the AreaCodeInfo
inner class was a String
rather than an Integer
so that searching would be a tad easier. If both the area code being looked at and the number keyed in to the search box are both String
types, then the startsWith()
method could be used like was shown above. If they were both Integer
types, however, it's a bit more involved.
The first thing that would need to happen is to trim down the area_code
member so that its length matches that of the number keyed in to the search box. The number keyed in to the search box can have a length of [0..3]. A length of 0 means nothing has been keyed in, so the original list is going to be shown. A length of 1 means some number in the [0..9] range has been keyed in; a length of 2 means some number in the [10..99] range has been keyed in; and a length of 3 means some number in the [100..999] range has been keyed in.
Trimming a number from the least significant digit (i.e., the right side), is simply a matter of dividing by one of the first three powers of 10: 1, 10, or 100. Using the aforementioned length and the pow()
method, we can get our 1, 10, or 100 divisor. We then just need to divide the area code being looked at by this divisor to produce a number that can be compared against the number keyed in to the search box. This all looks like:
int length_difference = 0;
for (StateInfo.AreaCodeInfo aci : si.area_codes)
{
if (search < 10)
length_difference = 2;
else if (search < 100)
length_difference = 1;
else if (search < 1000)
length_difference = 0;
int power = (int) Math.pow(10, length_difference);
int area_code = aci.area_code / power;
if (area_code == search)
{
aci.bold = true;
if (! bAdded)
{
statesTemp.add(si);
bAdded = true;
}
}
}
Epilogue
In summary, the searching code in the performFiltering()
method could be expanded to search any field of an item, whether it is an item that is being displayed or not. I'll admit that searching through area codes is probably not the most useful tool, but it was just for demonstration purposes. Enjoy!
History
- 22nd January, 2021: Initial version