2021-04-06 17:52:10 +02:00
|
|
|
import 'dart:collection';
|
|
|
|
|
2020-09-17 19:43:26 +02:00
|
|
|
import 'package:flutter/material.dart';
|
2021-01-09 01:35:43 +01:00
|
|
|
import 'package:flutter/services.dart';
|
2020-09-17 19:43:26 +02:00
|
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
|
|
|
2020-09-17 19:49:42 +02:00
|
|
|
import '../hooks/ref.dart';
|
2021-01-26 23:16:47 +01:00
|
|
|
import 'bottom_safe.dart';
|
2020-09-17 19:49:42 +02:00
|
|
|
|
2020-09-18 23:24:58 +02:00
|
|
|
class InfiniteScrollController {
|
2021-04-09 00:11:44 +02:00
|
|
|
late VoidCallback clear;
|
2020-09-18 23:24:58 +02:00
|
|
|
|
|
|
|
InfiniteScrollController() {
|
|
|
|
usedBeforeCreation() => throw Exception(
|
|
|
|
'Tried to use $runtimeType before it being initialized');
|
|
|
|
|
|
|
|
clear = usedBeforeCreation;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-02-09 20:39:31 +01:00
|
|
|
/// `ListView.builder` with asynchronous data fetching and no `itemCount`
|
2020-09-17 19:43:26 +02:00
|
|
|
class InfiniteScroll<T> extends HookWidget {
|
2021-02-09 20:39:31 +01:00
|
|
|
/// How many items should be fetched per call
|
2020-09-17 19:43:26 +02:00
|
|
|
final int batchSize;
|
2021-02-09 20:39:31 +01:00
|
|
|
|
|
|
|
/// Widget displayed at the bottom when InfiniteScroll is fetching
|
2020-09-17 19:43:26 +02:00
|
|
|
final Widget loadingWidget;
|
2021-02-09 20:39:31 +01:00
|
|
|
|
|
|
|
/// Builds your widget from the fetched data
|
|
|
|
final Widget Function(T data) itemBuilder;
|
|
|
|
|
|
|
|
/// Fetches data to be displayed. It is important to respect `batchSize`,
|
|
|
|
/// if the returned list has less than `batchSize` then the InfiniteScroll
|
|
|
|
/// is considered finished
|
|
|
|
final Future<List<T>> Function(int page, int batchSize) fetcher;
|
|
|
|
|
2021-04-09 00:11:44 +02:00
|
|
|
final InfiniteScrollController? controller;
|
2021-02-09 20:39:31 +01:00
|
|
|
|
|
|
|
/// Widget to be added at the beginning of the list
|
|
|
|
final Widget leading;
|
|
|
|
|
|
|
|
/// Padding for the [ListView.builder]
|
2021-04-09 00:11:44 +02:00
|
|
|
final EdgeInsetsGeometry? padding;
|
2020-09-17 19:43:26 +02:00
|
|
|
|
2021-02-09 20:39:31 +01:00
|
|
|
/// Widget that will be displayed if there are no items
|
|
|
|
final Widget noItems;
|
|
|
|
|
2021-04-06 17:52:10 +02:00
|
|
|
/// Maps an item to its unique property that will allow to detect possible
|
|
|
|
/// duplicates thus perfoming deduplication
|
2021-04-22 19:26:39 +02:00
|
|
|
final Object Function(T item)? uniqueProp;
|
2021-04-06 17:52:10 +02:00
|
|
|
|
2021-01-09 18:25:34 +01:00
|
|
|
const InfiniteScroll({
|
2020-09-17 19:43:26 +02:00
|
|
|
this.batchSize = 10,
|
2021-02-09 20:39:31 +01:00
|
|
|
this.leading = const SizedBox.shrink(),
|
2020-09-28 19:55:10 +02:00
|
|
|
this.padding,
|
2020-09-17 19:43:26 +02:00
|
|
|
this.loadingWidget =
|
|
|
|
const ListTile(title: Center(child: CircularProgressIndicator())),
|
2021-04-09 00:11:44 +02:00
|
|
|
required this.itemBuilder,
|
|
|
|
required this.fetcher,
|
2020-09-18 23:24:58 +02:00
|
|
|
this.controller,
|
2021-02-09 20:39:31 +01:00
|
|
|
this.noItems = const SizedBox.shrink(),
|
2021-04-06 17:52:10 +02:00
|
|
|
this.uniqueProp,
|
2021-04-09 00:11:44 +02:00
|
|
|
}) : assert(batchSize > 0);
|
2020-09-17 19:43:26 +02:00
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final data = useState<List<T>>([]);
|
2021-04-06 17:52:10 +02:00
|
|
|
// holds unique props of the data
|
|
|
|
final dataSet = useRef(HashSet<Object>());
|
2020-09-17 19:49:42 +02:00
|
|
|
final hasMore = useRef(true);
|
2021-04-22 21:08:30 +02:00
|
|
|
final page = useRef(1);
|
2020-09-17 19:49:42 +02:00
|
|
|
final isFetching = useRef(false);
|
2020-09-17 19:43:26 +02:00
|
|
|
|
2021-04-22 21:08:30 +02:00
|
|
|
final uniquePropFunc = uniqueProp ?? (e) => e as Object;
|
|
|
|
|
2020-09-18 23:24:58 +02:00
|
|
|
useEffect(() {
|
|
|
|
if (controller != null) {
|
2021-04-09 00:11:44 +02:00
|
|
|
controller?.clear = () {
|
2020-10-04 21:51:26 +02:00
|
|
|
data.value = [];
|
|
|
|
hasMore.current = true;
|
2021-04-22 21:08:30 +02:00
|
|
|
page.current = 1;
|
|
|
|
dataSet.current.clear();
|
2020-10-04 21:51:26 +02:00
|
|
|
};
|
2020-09-18 23:24:58 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}, []);
|
|
|
|
|
2021-01-09 01:35:43 +01:00
|
|
|
return RefreshIndicator(
|
|
|
|
onRefresh: () async {
|
2021-04-09 00:11:44 +02:00
|
|
|
data.value = [];
|
|
|
|
hasMore.current = true;
|
2021-04-22 21:08:30 +02:00
|
|
|
page.current = 1;
|
|
|
|
dataSet.current.clear();
|
2021-04-09 00:11:44 +02:00
|
|
|
|
2021-01-09 01:35:43 +01:00
|
|
|
await HapticFeedback.mediumImpact();
|
|
|
|
await Future.delayed(const Duration(seconds: 1));
|
|
|
|
},
|
|
|
|
child: ListView.builder(
|
|
|
|
padding: padding,
|
2021-02-09 20:39:31 +01:00
|
|
|
// +2 for the loading widget and leading widget
|
2021-01-09 01:35:43 +01:00
|
|
|
itemCount: data.value.length + 2,
|
|
|
|
itemBuilder: (_, i) {
|
|
|
|
if (i == 0) {
|
2021-02-09 20:39:31 +01:00
|
|
|
return leading;
|
2020-09-17 19:43:26 +02:00
|
|
|
}
|
2021-01-09 01:35:43 +01:00
|
|
|
i -= 1;
|
|
|
|
|
2021-02-09 20:39:31 +01:00
|
|
|
// if we are done but we have no data it means the list is empty
|
|
|
|
if (!hasMore.current && data.value.isEmpty) {
|
|
|
|
return Center(child: noItems);
|
|
|
|
}
|
|
|
|
|
2021-01-09 01:35:43 +01:00
|
|
|
// reached the bottom, fetch more
|
|
|
|
if (i == data.value.length) {
|
|
|
|
// if there are no more, skip
|
|
|
|
if (!hasMore.current) {
|
2021-01-26 23:16:47 +01:00
|
|
|
return const BottomSafe();
|
2021-01-09 01:35:43 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// if it's already fetching more, skip
|
|
|
|
if (!isFetching.current) {
|
|
|
|
isFetching.current = true;
|
2021-04-22 21:08:30 +02:00
|
|
|
fetcher(page.current, batchSize).then((incoming) {
|
2021-01-09 01:35:43 +01:00
|
|
|
// if got less than the batchSize, mark the list as done
|
2021-04-06 17:52:10 +02:00
|
|
|
if (incoming.length < batchSize) {
|
2021-01-09 01:35:43 +01:00
|
|
|
hasMore.current = false;
|
|
|
|
}
|
2021-04-06 17:52:10 +02:00
|
|
|
|
|
|
|
final newData = incoming.where(
|
2021-04-22 21:08:30 +02:00
|
|
|
(e) => !dataSet.current.contains(uniquePropFunc(e)),
|
2021-04-06 17:52:10 +02:00
|
|
|
);
|
|
|
|
|
2021-01-09 01:35:43 +01:00
|
|
|
// append new data
|
|
|
|
data.value = [...data.value, ...newData];
|
2021-04-22 21:08:30 +02:00
|
|
|
dataSet.current.addAll(newData.map(uniquePropFunc));
|
|
|
|
page.current += 1;
|
2021-01-09 01:35:43 +01:00
|
|
|
}).whenComplete(() => isFetching.current = false);
|
|
|
|
}
|
|
|
|
|
2021-01-24 22:59:20 +01:00
|
|
|
return SafeArea(
|
2021-01-26 23:16:47 +01:00
|
|
|
top: false,
|
2021-01-24 22:59:20 +01:00
|
|
|
child: loadingWidget,
|
|
|
|
);
|
2020-09-17 19:49:42 +02:00
|
|
|
}
|
2020-09-17 19:43:26 +02:00
|
|
|
|
2021-01-09 01:35:43 +01:00
|
|
|
// not last element, render list item
|
2021-02-09 20:39:31 +01:00
|
|
|
return itemBuilder(data.value[i]);
|
2021-01-09 01:35:43 +01:00
|
|
|
},
|
|
|
|
),
|
2020-09-17 19:43:26 +02:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|