flutter_smart_debouncer 1.0.1
flutter_smart_debouncer: ^1.0.1 copied to clipboard
A unified package providing debouncing utilities and smart Flutter widgets (TextField, Button, etc.).
flutter_smart_debouncer #
flutter_smart_debouncer
is a unified toolkit for taming noisy inputs. It combines production-ready debouncing and throttling utilities with polished Flutter widgets so you can guard API calls, smooth out text entry, and prevent accidental double submits using a single dependency.
Debouncing coalesces a burst of events into a single callback once the user pauses. It keeps search boxes snappy, throttles autosave traffic, and prevents repeated taps on important buttons.
Table of Contents #
Quick Start #
Add the package to your app:
dependencies:
flutter_smart_debouncer: ^1.0.1
Import the package:
import 'package:flutter_smart_debouncer/flutter_smart_debouncer.dart';
Core Features #
SmartDebouncer / Debouncer #
The SmartDebouncer
(aliased as Debouncer
) is the heart of this package. It coalesces rapid-fire calls into a single execution after the specified delay.
Basic Usage
final searchDebouncer = Debouncer<void>(
delay: const Duration(milliseconds: 300),
);
void onSearchChanged(String query) {
searchDebouncer(() async {
final results = await searchApi(query);
updateUI(results);
});
}
Leading Edge Execution
Execute immediately on the first call, then ignore subsequent calls during the delay period:
final debouncer = Debouncer<void>(
delay: const Duration(milliseconds: 500),
leading: true,
trailing: false,
);
// First call executes immediately
// Subsequent calls within 500ms are ignored
debouncer(() => print('Executed!'));
Trailing Edge Execution (Default)
Execute after the delay period when calls stop:
final debouncer = Debouncer<void>(
delay: const Duration(milliseconds: 300),
trailing: true, // default
);
// Executes 300ms after the last call
debouncer(() => print('Executed after pause'));
Both Leading and Trailing
Execute immediately on the first call AND after the delay when calls stop:
final debouncer = Debouncer<void>(
delay: const Duration(milliseconds: 300),
leading: true,
trailing: true,
);
Max Wait
Ensure execution happens at least every maxWait
duration, even if calls keep coming:
final debouncer = Debouncer<void>(
delay: const Duration(milliseconds: 300),
maxWait: const Duration(seconds: 2),
);
// Even if calls keep coming, execution happens at least every 2 seconds
Pause and Resume
Pause the debouncer timer and resume it later:
final debouncer = Debouncer<void>(
delay: const Duration(milliseconds: 500),
);
debouncer(() => print('Action'));
// Pause the timer
debouncer.pause();
// Resume the timer (continues from where it was paused)
debouncer.resume();
Cancel
Cancel any pending execution:
final debouncer = Debouncer<void>(
delay: const Duration(milliseconds: 500),
);
debouncer(() => print('This will be cancelled'));
// Cancel before execution
debouncer.cancel();
Flush
Execute pending action immediately:
final debouncer = Debouncer<void>(
delay: const Duration(milliseconds: 500),
);
debouncer(() => print('Flushed immediately'));
// Execute immediately instead of waiting
await debouncer.flush();
Error Handling
Handle errors from debounced callbacks:
final debouncer = Debouncer<void>(
delay: const Duration(milliseconds: 300),
onError: (error, stackTrace) {
print('Error in debounced callback: $error');
logError(error, stackTrace);
},
);
debouncer(() async {
throw Exception('Something went wrong');
});
Return Values
Get return values from debounced callbacks:
final debouncer = Debouncer<String>(
delay: const Duration(milliseconds: 300),
);
final result = await debouncer(() async {
final data = await fetchData();
return data;
});
print('Result: $result');
Check Status
// Check if there's a pending or running action
if (debouncer.isActive) {
print('Debouncer is active');
}
// Check if paused
if (debouncer.isPaused) {
print('Debouncer is paused');
}
Dispose
Always dispose when done:
@override
void dispose() {
debouncer.dispose();
super.dispose();
}
SmartThrottle #
SmartThrottle
limits how frequently a callback can execute. Unlike debouncing, throttling ensures execution happens at regular intervals.
Basic Usage
final scrollThrottle = SmartThrottle<void>(
interval: const Duration(milliseconds: 200),
);
void onScroll(ScrollNotification notification) {
scrollThrottle(() {
updateScrollPosition(notification.metrics.pixels);
});
}
Leading Edge (Default)
Execute immediately on the first call, then throttle subsequent calls:
final throttle = SmartThrottle<void>(
interval: const Duration(milliseconds: 500),
leading: true, // default
trailing: false,
);
// First call executes immediately
// Next call can only execute after 500ms
throttle(() => print('Throttled!'));
Trailing Edge
Execute after the interval when calls stop:
final throttle = SmartThrottle<void>(
interval: const Duration(milliseconds: 500),
leading: false,
trailing: true,
);
Both Leading and Trailing
final throttle = SmartThrottle<void>(
interval: const Duration(milliseconds: 500),
leading: true,
trailing: true,
);
Cancel and Flush
// Cancel pending execution
throttle.cancel();
// Execute pending action immediately
await throttle.flush();
Error Handling
final throttle = SmartThrottle<void>(
interval: const Duration(milliseconds: 300),
onError: (error, stackTrace) {
print('Error: $error');
},
);
Real-World Example: Scroll Tracking
class MyScrollableWidget extends StatefulWidget {
@override
State<MyScrollableWidget> createState() => _MyScrollableWidgetState();
}
class _MyScrollableWidgetState extends State<MyScrollableWidget> {
final _scrollThrottle = SmartThrottle<void>(
interval: const Duration(milliseconds: 100),
);
@override
void dispose() {
_scrollThrottle.dispose();
super.dispose();
}
void _handleScroll(ScrollNotification notification) {
_scrollThrottle(() {
// This executes at most once every 100ms
analytics.trackScroll(notification.metrics.pixels);
});
}
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
_handleScroll(notification);
return false;
},
child: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
);
}
}
DebouncePool #
DebouncePool
manages multiple debouncer instances grouped by string keys. Perfect for scenarios where you need separate debouncers for different items (e.g., form fields, list items).
Basic Usage
final pool = DebouncePool<void>(
defaultDelay: const Duration(milliseconds: 300),
);
// Each key gets its own debouncer
pool.call('field1', () => saveField1());
pool.call('field2', () => saveField2());
pool.call('field3', () => saveField3());
Obtain Specific Debouncer
final pool = DebouncePool<void>();
// Get or create a debouncer for a specific key
final debouncer = pool.obtain('userId_123');
// Use it multiple times
debouncer(() => updateUser());
Custom Configuration Per Key
final pool = DebouncePool<void>(
defaultDelay: const Duration(milliseconds: 300),
);
// Override defaults for specific keys
final fastDebouncer = pool.obtain(
'quickField',
delay: const Duration(milliseconds: 100),
leading: true,
);
final slowDebouncer = pool.obtain(
'slowField',
delay: const Duration(seconds: 1),
maxWait: const Duration(seconds: 3),
);
Time-To-Live (TTL)
Automatically dispose inactive debouncers after a period:
final pool = DebouncePool<void>(
defaultDelay: const Duration(milliseconds: 300),
ttl: const Duration(minutes: 5), // Auto-dispose after 5 minutes of inactivity
);
Cancel, Flush, and Dispose
// Cancel pending work for a specific key
pool.cancel('field1');
// Flush pending work for a specific key
await pool.flush('field2');
// Dispose a specific debouncer
pool.disposeKey('field3');
// Dispose all debouncers
pool.disposeAll();
Real-World Example: Form Auto-Save
class AutoSaveForm extends StatefulWidget {
@override
State<AutoSaveForm> createState() => _AutoSaveFormState();
}
class _AutoSaveFormState extends State<AutoSaveForm> {
final _pool = DebouncePool<void>(
defaultDelay: const Duration(milliseconds: 500),
ttl: const Duration(minutes: 10),
);
@override
void dispose() {
_pool.disposeAll();
super.dispose();
}
void _saveField(String fieldName, String value) {
_pool.call(fieldName, () async {
await api.saveField(fieldName, value);
print('Saved $fieldName: $value');
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
decoration: const InputDecoration(labelText: 'Name'),
onChanged: (value) => _saveField('name', value),
),
TextField(
decoration: const InputDecoration(labelText: 'Email'),
onChanged: (value) => _saveField('email', value),
),
TextField(
decoration: const InputDecoration(labelText: 'Phone'),
onChanged: (value) => _saveField('phone', value),
),
],
);
}
}
DebouncedValue #
DebouncedValue
is a reactive container that stores a value and emits updates through a stream after a debounce delay.
Basic Usage
final searchQuery = DebouncedValue<String>(
'',
delay: const Duration(milliseconds: 300),
);
// Listen to debounced updates
searchQuery.stream.listen((value) {
print('Debounced search: $value');
performSearch(value);
});
// Set values rapidly
searchQuery.set('a');
searchQuery.set('ab');
searchQuery.set('abc');
// Only 'abc' is emitted after 300ms
// Get current value immediately
print(searchQuery.value); // 'abc'
// Clean up
await searchQuery.close();
Real-World Example: Search with StreamBuilder
class SearchWidget extends StatefulWidget {
@override
State<SearchWidget> createState() => _SearchWidgetState();
}
class _SearchWidgetState extends State<SearchWidget> {
final _searchQuery = DebouncedValue<String>(
'',
delay: const Duration(milliseconds: 300),
);
@override
void dispose() {
_searchQuery.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
decoration: const InputDecoration(labelText: 'Search'),
onChanged: (value) => _searchQuery.set(value),
),
StreamBuilder<String>(
stream: _searchQuery.stream,
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Text('Type to search');
}
return FutureBuilder<List<String>>(
future: searchApi(snapshot.data!),
builder: (context, results) {
if (!results.hasData) {
return const CircularProgressIndicator();
}
return ListView(
shrinkWrap: true,
children: results.data!
.map((item) => ListTile(title: Text(item)))
.toList(),
);
},
);
},
),
],
);
}
}
Stream Extensions #
Debounce and throttle any Dart stream with convenient extensions.
debounceTime
final stream = Stream.periodic(
const Duration(milliseconds: 100),
(count) => count,
);
// Only emit after 300ms of silence
stream.debounceTime(const Duration(milliseconds: 300)).listen((value) {
print('Debounced: $value');
});
throttleTime
final stream = Stream.periodic(
const Duration(milliseconds: 50),
(count) => count,
);
// Emit at most once every 200ms
stream.throttleTime(
const Duration(milliseconds: 200),
leading: true,
trailing: true,
).listen((value) {
print('Throttled: $value');
});
Real-World Example: Text Field Stream
class StreamSearchWidget extends StatefulWidget {
@override
State<StreamSearchWidget> createState() => _StreamSearchWidgetState();
}
class _StreamSearchWidgetState extends State<StreamSearchWidget> {
final _controller = StreamController<String>();
late final Stream<String> _debouncedStream;
@override
void initState() {
super.initState();
_debouncedStream = _controller.stream
.debounceTime(const Duration(milliseconds: 300));
}
@override
void dispose() {
_controller.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
onChanged: (value) => _controller.add(value),
decoration: const InputDecoration(labelText: 'Search'),
),
StreamBuilder<String>(
stream: _debouncedStream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
return Text('Searching for: ${snapshot.data}');
},
),
],
);
}
}
Flutter Widgets #
SmartDebouncerTextField #
A drop-in replacement for TextField
with built-in debouncing.
Basic Usage
SmartDebouncerTextField(
delay: const Duration(milliseconds: 300),
decoration: const InputDecoration(labelText: 'Search'),
onChangedDebounced: (value) {
print('Debounced: $value');
performSearch(value);
},
)
All Parameters
SmartDebouncerTextField(
delay: const Duration(milliseconds: 300),
leading: false,
trailing: true,
maxWait: const Duration(seconds: 2),
// Debounced callback
onChangedDebounced: (value) => print('Debounced: $value'),
// Immediate callback (not debounced)
onChanged: (value) => print('Immediate: $value'),
// Standard TextField parameters
controller: myController,
focusNode: myFocusNode,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.search,
style: const TextStyle(fontSize: 16),
textAlign: TextAlign.start,
autofocus: false,
obscureText: false,
enabled: true,
minLines: 1,
maxLines: 1,
textCapitalization: TextCapitalization.none,
onSubmitted: (value) => print('Submitted: $value'),
)
Real-World Example: Search Field
class SearchPage extends StatefulWidget {
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
List<String> _results = [];
bool _isLoading = false;
Future<void> _performSearch(String query) async {
if (query.isEmpty) {
setState(() => _results = []);
return;
}
setState(() => _isLoading = true);
try {
final results = await searchApi(query);
setState(() {
_results = results;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
showError(e);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Search')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: SmartDebouncerTextField(
delay: const Duration(milliseconds: 300),
maxWait: const Duration(seconds: 2),
decoration: const InputDecoration(
labelText: 'Search',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChangedDebounced: _performSearch,
),
),
if (_isLoading)
const CircularProgressIndicator()
else
Expanded(
child: ListView.builder(
itemCount: _results.length,
itemBuilder: (context, index) {
return ListTile(title: Text(_results[index]));
},
),
),
],
),
);
}
}
SmartDebouncerButton #
A button that prevents accidental double-taps and rapid repeated presses.
Basic Usage
SmartDebouncerButton(
delay: const Duration(milliseconds: 500),
onPressed: () {
print('Button pressed (debounced)');
submitForm();
},
child: const Text('Submit'),
)
All Parameters
SmartDebouncerButton(
delay: const Duration(milliseconds: 500),
leading: true, // Execute immediately on first press
trailing: false, // Don't execute on trailing edge
maxWait: const Duration(seconds: 2),
onPressed: () => print('Pressed'),
// Standard button styling
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: const Text('Submit'),
)
Real-World Example: Form Submission
class SubmitFormButton extends StatefulWidget {
final VoidCallback onSubmit;
const SubmitFormButton({required this.onSubmit});
@override
State<SubmitFormButton> createState() => _SubmitFormButtonState();
}
class _SubmitFormButtonState extends State<SubmitFormButton> {
bool _isSubmitting = false;
Future<void> _handleSubmit() async {
if (_isSubmitting) return;
setState(() => _isSubmitting = true);
try {
widget.onSubmit();
await Future.delayed(const Duration(seconds: 2)); // Simulate API call
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Submitted successfully!')),
);
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
@override
Widget build(BuildContext context) {
return SmartDebouncerButton(
delay: const Duration(milliseconds: 700),
leading: true,
trailing: false,
onPressed: _isSubmitting ? null : _handleSubmit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
child: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Submit'),
);
}
}
Advanced Usage #
Combining Multiple Features #
class AdvancedSearchWidget extends StatefulWidget {
@override
State<AdvancedSearchWidget> createState() => _AdvancedSearchWidgetState();
}
class _AdvancedSearchWidgetState extends State<AdvancedSearchWidget> {
final _pool = DebouncePool<void>(
defaultDelay: const Duration(milliseconds: 300),
);
final _scrollThrottle = SmartThrottle<void>(
interval: const Duration(milliseconds: 100),
);
@override
void dispose() {
_pool.disposeAll();
_scrollThrottle.dispose();
super.dispose();
}
void _searchCategory(String category, String query) {
_pool.call('search_$category', () async {
final results = await searchInCategory(category, query);
updateResults(category, results);
});
}
void _trackScroll(double position) {
_scrollThrottle(() {
analytics.trackScrollPosition(position);
});
}
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
_trackScroll(notification.metrics.pixels);
return false;
},
child: ListView(
children: [
SmartDebouncerTextField(
delay: const Duration(milliseconds: 300),
decoration: const InputDecoration(labelText: 'Search Books'),
onChangedDebounced: (value) => _searchCategory('books', value),
),
SmartDebouncerTextField(
delay: const Duration(milliseconds: 300),
decoration: const InputDecoration(labelText: 'Search Movies'),
onChangedDebounced: (value) => _searchCategory('movies', value),
),
SmartDebouncerButton(
delay: const Duration(milliseconds: 500),
onPressed: () => submitAllSearches(),
child: const Text('Search All'),
),
],
),
);
}
}
Example App #
See example/lib/main.dart
for a complete Flutter demo that showcases:
- Core
Debouncer
with simulated API calls SmartDebouncerTextField
for search inputSmartDebouncerButton
for protected submissions- Real-time event logging
Run it with:
cd example
flutter run
API Reference #
SmartDebouncer / Debouncer #
Parameter | Type | Default | Description |
---|---|---|---|
delay |
Duration |
required | Time to wait before executing |
leading |
bool |
false |
Execute on the leading edge |
trailing |
bool |
true |
Execute on the trailing edge |
maxWait |
Duration? |
null |
Maximum time to wait before forcing execution |
onError |
Function? |
null |
Error handler for callback exceptions |
onLeadingInvoke |
Function? |
null |
Called when leading edge executes |
Methods:
call(action)
- Schedule an actioncancel()
- Cancel pending actionflush()
- Execute pending action immediatelypause()
- Pause timersresume()
- Resume timersdispose()
- Clean up resources
Properties:
isActive
- Whether there's pending or running workisPaused
- Whether timers are paused
SmartThrottle #
Parameter | Type | Default | Description |
---|---|---|---|
interval |
Duration |
required | Minimum time between executions |
leading |
bool |
true |
Execute on the leading edge |
trailing |
bool |
true |
Execute on the trailing edge |
onError |
Function? |
null |
Error handler for callback exceptions |
Methods:
call(action)
- Schedule an actioncancel()
- Cancel pending actionflush()
- Execute pending action immediatelydispose()
- Clean up resources
DebouncePool #
Parameter | Type | Default | Description |
---|---|---|---|
defaultDelay |
Duration? |
300ms |
Default delay for new debouncers |
defaultLeading |
bool |
false |
Default leading setting |
defaultTrailing |
bool |
true |
Default trailing setting |
defaultMaxWait |
Duration? |
null |
Default maxWait setting |
ttl |
Duration? |
null |
Time-to-live for inactive debouncers |
Methods:
obtain(key, ...)
- Get or create a debouncercall(key, action)
- Execute action with key's debouncercancel(key)
- Cancel pending work for keyflush(key)
- Flush pending work for keydisposeKey(key)
- Dispose specific debouncerdisposeAll()
- Dispose all debouncers
DebouncedValue #
Parameter | Type | Default | Description |
---|---|---|---|
initial |
T |
required | Initial value |
delay |
Duration |
required | Debounce delay |
Methods:
set(value)
- Set new value (debounced)close()
- Close the stream
Properties:
value
- Current value (immediate)stream
- Stream of debounced updates
Stream Extensions #
debounceTime(duration)
- Emits events only after the source has been silent for
duration
throttleTime(duration, {leading, trailing})
- Emits at most one event per
duration
SmartDebouncerTextField #
All standard TextField
parameters plus:
Parameter | Type | Default | Description |
---|---|---|---|
delay |
Duration |
required | Debounce delay |
leading |
bool |
false |
Leading edge execution |
trailing |
bool |
true |
Trailing edge execution |
maxWait |
Duration? |
null |
Maximum wait time |
onChangedDebounced |
ValueChanged<String>? |
null |
Debounced change callback |
SmartDebouncerButton #
All standard ElevatedButton
parameters plus:
Parameter | Type | Default | Description |
---|---|---|---|
delay |
Duration |
required | Debounce delay |
leading |
bool |
true |
Leading edge execution |
trailing |
bool |
false |
Trailing edge execution |
maxWait |
Duration? |
null |
Maximum wait time |
Changelog #
See CHANGELOG.md for release notes.
License #
Distributed under the MIT License.