From 8b4b5e042367ec7a745969ef16a8ed7a856963af Mon Sep 17 00:00:00 2001 From: julien-levarlet Date: Sat, 20 Dec 2025 00:22:33 +0100 Subject: [PATCH 1/4] asyncListFilter is now a an asynchronous function --- example/lib/main.dart | 3 +- lib/searchable_listview.dart | 79 +++++++++++++++++++++++++----------- 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 34bff41..689f83c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -221,7 +221,8 @@ class _ExampleAppState extends State { await Future.delayed(const Duration(seconds: 5)); return actors; }, - asyncListFilter: (query, list) { + asyncListFilter: (query, list) async { + await Future.delayed(const Duration(seconds: 3)); var result = actors .where((element) => element.name.contains(query) || diff --git a/lib/searchable_listview.dart b/lib/searchable_listview.dart index 55e08b9..48a70d3 100644 --- a/lib/searchable_listview.dart +++ b/lib/searchable_listview.dart @@ -1,5 +1,7 @@ // ignore_for_file: must_be_immutable +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:searchable_listview/resources/arrays.dart'; import 'package:searchable_listview/widgets/default_error_widget.dart'; @@ -271,7 +273,7 @@ class SearchableList extends StatefulWidget { /// Callback invoked when filtring the searchable list /// used when providing [asyncListCallback] /// can't be null when [asyncListCallback] isn't null - late List Function(String, List)? asyncListFilter; + late Future> Function(String, List)? asyncListFilter; /// Loading widget displayed when [asyncListCallback] is loading /// if nothing is provided in [loadingWidget] searchable list will display a [CircularProgressIndicator] @@ -494,7 +496,8 @@ class _SearchableListState extends State> { late List filtredListResult = widget.initialList; List filtredAsyncListResult = []; String searchText = ''; - bool dataDownloaded = false; + int numberOfRequestLoading = 0; + bool asyncError = false; List expansionTileControllers = []; @override @@ -514,6 +517,28 @@ class _SearchableListState extends State> { } }); widget.searchTextController?.addListener(_textControllerListener); + + if (widget.asyncListCallback != null) { + // Load the initial list for the async constructor + numberOfRequestLoading = 1; + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + final result = await widget.asyncListCallback!.call(); + if (result != null) { + filtredAsyncListResult = result; + } else { + asyncError = true; + } + } catch (e) { + asyncError = true; + } + if (mounted) { + setState(() { + numberOfRequestLoading--; + }); + } + }); + } } @override @@ -574,7 +599,7 @@ class _SearchableListState extends State> { Expanded( child: widget.isExpansionList ? renderExpandableListView() - : (widget.asyncListCallback != null && !dataDownloaded + : (widget.asyncListCallback != null ? renderAsyncListView() : renderSearchableListView()), ), @@ -583,21 +608,13 @@ class _SearchableListState extends State> { } Widget renderAsyncListView() { - return FutureBuilder( - future: widget.asyncListCallback!.call(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return widget.loadingWidget ?? const DefaultLoadingWidget(); - } - dataDownloaded = true; - if (snapshot.data == null) { - return widget.errorWidget ?? const DefaultErrorWidget(); - } - asyncListResult = snapshot.data as List; - filtredAsyncListResult = asyncListResult; - return renderSearchableListView(); - }, - ); + if (asyncError) { + return widget.errorWidget ?? const DefaultErrorWidget(); + } + if (numberOfRequestLoading != 0) { + return widget.loadingWidget ?? const DefaultLoadingWidget(); + } + return renderSearchableListView(); } Widget renderSearchableListView() { @@ -831,12 +848,26 @@ class _SearchableListState extends State> { } } } else if (widget.asyncListCallback != null) { - setState(() { - filtredAsyncListResult = widget.asyncListFilter!( - value, - asyncListResult, - ); - }); + () async { + if (mounted) { + setState(() { + numberOfRequestLoading++; + }); + } + try { + filtredAsyncListResult = await widget.asyncListFilter!( + value, + asyncListResult, + ); + } catch (e) { + asyncError = true; + } + if (mounted) { + setState(() { + numberOfRequestLoading--; + }); + } + }(); } else { setState(() { filtredListResult = widget.filter?.call(value) ?? []; From 844ae3df234082157f91fef8ab33b123aa246316 Mon Sep 17 00:00:00 2001 From: julien-levarlet Date: Sat, 20 Dec 2025 00:25:11 +0100 Subject: [PATCH 2/4] SearchableList.async --- example/lib/main.dart | 1 + lib/resources/debouncer.dart | 20 ++++++++++++++++++++ lib/searchable_listview.dart | 12 ++++++++++-- 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 lib/resources/debouncer.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 689f83c..a162338 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -230,6 +230,7 @@ class _ExampleAppState extends State { .toList(); return result; }, + asyncDebounceTime: 200, separatorBuilder: (context, index) { return Container( height: 30, diff --git a/lib/resources/debouncer.dart b/lib/resources/debouncer.dart new file mode 100644 index 0000000..465de42 --- /dev/null +++ b/lib/resources/debouncer.dart @@ -0,0 +1,20 @@ +import 'dart:async'; +import 'dart:ui'; + +class Debouncer { + final int milliseconds; + Timer? _timer; + + Debouncer({required this.milliseconds}); + + void run(VoidCallback action) { + if (_timer?.isActive ?? false) { + _timer?.cancel(); + } + _timer = Timer(Duration(milliseconds: milliseconds), action); + } + + void dispose() { + _timer?.cancel(); + } +} diff --git a/lib/searchable_listview.dart b/lib/searchable_listview.dart index 48a70d3..69009fb 100644 --- a/lib/searchable_listview.dart +++ b/lib/searchable_listview.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:searchable_listview/resources/arrays.dart'; +import 'package:searchable_listview/resources/debouncer.dart'; import 'package:searchable_listview/widgets/default_error_widget.dart'; import 'package:searchable_listview/widgets/default_loading_widget.dart'; import 'package:searchable_listview/widgets/list_view_rendering.dart'; @@ -84,6 +85,7 @@ class SearchableList extends StatefulWidget { required this.asyncListCallback, required this.asyncListFilter, required this.itemBuilder, + this.asyncDebounceTime = 0, this.loadingWidget, this.errorWidget, this.searchTextController, @@ -275,6 +277,10 @@ class SearchableList extends StatefulWidget { /// can't be null when [asyncListCallback] isn't null late Future> Function(String, List)? asyncListFilter; + /// Debouncing time when typing in search field in milliseconds + /// Wait [asyncDebounceTime] milliseconds without any new entry before invoking [asyncListFilter] + int asyncDebounceTime = 0; + /// Loading widget displayed when [asyncListCallback] is loading /// if nothing is provided in [loadingWidget] searchable list will display a [CircularProgressIndicator] Widget? loadingWidget; @@ -498,6 +504,7 @@ class _SearchableListState extends State> { String searchText = ''; int numberOfRequestLoading = 0; bool asyncError = false; + late Debouncer _debouncer; List expansionTileControllers = []; @override @@ -538,6 +545,7 @@ class _SearchableListState extends State> { }); } }); + _debouncer = Debouncer(milliseconds: widget.asyncDebounceTime); } } @@ -848,7 +856,7 @@ class _SearchableListState extends State> { } } } else if (widget.asyncListCallback != null) { - () async { + _debouncer.run(() async { if (mounted) { setState(() { numberOfRequestLoading++; @@ -867,7 +875,7 @@ class _SearchableListState extends State> { numberOfRequestLoading--; }); } - }(); + }); } else { setState(() { filtredListResult = widget.filter?.call(value) ?? []; From 5316c9f413ed24f8985064e472bba78b99f504c3 Mon Sep 17 00:00:00 2001 From: julien-levarlet Date: Sun, 3 May 2026 19:39:47 +0200 Subject: [PATCH 3/4] feat: interrupt async call when an other starts --- example/lib/main.dart | 3 ++ lib/searchable_listview.dart | 71 ++++++++++++++++++------------------ pubspec.yaml | 1 + 3 files changed, 39 insertions(+), 36 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index a162338..fa7b3bd 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -238,6 +238,9 @@ class _ExampleAppState extends State { }, textStyle: const TextStyle(fontSize: 25), emptyWidget: const EmptyView(), + loadingWidget: const Center( + child: CircularProgressIndicator(), + ), inputDecoration: InputDecoration( labelText: "Search Actor", fillColor: Colors.white, diff --git a/lib/searchable_listview.dart b/lib/searchable_listview.dart index d1d160c..aaab449 100644 --- a/lib/searchable_listview.dart +++ b/lib/searchable_listview.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:async/async.dart'; import 'package:flutter/material.dart'; import 'package:searchable_listview/resources/arrays.dart'; import 'package:searchable_listview/resources/debouncer.dart'; @@ -501,8 +502,9 @@ class _SearchableListState extends State> { late List filtredListResult = widget.initialList; List filtredAsyncListResult = []; String searchText = ''; - int numberOfRequestLoading = 0; bool asyncError = false; + // Current async task running, null otherwise + CancelableOperation?>? _activeOperation; late Debouncer _debouncer; List expansionTileControllers = []; @@ -526,23 +528,9 @@ class _SearchableListState extends State> { if (widget.asyncListCallback != null) { // Load the initial list for the async constructor - numberOfRequestLoading = 1; WidgetsBinding.instance.addPostFrameCallback((_) async { - try { - final result = await widget.asyncListCallback!.call(); - if (result != null) { - filtredAsyncListResult = result; - } else { - asyncError = true; - } - } catch (e) { - asyncError = true; - } - if (mounted) { - setState(() { - numberOfRequestLoading--; - }); - } + _asyncFilter(""); + if (mounted) setState(() {}); }); _debouncer = Debouncer(milliseconds: widget.asyncDebounceTime); } @@ -618,7 +606,7 @@ class _SearchableListState extends State> { if (asyncError) { return widget.errorWidget ?? const DefaultErrorWidget(); } - if (numberOfRequestLoading != 0) { + if (_activeOperation != null) { return widget.loadingWidget ?? const DefaultLoadingWidget(); } return renderSearchableListView(); @@ -856,24 +844,9 @@ class _SearchableListState extends State> { } } else if (widget.asyncListCallback != null) { _debouncer.run(() async { - if (mounted) { - setState(() { - numberOfRequestLoading++; - }); - } - try { - filtredAsyncListResult = await widget.asyncListFilter!( - value, - asyncListResult, - ); - } catch (e) { - asyncError = true; - } - if (mounted) { - setState(() { - numberOfRequestLoading--; - }); - } + _activeOperation?.cancel(); + _asyncFilter(value); + if (mounted) setState(() {}); }); } else { setState(() { @@ -899,4 +872,30 @@ class _SearchableListState extends State> { filterList(widget.searchTextController?.text ?? ''); } } + + Future _asyncFilter(String value) async { + final operation = CancelableOperation.fromFuture( + widget.asyncListFilter!( + value, + asyncListResult, + ), + ); + _activeOperation = operation; + try { + final result = await operation.valueOrCancellation(); + if (result != null && !operation.isCanceled) { + filtredAsyncListResult = result; + } + } catch (e) { + if (!operation.isCanceled) { + asyncError = true; + } + } finally { + // Make sure to not interrupt an other operation + if (_activeOperation == operation) { + _activeOperation = null; + } + } + setState(() {}); + } } diff --git a/pubspec.yaml b/pubspec.yaml index ab874d5..133c05c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ environment: sdk: '>=2.12.0 <4.0.0' flutter: '>=1.17.0' dependencies: + async: ^2.13.1 flutter: sdk: flutter dev_dependencies: From cd0dc4e36bdf706e97eb1aef65cfcbcdf1f5c0ce Mon Sep 17 00:00:00 2001 From: julien-levarlet Date: Sun, 3 May 2026 20:21:31 +0200 Subject: [PATCH 4/4] Fix: debouncer variable not initialised --- lib/searchable_listview.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/searchable_listview.dart b/lib/searchable_listview.dart index b1a5da2..ec49f94 100644 --- a/lib/searchable_listview.dart +++ b/lib/searchable_listview.dart @@ -516,7 +516,7 @@ class _SearchableListState extends State> { bool asyncError = false; // Current async task running, null otherwise CancelableOperation?>? _activeOperation; - late Debouncer _debouncer; + late Debouncer? _debouncer; List expansionTileControllers = []; @override @@ -798,7 +798,8 @@ class _SearchableListState extends State> { } } } else if (widget.asyncListCallback != null) { - _debouncer.run(() async { + // Debouncer always has a value in this case + _debouncer!.run(() async { _activeOperation?.cancel(); asyncFilter(value); if (mounted) setState(() {});