From f9af493dc8bbc8d2987de041b25500b30cd773c6 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 22 Aug 2024 16:47:48 +0200 Subject: [PATCH] feat: use Flashlist for smooth scrolling --- app/(auth)/(tabs)/libraries/[libraryId].tsx | 451 ++++++++++-------- app/(auth)/collections/[collectionId].tsx | 415 +++++++++------- components/common/HorrizontalScroll.tsx | 53 +- .../common/InfiniteHorrizontalScroll.tsx | 108 ++--- 4 files changed, 572 insertions(+), 455 deletions(-) diff --git a/app/(auth)/(tabs)/libraries/[libraryId].tsx b/app/(auth)/(tabs)/libraries/[libraryId].tsx index b2acf750..b0f69a54 100644 --- a/app/(auth)/(tabs)/libraries/[libraryId].tsx +++ b/app/(auth)/(tabs)/libraries/[libraryId].tsx @@ -1,9 +1,20 @@ -import { Text } from "@/components/common/Text"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { useLocalSearchParams, useNavigation } from "expo-router"; +import * as ScreenOrientation from "expo-screen-orientation"; +import { useAtom } from "jotai"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from "react"; +import { FlatList, View } from "react-native"; + import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { FilterButton } from "@/components/filters/FilterButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ItemCardText } from "@/components/ItemCardText"; -import { Loader } from "@/components/Loader"; import MoviePoster from "@/components/posters/MoviePoster"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { @@ -16,6 +27,7 @@ import { yearFilterAtom, } from "@/utils/atoms/filters"; import { + BaseItemDto, BaseItemDtoQueryResult, BaseItemKind, } from "@jellyfin/sdk/lib/generated-client/models"; @@ -24,26 +36,13 @@ import { getItemsApi, getUserLibraryApi, } from "@jellyfin/sdk/lib/utils/api"; -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { Stack, useLocalSearchParams, useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { NativeScrollEvent, ScrollView, View } from "react-native"; -import * as ScreenOrientation from "expo-screen-orientation"; +import { FlashList } from "@shopify/flash-list"; +import { opacity } from "react-native-reanimated/lib/typescript/reanimated2/Colors"; +import { Text } from "@/components/common/Text"; -const isCloseToBottom = ({ - layoutMeasurement, - contentOffset, - contentSize, -}: NativeScrollEvent) => { - const paddingToBottom = 200; - return ( - layoutMeasurement.height + contentOffset.y >= - contentSize.height - paddingToBottom - ); -}; +const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter); -const page: React.FC = () => { +const Page = () => { const searchParams = useLocalSearchParams(); const { libraryId } = searchParams as { libraryId: string }; @@ -61,6 +60,21 @@ const page: React.FC = () => { ScreenOrientation.Orientation.PORTRAIT_UP ); + useLayoutEffect(() => { + setSortBy([ + { + key: "SortName", + value: "Name", + }, + ]); + setSortOrder([ + { + key: "Ascending", + value: "Ascending", + }, + ]); + }, []); + useEffect(() => { const subscription = ScreenOrientation.addOrientationChangeListener( (event) => { @@ -68,7 +82,6 @@ const page: React.FC = () => { } ); - // Set the initial orientation ScreenOrientation.getOrientationAsync().then((initialOrientation) => { setOrientation(initialOrientation); }); @@ -76,7 +89,7 @@ const page: React.FC = () => { return () => { ScreenOrientation.removeOrientationChangeListener(subscription); }; - }, [ScreenOrientation]); + }, []); const { data: library } = useQuery({ queryKey: ["library", libraryId], @@ -86,8 +99,7 @@ const page: React.FC = () => { itemId: libraryId, userId: user?.Id, }); - const data = response.data; - return data; + return response.data; }, enabled: !!api && !!user?.Id && !!libraryId, staleTime: 0, @@ -101,7 +113,7 @@ const page: React.FC = () => { }): Promise => { if (!api || !library) return null; - const includeItemTypes: BaseItemKind[] = []; + let includeItemTypes: BaseItemKind[] | undefined = []; switch (library?.CollectionType) { case "movies": @@ -117,15 +129,14 @@ const page: React.FC = () => { includeItemTypes.push("MusicAlbum"); break; default: - includeItemTypes.push("Movie"); - includeItemTypes.push("Series"); + includeItemTypes = undefined; break; } const response = await getItemsApi(api).getItems({ userId: user?.Id, parentId: libraryId, - limit: 66, + limit: 20, startIndex: pageParam, sortBy: [sortBy[0].key, "SortName", "ProductionYear"], sortOrder: [sortOrder[0].key], @@ -154,7 +165,7 @@ const page: React.FC = () => { ] ); - const { data, isFetching, fetchNextPage } = useInfiniteQuery({ + const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ queryKey: [ "library-items", libraryId, @@ -190,177 +201,235 @@ const page: React.FC = () => { }); const flatData = useMemo(() => { - return data?.pages.flatMap((p) => p?.Items) || []; + return ( + (data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) || + [] + ); }, [data]); + const renderItem = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => ( + + + + + + + ), + [orientation] + ); + + const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); + + const ListHeaderComponent = useCallback( + () => ( + + , + }, + { + key: "genre", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: libraryId, + }); + return response.data.Genres || []; + }} + set={setSelectedGenres} + values={selectedGenres} + title="Genres" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "year", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: libraryId, + }); + return response.data.Years || []; + }} + set={setSelectedYears} + values={selectedYears} + title="Years" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => item.includes(search)} + /> + ), + }, + { + key: "tags", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: libraryId, + }); + return response.data.Tags || []; + }} + set={setSelectedTags} + values={selectedTags} + title="Tags" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortBy", + component: ( + sortOptions} + set={setSortBy} + values={sortBy} + title="Sort By" + renderItemLabel={(item) => item.value} + searchFilter={(item, search) => + item.value.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortOrder", + component: ( + sortOrderOptions} + set={setSortOrder} + values={sortOrder} + title="Sort Order" + renderItemLabel={(item) => item.value} + searchFilter={(item, search) => + item.value.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + ]} + renderItem={({ item }) => item.component} + keyExtractor={(item) => item.key} + /> + + ), + [ + libraryId, + api, + user?.Id, + selectedGenres, + setSelectedGenres, + selectedYears, + setSelectedYears, + selectedTags, + setSelectedTags, + sortBy, + setSortBy, + sortOrder, + setSortOrder, + isFetching, + ] + ); + if (!library) return null; return ( - + No results + + } contentInsetAdjustmentBehavior="automatic" - onScroll={({ nativeEvent }) => { - if (isCloseToBottom(nativeEvent)) { + data={flatData} + renderItem={renderItem} + keyExtractor={keyExtractor} + estimatedItemSize={255} + numColumns={ + orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5 + } + onEndReached={() => { + if (hasNextPage) { fetchNextPage(); } }} - scrollEventThrottle={400} - > - - - - - - { - if (!api) return null; - const response = await getFilterApi( - api - ).getQueryFiltersLegacy({ - userId: user?.Id, - parentId: libraryId, - }); - console.log("Resukt:", response.data.Genres || "Nothing..."); - return response.data.Genres || []; - }} - set={setSelectedGenres} - values={selectedGenres} - title="Genres" - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - { - if (!api) return null; - const response = await getFilterApi( - api - ).getQueryFiltersLegacy({ - userId: user?.Id, - parentId: libraryId, - }); - return response.data.Tags || []; - }} - set={setSelectedTags} - values={selectedTags} - title="Tags" - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - { - if (!api) return null; - const response = await getFilterApi( - api - ).getQueryFiltersLegacy({ - userId: user?.Id, - parentId: libraryId, - }); - return ( - response.data.Years?.sort((a, b) => b - a).map((y) => - y.toString() - ) || [] - ); - }} - set={setSelectedYears} - values={selectedYears} - title="Years" - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - { - return sortOptions; - }} - set={setSortBy} - values={sortBy} - title="Sort by" - renderItemLabel={(item) => item.value} - searchFilter={(item, search) => - item.value.toLowerCase().includes(search.toLowerCase()) || - item.value.toLowerCase().includes(search.toLowerCase()) - } - showSearch={false} - /> - { - return sortOrderOptions; - }} - set={setSortOrder} - values={sortOrder} - title="Order by" - renderItemLabel={(item) => item.value} - searchFilter={(item, search) => - item.value.toLowerCase().includes(search.toLowerCase()) || - item.value.toLowerCase().includes(search.toLowerCase()) - } - /> - - - {isFetching && ( - - )} - - - {flatData.map( - (item, index) => - item && ( - - - - - ) - )} - {flatData.length % 3 !== 0 && ( - - )} - - - + onEndReachedThreshold={0.5} + ListHeaderComponent={ListHeaderComponent} + contentContainerStyle={{ paddingBottom: 24 }} + ItemSeparatorComponent={() => ( + + )} + /> ); }; -export default page; +export default React.memo(Page); diff --git a/app/(auth)/collections/[collectionId].tsx b/app/(auth)/collections/[collectionId].tsx index 35e0bdf7..f1b85916 100644 --- a/app/(auth)/collections/[collectionId].tsx +++ b/app/(auth)/collections/[collectionId].tsx @@ -16,6 +16,7 @@ import { yearFilterAtom, } from "@/utils/atoms/filters"; import { + BaseItemDto, BaseItemDtoQueryResult, BaseItemKind, } from "@jellyfin/sdk/lib/generated-client/models"; @@ -24,23 +25,21 @@ import { getItemsApi, getUserLibraryApi, } from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { Stack, useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo } from "react"; -import { NativeScrollEvent, ScrollView, View } from "react-native"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from "react"; +import { FlatList, NativeScrollEvent, ScrollView, View } from "react-native"; +import * as ScreenOrientation from "expo-screen-orientation"; -const isCloseToBottom = ({ - layoutMeasurement, - contentOffset, - contentSize, -}: NativeScrollEvent) => { - const paddingToBottom = 200; - return ( - layoutMeasurement.height + contentOffset.y >= - contentSize.height - paddingToBottom - ); -}; +const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter); const page: React.FC = () => { const searchParams = useLocalSearchParams(); @@ -49,6 +48,9 @@ const page: React.FC = () => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const navigation = useNavigation(); + const [orientation, setOrientation] = useState( + ScreenOrientation.Orientation.PORTRAIT_UP + ); const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); @@ -56,7 +58,7 @@ const page: React.FC = () => { const [sortBy, setSortBy] = useAtom(sortByAtom); const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); - useEffect(() => { + useLayoutEffect(() => { setSortBy([ { key: "PremiereDate", @@ -83,7 +85,7 @@ const page: React.FC = () => { return data; }, enabled: !!api && !!user?.Id && !!collectionId, - staleTime: 0, + staleTime: 60 * 1000, }); useEffect(() => { @@ -130,7 +132,7 @@ const page: React.FC = () => { ] ); - const { data, isFetching, fetchNextPage } = useInfiniteQuery({ + const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ queryKey: [ "collection-items", collection, @@ -165,178 +167,235 @@ const page: React.FC = () => { enabled: !!api && !!user?.Id && !!collection, }); - useEffect(() => { - console.log("Data: ", data); - }, [data]); - - const type = useMemo(() => { - return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null; - }, [data]); - const flatData = useMemo(() => { - return data?.pages.flatMap((p) => p?.Items) || []; + return ( + (data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) || + [] + ); }, [data]); + const renderItem = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => ( + + + + + + + ), + [orientation] + ); + + const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); + + const ListHeaderComponent = useCallback( + () => ( + + , + }, + { + key: "genre", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Genres || []; + }} + set={setSelectedGenres} + values={selectedGenres} + title="Genres" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "year", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Years || []; + }} + set={setSelectedYears} + values={selectedYears} + title="Years" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => item.includes(search)} + /> + ), + }, + { + key: "tags", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Tags || []; + }} + set={setSelectedTags} + values={selectedTags} + title="Tags" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortBy", + component: ( + sortOptions} + set={setSortBy} + values={sortBy} + title="Sort By" + renderItemLabel={(item) => item.value} + searchFilter={(item, search) => + item.value.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortOrder", + component: ( + sortOrderOptions} + set={setSortOrder} + values={sortOrder} + title="Sort Order" + renderItemLabel={(item) => item.value} + searchFilter={(item, search) => + item.value.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + ]} + renderItem={({ item }) => item.component} + keyExtractor={(item) => item.key} + /> + + ), + [ + collectionId, + api, + user?.Id, + selectedGenres, + setSelectedGenres, + selectedYears, + setSelectedYears, + selectedTags, + setSelectedTags, + sortBy, + setSortBy, + sortOrder, + setSortOrder, + isFetching, + ] + ); + if (!collection) return null; return ( - + No results + + } contentInsetAdjustmentBehavior="automatic" - onScroll={({ nativeEvent }) => { - if (isCloseToBottom(nativeEvent)) { + data={flatData} + renderItem={renderItem} + keyExtractor={keyExtractor} + estimatedItemSize={255} + numColumns={ + orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5 + } + onEndReached={() => { + if (hasNextPage) { fetchNextPage(); } }} - scrollEventThrottle={400} - > - - - - - - { - if (!api) return null; - const response = await getFilterApi( - api - ).getQueryFiltersLegacy({ - userId: user?.Id, - includeItemTypes: type ? [type] : [], - parentId: collectionId, - }); - return response.data.Genres || []; - }} - set={setSelectedGenres} - values={selectedGenres} - title="Genres" - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - { - if (!api) return null; - const response = await getFilterApi( - api - ).getQueryFiltersLegacy({ - userId: user?.Id, - includeItemTypes: type ? [type] : [], - parentId: collectionId, - }); - return response.data.Tags || []; - }} - set={setSelectedTags} - values={selectedTags} - title="Tags" - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - { - if (!api) return null; - const response = await getFilterApi( - api - ).getQueryFiltersLegacy({ - userId: user?.Id, - includeItemTypes: type ? [type] : [], - parentId: collectionId, - }); - return ( - response.data.Years?.sort((a, b) => b - a).map((y) => - y.toString() - ) || [] - ); - }} - set={setSelectedYears} - values={selectedYears} - title="Years" - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - { - return sortOptions; - }} - set={setSortBy} - values={sortBy} - title="Sort by" - renderItemLabel={(item) => item.value} - searchFilter={(item, search) => - item.value.toLowerCase().includes(search.toLowerCase()) || - item.value.toLowerCase().includes(search.toLowerCase()) - } - showSearch={false} - /> - { - return sortOrderOptions; - }} - set={setSortOrder} - values={sortOrder} - title="Order by" - renderItemLabel={(item) => item.value} - searchFilter={(item, search) => - item.value.toLowerCase().includes(search.toLowerCase()) || - item.value.toLowerCase().includes(search.toLowerCase()) - } - /> - - - {!type && isFetching && ( - - )} - - - {flatData.map( - (item, index) => - item && ( - - - - - ) - )} - {flatData.length % 3 !== 0 && ( - - )} - - - + onEndReachedThreshold={0.5} + ListHeaderComponent={ListHeaderComponent} + contentContainerStyle={{ paddingBottom: 24 }} + ItemSeparatorComponent={() => ( + + )} + /> ); }; diff --git a/components/common/HorrizontalScroll.tsx b/components/common/HorrizontalScroll.tsx index b5b78c6a..eea84aa3 100644 --- a/components/common/HorrizontalScroll.tsx +++ b/components/common/HorrizontalScroll.tsx @@ -1,5 +1,6 @@ +import { FlashList, FlashListProps } from "@shopify/flash-list"; import React, { useEffect } from "react"; -import { ScrollView, ScrollViewProps, View, ViewStyle } from "react-native"; +import { View, ViewStyle } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue, @@ -8,7 +9,13 @@ import Animated, { import { Loader } from "../Loader"; import { Text } from "./Text"; -interface HorizontalScrollProps extends ScrollViewProps { +type PartialExcept = Partial & Pick; + +interface HorizontalScrollProps + extends PartialExcept< + Omit, "renderItem">, + "estimatedItemSize" + > { data?: T[] | null; renderItem: (item: T, index: number) => React.ReactNode; containerStyle?: ViewStyle; @@ -58,31 +65,31 @@ export function HorizontalScroll({ ); } + const renderFlashListItem = ({ item, index }: { item: T; index: number }) => ( + + {renderItem(item, index)} + + ); + return ( - - - {data.map((item, index) => ( - - {renderItem(item, index)} - - ))} - {data.length === 0 && ( + + ( No data available )} - - + {...props} + /> + ); } diff --git a/components/common/InfiniteHorrizontalScroll.tsx b/components/common/InfiniteHorrizontalScroll.tsx index 9402feff..2b5d27cd 100644 --- a/components/common/InfiniteHorrizontalScroll.tsx +++ b/components/common/InfiniteHorrizontalScroll.tsx @@ -1,11 +1,13 @@ -import React, { useEffect } from "react"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { - NativeScrollEvent, - ScrollView, - ScrollViewProps, - View, - ViewStyle, -} from "react-native"; + BaseItemDto, + BaseItemDtoQueryResult, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { FlashList, FlashListProps } from "@shopify/flash-list"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import React, { useEffect, useMemo } from "react"; +import { View, ViewStyle } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue, @@ -13,16 +15,9 @@ import Animated, { } from "react-native-reanimated"; import { Loader } from "../Loader"; import { Text } from "./Text"; -import { useInfiniteQuery } from "@tanstack/react-query"; -import { - BaseItemDto, - BaseItemDtoQueryResult, -} from "@jellyfin/sdk/lib/generated-client/models"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -interface HorizontalScrollProps extends ScrollViewProps { +interface HorizontalScrollProps + extends Omit, "renderItem" | "data"> { queryFn: ({ pageParam, }: { @@ -38,18 +33,6 @@ interface HorizontalScrollProps extends ScrollViewProps { loading?: boolean; } -const isCloseToBottom = ({ - layoutMeasurement, - contentOffset, - contentSize, -}: NativeScrollEvent) => { - const paddingToBottom = 50; - return ( - layoutMeasurement.height + contentOffset.y >= - contentSize.height - paddingToBottom - ); -}; - export function InfiniteHorizontalScroll({ queryFn, queryKey, @@ -64,7 +47,6 @@ export function InfiniteHorizontalScroll({ }: HorizontalScrollProps): React.ReactElement { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const navigation = useNavigation(); const animatedOpacity = useSharedValue(0); const animatedStyle1 = useAnimatedStyle(() => { @@ -73,7 +55,7 @@ export function InfiniteHorizontalScroll({ }; }); - const { data, isFetching, fetchNextPage } = useInfiniteQuery({ + const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ queryKey, queryFn, getNextPageParam: (lastPage, pages) => { @@ -100,6 +82,13 @@ export function InfiniteHorizontalScroll({ enabled: !!api && !!user?.Id, }); + const flatData = useMemo(() => { + return ( + (data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) || + [] + ); + }, [data]); + useEffect(() => { if (data) { animatedOpacity.value = 1; @@ -124,41 +113,34 @@ export function InfiniteHorizontalScroll({ } return ( - { - if (isCloseToBottom(nativeEvent)) { - fetchNextPage(); - } - }} - scrollEventThrottle={400} - style={containerStyle} - contentContainerStyle={contentContainerStyle} - showsHorizontalScrollIndicator={false} - {...props} - > - - {data?.pages - .flatMap((page) => page?.Items) - .map( - (item, index) => - item && ( - - {renderItem(item, index)} - - ) - )} - {data?.pages.flatMap((page) => page?.Items).length === 0 && ( + + ( + + {renderItem(item, index)} + + )} + estimatedItemSize={height} + horizontal + onEndReached={() => { + if (hasNextPage) { + fetchNextPage(); + } + }} + onEndReachedThreshold={0.5} + contentContainerStyle={{ + paddingHorizontal: 16, + ...contentContainerStyle, + }} + showsHorizontalScrollIndicator={false} + ListEmptyComponent={ No data available - )} - - + } + {...props} + /> + ); }