我基本上有一个聊天,一开始只加载了一部分。现在我需要在顶部和底部添加更多数据(小部件)。
现在在我的示例中,我已经实现了,当用户向上滚动并且剩余 50 个或更少的小部件时,它会调用后端来加载更多数据(因此,当用户进一步向上滚动时,加载已经在进行或完成)。工作正常,一旦加载数据,用户就可以进一步向上滚动。就像在 WhatsApp 和更多带有提要的应用程序中一样。
然而,也需要加载新消息。这些是在底部添加的。但是,这会移动列表视图中的每个项目,这是一个巨大的问题,因为当用户查看历史记录时,我们不想“自动滚动”新小部件的数量......
如何在不“移动”内容的情况下双向添加小部件?
###小后端模拟###
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();
}
}
### 我的自定义“列表视图”###
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
}
}