Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Mobile / Android

Searching and Highlighting using the SearchView Widget

0.00/5 (No votes)
22 Jan 2021CPOL9 min read 6.7K   63  
How to use the SearchView widget to search and highlight
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:

JSON
[
   {
      "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:

Java
public class StateInfo
{
    private String name;

    // this class represents ONE area code so that each can be rendered with/without bold
    private class AreaCodeInfo
    {
        private String area_code; // treat as string so searching is easier
        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:

Java
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:

Java
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:

Java
for (int x = 0; x < states.length(); x++)
{
    // get the name of the state
    JSONObject state = states.getJSONObject(x);
    Iterator<String> key = state.keys();
    String name = key.next();

    // get its area code(s)
    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:

Java
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:

Java
// sort array in case the json file is not quite right
arrStates.sort(new Comparator<StateInfo>()
{
    @Override
    public int compare( StateInfo o1, StateInfo o2 )
    {
        return o1.name.compareTo(o2.name); // ascending
    }
});

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

  1. a dialog that appears at the top of the activity window,
  2. a widget that is part of the layout, or
  3. 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:

XML
<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:

Java
@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));

        // set up a 'max length' constraint
        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:

Java
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.

Java
@Override
protected FilterResults performFiltering(CharSequence constraint)
{
    FilterResults filterResults = new FilterResults();
    if (constraint == null || constraint.length() == 0)
    {
        // go back to original list
        filterResults.count  = arrStates.size();
        filterResults.values = arrStates;

        // remove bold style from all state's area code(s)
        for (StateInfo si : arrStates)
            si.ResetAreaCodes();
    }
    else
    {
        ArrayList<StateInfo> statesTemp = new ArrayList<StateInfo>();
        String search = constraint.toString();

        // look through each state
        for (StateInfo si : arrStates)
        {
            si.ResetAreaCodes();    // remove bold style from this state's area code(s)
            boolean bAdded = false; // this state has not been added to filtered list yet

            // look through the area codes of each state
            for (StateInfo.AreaCodeInfo aci : si.area_codes)
            {
                // if the state's area code 'starts with' the search text, add it
                if (aci.area_code.startsWith(search))
                {
                    // render this area code as bold
                    aci.bold = true;

                    // we found a matching area code, 
                    // so add the owning state to the filtered list,
                    // but keep searching for more matches to embolden
                    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:

Java
try
{
    viewHolder.tvState.setText(arrStatesFiltered.get(position).name);

    // look through each state's area code(s) to see which ones need to be bold
    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:

Java
int length_difference = 0;

// look through the area codes of each state
for (StateInfo.AreaCodeInfo aci : si.area_codes)
{
    // if the state's area code 'starts with' search text, add it
    if (search < 10)        // 0..9
        length_difference = 2;
    else if (search < 100)  // 10..99
        length_difference = 1;
    else if (search < 1000) // 100..999
        length_difference = 0;

    int power = (int) Math.pow(10, length_difference);
    int area_code = aci.area_code / power;
    if (area_code == search)
    {
        // render this area code as bold
        aci.bold = true;

        // we found a matching area code, so add the owning state to the filtered list,
        // but keep searching for more matches to embolden
        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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)