Flutter - Keep IM message position greatly upgraded (supports generative messages like ChatGPT) 🤖

LinXunFeng
8 min readNov 5, 2023

--

Overview

In [Flutter - Quickly implement the effect of the chat session list, perfect 💯], the article introduces how to maintain the position of messages in regular chat scenarios and its implementation principle. On this basis, this article will complete the function of ChatGPT to maintain the position of generative messages.

As shown in the figure above, the operation and effect are:

  1. After clicking the button in the upper right corner, a message will be inserted to simulate issuing a question to ChatGPT.
  2. Then after 1 second, a generative message will be automatically inserted to simulate ChatGPT continuously replying to our question.

Layout

The layout is very simple, just a ListView, but it needs to be combined with ChatObserver to do some configuration.

1 Configure ChatObserver

// Instantiate ListObserverController
// Since we are using ListView as the ScrollView,
// we select the corresponding ListObserverController here.
// Otherwise, select the corresponding ObserverController.
observerController = ListObserverController(controller: scrollController)
// Turn off the offset cache of module positioning. Because IM often adds
// or deletes messages, the offset cache is easily outdated.
// If you will not use the module positioning function, you can ignore it.
..cacheJumpIndexOffset = false;

// Instantiate ChatScrollObserver
chatObserver = ChatScrollObserver(observerController)
// The function of maintaining the position of IM messages will only be
// enabled when the offset of the ScrollView exceeds 5
..fixedPositionOffset = 5
// This callback will be triggered internally when the value of isShrinkWrap
// is switched.
// When this callback is triggered, you can only refresh the ScrollView.
// Because this is a Demo, I call setState directly here.
..toRebuildScrollViewCallback = () {
setState(() {});
};

2 Configure ListView

Widget _buildListView() {
Widget resultWidget = ListView.builder(
// ChatObserverClampingScrollPhysics contains core logic to handle
// Keeping the position of IM messages.
physics: ChatObserverClampingScrollPhysics(observer: chatObserver),
padding: const EdgeInsets.only(left: 10, right: 10, top: 15, bottom: 15),
// Switch the layout mode of the ListView. When the number of messages
// is less than one screen, this value is true so that all messages are
// displayed at the top.
// When the number of messages exceeds one screen, the shrinkWrap's value
// is false and the messages are displayed at the bottom. ChatObserver will
// change the value internally in a timely manner.
// If you want it to always be displayed at the bottom, you can comment
// this line
shrinkWrap: chatObserver.isShrinkWrap,
// Messages are sorted from the bottom up, so new messages should be
// inserted at position 0.
reverse: true,
controller: scrollController,
itemBuilder: ((context, index) {
return ChatItemWidget(...);
}),
itemCount: chatModels.length,
);

// Observe the ScrollView.
resultWidget = ListViewObserver(
controller: observerController,
child: resultWidget,
);

// Importantly, if you want all messages to be displayed at the top when
// the number of messages is less than one screen, and it does not take
// effect, remember to set alignment to Alignment.topCenter as shown below
resultWidget = Align(
child: resultWidget,
alignment: Alignment.topCenter,
);
return resultWidget;
}

The basic configuration is now complete. Let’s take a look at how to maintain position for generative message.

Practical part

The button on the right side of the AppBar simulates sending a question message when clicked. Then after waiting for 1 second, ChatGPT will answer the question by continuously updating message.

IconButton(
onPressed: () async {
// Stop update the last generative message
stopMsgUpdateStream();
// Simulate sending a question message
_addMessage(isOwn: true);
// Wait one second
await Future.delayed(const Duration(seconds: 1));
// Insert a generative message
insertGenerativeMsg();
},
icon: const Icon(Icons.add_comment),
)

Stop updating old generative message to control that only one generative message is allowed to exist at the same time.

stopMsgUpdateStream() {
timer?.cancel();
timer = null;
}

Insert a new message method.

_addMessage({
required bool isOwn,
}) {
// Keep current IM message position if conditions are met
chatObserver.standby(changeCount: 1);
setState(() {
chatModels.insert(0, ChatDataHelper.createChatModel(isOwn: isOwn));
});
}

Insert a generative message to simulate ChatGPT answering a question.

insertGenerativeMsg() {
// Stop updating previous generative message
stopMsgUpdateStream();
// Insert a message
_addMessage(isOwn: false);
// Start simulating data update for generative message.
int count = 0;
timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
// Data updated
if (count >= 60) {
stopMsgUpdateStream();
return;
}
count++;
// Get the latest generative message
final model = chatModels.first;
// Update message content
final newString = '${model.content}-1+1';
// Replace message
final newModel = ChatModel(isOwn: model.isOwn, content: newString);
chatModels[0] = newModel;
// Prepare to hold position
chatObserver.standby(
// The key point is to use the generative message processing mode
mode: ChatScrollObserverHandleMode.generative,
// changeCount: 1,
);
// The usage of this mode will be explained later
// chatObserver.standby(
// changeCount: 1,
// mode: ChatScrollObserverHandleMode.specified,
// refItemRelativeIndex: 2,
// refItemRelativeIndexAfterUpdate: 2,
// );
// You can only refresh the ScrollView, because this is just a demo,
// so I call setState method directly.
setState(() {});
});
}

Usage analysis

In fact, in the above pile of code, the focus is on the usage of standby method.

