Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

maintainVisibleContentPosition interferes with ListEmptyComponent positioning in LegendList #92

Open
Yembot31013 opened this issue Feb 5, 2025 · 8 comments

Comments

@Yembot31013
Copy link

Thanks a lot for this amazing project. I just left FlashList for LegendList today because scrolling is not smooth with FlashList and the low-resource Android phone.

When using maintainVisibleContentPosition={true} in LegendList, it affects the positioning of ListEmptyComponent, forcing it to center within the list container height instead of respecting the component's intended position styles. Also, if using maintainVisibleContentPosition={true} and keyExtractor={(item) => item.id} I am getting an error of TypeError: Cannot read property 'id' of undefined even when data is an empty array

Additionally, LegendList currently lacks support for stickyHeaderIndices which is essential for chat applications to maintain date headers visible while scrolling through message groups.

Environment:

  • @legendapp/list: ^1.0.0-beta.2
  • React Native: 0.74.5
  • Platform: Android

Reproduction:

// MessageListContainer.tsx
import { LegendList, LegendListRef } from "@legendapp/list"

export default function MessageListContainer() {
  const flatListRef = useRef<LegendListRef | null>(null)
  
  return (
   <View style={{ flex: 1 }}>
      <LegendList
        ref={flatListRef}
        data={[]}
        renderItem={renderItem}
        estimatedItemSize={234}
        keyExtractor={(item) => item.id}
        maintainVisibleContentPosition={true}
        showsVerticalScrollIndicator={false}
        ListEmptyComponent={<EmptyChat loading={loading} />}
      />
    </View>
  )
}

// EmptyChat.tsx
export function EmptyChat({ loading }: { loading: boolean }) {
  if (loading) {
    return (
      <View style={{ alignItems: "center", marginTop: 20 }}>
        <ActivityIndicator size="large" color="#000" />
      </View>
    )
  }

  return (
    <View
      style={{
        alignItems: "center",
        // marginTop: 20,
        display: "flex",
        // backgroundColor: "red",
        padding: 10,
      }}
    >
      <Text
        style={{
          color: "#999",
          padding: 20,
          backgroundColor: "#F7F7F7",
          fontWeight: 400,
          fontSize: 12,
          borderRadius: 8,
          display: "flex",
          justifyContent: "center",
          alignItems: "center",
        }}
        text={"All messages sent by participants in this conversation are encrypted. Learn More"}
      />
    </View>
  )
}

Expected Behavior:

  1. ListEmptyComponent should maintain its position styles regardless of maintainVisibleContentPosition
  2. Support for stickyHeaderIndices to allow date headers to stick while scrolling, similar to FlashList's implementation
  3. I shouldn't get a TypeError from keyExtractor if data is empty when maintainVisibleContentPosition is enabled.
@jmeistrich
Copy link
Contributor

Thanks! We'll look into those bugs and fix them asap.

And sticky headers is in progress: #72

@hirbod
Copy link

hirbod commented Feb 14, 2025

I can confirm that ListEmptyComponent styling breaks with maintainVisibleContentPosition. For me, simply pulling the ScrollView fixes it and makes the ListEmptyComponent appear.

Simulator.Screen.Recording.-.iPhone.16.Pro.-.2025-02-14.at.05.49.07.mp4

@jmeistrich
Copy link
Contributor

I believe the ListEmptyComponent bug should be fixed in beta.7. Is that working correctly for you now? If so let's close this issue as the TypeError is also fixed and we're tracking the stickyIndices feature in #28.

@Yembot31013
Copy link
Author

okay please let me update and test this out.

@Yembot31013
Copy link
Author

Actually after I updated the version to beta.8, I have a very huge issue with the UI, I am very terrible at explaining by typing, please checkout this video demo below where I explain it.

Watch the demo: https://vimeo.com/1057060759

@hirbod
Copy link

hirbod commented Feb 15, 2025

Here is the video of @Yembot31013 since Vimeo requires a fucking sign-up

bfafbc4e-b9ba744c.mp4

@jmeistrich
Copy link
Contributor

@Yembot31013 Hmm, I'm having trouble reproducing that. Which version broke it? Can you share more code to help me test it?

@Yembot31013
Copy link
Author

thanks for your response. The version that broke it is beta.8. so based on my observation, you can reproduce this issue if you keep adding to the data array dynamically when it was first empty. so for example:

EmptyChat.tsx

import { ActivityIndicator, View } from "react-native"

import { Text } from "../Text"

export function EmptyChat({ loading }: { loading: boolean }) {
  if (loading) {
    return (
      <View style={{ alignItems: "center", marginTop: 20 }}>
        <ActivityIndicator size="large" color="#000" />
      </View>
    )
  }

  return (
    <View
      style={{
        alignItems: "center",
        // marginTop: 20,
        display: "flex",
        // backgroundColor: "red",
        padding: 10,
      }}
    >
      <Text
        style={{
          color: "#999",
          padding: 20,
          backgroundColor: "#F7F7F7",
          fontWeight: 400,
          fontSize: 12,
          borderRadius: 8,
          display: "flex",
          justifyContent: "center",
          alignItems: "center",
        }}
        text={"All messages sent by participants in this conversation are encrypted. Learn More"}
      />
    </View>
  )
}

MessageListContainer.tsx

import { MaterialCommunityIcons } from "@expo/vector-icons"
import { LegendList, type LegendListRef } from "@legendapp/list"
import { type IWaveformRef } from "@simform_solutions/react-native-audio-waveform"
import { format } from "date-fns"
import type React from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { StyleSheet, View } from "react-native"
import { TouchableOpacity } from "react-native-gesture-handler"

