react-nativeexporeact-native-flatlistflash-list

How to preserve scroll position in FlashList with inverted when loading earlier messages?


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:

  1. Using inverted={true} to reverse the list so that new messages appear at the bottom.
  2. Tracking the content height of the list using onContentSizeChange before and after adding new messages.
  3. Adjusting the scroll offset manually using scrollToOffset to preserve the position.

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:

  1. Using onContentSizeChange: I capture the content height before and after loading messages and adjust the scroll offset accordingly.

  2. 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!


Solution

  • I were able to fix above issue, below are the keythings i have done to fx the issue

    1. migrated to FlatList as messages.
    2. removed manual Adjusting scroll position and left that task for 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;
              }
            }
          })