lemmur-app-android/lib/widgets/infinite_scroll.dart

154 lines
4.4 KiB
Dart
Raw Normal View History

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';
import '../hooks/ref.dart';
2021-01-26 23:16:47 +01:00
import 'bottom_safe.dart';
class InfiniteScrollController {
late VoidCallback clear;
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;
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]
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
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(),
this.padding,
2020-09-17 19:43:26 +02:00
this.loadingWidget =
const ListTile(title: Center(child: CircularProgressIndicator())),
required this.itemBuilder,
required this.fetcher,
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,
}) : 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>());
final hasMore = useRef(true);
2021-04-22 21:08:30 +02:00
final page = useRef(1);
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;
useEffect(() {
if (controller != null) {
controller?.clear = () {
data.value = [];
hasMore.current = true;
2021-04-22 21:08:30 +02:00
page.current = 1;
dataSet.current.clear();
};
}
return null;
}, []);
2021-01-09 01:35:43 +01:00
return RefreshIndicator(
onRefresh: () async {
data.value = [];
hasMore.current = true;
2021-04-22 21:08:30 +02:00
page.current = 1;
dataSet.current.clear();
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: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
);
}
}