1  comments

Searching through all kinds of various content in your Flutter app is something you'll likely have to implement one day. A successful search implementation needs basically two things - a usable search history and also the search bar widget. Now, we're going to take a look at the most extensible and good-looking way to do this.

The project we'll build

We're going to build a simple project that will contain all of the basic building blocks of a good search implementation. The package that we're going to use for the UI part is called material_floating_search_bar. There are many other packages and even the built-in showSearch function, but the package we're using is one of the best ones out there in terms of customizability and it also looks really good out of the box.

When it comes to the search history part, we're going to keep it simple. We could introduce all kinds of third-party dependencies like different state management packages and all kinds of persistent databases. However, we're going to implement the history only inside a StatefulWidget and as an in-memory List. Yet, as you'll see in a little while, the code will be easily applicable to any state management and database solution you like.

If you want to follow along with this tutorial, get the starter project from above which contains a bit of uninteresting yet necessary code already written for you.

Search history & logic

It's much simpler to implement the UI when you have the logic and state part out of the way. There are a bunch of things we should be able to do with the search history which are demonstrated in the video above.

  • Add a search term and limit the maximum number of terms in history.
  • Delete a search term.
  • Get filtered search terms based on the user input.
  • Reorder the recently searched-for term to be displayed first.

As I've already mentioned, we're going to use an in-memory List and a StatefulWidget. No matter which state mangement solution and database you want to use in your project, the principles will remain the same.

Inside the _HomePageState class, let's create the following fields:

main.dart

static const historyLength = 5;

// The "raw" history that we don't access from the UI, prefilled with values
List<String> _searchHistory = [
  'fuchsia',
  'flutter',
  'widgets',
  'resocoder',
];
// The filtered & ordered history that's accessed from the UI
List<String> filteredSearchHistory;

// The currently searched-for term
String selectedTerm;

Filtering terms

As the user types in the search term, the history should display only the terms which start with the user input. In other words, typing in "fl" should only show terms such as "flutter" or "flask" because they start with "fl".

filterSearchTerms will be a function that will take the raw _searchHistory and output such a filtered list.

main.dart

List<String> filterSearchTerms({
  @required String filter,
}) {
  if (filter != null && filter.isNotEmpty) {
    // Reversed because we want the last added items to appear first in the UI
    return _searchHistory.reversed
        .where((term) => term.startsWith(filter))
        .toList();
  } else {
    return _searchHistory.reversed.toList();
  }
}
Because we're using a List to store the history data, we also use the filtering function to reverse the order of the list items. This will ensure that the last added terms will be displayed as first.

Adding a search term

Adding search terms is not as simple as calling add on a List object. We want to ensure there are no duplicate terms in the history. If the term we're trying to add already exists in the history, we instead just want to reorder the already existing term to appear first in the UI.

Also, there's a limit on how many terms the history holds which we've set to 5, so we want to apply the so-called least recently used algorithm and remove the least recently used search term if we go over the history limit.

main.dart

void addSearchTerm(String term) {
  if (_searchHistory.contains(term)) {
    // This method will be implemented soon
    putSearchTermFirst(term);
    return;
  }
  _searchHistory.add(term);
  if (_searchHistory.length > historyLength) {
    _searchHistory.removeRange(0, _searchHistory.length - historyLength);
  }
  // Changes in _searchHistory mean that we have to update the filteredSearchHistory
  filteredSearchHistory = filterSearchTerms(filter: null);
}

Deleting & reordering

While there's not much to say about deleting a term from history...

main.dart

void deleteSearchTerm(String term) {
  _searchHistory.removeWhere((t) => t == term);
  filteredSearchHistory = filterSearchTerms(filter: null);
}

Reordering or "putting the search term first" has an interesting implementation. At first we delete the term and then we add it again. Since newly added terms are displayed as first, this method serves its purpose well.

main.dart