import { EmptyChat } from "@/components/conversation/EmptyChat"
import { UnreadIndicator } from "@/components/MessageType/UnreadIndicator"
import { type Message } from "@/models/databases/Message"
import { type Thread } from "@/models/databases/Thread"
import { type ChatMessage } from "@/screens/chats/ConversationScreen"

type MessageSection = {
  type: "date" | "unread" | "message"
  id: string
  data: Message | number | null
}

type Props = {
  thread: Thread | null
  messageData: Message[] | []
  loading: boolean
  user: { id: string } | null
  threadDetails: { conversationType: "individual" | "group" }
  handleReply: (message: ChatMessage) => void
  currentPlayingRef: React.RefObject<IWaveformRef> | undefined
}

export default function MessageListContainer({
  thread,
  messageData,
  loading,
  user,
  handleReply,
  threadDetails,
  currentPlayingRef,
}: Props) {
  // const flatListRef = useRef<FlashList<MessageSection> | null>(null)
  const flatListRef = useRef<LegendListRef | null>(null)
  const [unreadCount, setUnreadCount] = useState(0)
  const [showScrollToBottom, setShowScrollToBottom] = useState(false)

  useEffect(() => {
    if (thread) {
      thread.getUnreadCount(user!.id).then((count) => {
        setUnreadCount(count)
      })
    }
  }, [thread, user])

  const usePreparedMessages = (messages: Message[]) => {
    return useMemo(() => {
      const sections: MessageSection[] = []
      let currentDate: string | null = null

      messages.forEach((message, index) => {
        const messageDate = format(new Date(message.timestamp), "yyyy-MM-dd")

        if (messageDate !== currentDate) {
          sections.push({
            type: "date",
            id: `date-${messageDate}`,
            data: message.timestamp.getTime(),
          })
          currentDate = messageDate
        }

        if (
          index ===
            messages.findIndex(
              async (msg) =>
                msg.status === "delivered" && (await msg.sender).userIdentifier !== user!.id,
            ) &&
          unreadCount > 0
        ) {
          sections.push({
            type: "unread",
            id: "unread",
            data: null,
          })
        }

        sections.push({
          type: "message",
          id: message.id,
          data: message,
        })
      })

      return sections
    }, [messages])
  }

  const preparedMessages = usePreparedMessages(messageData)

  const renderItem = ({ item }: { item: MessageSection }) => {
    switch (item.type) {
      case "date":
        return (
          <View style={styles.stickyHeader}>
            <DateHeader date={item.data as any} />
          </View>
        )
      case "unread":
        return <UnreadIndicator />
      case "message":
        return (
          <EnhancedMessage
            message={item.data as Message}
            handleReply={handleReply}
            conversationType={threadDetails.conversationType}
            currentPlayingRef={currentPlayingRef}
          />
        )
    }
  }

  const scrollToBottom = () => {
    flatListRef.current?.scrollToEnd({ animated: true })
    setShowScrollToBottom(false)
  }

  const handleScroll = ({ nativeEvent }: any) => {
    const isCloseToBottom =
      nativeEvent.layoutMeasurement.height + nativeEvent.contentOffset.y >=
      nativeEvent.contentSize.height - 20
    setShowScrollToBottom(!isCloseToBottom)
  }

  return (
    <View style={{ flex: 1 }}>
      <LegendList
        ref={flatListRef}
        data={preparedMessages}
        renderItem={renderItem}
        estimatedItemSize={234}
        keyExtractor={(item) => item?.id}
        onScroll={handleScroll}
        recycleItems={true}
        alignItemsAtEnd={preparedMessages.length > 0}
        maintainScrollAtEnd={preparedMessages.length > 0}
        maintainScrollAtEndThreshold={0.1}
        maintainVisibleContentPosition={preparedMessages.length > 0}
        initialScrollIndex={preparedMessages.length - 1}
        showsVerticalScrollIndicator={false}
        ListEmptyComponent={<EmptyChat loading={loading} />}
      />

      {showScrollToBottom && (
        <TouchableOpacity style={styles.scrollToBottomButton} onPress={scrollToBottom}>
          <MaterialCommunityIcons name="arrow-down" size={24} color="#333333" />
        </TouchableOpacity>
      )}
    </View>
  )
}

const styles = StyleSheet.create({
  scrollToBottomButton: {
    alignItems: "center",
    backgroundColor: "white",
    borderRadius: 20,
    bottom: 20,
    elevation: 5,
    height: 50,
    justifyContent: "center",
    position: "absolute",
    right: 16,
    shadowColor: "#000",
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
    width: 50,
  },
  stickyHeader: {
    backgroundColor: "#fff",
    paddingVertical: 8,
    paddingHorizontal: 16,
    zIndex: 1,
  },
})

so when the preparedMessages is an empty array ([]) it is supposed to show ListEmptyComponent which actually works, if I send a message and the preparedMessages change when the issue comes in. but if you refresh or do anything to make the LegendList re-render again with a value in preparedMessages, it will render perfectly well. so also if you clear the preparedMessages dynamically, it renders the ListEmptyComponent badly, like putting it in the centre or maybe at the bottom sometimes or showing half part of the component, but if you do anything to make the LegendList re-render again fully, it renders perfectly well. so my conclusion is that if the data changes in real-time, LegendList didn't handle the render properly, and this fact is when the data is an empty array, and then you add something into it dynamically in real-time, or if the data has a lot of values like over 7 items and then you clear it dynamically, this will break the rendering.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants