From 8760edc9242e945baf027dd4410fd3945a5a473b Mon Sep 17 00:00:00 2001 From: Alessandro Dell'Oste Date: Thu, 16 May 2024 15:52:48 +0200 Subject: [PATCH] fix: [IOPAE-1164] Fix infinite scroll in `InstitutionServicesScreen` (#5774) ## Short description This PR fixes the infinite scroll in `InstitutionServicesScreen` by removing external `ScrollView` because the `FlatList` inside animated `ScrollView` causes infinite triggering of the `onEndReached` method without even scrolling. ## How to test Using `io-dev-api-server`, navigate to the services tab and tap an institution. Check that infinite scroll works correctly and that `onEndReached` is triggered only when scrolling to the bottom of the list. --- .../InstitutionServicesScreenComponent.tsx | 98 ---------- .../components/ServiceListItemSkeleton.tsx | 14 -- .../components/ServiceListSkeleton.tsx | 33 ++++ .../institution/hooks/useServicesFetcher.tsx | 55 ++++-- .../screens/InstitutionServicesScreen.tsx | 182 ++++++++++++------ 5 files changed, 190 insertions(+), 192 deletions(-) delete mode 100644 ts/features/services/institution/components/InstitutionServicesScreenComponent.tsx delete mode 100644 ts/features/services/institution/components/ServiceListItemSkeleton.tsx create mode 100644 ts/features/services/institution/components/ServiceListSkeleton.tsx diff --git a/ts/features/services/institution/components/InstitutionServicesScreenComponent.tsx b/ts/features/services/institution/components/InstitutionServicesScreenComponent.tsx deleted file mode 100644 index a934377c9cb..00000000000 --- a/ts/features/services/institution/components/InstitutionServicesScreenComponent.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React, { useLayoutEffect, useMemo } from "react"; -import { RefreshControl, RefreshControlProps, StyleSheet } from "react-native"; -import Animated, { - useAnimatedScrollHandler, - useSharedValue -} from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useHeaderHeight } from "@react-navigation/elements"; -import { IOVisualCostants } from "@pagopa/io-app-design-system"; -import { useHeaderSecondLevel } from "../../../../hooks/useHeaderSecondLevel"; -import { useIONavigation } from "../../../../navigation/params/AppParamsList"; - -type InstitutionServicesScreenComponentProps = { - children: React.ReactNode; - goBack?: () => void; - title?: string; -} & Pick; - -const scrollTriggerOffsetValue: number = 88; - -const styles = StyleSheet.create({ - scrollContentContainer: { - flexGrow: 1 - }, - refreshControlContainer: { - zIndex: 1 - } -}); - -export const InstitutionServicesScreenComponent = ({ - children, - refreshing, - goBack, - onRefresh, - title = "" -}: InstitutionServicesScreenComponentProps) => { - const navigation = useIONavigation(); - const headerHeight = useHeaderHeight(); - - const safeAreaInsets = useSafeAreaInsets(); - - const scrollTranslationY = useSharedValue(0); - - const bottomMargin: number = useMemo( - () => - safeAreaInsets.bottom === 0 - ? IOVisualCostants.appMarginDefault - : safeAreaInsets.bottom, - [safeAreaInsets] - ); - - const scrollHandler = useAnimatedScrollHandler(event => { - // eslint-disable-next-line functional/immutable-data - scrollTranslationY.value = event.contentOffset.y; - }); - - useHeaderSecondLevel({ - goBack, - title, - supportRequest: true, - transparent: true, - scrollValues: { - triggerOffset: scrollTriggerOffsetValue, - contentOffsetY: scrollTranslationY - } - }); - - useLayoutEffect(() => { - navigation.setOptions({ - headerShown: true - }); - }, [navigation]); - - const refreshControl = ( - - ); - - return ( - - {children} - - ); -}; diff --git a/ts/features/services/institution/components/ServiceListItemSkeleton.tsx b/ts/features/services/institution/components/ServiceListItemSkeleton.tsx deleted file mode 100644 index b35be1194b0..00000000000 --- a/ts/features/services/institution/components/ServiceListItemSkeleton.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; -import { View } from "react-native"; -import { IOListItemStyles, IOStyles } from "@pagopa/io-app-design-system"; -import Placeholder from "rn-placeholder"; - -export const ServiceListItemSkeleton = () => ( - - - - - - - -); diff --git a/ts/features/services/institution/components/ServiceListSkeleton.tsx b/ts/features/services/institution/components/ServiceListSkeleton.tsx new file mode 100644 index 00000000000..17f4a709af3 --- /dev/null +++ b/ts/features/services/institution/components/ServiceListSkeleton.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { View } from "react-native"; +import { + Divider, + IOListItemStyles, + IOStyles +} from "@pagopa/io-app-design-system"; +import Placeholder from "rn-placeholder"; + +const ServiceListItemSkeleton = () => ( + + + + + + + +); + +type ServiceListSkeletonProps = { + size?: number; +}; + +export const ServiceListSkeleton = ({ size = 3 }: ServiceListSkeletonProps) => ( + + {Array.from({ length: size }).map((_, index) => ( + + + {index < size - 1 ? : undefined} + + ))} + +); diff --git a/ts/features/services/institution/hooks/useServicesFetcher.tsx b/ts/features/services/institution/hooks/useServicesFetcher.tsx index 4f66f401772..e5f2140e4e7 100644 --- a/ts/features/services/institution/hooks/useServicesFetcher.tsx +++ b/ts/features/services/institution/hooks/useServicesFetcher.tsx @@ -1,3 +1,4 @@ +import { useCallback, useEffect, useState } from "react"; import { useIODispatch, useIOSelector } from "../../../../store/hooks"; import { paginatedServicesGet } from "../store/actions"; import { @@ -9,7 +10,7 @@ import { paginatedServicesSelector } from "../store/reducers"; -const LIMIT: number = 10; +const LIMIT: number = 20; export const useServicesFetcher = (institutionId: string) => { const dispatch = useIODispatch(); @@ -21,27 +22,44 @@ export const useServicesFetcher = (institutionId: string) => { const isUpdating = useIOSelector(isUpdatingPaginatedServicesSelector); const isError = useIOSelector(isErrorPaginatedServicesSelector); - const fetchPage = (page: number) => { - if (!isLoading && !isUpdating) { - dispatch( - paginatedServicesGet.request({ - institutionId, - offset: page * LIMIT, - limit: LIMIT - }) - ); - } - }; + const [isRefreshing, setIsRefreshing] = useState(false); - const fetchServices = (page: number) => { - if (isLastPage) { - return; + useEffect(() => { + if (isRefreshing && !isUpdating) { + setIsRefreshing(false); } + }, [isRefreshing, isUpdating]); - fetchPage(page); - }; + const fetchPage = useCallback( + (page: number) => { + if (!isLoading && !isUpdating) { + dispatch( + paginatedServicesGet.request({ + institutionId, + offset: page * LIMIT, + limit: LIMIT + }) + ); + } + }, + [dispatch, institutionId, isLoading, isUpdating] + ); + + const fetchServices = useCallback( + (page: number) => { + if (isLastPage) { + return; + } + + fetchPage(page); + }, + [isLastPage, fetchPage] + ); - const refreshServices = () => fetchPage(0); + const refreshServices = useCallback(() => { + setIsRefreshing(true); + fetchPage(0); + }, [fetchPage]); return { currentPage, @@ -49,6 +67,7 @@ export const useServicesFetcher = (institutionId: string) => { isError, isLoading, isUpdating, + isRefreshing, fetchServices, refreshServices }; diff --git a/ts/features/services/institution/screens/InstitutionServicesScreen.tsx b/ts/features/services/institution/screens/InstitutionServicesScreen.tsx index 34a6887b407..19db7e1f654 100644 --- a/ts/features/services/institution/screens/InstitutionServicesScreen.tsx +++ b/ts/features/services/institution/screens/InstitutionServicesScreen.tsx @@ -1,28 +1,34 @@ -import React, { useCallback, useEffect } from "react"; -import { FlatList, ListRenderItemInfo } from "react-native"; +import React, { useCallback, useEffect, useLayoutEffect } from "react"; +import { ListRenderItemInfo, RefreshControl, StyleSheet } from "react-native"; +import Animated, { + useAnimatedScrollHandler, + useSharedValue +} from "react-native-reanimated"; import { Divider, IOStyles, IOToast, + IOVisualCostants, ListItemNav, VSpacer } from "@pagopa/io-app-design-system"; +import { useHeaderHeight } from "@react-navigation/elements"; +import { ServiceId } from "../../../../../definitions/backend/ServiceId"; import { ServiceMinified } from "../../../../../definitions/services/ServiceMinified"; +import { useHeaderSecondLevel } from "../../../../hooks/useHeaderSecondLevel"; import I18n from "../../../../i18n"; import { IOStackNavigationRouteProps } from "../../../../navigation/params/AppParamsList"; +import { useIODispatch } from "../../../../store/hooks"; import { useOnFirstRender } from "../../../../utils/hooks/useOnFirstRender"; +import { ServicesHeaderSection } from "../../common/components/ServicesHeaderSection"; import { useFirstRender } from "../../common/hooks/useFirstRender"; import { ServicesParamsList } from "../../common/navigation/params"; +import { SERVICES_ROUTES } from "../../common/navigation/routes"; import { getLogoForInstitution } from "../../common/utils"; -import { ServiceListItemSkeleton } from "../components/ServiceListItemSkeleton"; -import { InstitutionServicesScreenComponent } from "../components/InstitutionServicesScreenComponent"; +import { InstitutionServicesFailure } from "../components/InstitutionServicesFailure"; +import { ServiceListSkeleton } from "../components/ServiceListSkeleton"; import { useServicesFetcher } from "../hooks/useServicesFetcher"; -import { SERVICES_ROUTES } from "../../common/navigation/routes"; -import { ServiceId } from "../../../../../definitions/backend/ServiceId"; -import { ServicesHeaderSection } from "../../common/components/ServicesHeaderSection"; -import { useIODispatch } from "../../../../store/hooks"; import { paginatedServicesGet } from "../store/actions"; -import { InstitutionServicesFailure } from "../components/InstitutionServicesFailure"; export type InstitutionServicesScreenRouteParams = { institutionId: string; @@ -34,6 +40,17 @@ type InstitutionServicesScreen = IOStackNavigationRouteProps< "INSTITUTION_SERVICES" >; +const scrollTriggerOffsetValue: number = 88; + +const styles = StyleSheet.create({ + contentContainer: { + flexGrow: 1 + }, + refreshControlContainer: { + zIndex: 1 + } +}); + export const InstitutionServicesScreen = ({ navigation, route @@ -43,6 +60,9 @@ export const InstitutionServicesScreen = ({ const dispatch = useIODispatch(); const isFirstRender = useFirstRender(); + const headerHeight = useHeaderHeight(); + const scrollTranslationY = useSharedValue(0); + const { currentPage, data, @@ -50,6 +70,7 @@ export const InstitutionServicesScreen = ({ isError, isLoading, isUpdating, + isRefreshing, refreshServices } = useServicesFetcher(institutionId); @@ -66,6 +87,28 @@ export const InstitutionServicesScreen = ({ navigation.goBack(); }, [dispatch, navigation]); + useHeaderSecondLevel({ + goBack, + title: institutionName, + supportRequest: true, + transparent: true, + scrollValues: { + triggerOffset: scrollTriggerOffsetValue, + contentOffsetY: scrollTranslationY + } + }); + + useLayoutEffect(() => { + navigation.setOptions({ + headerShown: true + }); + }, [navigation]); + + const scrollHandler = useAnimatedScrollHandler(event => { + // eslint-disable-next-line functional/immutable-data + scrollTranslationY.value = event.contentOffset.y; + }); + const navigateToServiceDetails = useCallback( (service: ServiceMinified) => navigation.navigate(SERVICES_ROUTES.SERVICES_NAVIGATOR, { @@ -93,73 +136,88 @@ export const InstitutionServicesScreen = ({ [navigateToServiceDetails] ); - const renderListFooterComponent = useCallback(() => { - if (isUpdating && currentPage > 0) { + const renderListEmptyComponent = useCallback(() => { + if (isFirstRender || isLoading) { return ( <> - - - - - + + + + ); + } + return <>; + }, [isFirstRender, isLoading]); + + const renderListHeaderComponent = useCallback(() => { + if (isFirstRender || isLoading) { + return ( + <> + + ); } - return ; - }, [currentPage, isUpdating]); - if (isFirstRender || isLoading) { return ( - - - - } - contentContainerStyle={IOStyles.horizontalContentPadding} - data={Array.from({ length: 5 })} - keyExtractor={(_, index) => `service-placeholder-${index}`} - renderItem={() => } - scrollEnabled={false} - testID="intitution-services-list-skeleton" + <> + - + + ); - } + }, [data?.count, isFirstRender, isLoading, institutionId, institutionName]); + + const renderListFooterComponent = useCallback(() => { + if (isUpdating && !isRefreshing) { + return ( + <> + + + + ); + } + return ; + }, [isUpdating, isRefreshing]); if (!data && isError) { return fetchServices(0)} />; } - return ( - - - - } - ListFooterComponent={renderListFooterComponent} - contentContainerStyle={IOStyles.horizontalContentPadding} - data={data?.services || []} - keyExtractor={(item, index) => `service-${item.id}-${index}`} - renderItem={renderItem} - onEndReached={handleEndReached} - onEndReachedThreshold={0.001} - scrollEnabled={false} - testID="intitution-services-list" - /> - + progressViewOffset={headerHeight} + refreshing={isRefreshing} + style={styles.refreshControlContainer} + /> + ); + + return ( + } + ListEmptyComponent={renderListEmptyComponent} + ListHeaderComponent={renderListHeaderComponent} + ListHeaderComponentStyle={{ + marginHorizontal: -IOVisualCostants.appMarginDefault + }} + ListFooterComponent={renderListFooterComponent} + contentContainerStyle={[ + styles.contentContainer, + IOStyles.horizontalContentPadding + ]} + data={data?.services || []} + keyExtractor={(item, index) => `service-${item.id}-${index}`} + onEndReached={handleEndReached} + onEndReachedThreshold={0.001} + renderItem={renderItem} + refreshControl={refreshControl} + testID="intitution-services-list" + /> ); };