void putSearchTermFirst(String term) {
  deleteSearchTerm(term);
  addSearchTerm(term);
}
This approach is perfect if you want to store only simple Strings in the history.  If you'd rather use your own class that's called, let's say, SearchTerm, feel free to add a timestamp field to it which you can then update and base your sorting upon accordingly.

Lastly, let's initialize the filteredSearchHistory from initState.

main.dart

@override
void initState() {
  super.initState();
  filteredSearchHistory = filterSearchTerms(filter: null);
}

Search bar UI

As you already know, we want to use the material_floating_search_bar package, so let's add it to the pubspec.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  material_floating_search_bar: ^0.2.6

This package comes with a FloatingSearchBar widget. Unlike the default AppBar, this widget should be put into the Scaffold's body parameter. The content of the actual page, which in this case is the SearchResultsListView from the starter project, is in turn put into the body of the FloatingSearchBar.

main.dart

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: FloatingSearchBar(
      body: SearchResultsListView(
        searchTerm: selectedTerm,
      ),
    ),
  );
}

Since we'll want to programmatically control the search bar, let's also set up the a controller for it.

main.dart

FloatingSearchBarController controller;

@override
void initState() {
  super.initState();
  controller = FloatingSearchBarController();
  filteredSearchHistory = filterSearchTerms(filter: null);
}

@override
void dispose() {
  controller.dispose();
  super.dispose();
}

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: FloatingSearchBar(
      controller: controller,
      body: SearchResultsListView(
        searchTerm: selectedTerm,
      ),
    ),
  );
}

To enable the commonly desired behavior where the search bar hides when the user scrolls down in a list, we need to wrap the search bar's body in a FloatingSearchBarScrollNotifier

main.dart

...
body: FloatingSearchBar(
  controller: controller,
  body: FloatingSearchBarScrollNotifier(
    child: SearchResultsListView(
      searchTerm: selectedTerm,
    ),
  ),
...

The FloatingSearchBar also takes in the following cosmetic fields

main.dart

transition: CircularFloatingSearchBarTransition(),
// Bouncing physics for the search history
physics: BouncingScrollPhysics(),
// Title is displayed on an unopened (inactive) search bar
title: Text(
  selectedTerm ?? 'The Search App',
  style: Theme.of(context).textTheme.headline6,
),
// Hint gets displayed once the search bar is tapped and opened
hint: 'Search and find out...',

To easily clear the string which the user has typed in, there's the searchToClear action.

main.dart

actions: [
  FloatingSearchBarAction.searchToClear(),
],

Callbacks

The first callback function we're interested in is onQueryChanged that gets triggered when a user types in characters. In our case, we just want to set the filteredSearchHistory to be filtered with the currently typed-in string.

main.dart

onQueryChanged: (query) {
  setState(() {
    filteredSearchHistory = filterSearchTerms(filter: query);
  });
},
If you're performing an expensive operation in onQueryChanged such as a network call, consider setting the debounceDelay so that the callback will not run for every single typed-in character.

The second callback is onSubmitted. Here we want to add and select the submitted query and also programmatically close the search bar.

main.dart

onSubmitted: (query) {
  setState(() {
    addSearchTerm(query);
    selectedTerm = query;
  });
  controller.close();
},

Displaying (not only) the history

The builder parameter of the FloatingSearchBar is what holds the "search history" widget that appears when a user taps on the search bar. We have a completely free hand at how it's styled, so let's begin with making it rounded and white. We're going to use the Material widget so that the ink splash effect will be visible once we add ListTiles.

main.dart

builder: (context, transition) {
  return ClipRRect(
    borderRadius: BorderRadius.circular(8),
    child: Material(
      color: Colors.white,
      elevation: 4,
      child: Placeholder(
        fallbackHeight: 200,
      ),
    ),
  );
},

We're not always going to display only the filteredSearchHistory though. If the history is empty and the user has not typed in any search query yet, we want to display a "start searching" message. This means we'll write an if statement and to run such conditional logic, we'll need to break out our code into another build method with a Builder (or create a new separate widget class, but I'm lazy 😉).

