I basically have a chat in where at the beginning only a part is loaded. Now i need to add more data (widgets) in both the top and the buttom.
Now in my example i've already implemented, that when the user scrolls up and 50 or less widgets are remaining it calls the backend to load more data (so when the user scrolls up even further already the loading is in progress or finished). Works fine, as soon as the data got loaded the user can scroll further up. Its like in WhatsApp and more apps with a feed.
However also new messages need to be loaded. These ones get added at the buttom. However that moves every single item in the listview which is a huge problem cause when the user watches the history, we dont want to "auto scroll" by the amount of new widgets...
How can i add widgets in both direction without "moving" the content?
### Small backend simulation ###
import 'dart:math';
class Msg {
int i; // index of message
String data;
Msg({required this.i}) : data = 'Msg ${i + 1}';
}
class Backend {
static const
FORWARD = 1,
BACKWARD = -1,
LIMIT = 100;
/// simulate backend call to fetch history or new data
Future<List<int>> _loadData(int index, int direction) async {
final random = new Random();
// // 1000ms - 4000ms response time
final waitMillis = 1000 + random.nextInt(4101 - 1000);
await Future.delayed(Duration(milliseconds: waitMillis));
if (direction == BACKWARD) {
// backward (last 50): index - LIMIT - 1 -> ... -> index - 1
// LOOKS LIKE: 50, 51, 52, 53, ... index - 1
if (index <= 0) return []; // no messages left
// prevent that count is higher than amount of messages left before current index
// so minimal index is 0
final count = index < LIMIT ? index : LIMIT;
return List<int>.generate(count, (i) => index - count + i);
}
// forward (next 50): index + 1 -> ... -> index + LIMIT
// LOOKS LIKE: index + 1, 50, 51, 53, ...
// 40% 0, 20% 1, 20% 2 and 20% 3 new messages
final randI = random.nextInt(6);
return List<int>.generate(randI < 2 ? 0 : (randI - 2), (i) => index + i + 1);
}
/// calls the backend simulation and parses the data
Future<List<Msg>> loadEntries(int index, int direction) async {
if (direction == BACKWARD && index <= 0) return [];
final data = await _loadData(index, direction);
return data.map<Msg>((i) => Msg(i: i)).toList();
}
}
### My custom "listview" ###
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:list/backend.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class CustomListWidget extends StatefulWidget {
const CustomListWidget({Key? key}) : super(key: key);
@override
_CustomListWidgetState createState() => _CustomListWidgetState();
}
class _CustomListWidgetState extends State<CustomListWidget> {
// when the user scrolls up and x messages are left more history gets loaded
static const MIN_HISTORY_LOADING_TRIGGER = 50;
static const _SPIN_KIT = SpinKitRing(
lineWidth: 1.5,
color: Colors.black,
size: 20.0,
);
final _itemScrollController = ItemScrollController();
final _itemPositionListener = ItemPositionsListener.create();
final _backend = Backend();
bool _isLoadingHistory = false, _showLoading = false, _disposed = false;
// startIndex - LIMIT + 1 -> ... -> startIndex
// 400 to begin with for testing
List<Msg> entries = List<Msg>.generate(Backend.LIMIT, (i) => Msg(i: 400 - Backend.LIMIT + i + 1));
/// loads more history data
Future<void> _loadMoreHistory() async {
if (entries.first.i == 0) return; // no messages left
_isLoadingHistory = true;
final newMsgsPending = _backend.loadEntries(entries.first.i, Backend.BACKWARD);
bool isLoadingHistoryThisInstance = true;
Future.delayed(Duration(milliseconds: 1000)).then((_) {
// show loading if after 1000 seconds not loaded yet
if (isLoadingHistoryThisInstance) setState(() => _showLoading = true);
});
final List<Msg> newMsgs = await newMsgsPending;
isLoadingHistoryThisInstance = false;
if (newMsgs.isNotEmpty) {
setState(() {
entries = newMsgs + entries;
_showLoading = false;
});
} else if (_showLoading) setState(() => _showLoading = false);
_isLoadingHistory = false;
}
/// loads new data
Future<void> _loadUpdates() async {
while (!_disposed) {
final newMsgs = await _backend.loadEntries(entries.last.i, Backend.FORWARD);
if (newMsgs.isNotEmpty) {
setState(() {
entries = entries + newMsgs;
});
}
}
}
@override
void setState(VoidCallback fn) {
if (_disposed) return;
super.setState(fn);
}
@override
void dispose() {
super.dispose();
_disposed = true;
}
@override
void initState() {
super.initState();
_itemPositionListener.itemPositions.addListener(_scrollListener);
_loadUpdates();
}
/// memory efficient list widget with widget tracking
Widget _getScrollablePositionedList() {
return Scrollbar(
child: ScrollablePositionedList.builder(
reverse: true,
itemCount: entries.length,
itemBuilder: (context, index) => ListTile(
title: Text(entries[entries.length - index - 1].data),
),
itemScrollController: _itemScrollController,
itemPositionsListener: _itemPositionListener,
),
);
}
/// loading indicator when backend call takes longer
Widget _getLoadingWidget() {
return Positioned(
top: 15,
right: 15,
child: Opacity(
opacity: _showLoading ? 1.0 : 0.0,
child: _SPIN_KIT,
),
);
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
_getScrollablePositionedList(),
_getLoadingWidget(),
],
);
}
/// called while scrolling to track what the position is, calls to load more history
void _scrollListener() {
if (!_isLoadingHistory) {
// topPosition is 1 when "oldest" or first widget of entries got loaded, so we can use it to
// determine if we should load more data
final topPosition = (_itemPositionListener.itemPositions.value.last.index - entries.length) * -1;
if (topPosition <= MIN_HISTORY_LOADING_TRIGGER) {
// x entries remaining, load more history
_loadMoreHistory();
}
}
// could also verify which widgets have been viewed for tracking and reading verifications
}
}