65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 SearchView 小部件进行搜索和高亮显示

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2021 年 1 月 22 日

CPOL

9分钟阅读

viewsIcon

7319

downloadIcon

104

如何使用 SearchView 小部件进行搜索和高亮显示。

引言

有些列表直接在列表中显示要搜索的信息,而有些列表则可能不显示,而是作为列表“背后”的底层数据的一部分。虽然我的示例显示的是州及其对应的区号列表,但后者仅用于演示目的。

免责声明:Google Play 商店中有大约 300 万个应用,我相信其中会有一些应用会与我在此陈述的观点相矛盾。

背景

在我使用过的少数提供搜索/过滤项目列表方式的应用中,被搜索的文本通常是列表中显示文本的一个子集。根据应用的配置程度,有些项目可能不会被显示。例如,在我的联系人应用中,只显示联系人的名字和姓氏,但我可以搜索他们的姓名、地址、电话号码等。底层搜索算法会遍历联系人的所有字段,而不仅仅是名字和姓氏。

为了直观地了解某个项目为何匹配搜索条件,我还将展示如何高亮显示匹配的文本。

当我开始这个项目时,我第一个想到的搜索内容是哪个社会保障号(SSN)属于哪个州。那个列表很容易找到,但我没有意识到它已经不再有效了。出于安全原因,您的 SSN 的前三位数(即地区号码)不再能识别您在分配时居住的州。然后我决定区号对这个练习同样有效。

代码

JSON

我不想重复造轮子或进行大量数据录入,所以我快速搜索了一个包含所有 50 个州区号的 JSON 文件。我在这里找到了一个,但它显示不完整,虽然语法正确,但格式不可用(至少我看不出来)。找不到更好的,我还是决定用它。我添加了一些缺失的区号,并进行了全局搜索/替换,以便将文件视为包含区号数组的州数组。然后我将文件通过这个格式化工具进行格式化。该文件的前几个州看起来像

[
   {
      "Alabama":[
         205,
         251,
         256,
         334,
         659,
         938
      ]
   },
   {
      "Alaska":[
         907
      ]
   },
   {
      "American Samoa":[
         684
      ]
   },
   {
      "Arizona":[
         480,
         520,
         602,
         623,
         928
      ]
   },
   ...
]

它还包含一个联邦直辖区和五个领地。为了表示一个带有区号的州,我创建了一个StateInfo类,看起来像

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>();
    }
    ...
}

这个类相当直接。内部的AreaCodeInfo类被添加进来,以便每个单独的区号在必要时都可以显示为粗体。稍后会详细介绍。

我写过几篇关于下载和解析 JSON 文件的 Code Project 文章,但这篇文章有所不同,文件不是下载的,而是打包在应用中的。它包含在assets文件夹中。我们只需要将 JSON 文件分配给一个InputStream对象,然后使用read()方法将文件读入缓冲区,就像这样

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();
    ...
}

现在可以将缓冲区分配给JSONArray对象,就像这样

String str = new String(buffer);
JSONArray states = new JSONArray(str);

此时,states数组可以像往常一样进行迭代,每个州所属的区号可以添加到area_codes数组中。那个循环看起来像

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);
}

由于AreaCodeInfo类是StateInfoprivate成员,因此使用AddAreaCode()方法将区号添加到州。它看起来像

public void AddAreaCode(String area_code)
{
    area_codes.add(new AreaCodeInfo(area_code));
}

如前所述,即使 JSON 文件中的区号是整数,area_code变量也是String类型,这样搜索会稍微容易一些。我们可以在初始化时一次性将整数转换为string,而不是在搜索/过滤时多次转换。

JSON 文件现在已加载到ArrayList<StateInfo>对象中,因此可以创建列表的适配器并调用setAdapter()。虽然不是必需的,但可以将州列表按字母顺序排序,就像这样

// 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
    }
});

我想在上面的内部for()循环之后,你也可以对每个州的区号列表做同样的事情。

我不敢自称 JSON 专家,所以如果你在 JSON 文件中的数组以及上面的解析代码中看到有什么可以改进的地方,请告诉我。

实现 SearchView

关于创建搜索界面已经有很多文章了。你可以通过以下方式实现:

  1. 一个出现在活动窗口顶部的对话框,
  2. 布局中的一个小部件,或
  3. 应用栏中的一个操作视图。

为了简单起见,我选择了最后一种方法。界面本身无关紧要;搜索的处理方式对每种方式都相同。为了在应用栏中显示放大镜搜索图标,需要做一些事情,例如

首先是一个特殊的菜单项,看起来像

<item android:id="@+id/searchView"
    android:title="Search"
    android:icon="@android:drawable/ic_menu_search"
    app:showAsAction="always"
    app:actionViewClass="android.widget.SearchView" />

