Search
SearchBar widget, TextField with onChange, filtering list in memory, FutureBuilder with API search, and debounce pattern.
SearchBar Widget (Material 3)
The SearchBar widget is a Material 3 component that provides a built-in search UI with a controller and callbacks.
class MySearchPage extends StatefulWidget {
const MySearchPage({super.key});
@override
State<MySearchPage> createState() => _MySearchPageState();
}
class _MySearchPageState extends State<MySearchPage> {
final SearchController _searchController = SearchController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Search Demo')),
body: Padding(
padding: const EdgeInsets.all(16),
child: SearchBar(
controller: _searchController,
hintText: 'Search something...',
leading: const Icon(Icons.search),
trailing: [
IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _searchController.clear(),
),
],
onChanged: (value) {
print('Query: $value');
},
onSubmitted: (value) {
print('Submitted: $value');
},
),
),
);
}
}
TextField with onChanged
Use a plain TextField with onChanged to react to every keystroke. Pair it with a TextEditingController for full control.
class TextFieldSearch extends StatefulWidget {
const TextFieldSearch({super.key});
@override
State<TextFieldSearch> createState() => _TextFieldSearchState();
}
class _TextFieldSearchState extends State<TextFieldSearch> {
final TextEditingController _controller = TextEditingController();
String _query = '';
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Type to search...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _query.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
setState(() => _query = '');
},
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
onChanged: (value) {
setState(() => _query = value);
},
);
}
}
Filtering a List in Memory
Keep an original list and a filtered list in state. On every query change, re-filter the original list using where() and rebuild.
class FilterableList extends StatefulWidget {
const FilterableList({super.key});
@override
State<FilterableList> createState() => _FilterableListState();
}
class _FilterableListState extends State<FilterableList> {
final List<String> _allItems = [
'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry',
'Fig', 'Grape', 'Honeydew', 'Kiwi', 'Lemon',
];
List<String> _filtered = [];
@override
void initState() {
super.initState();
_filtered = List.from(_allItems);
}
void _filterList(String query) {
setState(() {
_filtered = _allItems
.where((item) => item.toLowerCase().contains(query.toLowerCase()))
.toList();
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
decoration: const InputDecoration(
hintText: 'Filter fruits...',
prefixIcon: Icon(Icons.filter_list),
),
onChanged: _filterList,
),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
itemCount: _filtered.length,
itemBuilder: (context, index) => ListTile(
title: Text(_filtered[index]),
),
),
),
],
);
}
}
FutureBuilder with API Search
Use FutureBuilder to call an API on demand. Store the future in state and rebuild with the new future when the user submits a query.
Future<List<String>> searchApi(String query) async {
final response = await http.get(
Uri.parse('https://api.example.com/search?q=$query'),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as List;
return data.map((e) => e['name'] as String).toList();
}
throw Exception('Failed to search');
}
class ApiSearchPage extends StatefulWidget {
const ApiSearchPage({super.key});
@override
State<ApiSearchPage> createState() => _ApiSearchPageState();
}
class _ApiSearchPageState extends State<ApiSearchPage> {
Future<List<String>>? _resultFuture;
void _search(String query) {
if (query.isEmpty) return;
setState(() => _resultFuture = searchApi(query));
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
onSubmitted: _search,
decoration: const InputDecoration(hintText: 'Press Enter to search...'),
),
const SizedBox(height: 12),
if (_resultFuture != null)
FutureBuilder<List<String>>(
future: _resultFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
final results = snapshot.data!;
return ListView.builder(
shrinkWrap: true,
itemCount: results.length,
itemBuilder: (_, i) => ListTile(title: Text(results[i])),
);
},
),
],
);
}
}
Debounce Pattern
Debouncing delays the search call until the user stops typing for a set duration. Use a Timer that resets on each keystroke to avoid excessive API calls.
import 'dart:async';
class DebouncedSearch extends StatefulWidget {
const DebouncedSearch({super.key});
@override
State<DebouncedSearch> createState() => _DebouncedSearchState();
}
class _DebouncedSearchState extends State<DebouncedSearch> {
Timer? _debounce;
List<String> _results = [];
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
void _onSearchChanged(String query) {
// Cancel previous timer if still pending
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
// This block runs 500ms after the user stops typing
_performSearch(query);
});
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) {
setState(() => _results = []);
return;
}
// Replace with real API call
final results = await searchApi(query);
setState(() => _results = results);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
onChanged: _onSearchChanged,
decoration: const InputDecoration(
hintText: 'Search with debounce...',
prefixIcon: Icon(Icons.search),
),
),
Expanded(
child: ListView.builder(
itemCount: _results.length,
itemBuilder: (_, i) => ListTile(title: Text(_results[i])),
),
),
],
);
}
}