I am building a chat application in React Native using FlashList from @shopify/flash-list. My goal is to load earlier messages when the user scrolls to the top while keeping the scroll position preserved (i.e., no jump or flicker). I want the list to stay in the same visual position after loading new messages, without scrolling to the very end or top.
Here is what I have tried:
However, the list still scrolls to the very end after new messages are loaded instead of preserving the user's current position.
My Code:
import {
loadConversation,
selectChatState,
} from "@/src/store/features/conversation/conversationSlice";
import { ChatItem } from "@/src/store/features/conversation/types";
import { useAppDispatch, useAppSelector } from "@/src/store/hooks/storeHooks";
import cnStyles from "@/src/styles";
import { FlashList } from "@shopify/flash-list";
import React, { FC, useCallback, useRef } from "react";
import { KeyboardAvoidingView, StyleSheet, View } from "react-native";
import BlockedStrip from "./BlockedStrip";
import InputBox from "./InputBox";
import { MessageBubble } from "./MessageBubble";
import PrevLoader from "./PrevLoader";
type PropType = {
SendMessage: (value: string) => void;
};
export const ChatList: FC<PropType> = ({ SendMessage }) => {
const flatListRef = useRef<FlashList<ChatItem>>(null);
const dispatch = useAppDispatch();
const loadingMoreRef = useRef(false);
const { isLoading, messages, start, userInfo, fullyLoaded, you_id } =
useAppSelector(selectChatState);
const scrollOffset = useRef(0); // Instead of useSharedValue
// Function to load earlier messages
const loadPreviousMessages = useCallback(async () => {
if (!fullyLoaded && !isLoading && !loadingMoreRef.current) {
loadingMoreRef.current = true;
// Capture current scroll position
const currentOffset = scrollOffset.current;
try {
const res = await dispatch(
loadConversation({
reqBody: { start, conv_id: you_id },
})
).unwrap();
if (res.success === "1") {
// Calculate height based on number of new items
const newItemsCount = res.user_chat?.length || 0;
const estimatedItemHeight = 100; // Match your estimatedItemSize
const heightDifference = newItemsCount * estimatedItemHeight;
// Adjust scroll after data renders
setTimeout(() => {
flatListRef.current?.scrollToOffset({
offset: currentOffset + heightDifference,
animated: false,
});
}, 0);
}
} finally {
loadingMoreRef.current = false;
}
}
}, [fullyLoaded, isLoading, start, you_id, dispatch]);
const onSubmitMsg = (msg: string) => {
SendMessage(msg);
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}, 10);
};
return (
<KeyboardAvoidingView style={cnStyles.container} behavior="padding">
<View style={styles.messagesList}>
<FlashList
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.key}
renderItem={({ item }) => <MessageBubble item={item} />}
estimatedItemSize={100}
ListFooterComponent={isLoading ? <PrevLoader /> : null}
inverted={true} // Keep inverted to support upward scrolling
onScroll={(event) => {
scrollOffset.current = event.nativeEvent.contentOffset.y;
}}
scrollEventThrottle={16}
onEndReached={loadPreviousMessages} // Trigger loading earlier messages
onEndReachedThreshold={0.1} // Trigger when near the top
/>
</View>
{userInfo?.button_status === 4 ? (
<BlockedStrip />
) : (
<InputBox onSubmit={onSubmitMsg} />
)}
</KeyboardAvoidingView>
);
};
Expected Behavior:
Current Behavior:
What I’ve Tried:
Using onContentSizeChange: I capture the content height before and after loading messages and adjust the scroll offset accordingly.
Manual Adjustment of Scroll Offset: I calculate the height difference and add it to the current scrollOffset.
What am I missing in my approach? How can I ensure that the scroll position is preserved when new messages are added with inverted={true}?
Any insights or suggestions would be greatly appreciated!
I were able to fix above issue, below are the keythings i have done to fx the issue
FlatList
as messages.FlatList
itself.My component logic
import {
FlatList,
KeyboardAvoidingView,
SafeAreaView,
View,
} from "react-native";
export default function ChatScreen() {
const dispatch = useAppDispatch();
const flatListRef = useRef<FlatList<ChatItem>>(null);
const {
isLoading,
fullyLoaded,
start,
you_id,
messages,
} = useAppSelector(selectChatState);
const loadChats = async () => {
if (isLoading || fullyLoaded) return;
dispatch(loadConversation());
};
const loadEarlierMsgs = () => {
loadChats();
};
useFocusEffect(
useCallback(() => {
loadChats();
return () => {
dispatch(resetChatState());
};
}, [dispatch])
);
const onSendMsg = (msg: string) => {
dispatch(appendToChat(msg));
dispatch(sendMessage({ reqBody: { conv_message: msg, conv_id: you_id } }));
flatListRef.current?.scrollToOffset({ offset: 0, animated: false });
};
const renderItem = ({ item }: { item: ChatItem }) => (
<MessageBubble item={item} />
);
return (
<SafeAreaView style={styles.safeview}>
<Header />
<ChatInfoBlock />
<KeyboardAvoidingView style={cnStyles.container} behavior="padding">
<View style={styles.messagesList}>
{messages.length > 0 && (
<FlatList
ListFooterComponent={isLoading ? <PrevLoader /> : null}
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.key}
renderItem={renderItem}
inverted={true}
onEndReached={loadEarlierMsgs}
/>
)}
</View>
{userInfo?.button_status === 4 ? (
<BlockedStrip you_name={userInfo.you_name} />
) : (
<InputBox onSubmit={onSendMsg} />
)}
</KeyboardAvoidingView>
</SafeAreaView>
);
}
My redux logic
.addCase(loadConversation.fulfilled, (state, action) => {
const res = action.payload;
const success = +res.success;
try {
if (success == 1) {
let { user_chat } = res;
user_chat = user_chat.map((item, index) => {
item.key = item.conv_id.toString() + index;
return item;
});
state.top_strip = res.top_strip;
state.messages = [...state.messages, ...user_chat];
if (user_chat.length < CONVERSATION_START) state.fullyLoaded = true;
}
if (success !== 1) state.start -= CONVERSATION_START;
if (success === 2) {
state.fullyLoaded = true;
}
} finally {
state.isLoading = false;
if (!state.success) {
state.success = success;
}
}
})