为了在应用栏中渲染这个菜单项,我们需要在活动的onCreateOptionsMenu()方法中添加一些代码。menuItem.getActionView()调用会检索前面显示的菜单项。调用setOnQueryTextListener()方法,以便在点击搜索图标时,开始过滤。这看起来像

@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;
}

由于我们处理的是区号,我添加了一些约束条件来帮助实现这一目标。第一个是只允许数字作为输入类型。第二个是将这些数字的长度限制为三位。这看起来比预期的代码多一些,但由于android:maxLength属于TextView(及其后代),我们必须查看SearchView小部件内部并获取其EditText属性来设置最大长度。

当用户在搜索框中输入每个数字时,会调用onQueryTextChange()方法,以便相应地过滤列表内容。我们不关心使用“enter”键或“submit”按钮提交任何内容,因此onQueryTextSubmit()仅返回false。此时,SearchView会被渲染成如下样子

需要看的最后一段代码,也是可能最重要的一段,是实际的过滤。适配器是Filterable的,这意味着它的内容可以通过过滤器进行约束。通常,你会构建列表的适配器,并将要显示的项目数组传递给它。可过滤的适配器也没有什么不同,只是我们会保留项目的两个副本:原始数组将保持不变,而过滤后的数组将发生变化(很多)。这看起来像

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;
    }
    ...
}

当上面onQueryTextChange()调用getFilter()时,每次在搜索框中键入一个数字,都会发生两件事。首先,创建一个Filter对象,并调用其performFiltering()方法来执行过滤“规则”。换句话说,当你键入数字 7 时,你想对它做什么?在这种情况下,我想找到所有以数字 7 开头的区号。这将在performFiltering()方法中发生,该方法要求我们构造并返回一个FilterResults对象。该对象有两个必须赋值的成员:countvalues。如果搜索框为空,我们只需将原始的、未修改的项目数组赋值给这两个成员。但是,如果搜索框包含一个数字,我们会取该数字,并查找每个州每个区号的匹配项。如果找到匹配的区号,我们将拥有该州的父项添加到临时项目数组中。最后,这个临时项目数组被赋值给countvalues成员,就像之前一样。

其次,所有匹配的区号都以粗体显示。这是为了帮助高亮显示哪些区号满足搜索条件。当适配器的内容被重置为原始列表时,每个区号的“粗体”标志将通过ResetAreaCodes()方法关闭。同样,当在搜索框中键入一个数字时,每个州都会被迭代。对于每个州,其所有区号都会通过ResetAreaCodes()方法关闭其“粗体”标志,然后对于找到的每个匹配的区号,其“粗体”标志将被打开。

@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;
}

你会注意到在内部for()循环中,当找到匹配项时没有break语句。相反,搜索会继续,以便可以标记一个州内所有匹配的区号为粗体。但是,该州本身只会被添加一次到临时数组中。所以,例如,如果我键入数字 51,有六个州的区号以 51 开头,其中纽约有两个。这次搜索的结果看起来像

那么列表中的一个项目是如何以粗体显示的呢?这一切都发生在适配器的getView()方法中。对于(过滤后的)列表中的每个州,其区号都与StringJoiner对象连接并用逗号分隔。如果一个区号的粗体标志被打开,该区号将被<b> HTML 标签包围。然后将string发送到Html.fromHtml(),它返回一个Spanned对象,setText()可以使用该对象。这一切看起来像

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());
}

关注点

我之前提到AreaCodeInfo内部类的area_code成员是String而不是Integer,以便搜索稍微容易一些。如果正在查看的区号和键入搜索框的数字都是String类型,那么就可以使用上面所示的startsWith()方法。但是,如果它们都是Integer类型,则会更复杂一些。

首先需要做的是截断area_code成员,使其长度与键入搜索框的数字长度匹配。键入搜索框的数字长度可以是 [0..3]。长度为 0 表示没有键入任何内容,因此将显示原始列表。长度为 1 表示键入了一些 [0..9] 范围内的数字;长度为 2 表示键入了一些 [10..99] 范围内的数字;长度为 3 表示键入了一些 [100..999] 范围内的数字。

从最低有效数字(即右侧)截断数字,只需将数字除以 10 的前三个幂之一:1、10 或 100。使用上述长度和pow()方法,我们可以得到 1、10 或 100 除数。然后只需将正在查看的区号除以该除数,即可得到一个可以与键入搜索框中的数字进行比较的数字。这看起来像

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;
        }
    }
}

结语

总而言之,performFiltering()方法中的搜索代码可以扩展为搜索项目中的任何字段,无论该项目是否正在显示。我承认通过区号进行搜索可能不是最有用的工具,但它只是用于演示目的。请享用!

历史

  • 2021 年 1 月 22 日:初始版本
© . All rights reserved.