How to Filter a ListView with a Search Filter
A searchable ListView helps users find an item without scrolling through a long collection. In an Android app, the usual pattern is to place an EditText or SearchView above the list, observe the text as it changes, and ask the adapter to display matching rows.
This approach suits practical apps such as a café menu in Melbourne, a directory of tradespeople in Brisbane, or a list of suburbs and postcodes across New South Wales. It also works well for beginner projects because the filtering logic stays close to the adapter that already manages the list.
The example below uses Java and a custom ArrayAdapter. The same design can be adapted to Kotlin, but the important ideas remain the same: keep an original data set, filter a separate visible collection, and refresh the ListView after each search.
| Approach | Best For | Main Advantage | Limitation |
|---|---|---|---|
ArrayAdapter with Filterable |
Small and medium lists | Simple implementation | Less suitable for very large data sets |
Manual TextWatcher filtering |
Custom matching rules | Full control over searches | More code to maintain |
SearchView with an adapter |
Toolbar-based search | Familiar Android interface | Requires menu and lifecycle setup |
| SQLite query filtering | Large or persistent data | Efficient database searches | Needs SQL and cursor handling |
Prepare The ListView Layout
Create a layout containing a search input and a ListView. A vertical LinearLayout is enough for a basic tutorial. Give the search field a useful hint so people know what they can type.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<EditText
android:id="@+id/searchBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Search cafés or suburbs"
android:inputType="text"
android:singleLine="true" />
<ListView
android:id="@+id/cafeList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
The 0dp height combined with layout_weight="1" allows the list to occupy the remaining screen space. On a smaller phone, such as one commonly used on a Sydney train commute, the search box remains visible while the results scroll underneath it.
Use a row layout that displays one item at a time. A plain TextView is enough for the first version, although a custom row can later include a suburb, opening hours, or a rating.
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rowText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:textSize="18sp" />
Create A Filterable Adapter
A custom adapter can extend ArrayAdapter<String> and implement Filterable. Keep two lists: allItems stores every value, while visibleItems contains the current search results. This prevents a second search from filtering an already filtered list.
public class CafeAdapter extends ArrayAdapter<String> {
private final List<String> allItems;
private final List<String> visibleItems;
public CafeAdapter(Context context, List<String> items) {
super(context, R.layout.row_text, items);
allItems = new ArrayList<>(items);
visibleItems = new ArrayList<>(items);
}
@Override
public int getCount() {
return visibleItems.size();
}
@Override
public String getItem(int position) {
return visibleItems.get(position);
}
@Override
public Filter getFilter() {
return cafeFilter;
}
private final Filter cafeFilter = new Filter() {
@Override
protected FilterResults performFiltering(CharSequence constraint) {
List<String> matches = new ArrayList<>();
if (constraint == null || constraint.length() == 0) {
matches.addAll(allItems);
} else {
String query = constraint.toString().toLowerCase(Locale.ROOT).trim();
for (String item : allItems) {
if (item.toLowerCase(Locale.ROOT).contains(query)) {
matches.add(item);
}
}
}
FilterResults results = new FilterResults();
results.values = matches;
results.count = matches.size();
return results;
}
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
visibleItems.clear();
visibleItems.addAll((List<String>) results.values);
notifyDataSetChanged();
}
};
}
performFiltering() runs the matching operation, while publishResults() updates the user interface. Locale.ROOT provides predictable lower-case conversion, which is preferable to relying on a device’s language settings.
For a real Australian directory, the source values could include “Surry Hills Café”, “Fitzroy Coffee Bar”, “South Bank Espresso”, and “Glenelg Beach Kiosk”. Searching for “cafe”, “surry”, or “south” then returns relevant rows without requiring an exact match.
Connect The Search Input
In the activity, create the data list, attach the adapter, and connect the input field to the adapter’s filter. A TextWatcher sends the current text whenever the user types or deletes a character.
public class MainActivity extends AppCompatActivity {
private CafeAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
EditText searchBox = findViewById(R.id.searchBox);
ListView cafeList = findViewById(R.id.cafeList);
List<String> cafes = Arrays.asList(
"Surry Hills Café",
"Fitzroy Coffee Bar",
"South Bank Espresso",
"Glenelg Beach Kiosk",
"Fremantle Harbour Café"
);
adapter = new CafeAdapter(this, cafes);
cafeList.setAdapter(adapter);
searchBox.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 filter updates immediately, so there is no search button to press. This feels natural for short lists, such as shops around Adelaide Central Market or venues near a Queensland beach. For a longer list, a small delay or a SearchView can reduce unnecessary work while the user is typing.
The list automatically returns to its full state when the input is empty. That behaviour is useful after a user searches for a postcode, clears it, and wants to browse the complete collection again.
Improve Matching And User Feedback
A basic contains() search is often enough, but you can make the experience more useful by normalising whitespace and searching multiple fields. For example, a model object might contain a venue name, suburb, state, and postcode. The filter can compare the query with a combined string.
String searchableText = item.getName() + " "
+ item.getSuburb() + " "
+ item.getPostcode();
if (searchableText.toLowerCase(Locale.ROOT).contains(query)) {
matches.add(item);
}
Australian addresses may contain abbreviations such as “St”, “Rd”, and “NSW”, so decide whether those variations should match. A directory covering Perth, Canberra, and Hobart may also benefit from postcode searches because users often remember a number rather than a business name.
Useful Search Behaviours
- Ignore leading and trailing spaces.
- Match upper-case and lower-case text equally.
- Search names, suburbs, states, and postcodes.
- Restore every row when the field is cleared.
When no rows match, the empty list can look like a broken screen. Add a TextView with a message such as “No cafés found” and toggle its visibility alongside the ListView. For a polished interface, the message could mention the current suburb or search term without making the wording too technical.
Handle Larger Data Sets Carefully
ArrayAdapter filtering is suitable when the complete list is already loaded in memory. It works well for dozens or a few hundred rows, such as a local list of Australian parks, restaurants, or public libraries. It becomes less efficient when thousands of records are downloaded or stored in SQLite.
For a database-backed application, filter the query instead of loading every row into an adapter. A SQLite statement might use a LIKE condition:
SELECT name, suburb
FROM venues
WHERE name LIKE ? OR suburb LIKE ?
ORDER BY name
Pass a value such as "%fitzroy%" as the argument rather than joining raw user input into the SQL string. This avoids SQL injection and handles search text more safely.
If data comes from an online service, consider debouncing requests so the app does not send a network call for every keystroke. Showing a progress indicator and retaining the previous results can make searches feel steadier on mobile networks, including when travelling between regional towns or areas with inconsistent coverage.
Test the filter with empty input, spaces, partial words, mixed capitalisation, no matches, and repeated searches. Also check rotation and activity recreation so the search state behaves sensibly. Once the pattern works, you can replace ListView with RecyclerView while keeping the same filtering principles.