Building a Filter Search Widget in Flutter
Introduction
In modern applications, users frequently interact with large datasets. To enhance user experience and efficiency, providing a robust search and filter mechanism is crucial. A filter search widget allows users to quickly narrow down a list of items based on specific criteria, making it easier to find relevant information. This article will guide you through the process of building a dynamic and responsive filter search widget in Flutter.
Core Concepts
Before diving into the implementation, let's understand the key concepts involved:
- State Management: We'll use Flutter's built-in
StatefulWidgetandsetStateto manage the UI's state, specifically the search query and the filtered list. TextEditingController: This controller is essential for managing and listening to changes in aTextFieldwidget, which will serve as our search input.ListView.builder: An efficient way to display a scrollable list of items, especially when the number of items can be large or dynamic.- Filtering Logic: The core mechanism to process the original list based on the search query and produce a new, filtered list.
- Debouncing (Optional but Recommended): A technique to delay the execution of a function until a certain amount of time has passed without any further calls. This prevents excessive widget rebuilds and API calls as the user types, improving performance.
Step-by-Step Implementation
1. Setting Up the Project and Data
First, ensure you have a basic Flutter project set up. We'll create a simple list of strings to demonstrate the filtering functionality.
import 'package:flutter/material.dart';
import 'dart:async'; // For debouncing
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Filter Search Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const FilterSearchScreen(),
);
}
}
class FilterSearchScreen extends StatefulWidget {
const FilterSearchScreen({super.key});
@override
State createState() => _FilterSearchScreenState();
}
class _FilterSearchScreenState extends State {
final List<String> _allProducts = [
'Apple',
'Banana',
'Orange',
'Strawberry',
'Mango',
'Pineapple',
'Grape',
'Watermelon',
'Blueberry',
'Raspberry',
];
List<String> _foundProducts = [];
TextEditingController _searchController = TextEditingController();
Timer? _debounce;
@override
void initState() {
_foundProducts = _allProducts; // Initially, show all products
super.initState();
}
@override
void dispose() {
_searchController.dispose();
_debounce?.cancel();
super.dispose();
}
// ... rest of the code
}
2. Implementing the Search Input and UI Structure
We'll add an AppBar with a TextField for the search input and a ListView.builder to display the products.
// ... inside _FilterSearchScreenState
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Product Search'),
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search for products...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_runFilter('');
},
)
: null,
),
onChanged: (value) {
// We'll add the debouncing logic here
_onSearchChanged(value);
},
),
const SizedBox(height: 20),
Expanded(
child: _foundProducts.isNotEmpty
? ListView.builder(
itemCount: _foundProducts.length,
itemBuilder: (context, index) {
return Card(
key: ValueKey(_foundProducts[index]),
elevation: 2,
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: const Icon(Icons.shopping_bag),
title: Text(_foundProducts[index]),
),
);
},
)
: const Center(
child: Text(
'No results found',
style: TextStyle(fontSize: 24),
),
),
),
],
),
),
);
}
// ... filtering and debouncing methods
3. Implementing the Filtering Logic
The _runFilter method will take the search query and update the _foundProducts list based on whether a product's name contains the query (case-insensitive).
// ... inside _FilterSearchScreenState
void _runFilter(String enteredKeyword) {
List<String> results = [];
if (enteredKeyword.isEmpty) {
// If the search field is empty, show all products
results = _allProducts;
} else {
results = _allProducts
.where((product) =>
product.toLowerCase().contains(enteredKeyword.toLowerCase()))
.toList();
}
// Refresh the UI
setState(() {
_foundProducts = results;
});
}
// ... debouncing methods
4. Debouncing the Search Input (Recommended)
To prevent the _runFilter method from being called on every keystroke, which can be inefficient for large lists or when fetching data from an API, we'll implement a simple debouncing mechanism using dart:async.Timer.
// ... inside _FilterSearchScreenState
void _onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
_runFilter(query);
});
}
// ... rest of the class
The _onSearchChanged method will be called when the TextField's onChanged callback fires. It cancels any pending debounce timer and starts a new one. If the user stops typing for 300 milliseconds, the _runFilter method is finally executed.
Complete Code Example
Here's the full code for the FilterSearchScreen:
import 'package:flutter/material.dart';
import 'dart:async'; // For debouncing
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Filter Search Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const FilterSearchScreen(),
);
}
}
class FilterSearchScreen extends StatefulWidget {
const FilterSearchScreen({super.key});
@override
State createState() => _FilterSearchScreenState();
}
class _FilterSearchScreenState extends State {
final List<String> _allProducts = [
'Apple',
'Banana',
'Orange',
'Strawberry',
'Mango',
'Pineapple',
'Grape',
'Watermelon',
'Blueberry',
'Raspberry',
'Kiwi',
'Peach',
'Plum',
'Cherry',
'Lemon',
'Lime',
];
List<String> _foundProducts = [];
TextEditingController _searchController = TextEditingController();
Timer? _debounce;
@override
void initState() {
_foundProducts = _allProducts; // Initially, show all products
super.initState();
}
@override
void dispose() {
_searchController.dispose();
_debounce?.cancel();
super.dispose();
}
void _runFilter(String enteredKeyword) {
List<String> results = [];
if (enteredKeyword.isEmpty) {
// If the search field is empty, show all products
results = _allProducts;
} else {
results = _allProducts
.where((product) =>
product.toLowerCase().contains(enteredKeyword.toLowerCase()))
.toList();
}
// Refresh the UI
setState(() {
_foundProducts = results;
});
}
void _onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
_runFilter(query);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Product Search'),
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search for products...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_runFilter('');
// Ensure the text field updates immediately
setState(() {});
},
)
: null,
),
onChanged: (value) {
_onSearchChanged(value);
// Also update the UI to show/hide clear button
setState(() {});
},
),
const SizedBox(height: 20),
Expanded(
child: _foundProducts.isNotEmpty
? ListView.builder(
itemCount: _foundProducts.length,
itemBuilder: (context, index) {
return Card(
key: ValueKey(_foundProducts[index]),
elevation: 2,
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: const Icon(Icons.shopping_bag),
title: Text(_foundProducts[index]),
),
);
},
)
: const Center(
child: Text(
'No results found',
style: TextStyle(fontSize: 24),
),
),
),
],
),
),
);
}
}
Conclusion
Building a filter search widget in Flutter is a fundamental skill for creating dynamic and user-friendly applications. By combining TextEditingController, state management with setState, and efficient list rendering with ListView.builder, you can create a powerful search experience. Incorporating debouncing further refines the performance, ensuring a smooth interaction even with large datasets. You can extend this basic structure to filter more complex data models, integrate with backend APIs, and add advanced filtering options based on multiple criteria.