import React, { useCallback, useRef, useState } from 'react'
import { FlatList, View, Text, ActivityIndicator } from 'react-native'
import { cn } from '@/lib/utils'
const DefaultLoader = () => (
<View className="flex justify-center py-4">
<ActivityIndicator size="small" />
</View>
)
const DefaultEndMessage = () => (
<View className="text-center py-4">
<Text className="text-muted-foreground">No more items to load</Text>
</View>
)
interface InfiniteScrollProps<T> {
items: T[]
hasNextPage: boolean
isLoading: boolean
onLoadMore: () => void | Promise<void>
threshold?: number
loader?: React.ComponentType
endMessage?: React.ReactNode
errorMessage?: React.ReactNode
renderItem: (item: T, index?: number) => React.ReactNode
className?: string
itemClassName?: string
reverse?: boolean
initialLoad?: boolean
estimateSize?: number
keyExtractor?: (item: T, index: number) => string
}
export function InfiniteScroll<T>({
items,
hasNextPage,
isLoading,
onLoadMore,
threshold = 0.5,
loader: Loader = DefaultLoader,
endMessage = <DefaultEndMessage />,
errorMessage,
renderItem,
className,
itemClassName,
reverse = false,
initialLoad = false,
estimateSize = 50,
keyExtractor,
}: InfiniteScrollProps<T>) {
const [internalLoading, setInternalLoading] = useState(false)
const flatListRef = useRef<FlatList>(null)
const handleLoadMore = useCallback(() => {
if (internalLoading || !hasNextPage || isLoading) return
setInternalLoading(true)
Promise.resolve(onLoadMore()).finally(() => {
setInternalLoading(false)
})
}, [hasNextPage, isLoading, internalLoading, onLoadMore])
const defaultKeyExtractor = useCallback(
(_item: T, index: number) => index.toString(),
[]
)
const renderItemWrapper = useCallback(
({ item, index }: { item: T; index: number }) => (
<View className={cn(itemClassName)}>
{renderItem(item, index)}
</View>
),
[renderItem, itemClassName]
)
const renderFooter = useCallback(() => {
if (isLoading || internalLoading) {
return <Loader />
}
if (!hasNextPage && items.length > 0) {
return <>{endMessage}</>
}
return null
}, [isLoading, internalLoading, hasNextPage, items.length, endMessage, Loader])
const renderEmpty = useCallback(() => {
if (isLoading || internalLoading) {
return null
}
return (
<View className="flex justify-center items-center py-8">
<Text className="text-muted-foreground">No items found</Text>
</View>
)
}, [isLoading, internalLoading])
return (
<View className={cn("flex-1", className)}>
<FlatList
ref={flatListRef}
data={items}
renderItem={renderItemWrapper}
keyExtractor={keyExtractor || defaultKeyExtractor}
onEndReached={handleLoadMore}
onEndReachedThreshold={threshold}
ListFooterComponent={renderFooter}
ListEmptyComponent={renderEmpty}
inverted={reverse}
getItemLayout={
estimateSize
? (_data, index) => ({
length: estimateSize,
offset: estimateSize * index,
index,
})
: undefined
}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
removeClippedSubviews={true}
contentContainerStyle={
items.length === 0 ? { flexGrow: 1 } : { paddingBottom: 16 }
}
/>
{errorMessage && (
<View className="text-center py-4">
<Text className="text-destructive">{errorMessage}</Text>
</View>
)}
</View>
)
}
export type { InfiniteScrollProps }