standby({
// The ScrollView’s BuildContext.
// It only needs to be passed in when the layout is complex
// and scrollview_observer cannot automatically find the BuildContext of
// the ScrollView smoothly.
// For example, CustomScrollView contains multiple SliverList and SliverGrid.
BuildContext? sliverContext,
// Is it an operation to delete the message, it will not enable the keep
// position function, but may switch the value of isShrinkWrap
bool isRemove = false,
// Number of changes in IM messages
int changeCount = 1,
// Handleing mode
ChatScrollObserverHandleMode mode = ChatScrollObserverHandleMode.normal,
// The index of the relative item referenced before refreshing the ScrollView.
int refItemRelativeIndex = 0,
// The index of the relative item referenced After refreshing the scroll view.
int refItemRelativeIndexAfterUpdate = 0,
})

The handling mode is defined as follows

enum ChatScrollObserverHandleMode {
/// Regular mode
/// Such as inserting or deleting messages.
normal,

/// Generative mode
/// Such as ChatGPT streaming messages.
generative,

/// Specified mode
/// You can specify the index of the reference message in this mode.
specified,
}

1 Normal mode

Normal mode is the default handling mode and is used in daily IM adding and deleting messages. It is relatively simple.

Insert multiple messages.

_addMessage(int count) {
// Enter standby state, used to keep IM message position
chatObserver.standby(changeCount: count);
// Insert multiple messages at once and refresh the ScrollView
setState(() {
needIncrementUnreadMsgCount = true;
for (var i = 0; i < count; i++) {
chatModels.insert(0, ChatDataHelper.createChatModel());
}
});
}

Delete message

chatObserver.standby(isRemove: true);
setState(() {
chatModels.removeAt(index);
});

2 Generative mode (imitating ChatGPT)

It specially handles the scenario of generating messages such as ChatGPT.

final model = chatModels.first;
final newString = '${model.content}-1+1';
final newModel = ChatModel(isOwn: model.isOwn, content: newString);
// Update data for generative messages
chatModels[0] = newModel;
// Enter standby state
chatObserver.standby(
mode: ChatScrollObserverHandleMode.generative,
// changeCount: 1,
);
// Refresh the ScrollView
setState(() {});
  • We need specify the processing mode mode as .generative
  • The default value of changeCount is 1.

The relative index of the reference message will be calculated and recorded internally based on changeCount (relative index will be explained in the Specify Mode section), which means that this mode supports multiple consecutive generative messages to maintain positions. For example, if the latest two messages are both generative, it is supported.

But if the latest three messages, the 0th and 2nd messages are both generative messages but the 1st message is not, or normal messages are inserted and generative messages are updated at the same time, then this mode cannot support keeping the message position well. If you want to keep position to continue to work, you can use specify mode.

3 Specify Mode

As the name suggests, you can specify the relative index of the referenced IM message, so you can freely use the function of keeping the position of the message. Of course, freedom also means using more parameters and needing to know more!

Let’s first understand what is the relative index of the referenced IM message.

Note: The items rendered in the ScrollView may not be displayed. If the items rendered by the ScrollView mentioned below are difficult for you to understand, you can directly think of them as the items being displayed on the screen.

If you are currently browsing the latest messages, item0 to item4 are rendered in the ScrollView, and their relative index are 0 to 4.

     trailing        relativeIndex
----------------- -----------------
| item4 | 4
| item3 | 3
| item2 | 2
| item1 | 1
| item0 | 0
----------------- -----------------
leading

If you are browsing historical messages at this time, item10 to item14 are rendered in the ScrollView, and their relative index are also 0 to 4.

     trailing        relativeIndex
----------------- -----------------
| item14 | 4
| item13 | 3
| item12 | 2
| item11 | 1
| item10 | 0
----------------- -----------------
leading

Here 0 to 4 are relative indexes. Let’s use this mode (.specified) to complete the function of the .generative mode.

In the above example, ChatGPT will start to answer the question after 1 second after the question is sent. At this time, we insert a generative message and continuously update the message content.

Note: The insertGenerativeMsg method has already processed to keep the position of the message, so our focus is on updating the message.

     trailing        relativeIndex
----------------- -----------------
| item4 | 4
| item3 | 3
| item2 | 2
| item1 | 1
| item0 | 0
----------------- -----------------
leading

Assume that item0 is a generative message at this time, and its message content is constantly increasing. If we do not do anything, messages above item0 will gradually be pushed up. Therefore, our purpose here is to keep the position of item1 message and above messages no matter how item0 changes, so item1 has become our reference message, its index at this moment is 1, and before and after the change of the generative message, the index of item1 has always been 1!

The modified code is as follows:

chatObserver.standby(
changeCount: 1,
// Set handling mode to .specified.
mode: ChatScrollObserverHandleMode.specified,
// The relative index of the referenced message before the ScrollView is updated.
refItemRelativeIndex: 1,
// The relative index of the referenced message after the ScrollView is updated.
refItemRelativeIndexAfterUpdate: 1,
);

Note that refItemRelativeIndex and refItemRelativeIndexAfterUpdate should point to the same message!

In fact, the referenced message can theoretically be a rendered message above item0, that is, the relative indexes of the above referenced messages can also be 2, 3, and 4.

Note: If the referenced message cannot be rendered after updating the ScrollView, this function will be invalid, so it is recommended to select the relative index of the previous message of the currently changed message~

What’s more interesting is that, assuming we turn up the page after issuing the question, the messages rendered by the ScrollView after 1 second are item10 to item14.

     trailing        relativeIndex
----------------- -----------------
| item14 | 4
| item13 | 3
| item12 | 2
| item11 | 1
| item10 | 0
----------------- -----------------
leading

At this time, the relative index of your referenced message can be 0, but you need to determine whether the first item currently rendered is a generative message, which is very troublesome and unnecessary.

In summary, the referenced message cannot be the changed message itself, but the item that will be rendered before and after the ScrollView is updated!

--

--