Skip to content

Instantly share code, notes, and snippets.

@chornthorn
Last active September 2, 2025 07:32
Show Gist options
  • Select an option

  • Save chornthorn/61eeccb23eaa6ee69f2391b665cee05e to your computer and use it in GitHub Desktop.

Select an option

Save chornthorn/61eeccb23eaa6ee69f2391b665cee05e to your computer and use it in GitHub Desktop.
DebouncedSearchField
import 'dart:async';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Debounced Search Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const SearchPage(),
);
}
}
class DebouncedSearchField extends StatefulWidget {
final Function(String) onSearch;
final String? hintText;
final Duration debounceDuration;
const DebouncedSearchField({
Key? key,
required this.onSearch,
this.hintText = 'Search...',
this.debounceDuration = const Duration(milliseconds: 600),
}) : super(key: key);
@override
State<DebouncedSearchField> createState() => _DebouncedSearchFieldState();
}
class _DebouncedSearchFieldState extends State<DebouncedSearchField> {
final TextEditingController _searchController = TextEditingController();
Timer? _debounceTimer;
@override
void dispose() {
_debounceTimer?.cancel();
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String query) {
// Cancel the previous timer
_debounceTimer?.cancel();
// Start a new timer
_debounceTimer = Timer(widget.debounceDuration, () {
widget.onSearch(query);
});
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: widget.hintText,
hintStyle: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
prefixIcon: Icon(
Icons.search,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(
Icons.clear,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
onPressed: () {
_searchController.clear();
_debounceTimer?.cancel();
widget.onSearch('');
setState(() {});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
style: Theme.of(context).textTheme.bodyLarge,
);
}
}
class SearchPage extends StatefulWidget {
const SearchPage({Key? key}) : super(key: key);
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
List<String> _allItems = [
'Apple iPhone 15',
'Samsung Galaxy S24',
'Google Pixel 8',
'OnePlus 12',
'Xiaomi 14',
'Sony Xperia 1 V',
'Nothing Phone 2',
'Motorola Edge 40',
'Realme GT 5',
'Oppo Find X6',
'Vivo X90',
'Honor Magic 5',
'Asus ROG Phone 7',
'Red Magic 8',
'Black Shark 5',
];
List<String> _searchResults = [];
bool _isLoading = false;
String _currentQuery = '';
void _performSearch(String query) async {
setState(() {
_currentQuery = query;
_isLoading = true;
});
// Simulate network delay
await Future.delayed(const Duration(milliseconds: 300));
if (query.trim().isEmpty) {
setState(() {
_searchResults = [];
_isLoading = false;
});
return;
}
// Filter items based on query
final results = _allItems
.where((item) => item.toLowerCase().contains(query.toLowerCase()))
.toList();
setState(() {
_searchResults = results;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
appBar: AppBar(
title: Text(
'Search Demo',
style: Theme.of(context).textTheme.titleLarge,
),
backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 0,
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
DebouncedSearchField(
onSearch: _performSearch,
hintText: 'Search phones...',
),
const SizedBox(height: 16),
if (_currentQuery.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Results for "$_currentQuery"',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
Expanded(
child: _isLoading
? Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
)
: _currentQuery.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'Start typing to search',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
)
: _searchResults.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No results found',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Try searching for something else',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
)
: ListView.builder(
itemCount: _searchResults.length,
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 2,
color: Theme.of(context).colorScheme.surface,
child: ListTile(
title: Text(
_searchResults[index],
style: Theme.of(context).textTheme.bodyLarge,
),
leading: CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
child: Icon(
Icons.phone_android,
color: Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
trailing: Icon(
Icons.arrow_forward_ios,
size: 16,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Selected: ${_searchResults[index]}',
),
backgroundColor: Theme.of(
context,
).colorScheme.primary,
),
);
},
),
);
},
),
),
],
),
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment