Implementing Search by Character in Flutter: A Complete Guide with Examples

Searching is one of the most common features in mobile applications. Whether you're building a contact list, product catalog, employee directory, or chat application, users expect search results to update instantly as they type.

Flutter makes it easy to implement search by character, where the list filters after every keystroke without requiring users to tap a search button.

In this guide, you'll learn how to implement a fast, efficient, and scalable character-by-character search in Flutter.


What is Search by Character?

Search by character (also called live search or incremental search) filters data every time the user types a new character.

For example:

User Types          Results
----------------------------------------
A                   Apple, Amazon, Adobe
Ap                  Apple
App                 Apple
Appl                Apple
Apple               Apple

Instead of waiting for the user to press Search, the UI updates automatically.


When Should You Use It?

Character search is ideal for:

  • Product catalogs

  • Contact lists

  • Employee directories

  • Chat users

  • Countries list

  • Cities list

  • Music playlists

  • Restaurant menus

  • Shopping apps


How It Works

The search process is straightforward:

  1. User types into a TextField.

  2. Flutter captures the text using onChanged.

  3. The app filters the list.

  4. The UI rebuilds with matching results.

User Types
     │
     ▼
TextField
     │
     ▼
onChanged()
     │
     ▼
Filter List
     │
     ▼
Update UI

Sample Data

Let's start with a simple list of fruits.

final List<String> fruits = [
  'Apple',
  'Banana',
  'Orange',
  'Mango',
  'Pineapple',
  'Watermelon',
  'Kiwi',
  'Grapes',
  'Strawberry',
];

Create the Search Screen

Create a new StatefulWidget.

class SearchPage extends StatefulWidget {
  const SearchPage({super.key});

  @override
  State<SearchPage> createState() => _SearchPageState();
}

Create a Filtered List

Maintain two lists:

List<String> fruits = [...];

List<String> filteredFruits = [];

Initialize the filtered list:

@override
void initState() {
  super.initState();
  filteredFruits = fruits;
}

Add a Search TextField

Flutter provides the onChanged callback, which is triggered after every keystroke.

TextField(
  decoration: const InputDecoration(
    hintText: "Search fruits",
    prefixIcon: Icon(Icons.search),
  ),
  onChanged: (value) {
    searchFruit(value);
  },
)

Implement the Search Function

Use the where() method to filter the list.

void searchFruit(String keyword) {
  final results = fruits.where((fruit) {
    return fruit
        .toLowerCase()
        .contains(keyword.toLowerCase());
  }).toList();

  setState(() {
    filteredFruits = results;
  });
}

This implementation is:

  • Case-insensitive

  • Easy to understand

  • Efficient for small and medium-sized lists


Display Search Results

Use a ListView.builder to display the filtered list.

Expanded(
  child: ListView.builder(
    itemCount: filteredFruits.length,
    itemBuilder: (context, index) {
      return ListTile(
        title: Text(filteredFruits[index]),
      );
    },
  ),
)

The list updates automatically as the user types.


Complete Example

class SearchPage extends StatefulWidget {
  const SearchPage({super.key});

  @override
  State<SearchPage> createState() => _SearchPageState();
}

class _SearchPageState extends State<SearchPage> {

  final List<String> fruits = [
    'Apple',
    'Banana',
    'Orange',
    'Mango',
    'Pineapple',
    'Kiwi',
    'Strawberry',
    'Watermelon',
  ];

  late List<String> filteredFruits;

  @override
  void initState() {
    super.initState();
    filteredFruits = fruits;
  }

  void searchFruit(String keyword) {
    setState(() {
      filteredFruits = fruits.where((fruit) {
        return fruit
            .toLowerCase()
            .contains(keyword.toLowerCase());
      }).toList();
    });
  }

  @override
  Widget build(BuildContext context) {

    return Scaffold(

      appBar: AppBar(
        title: const Text("Character Search"),
      ),

      body: Column(

        children: [

          Padding(
            padding: const EdgeInsets.all(12),

            child: TextField(

              decoration: const InputDecoration(
                hintText: "Search...",
                prefixIcon: Icon(Icons.search),
              ),

              onChanged: searchFruit,

            ),
          ),

          Expanded(

            child: ListView.builder(

              itemCount: filteredFruits.length,

              itemBuilder: (_, index) {

                return ListTile(
                  title: Text(filteredFruits[index]),
                );

              },

            ),

          )

        ],

      ),

    );

  }

}

Searching Objects Instead of Strings

In real-world apps, you'll usually search objects.

class Employee {

  final String name;

  final String department;

  Employee(this.name, this.department);

}

Filter by name:

employees.where((employee) {

    return employee.name
        .toLowerCase()
        .contains(keyword.toLowerCase());

}).toList();

You can also search multiple fields:

employees.where((employee) {

    return employee.name
            .toLowerCase()
            .contains(keyword.toLowerCase())

        ||

        employee.department
            .toLowerCase()
            .contains(keyword.toLowerCase());

}).toList();

Improve Performance with Debouncing

If your search calls an API, avoid sending a request for every keystroke. Use debouncing to wait until the user pauses typing.

Timer? _debounce;

void onSearchChanged(String query) {
  if (_debounce?.isActive ?? false) {
    _debounce!.cancel();
  }

  _debounce = Timer(
    const Duration(milliseconds: 500),
    () {
      searchApi(query);
    },
  );
}

This reduces unnecessary network requests and improves performance.


Show a "No Results" Message

Handle empty results gracefully.

if (filteredFruits.isEmpty) {
  return const Center(
    child: Text("No matching results found"),
  );
}

Providing feedback improves the user experience.


Best Practices

  • Convert both the search query and the data to lowercase for case-insensitive matching.

  • Use debouncing for API-based searches.

  • Keep the original list unchanged and filter into a separate list.

  • Display a loading indicator for network searches.

  • Show a clear message when no results are found.

  • Dispose of timers or controllers to avoid memory leaks.


Common Mistakes

Avoid these common pitfalls:

  • Modifying the original list directly.

  • Ignoring case sensitivity.

  • Calling APIs on every keystroke without debouncing.

  • Not handling empty search results.

  • Forgetting to dispose of TextEditingController or Timer.


Conclusion

Character-by-character search is a simple yet powerful feature that significantly improves user experience. Flutter's TextField, onChanged, and collection methods like where() make it easy to implement responsive search functionality.

For local data, filtering a list in memory is usually sufficient. For large datasets or remote APIs, combine live search with debouncing or pagination to build a scalable and efficient search experience.

With these techniques, you can create fast, intuitive search experiences for almost any Flutter application.

Comments