diff --git a/example/lib/main.dart b/example/lib/main.dart index 620c1ca..24ca40e 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,10 +242,15 @@ 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 { + var result = actors + .where((element) => + element.name.contains(query) || + element.lastName.contains(query)) + .toList(); return result; }, + asyncDebounceTime: 200, separatorBuilder: (context, index) { return Container( height: 30, @@ -249,6 +258,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, @@ -277,7 +289,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/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 b232ca8..ec49f94 100644 --- a/lib/searchable_listview.dart +++ b/lib/searchable_listview.dart @@ -1,7 +1,9 @@ // ignore_for_file: must_be_immutable +import 'package:async/async.dart'; 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 +86,7 @@ class SearchableList extends StatefulWidget { required this.asyncListCallback, required this.asyncListFilter, required this.itemBuilder, + this.asyncDebounceTime = 0, this.loadingWidget, this.errorWidget, this.searchTextController, @@ -279,7 +282,11 @@ 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 query, List 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] @@ -296,7 +303,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 +506,46 @@ 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; + bool asyncError = false; + // Current async task running, null otherwise + CancelableOperation?>? _activeOperation; + late Debouncer? _debouncer; 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.asyncListCallback != null) { + // Load the initial list for the async constructor + WidgetsBinding.instance.addPostFrameCallback((_) async { + asyncFilter(""); + if (mounted) setState(() {}); + }); + _debouncer = Debouncer(milliseconds: widget.asyncDebounceTime); + } if (widget.searchMode == SearchMode.onEdit) { - widget.searchTextController?.addListener(_textControllerListener); + widget.searchTextController?.addListener(textControllerListener); } } @@ -529,12 +554,12 @@ class _SearchableListState extends State> { if (widget.scrollController == null) { scrollController.dispose(); } - widget.searchTextController?.removeListener(_textControllerListener); + widget.searchTextController?.removeListener(textControllerListener); super.dispose(); } @override - void didUpdateWidget(covariant SearchableList oldWidget) { + void didUpdateWidget(SearchableList oldWidget) { super.didUpdateWidget(oldWidget); // Reload the list if the initialList changed @@ -547,7 +572,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 +583,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 +599,30 @@ class _SearchableListState extends State> { ), ), Expanded( - child: widget.isExpansionList ? renderExpandableListView() : (widget.asyncListCallback != null && !dataDownloaded ? renderAsyncListView() : renderSearchableListView()), + child: widget.isExpansionList + ? renderExpandableListView() + : (widget.asyncListCallback != null && !dataDownloaded + ? 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 (_activeOperation != null) { + 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 +676,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 +726,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 +786,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,11 +798,11 @@ class _SearchableListState extends State> { } } } else if (widget.asyncListCallback != null) { - setState(() { - filtredAsyncListResult = widget.asyncListFilter!( - value, - asyncListResult, - ); + // Debouncer always has a value in this case + _debouncer!.run(() async { + _activeOperation?.cancel(); + asyncFilter(value); + if (mounted) setState(() {}); }); } else { setState(() { @@ -791,12 +823,38 @@ class _SearchableListState extends State> { } } - void _textControllerListener() { + void textControllerListener() { if (searchText != widget.searchTextController?.text) { 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(() {}); + } + /// Builds and returns a SearchTextField widget with all necessary parameters Widget _buildSearchTextField() { return SearchTextField( 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: 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(), );