Flutter - IM保持消息位置大升级(支持ChatGPT生成式消息) 🤖
# 一、概述
在 【Flutter - 快速实现聊天会话列表的效果,完美💯】 一文中介绍了常规聊天场景下如何保持消息位置及其实现原理,本篇将在此基础上完成 ChatGPT
这种生成式消息保持位置的功能。
如上图所示,操作与现象:
- 点击右上角的按钮后,会插入一条消息,模拟向
ChatGPT
发出问题 - 然后间隔
1s
会自动插入一条生成式消息,模拟ChatGPT
在不断回复我们的问题
# 二、布局
布局非常简单,就一个 ListView
,不过需要结合 ChatObserver
做一些配置
# 1、ChatObserver
配置
// 实例化 ListObserverController
// 由于滚动视图使用的是 ListView,所以选择 ListObserverController
// 否则请选择相应的 ObserverController
observerController = ListObserverController(controller: scrollController)
// 关闭模块定位的偏移缓存功能,因为IM经常添加或删除消息,偏移的缓存容易过时
// 如果你不会使用到模块定位功能,则可以不用理会
..cacheJumpIndexOffset = false;
// 实例化 ChatScrollObserver
chatObserver = ChatScrollObserver(observerController)
// 滚动视图的偏移量超过 5 时才会启用保持IM消息位置的功能
..fixedPositionOffset = 5
// 内部在切换 isShrinkWrap 的值时会触发该回调,
// 触发时局部刷新列表视图即可,这里因为是 Demo 的缘故,做法比较简单粗暴
..toRebuildScrollViewCallback = () {
setState(() {});
};
# 2、ListView
配置
Widget _buildListView() {
Widget resultWidget = ListView.builder(
// 内含处理保持IM消息位置的核心逻辑
physics: ChatObserverClampingScrollPhysics(observer: chatObserver),
padding: const EdgeInsets.only(left: 10, right: 10, top: 15, bottom: 15),
// 切换滚动视图的高度模式,当消息不满一屏时,该值为 true,使消息在顶部展示,
// 消息超一屏时为 false,消息在底部展示,chatObserver 内部会适时变更该值
// 如果你希望一直都是底部展示,可以注释该行
shrinkWrap: chatObserver.isShrinkWrap,
// 消息从底部往上开始排序,所以来新消息时应该插入到 0 的位置
reverse: true,
controller: scrollController,
itemBuilder: ((context, index) {
return ChatItemWidget(...);
}),
itemCount: chatModels.length,
);
// 对滚动视图监听
resultWidget = ListViewObserver(
controller: observerController,
child: resultWidget,
);
// 重点,如果你希望不满一屏时在顶部展示消息,而又不生效时,
// 记得如下所示设置 alignment 为 Alignment.topCenter
resultWidget = Align(
child: resultWidget,
alignment: Alignment.topCenter,
);
return resultWidget;
}
基本的配置到这就完成了,接下来看看如何为生成式消息保持位置吧
# 三、实战
AppBar
右边的按钮,点击时模拟发出一条问题消息,然后等待1s后 ChatGPT
以不断更新消息的方式回答问题
IconButton(
onPressed: () async {
// 关闭上一条更新生成式消息
stopMsgUpdateStream();
// 模拟发送一条问题消息
_addMessage(isOwn: true);
// 等待1s
await Future.delayed(const Duration(seconds: 1));
// 插入生成式消息
insertGenerativeMsg();
},
icon: const Icon(Icons.add_comment),
)
停止旧生成式消息的更新,控制同一时间只允许存在一条生成式消息
stopMsgUpdateStream() {
timer?.cancel();
timer = null;
}
插入一条新消息方法
_addMessage({
required bool isOwn,
}) {
// 在符合条件的情况下保持当前的IM消息位置
chatObserver.standby(changeCount: 1);
setState(() {
chatModels.insert(0, ChatDataHelper.createChatModel(isOwn: isOwn));
});
}
插入生成式消息,模拟 ChatGPT
正在回答问题
insertGenerativeMsg() {
// 停止之前的生成式消息
stopMsgUpdateStream();
// 插入一条
_addMessage(isOwn: false);
// 开始模拟接收更新的消息数据
int count = 0;
timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
// 接收完毕
if (count >= 60) {
stopMsgUpdateStream();
return;
}
count++;
// 取出最新的生成式消息
final model = chatModels.first;
// 更新消息内容
final newString = '${model.content}-1+1';
// 替换消息
final newModel = ChatModel(isOwn: model.isOwn, content: newString);
chatModels[0] = newModel;
// 准备保持位置
chatObserver.standby(
// 重点,使用生成式消息的处理模式
mode: ChatScrollObserverHandleMode.generative,
// changeCount: 1,
);
// 后面会解释该模式的使用
// chatObserver.standby(
// changeCount: 1,
// mode: ChatScrollObserverHandleMode.specified,
// refItemRelativeIndex: 2,
// refItemRelativeIndexAfterUpdate: 2,
// );
// 刷新滚动视图,可局部刷新,Demo 缘故,做法比较简单粗暴
setState(() {});
});
}
# 四、用法解析
其实上面那一堆代码中,重点在于 standby
方法的使用
standby({
// 滚动视图的 BuildContext
// 当布局复杂使 scrollview_observer 无法顺利自动找到滚动视图的 BuildContext 时才需要传入
// 如 CustomScrollView,或 ObserverWidget 包裹的 child 中有多个 ListView、GridView
BuildContext? sliverContext,
// 是否是删除消息的操作,它不会启用保持位置功能,而是可能切换 isShrinkWrap 的值
bool isRemove = false,
// 变更的IM消息数
int changeCount = 1,
// 处理模式
ChatScrollObserverHandleMode mode = ChatScrollObserverHandleMode.normal,
// 刷新滚动视图前,参照的相对item的下标
int refItemRelativeIndex = 0,
// 刷新滚动视图后,参照的相对item的下标
int refItemRelativeIndexAfterUpdate = 0,
})
处理模式的定义如下
enum ChatScrollObserverHandleMode {
/// 常规模式
/// 插入或删除IM消息的时候使用
normal,
/// 生成式模式
/// 专门处理 ChatGPT 这种不断更新消息的情况
generative,
/// 指定模式
/// 在该模式下我们可以指定参照的IM消息的相对下标
specified,
}
# 1、常规模式
常规模式为默认的处理模式,应用于日常的 IM
添加和删除消息的场景,比较简单
插入多条消息
_addMessage(int count) {
// 进入待命状态,保持IM消息位置
chatObserver.standby(changeCount: count);
// 一次性插入多条消息,并刷新滚动视图
setState(() {
needIncrementUnreadMsgCount = true;
for (var i = 0; i < count; i++) {
chatModels.insert(0, ChatDataHelper.createChatModel());
}
});
}
删除消息
chatObserver.standby(isRemove: true);
setState(() {
chatModels.removeAt(index);
});
# 2、生成式模式(仿ChatGPT
)
专门处理 ChatGPT
这种生成式消息的场景
final model = chatModels.first;
final newString = '${model.content}-1+1';
final newModel = ChatModel(isOwn: model.isOwn, content: newString);
// 更新生成式消息的数据
chatModels[0] = newModel;
// 进入待命状态
chatObserver.standby(
mode: ChatScrollObserverHandleMode.generative,
// changeCount: 1,
);
// 刷新列表
setState(() {});
- 指定处理模式
mode
为.generative
changeCount
默认值为1
,传与不传一样
内部会根据 changeCount
来计算并记录参照消息的相对下标(关于相对下标的内容会在 指定模式
一节中说明),这意味着该模式支持连续的多条生成式消息保持位置,比如最新的两条消息都是生成式的情况是支持。
而如果最新的三条消息,0
和 2
都是生成式消息而 1
不是,又或者同一时间插入消息和更新生成式消息,此时该模式是无法很好的支持保持消息位置这一功能的,那有什么办法呢?你可以使用 指定模式
# 3、指定模式
顾名思义,可以指定参照的 IM
消息的相对下标,从而可以自由的使用保持消息位置的功能,当然了,自由也意味着传入的数据和需要了解的会更多!
我们先来了解一下什么是参照的 IM
消息的相对下标?
注: 滚动视图内已渲染的
item
不一定会被显示出来,如果下文中提及的滚动视图渲染的item
让你不好理解,你可以直接认为是屏幕中正在展示的item
。
假如当前是正在看最新消息的情况,滚动视图内渲染了 item0
到 item4
,其相对下标为 0
到 4
。
trailing relativeIndex
----------------- -----------------
| item4 | 4
| item3 | 3
| item2 | 2
| item1 | 1
| item0 | 0
----------------- -----------------
leading
如果此时你在翻看历史消息,滚动视图内渲染了 item10
到 item14
,其相对下标也为 0
到 4
。
trailing relativeIndex
----------------- -----------------
| item14 | 4
| item13 | 3
| item12 | 2
| item11 | 1
| item10 | 0
----------------- -----------------
leading
这里 0
到 4
的下标即为相对下标,我们来使用该模式(.specified
)来完成 .generative
模式的功能
在上述示例中,离发出问题间隔了1秒后,ChatGPT
会开始回答问题,此时我们插入生成式的消息,并不断更新该消息内容。
注意: 插入消息的方法内已经做了保持消息位置的处理,所以我们的关注点在于更新消息
trailing relativeIndex
----------------- -----------------
| item4 | 4
| item3 | 3
| item2 | 2
| item1 | 1
| item0 | 0
----------------- -----------------
leading
假设此时 item0
为生成式消息,它的消息内容在不断变多,如果放任不管,item0
以上的消息会逐渐被往上顶,因此在这里我们的目的是不管 item0
如何变化,都需要保持 item1
及以上消息的位置,所以 item1
就成为了我们的参照消息,它此刻的下标为 1
,而生成式消息的变化前后,item1
的下标一直都是 1
!
改造的代码如下:
chatObserver.standby(
changeCount: 1,
// 设置处理模式为 .specified
mode: ChatScrollObserverHandleMode.specified,
// 滚动视图更新前,参照消息的相对下标
refItemRelativeIndex: 1,
// 滚动视图更新后,参照消息的相对下标
refItemRelativeIndexAfterUpdate: 1,
);
注意,refItemRelativeIndex
和 refItemRelativeIndexAfterUpdate
应该指向同一条消息!
其实参照消息在理论上只要是 item0
以上的已经渲染的消息即可,即上述的参照消息的相对下标也可以是 2
、 3
和 4
。
注: 如果更新滚动视图后参照的消息无法得到渲染,则该功能就会失效,所以建议还是选择当前发生变化的消息的上一条消息的相对下标~
比较有意思的是,假设我们在发出问题后,往上翻页了,1秒后滚动视图渲染的消息是 item10
到 item14
trailing relativeIndex
----------------- -----------------
| item14 | 4
| item13 | 3
| item12 | 2
| item11 | 1
| item10 | 0
----------------- -----------------
leading
此时你的参照消息的相对下标就可以为 0
了,但是你需要判断当前已渲染的第一个 item
是否为生成式消息,这就很麻烦,没有必要。
总结来说,参照的消息不可以为发生变化的消息本身,而是为滚动视图在更新前后都会被渲染的
item
即可!
# 五、最后
通过上述示例的讲解,相信你对 scrollview_observer
的使用又更加清楚,如果你也觉得这个库好用,请不吝给个 Star
👍
GitHub: https://github.com/LinXunFeng/flutter_scrollview_observer (opens new window)
- 01
- Flutter - 危!3.24版本苹果审核被拒!11-13
- 02
- Flutter - 轻松搞定炫酷视差(Parallax)效果09-21
- 03
- Flutter - 轻松实现PageView卡片偏移效果09-08