From a8122c06b99daad1cb1cdbc8ed5b7770d8f382fb Mon Sep 17 00:00:00 2001 From: julien-levarlet Date: Sat, 20 Dec 2025 00:22:33 +0100 Subject: [PATCH 1/5] asyncListFilter is now a an asynchronous function --- example/lib/main.dart | 22 +++++-- lib/searchable_listview.dart | 121 ++++++++++++++++++++++++----------- 2 files changed, 103 insertions(+), 40 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 620c1ca..7ae70d9 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -160,7 +160,11 @@ class _ExampleAppState extends State { ], ), filter: (query) { - filteredActors = actors.where((element) => element.name.toLowerCase().contains(query.toLowerCase()) || element.lastName.toLowerCase().contains(query.toLowerCase())).toList(); + filteredActors = actors + .where((element) => + element.name.toLowerCase().contains(query.toLowerCase()) || + element.lastName.toLowerCase().contains(query.toLowerCase())) + .toList(); return filteredActors; }, initialList: actors, @@ -238,8 +242,13 @@ class _ExampleAppState extends State { await Future.delayed(const Duration(seconds: 5)); return actors; }, - asyncListFilter: (query, list) { - var result = actors.where((element) => element.name.contains(query) || element.lastName.contains(query)).toList(); + asyncListFilter: (query, list) async { + await Future.delayed(const Duration(seconds: 3)); + var result = actors + .where((element) => + element.name.contains(query) || + element.lastName.contains(query)) + .toList(); return result; }, separatorBuilder: (context, index) { @@ -277,7 +286,12 @@ class _ExampleAppState extends State { ); }, filterExpansionData: (p0) { - final filteredMap = {for (final entry in mapOfActors.entries) entry.key: (mapOfActors[entry.key] ?? []).where((element) => element.name.contains(p0)).toList()}; + final filteredMap = { + for (final entry in mapOfActors.entries) + entry.key: (mapOfActors[entry.key] ?? []) + .where((element) => element.name.contains(p0)) + .toList() + }; return filteredMap; }, textStyle: const TextStyle(fontSize: 25), diff --git a/lib/searchable_listview.dart b/lib/searchable_listview.dart index b232ca8..82d0814 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'; @@ -279,7 +281,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 query, List 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] @@ -296,7 +298,8 @@ class SearchableList extends StatefulWidget { /// [expansionGroupIndex] : expansion group index /// [listItem] the current item model that will be rendered. /// Used only for expansion list constructor - late Widget Function(int expansionGroupIndex, T listItem)? expansionListBuilder; + late Widget Function(int expansionGroupIndex, T listItem)? + expansionListBuilder; /// The widget to be displayed when the filter returns an empty list. /// Defaults to `const SizedBox.shrink()`. @@ -498,29 +501,54 @@ class SearchableList extends StatefulWidget { class _SearchableListState extends State> { /// Create scroll controller instance /// attached to the listview widget - late ScrollController scrollController = widget.scrollController ?? ScrollController(); + late ScrollController scrollController = + widget.scrollController ?? ScrollController(); List asyncListResult = []; late List filtredListResult = widget.initialList; List filtredAsyncListResult = []; String searchText = ''; - bool dataDownloaded = false; + int numberOfRequestLoading = 0; + bool asyncError = false; List expansionTileControllers = []; @override void initState() { super.initState(); scrollController.addListener(() { - if (widget.closeKeyboardWhenScrolling && widget.focusNode?.hasFocus == true) { + if (widget.closeKeyboardWhenScrolling && + widget.focusNode?.hasFocus == true) { FocusScope.of(context).requestFocus(FocusNode()); } - if (widget.onPaginate != null && scrollController.position.pixels == scrollController.position.maxScrollExtent) { + if (widget.onPaginate != null && + scrollController.position.pixels == + scrollController.position.maxScrollExtent) { setState(() { widget.onPaginate?.call(); }); } }); - if (widget.searchMode == SearchMode.onEdit) { - widget.searchTextController?.addListener(_textControllerListener); + 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--; + }); + } + }); } } @@ -547,7 +575,8 @@ class _SearchableListState extends State> { filterList(searchText); } } - if (widget.isExpansionList && oldWidget.expansionListData != widget.expansionListData) { + if (widget.isExpansionList && + oldWidget.expansionListData != widget.expansionListData) { filterList(searchText); } } @@ -557,7 +586,10 @@ class _SearchableListState extends State> { return widget.sliverScrollEffect ? renderSliverEffect() : Column( - verticalDirection: widget.searchTextPosition == SearchTextPosition.top ? VerticalDirection.down : VerticalDirection.up, + verticalDirection: + widget.searchTextPosition == SearchTextPosition.top + ? VerticalDirection.down + : VerticalDirection.up, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.showSearchField) @@ -570,32 +602,30 @@ class _SearchableListState extends State> { ), ), Expanded( - child: widget.isExpansionList ? renderExpandableListView() : (widget.asyncListCallback != null && !dataDownloaded ? renderAsyncListView() : renderSearchableListView()), + child: widget.isExpansionList + ? renderExpandableListView() + : (widget.asyncListCallback != null + ? renderAsyncListView() + : renderSearchableListView()), ), ], ); } 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() { - List renderedList = widget.asyncListCallback != null ? filtredAsyncListResult : filtredListResult; + List renderedList = widget.asyncListCallback != null + ? filtredAsyncListResult + : filtredListResult; return buildSearchableListView( list: renderedList, ); @@ -649,7 +679,8 @@ class _SearchableListState extends State> { } Widget renderExpandableListView() { - if (widget.expansionListData.isEmpty || widget.expansionListData.values.every((element) => element.isEmpty)) { + if (widget.expansionListData.isEmpty || + widget.expansionListData.values.every((element) => element.isEmpty)) { return widget.emptyWidget ?? const SizedBox.shrink(); } else { expansionTileControllers.addAll( @@ -698,7 +729,10 @@ class _SearchableListState extends State> { return widget.scrollDirection == Axis.horizontal ? Column( crossAxisAlignment: CrossAxisAlignment.start, - verticalDirection: widget.searchTextPosition == SearchTextPosition.top ? VerticalDirection.down : VerticalDirection.up, + verticalDirection: + widget.searchTextPosition == SearchTextPosition.top + ? VerticalDirection.down + : VerticalDirection.up, children: [ if (widget.showSearchField) Padding( @@ -755,7 +789,8 @@ class _SearchableListState extends State> { searchText = value; if (widget.isExpansionList) { setState(() { - widget.expansionListData = widget.filterExpansionData?.call(value) ?? {}; + widget.expansionListData = + widget.filterExpansionData?.call(value) ?? {}; }); for (var controller in expansionTileControllers) { try { @@ -766,12 +801,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 e79fa565d9c3c81dd60724bd66b817d0ea2d8886 Mon Sep 17 00:00:00 2001 From: julien-levarlet Date: Sat, 20 Dec 2025 00:25:11 +0100 Subject: [PATCH 2/5] 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 7ae70d9..ded5a7c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -251,6 +251,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 82d0814..84d7fdf 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'; @@ -86,6 +87,7 @@ class SearchableList extends StatefulWidget { required this.asyncListCallback, required this.asyncListFilter, required this.itemBuilder, + this.asyncDebounceTime = 0, this.loadingWidget, this.errorWidget, this.searchTextController, @@ -283,6 +285,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; @@ -509,6 +515,7 @@ class _SearchableListState extends State> { String searchText = ''; int numberOfRequestLoading = 0; bool asyncError = false; + late Debouncer _debouncer; List expansionTileControllers = []; @override @@ -549,6 +556,7 @@ class _SearchableListState extends State> { }); } }); + _debouncer = Debouncer(milliseconds: widget.asyncDebounceTime); } } @@ -801,7 +809,7 @@ class _SearchableListState extends State> { } } } else if (widget.asyncListCallback != null) { - () async { + _debouncer.run(() async { if (mounted) { setState(() { numberOfRequestLoading++; @@ -820,7 +828,7 @@ class _SearchableListState extends State> { numberOfRequestLoading--; }); } - }(); + }); } else { setState(() { filtredListResult = widget.filter?.call(value) ?? []; From b3b76d1d014fe93300a81e3467efa3662b51bb90 Mon Sep 17 00:00:00 2001 From: julien-levarlet Date: Sun, 3 May 2026 19:39:47 +0200 Subject: [PATCH 3/5] 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 ded5a7c..fa341b8 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -259,6 +259,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 84d7fdf..656c8df 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'; @@ -513,8 +514,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 = []; @@ -538,23 +540,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); } @@ -624,7 +612,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(); @@ -810,24 +798,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(() { @@ -886,4 +859,30 @@ class _SearchableListState extends State> { formFieldKey: widget.formFieldKey, ); } + + 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 b86f75ee6090926af7ad7e7ff6dcd398e38f7436 Mon Sep 17 00:00:00 2001 From: julien-levarlet Date: Sun, 3 May 2026 20:21:31 +0200 Subject: [PATCH 4/5] 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 656c8df..657c00e 100644 --- a/lib/searchable_listview.dart +++ b/lib/searchable_listview.dart @@ -517,7 +517,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 @@ -797,7 +797,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(() {}); From 765d8706c11a4bc18094181ac0bf60b265885760 Mon Sep 17 00:00:00 2001 From: julien-levarlet Date: Thu, 14 May 2026 11:46:23 +0200 Subject: [PATCH 5/5] Fix: tests not compiling due to the new async method --- test/async_searchable_listview_test.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/async_searchable_listview_test.dart b/test/async_searchable_listview_test.dart index 99cfe04..aa84718 100644 --- a/test/async_searchable_listview_test.dart +++ b/test/async_searchable_listview_test.dart @@ -16,7 +16,7 @@ void main() { var basicSearchableList = SearchableList.async( asyncListCallback: () async => intList, itemBuilder: (item) => DummyListItem(index: item), - asyncListFilter: (query, _) => + asyncListFilter: (query, _) async => intList.where((item) => item.toString().contains(query)).toList(), ); @@ -55,7 +55,7 @@ void main() { labelText: 'Search Items', border: OutlineInputBorder(), ), - asyncListFilter: (query, _) => + asyncListFilter: (query, _) async => intList.where((item) => item.toString().contains(query)).toList(), ); @@ -92,7 +92,7 @@ void main() { var basicSearchableList = SearchableList.async( asyncListCallback: () async => intList, itemBuilder: (item) => DummyListItem(index: item), - asyncListFilter: (query, _) => + asyncListFilter: (query, _) async => intList.where((item) => item.toString().contains(query)).toList(), ); @@ -124,7 +124,7 @@ void main() { var basicSearchableList = SearchableList.async( asyncListCallback: () async => intList, itemBuilder: (item) => DummyListItem(index: item), - asyncListFilter: (query, _) => + asyncListFilter: (query, _) async => intList.where((item) => item.toString().contains(query)).toList(), ); @@ -151,7 +151,7 @@ void main() { var basicSearchableList = SearchableList.async( asyncListCallback: () async => [], itemBuilder: (item) => DummyListItem(index: item), - asyncListFilter: (query, _) => [], + asyncListFilter: (query, _) async => [], ); await test.pumpWidget( @@ -180,7 +180,7 @@ void main() { asyncListCallback: () async => intList, itemBuilder: (item) => DummyListItem(index: item), emptyWidget: const Text('No items found'), - asyncListFilter: (query, _) => + asyncListFilter: (query, _) async => intList.where((item) => item.toString().contains(query)).toList(), );