246 lines
6.6 KiB
Dart
246 lines
6.6 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:meta/meta.dart';
|
|
|
|
class BidirectionalController<T> {
|
|
BidirectionalController({
|
|
required this.pageSize,
|
|
required this.maxPageAmount,
|
|
this.scrollActivationOffset = 10,
|
|
}) {
|
|
_controller.addListener(handleScroll);
|
|
}
|
|
|
|
/// The offset for scrolling at which to trigger data fetching.
|
|
final double scrollActivationOffset;
|
|
|
|
/// The amount of data items to expect per fetch.
|
|
final int pageSize;
|
|
|
|
/// The amount of pages to keep in the cache before evicting items
|
|
final int maxPageAmount;
|
|
|
|
/// The controller that deals with scrolling
|
|
final ScrollController _controller = ScrollController();
|
|
ScrollController get scrollController => _controller;
|
|
|
|
/// The backing cache:
|
|
/// 0: The oldest data item we know about
|
|
/// _cache.length - 1: The newest data item we know about
|
|
final List<T> _cache = List<T>.empty(growable: true);
|
|
final StreamController<List<T>> _dataStreamController =
|
|
StreamController<List<T>>.broadcast();
|
|
Stream<List<T>> get dataStream => _dataStreamController.stream;
|
|
|
|
//@protected
|
|
List<T> get cache => _cache;
|
|
|
|
/// True if the cache has exceeded the size limit of pageSize * maxPageAmount.
|
|
bool get _isCacheTooBig => _cache.length >= pageSize * maxPageAmount;
|
|
|
|
/// Flag indicating whether we are currently fetching data
|
|
bool _isFetching = false;
|
|
bool get isFetching => _isFetching;
|
|
final StreamController<bool> _isFetchingStreamController =
|
|
StreamController<bool>.broadcast();
|
|
Stream<bool> get isFetchingStream => _isFetchingStreamController.stream;
|
|
|
|
/// Flag indicating whether we are able to request newer data
|
|
@protected
|
|
bool hasNewerData = false;
|
|
|
|
/// Flag indicating whether we are able to request older data
|
|
@protected
|
|
bool hasOlderData = true;
|
|
|
|
/// Flag indicating whether data has been loaded at least once
|
|
bool hasFetchedOnce = false;
|
|
|
|
/// True if we are scrolled to the bottom of the view. False, otherwise.
|
|
bool get isScrolledToBottom => _controller.offset <= scrollActivationOffset;
|
|
|
|
/// True if we are scrolled to the top of the viwe. False, otherwise.
|
|
bool get isScrolledToTop =>
|
|
_controller.offset >=
|
|
_controller.position.maxScrollExtent - scrollActivationOffset;
|
|
|
|
@visibleForOverriding
|
|
void handleScroll() {
|
|
if (!_controller.hasClients) return;
|
|
if (_isFetching) return;
|
|
|
|
if (isScrolledToTop) {
|
|
// Fetch older messages when we reach the top edge of the list
|
|
unawaited(fetchOlderData());
|
|
} else if (isScrolledToBottom) {
|
|
// Fetch newer data when we reach the bottom edge of the list
|
|
unawaited(_fetchNewerData());
|
|
}
|
|
}
|
|
|
|
/// Set the _isFetching flag, but also update the UI using the stream
|
|
void _setIsFetching(bool state) {
|
|
_isFetching = state;
|
|
_isFetchingStreamController.add(state);
|
|
}
|
|
|
|
Future<void> fetchOlderData() async {
|
|
if (_isFetching || _cache.isEmpty && hasFetchedOnce) return;
|
|
if (!hasOlderData) return;
|
|
|
|
_setIsFetching(true);
|
|
|
|
final data = await fetchOlderDataImpl(
|
|
_cache.isEmpty ? null : _cache.first,
|
|
);
|
|
|
|
hasFetchedOnce = true;
|
|
hasOlderData = data.length >= pageSize;
|
|
|
|
// Don't trigger an update if we fetched nothing
|
|
_setIsFetching(false);
|
|
_cache.insertAll(0, data);
|
|
|
|
// Evict items from the cache if we overstep the maximum
|
|
if (_cache.length >= pageSize * maxPageAmount) {
|
|
_cache.removeRange(_cache.length - 1 - pageSize, _cache.length);
|
|
hasNewerData = true;
|
|
}
|
|
|
|
// Update the UI
|
|
_setIsFetching(false);
|
|
_dataStreamController.add(_cache);
|
|
}
|
|
|
|
Future<void> _fetchNewerData() async {
|
|
if (_isFetching || _cache.isEmpty && hasFetchedOnce) return;
|
|
if (!hasNewerData) return;
|
|
|
|
_setIsFetching(true);
|
|
|
|
final data = await fetchNewerDataImpl(
|
|
_cache.isEmpty ? null : _cache.last,
|
|
);
|
|
|
|
hasFetchedOnce = true;
|
|
hasNewerData = data.length >= pageSize;
|
|
|
|
// Don't trigger an update if we fetched nothing
|
|
if (data.isEmpty) {
|
|
_setIsFetching(false);
|
|
return;
|
|
}
|
|
|
|
_cache.addAll(data);
|
|
|
|
// Evict items from the cache if we overstep the maximum
|
|
if (_cache.length >= pageSize * maxPageAmount) {
|
|
_cache.removeRange(0, pageSize);
|
|
hasOlderData = true;
|
|
}
|
|
|
|
// Update the UI
|
|
_setIsFetching(false);
|
|
_dataStreamController.add(_cache);
|
|
}
|
|
|
|
@visibleForOverriding
|
|
Future<List<T>> fetchOlderDataImpl(T? oldestElement) async {
|
|
return [];
|
|
}
|
|
|
|
@visibleForOverriding
|
|
Future<List<T>> fetchNewerDataImpl(T? newestElement) async {
|
|
return [];
|
|
}
|
|
|
|
/// Add [item] to the cache, deal with the cache and update the UI.
|
|
void addItem(T item) {
|
|
_cache.add(item);
|
|
|
|
if (_isCacheTooBig) {
|
|
_cache.removeAt(0);
|
|
hasOlderData = true;
|
|
}
|
|
|
|
_dataStreamController.add(_cache);
|
|
}
|
|
|
|
/// Adds [item] to the first index where [test] returns true.
|
|
/// [test] takes the item and the next item (or null).
|
|
bool addItemWhereFirst(bool Function(T, T?) test, T item) {
|
|
var foundPlace = false;
|
|
for (var i = 0; i < _cache.length; i++) {
|
|
final nextItem = i + 1 < _cache.length ? _cache[i + 1] : null;
|
|
if (test(_cache[i], nextItem)) {
|
|
foundPlace = true;
|
|
_cache.insert(i, item);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Update the UI
|
|
if (foundPlace) {
|
|
_dataStreamController.add(_cache);
|
|
}
|
|
|
|
return foundPlace;
|
|
}
|
|
|
|
/// Sends the current cache to the UI to force an update.
|
|
@protected
|
|
void forceUpdateUI() {
|
|
_dataStreamController.add(_cache);
|
|
}
|
|
|
|
/// Replaces the first item for which [test] returns true with [newItem].
|
|
bool replaceItem(bool Function(T) test, T newItem) {
|
|
// We iterate in reverse as we can assume that the newer messages have a higher
|
|
// likeliness of being updated than older messages.
|
|
var found = false;
|
|
for (var i = _cache.length - 1; i >= 0; i--) {
|
|
if (test(_cache[i])) {
|
|
_cache[i] = newItem;
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (found) {
|
|
_dataStreamController.add(_cache);
|
|
}
|
|
|
|
return found;
|
|
}
|
|
|
|
/// Removes the first item for which [test] returns true.
|
|
void removeItem(bool Function(T) test) {
|
|
var found = false;
|
|
for (var i = 0; i < _cache.length; i++) {
|
|
if (test(_cache[i])) {
|
|
_cache.removeAt(i);
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (found) {
|
|
_dataStreamController.add(_cache);
|
|
}
|
|
}
|
|
|
|
/// Animate to the bottom of the view.
|
|
void animateToBottom() {
|
|
_controller.animateTo(
|
|
_controller.position.minScrollExtent,
|
|
curve: Curves.easeIn,
|
|
duration: const Duration(milliseconds: 300),
|
|
);
|
|
}
|
|
|
|
/// Dispose of the backing controller
|
|
void dispose() {
|
|
_controller.dispose();
|
|
}
|
|
}
|