main.dart

return ClipRRect(
  borderRadius: BorderRadius.circular(8),
  child: Material(
    color: Colors.white,
    elevation: 4,
    child: Builder(
      builder: (context) {
        if (filteredSearchHistory.isEmpty &&
            controller.query.isEmpty) {
          return Container(
            height: 56,
            width: double.infinity,
            alignment: Alignment.center,
            child: Text(
              'Start searching',
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: Theme.of(context).textTheme.caption,
            ),
          );
        }
      },
    ),
  ),
);

Another special case that can occur is when the filtered history is empty and the user has already started typing in the search term. In such a case, we want to display the currently typed-in term in a single ListTile . Tapping this tile behaves just like the onSubmit callback we've already implemented.

Continuing in the inner builder method from above...

main.dart

else if (filteredSearchHistory.isEmpty) {
  return ListTile(
    title: Text(controller.query),
    leading: const Icon(Icons.search),
    onTap: () {
      setState(() {
        addSearchTerm(controller.query);
        selectedTerm = controller.query;
      });
      controller.close();
    },
  );
}

Lastly, in the else clause, we want to display the filtered history in a Column.  While we technically could use a ListView, we'd have to do a bunch of shenaningans. As the package docs say:

By default, the widget returned by the builder is not allowed to have an unbounded (infinite) height. This is necessary in order for the search bar to be able to dismiss itself, when the user taps below the area of the child.

Therefore, the easiest way to get a vertical list of widgets is to use a Column with mainAxisSize set to be the minimum.

The ListTiles will have a delete button when the user taps them, the tapped search term will be selected and also put first in the history.

main.dart

else {
  return Column(
    mainAxisSize: MainAxisSize.min,
    children: filteredSearchHistory
        .map(
          (term) => ListTile(
            title: Text(
              term,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
            ),
            leading: const Icon(Icons.history),
            trailing: IconButton(
              icon: const Icon(Icons.clear),
              onPressed: () {
                setState(() {
                  deleteSearchTerm(term);
                });
              },
            ),
            onTap: () {
              setState(() {
                putSearchTermFirst(term);
                selectedTerm = term;
              });
              controller.close();
            },
          ),
        )
        .toList(),
  );
}

When you run the app now, it works! Searching for a term displays 50 search results in the SearchResultsListView. Have you noticed something weird though?

Padding the content

The problem is that the first search result is not visible! The count starts from 0, but the only the item number 1 can be seen. 

It so happens, that the very first ListTile is perfectly hidden under the search bar but it's there! The solution is to add a padding to the ListView inside of the SearchResultsListView widget. It's best not to hardcode any values but instead to add padding based on the true height of the FloatingSearchBar. We can easily look up the FloatingSearchBarState that gives us access to the height and and vertical margin values with the familiar of helper method.

main.dart

class SearchResultsListView extends StatelessWidget {
  ...
  
  @override
  Widget build(BuildContext context) {
    ...

    final fsb = FloatingSearchBar.of(context);

    return ListView(
      padding: EdgeInsets.only(top: fsb.height + fsb.margins.vertical),
      children: List.generate(
        50,
        (index) => ListTile(
          title: Text('$searchTerm search result'),
          subtitle: Text(index.toString()),
        ),
      ),
    );
  }
}

Now even the very first search result is visible and you're fully set to use this kind of a search bar setup in your own apps. You should be able to take these methods and easily adapt them to your favorite state management solution and database.

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a Flutter freelancer and most importantly developer educator, he doesn't have a lot of free time 😅 Yet he still manages to squeeze in tough workouts 💪

You may also like

Snackbar, Toast & Dialog in Flutter (Flash Package)

Flutter Integration Test Tutorial + Firebase Test Lab & Codemagic

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >