From 75ed92483003bc08bd4be12e158109453d551205 Mon Sep 17 00:00:00 2001 From: Jan Ole Schmidt <jan.ole.schmidt@holi.team> Date: Wed, 29 Jan 2025 10:17:18 +0000 Subject: [PATCH] HOLI-10877: implement scroll back to top button on home feed --- core/screens/homeFeed/Feed.tsx | 8 +- core/tracking/TrackableFlatList.shared.tsx | 6 +- core/tracking/TrackableFlatList.tsx | 218 +++++++++++---------- core/tracking/TrackableFlatList.web.tsx | 20 +- 4 files changed, 140 insertions(+), 112 deletions(-) diff --git a/core/screens/homeFeed/Feed.tsx b/core/screens/homeFeed/Feed.tsx index be2ad0d6d3..f77803ef1a 100644 --- a/core/screens/homeFeed/Feed.tsx +++ b/core/screens/homeFeed/Feed.tsx @@ -1,5 +1,5 @@ import { withProfiler } from '@sentry/react' -import React, { memo, useContext, useMemo, useCallback } from 'react' +import React, { memo, useContext, useMemo, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { StyleSheet, View, RefreshControl } from 'react-native' import { GestureHandlerRootView } from 'react-native-gesture-handler' @@ -34,6 +34,7 @@ import type { VolunteeringReco } from '@holi-apps/volunteering/types' import VolunteeringCard from '@holi-apps/volunteering/components/VolunteeringCard' import { useFeedQuery } from '@holi/core/screens/homeFeed/useFeedQuery' import InsightCard from '@holi/core/screens/insights/components/InsightCard' +import { useScrollToTop } from '@react-navigation/native' const surveyUrl = 'https://tally.so/r/nrdGxX' @@ -48,6 +49,8 @@ const HomeFeed = () => { const { initialLoading, canLoadMore, combinedFeedData, fetchMore, refetch, refreshing, insightsCreators } = useFeedQuery() + const ref = useRef(null) + const keyExtractor = useCallback( ( item: FeedItem & { @@ -140,6 +143,8 @@ const HomeFeed = () => { ) } + useScrollToTop(ref) + useIsomorphicLayoutEffect(() => { setScreenOptions({ title: '', @@ -209,6 +214,7 @@ const HomeFeed = () => { trackOnce={true} withFlashList={false} estimatedItemSize={260} + ref={ref} /> </ErrorBoundary> </View> diff --git a/core/tracking/TrackableFlatList.shared.tsx b/core/tracking/TrackableFlatList.shared.tsx index 7fbdf3a769..7f6557a82c 100644 --- a/core/tracking/TrackableFlatList.shared.tsx +++ b/core/tracking/TrackableFlatList.shared.tsx @@ -1,7 +1,9 @@ -import type { FlatListProps } from 'react-native' +import type { FlatList, FlatListProps } from 'react-native' import type { TrackingEvent } from '@holi/core/tracking/events' +import type { FlashList } from '@shopify/flash-list' + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type TrackableFlatListItem = any @@ -25,6 +27,8 @@ export type TrackableFlatListParams = { // a flag that defines whether an item should be tracked only once or every time it's viewed trackOnce?: boolean + + ref?: React.Ref<FlatList<TrackableFlatListItem> | FlashList<TrackableFlatListItem>> } export type TrackableFlatListProps = FlatListProps<TrackableFlatListItem> & diff --git a/core/tracking/TrackableFlatList.tsx b/core/tracking/TrackableFlatList.tsx index 8524b39e25..8feffd71b4 100644 --- a/core/tracking/TrackableFlatList.tsx +++ b/core/tracking/TrackableFlatList.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useCallback, useRef, useState } from 'react' +import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react' import { FlatList, type LayoutChangeEvent } from 'react-native' import { FlashList, type FlashListProps } from '@shopify/flash-list' import ScrollBasedCallbackInvocation from '@holi/core/components/ScrollBasedCallbackInvocation' @@ -11,124 +11,134 @@ import { } from '@holi/core/tracking/TrackableFlatList.shared' import useTracking from '@holi/core/tracking/hooks/useTracking' -const TrackableFlatList = ({ - itemVisiblePercentThreshold, - minimumViewTime, - listItemTrackingEvent, - listItemIdentifier, - trackOnce, - withFlashList = false, - estimatedItemSize, - ...restProps -}: TrackableFlatListProps) => { - const { track } = useTracking() - - const list = useRef<FlatList | FlashList<TrackableFlatListItem>>(null) - - const [listBelowFoldThreshold, setListBelowFoldThreshold] = useState(0) - const [isListTrackingInitialized, setIsListTrackingInitialized] = useState(false) - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, setTrackedItems] = useState<Record<string, boolean>>({}) +const TrackableFlatList = forwardRef( + ( + { + itemVisiblePercentThreshold, + minimumViewTime, + listItemTrackingEvent, + listItemIdentifier, + trackOnce, + withFlashList = false, + estimatedItemSize, + ...restProps + }: TrackableFlatListProps, + ref + ) => { + const { track } = useTracking() + + const listRef = useRef<FlatList | FlashList<TrackableFlatListItem>>(null) + + // Expose the listRef to the parent component to allow for scrollToTop + useImperativeHandle(ref, () => listRef.current as FlatList | FlashList<TrackableFlatListItem>) + + const [listBelowFoldThreshold, setListBelowFoldThreshold] = useState(0) + const [isListTrackingInitialized, setIsListTrackingInitialized] = useState(false) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, setTrackedItems] = useState<Record<string, boolean>>({}) + + const trackItem = (item: TrackableFlatListItem) => { + track(listItemTrackingEvent(item)) + } - const trackItem = (item: TrackableFlatListItem) => { - track(listItemTrackingEvent(item)) - } + const viewabilityConfig: CustomViewabilityConfig = { + ...defaultViewabilityConfig, + minimumViewTime: minimumViewTime ?? defaultViewabilityConfig.minimumViewTime, + itemVisiblePercentThreshold: itemVisiblePercentThreshold ?? defaultViewabilityConfig.itemVisiblePercentThreshold, + } - const viewabilityConfig: CustomViewabilityConfig = { - ...defaultViewabilityConfig, - minimumViewTime: minimumViewTime ?? defaultViewabilityConfig.minimumViewTime, - itemVisiblePercentThreshold: itemVisiblePercentThreshold ?? defaultViewabilityConfig.itemVisiblePercentThreshold, - } + const onViewableItemsChanged: TrackableFlatListProps['onViewableItemsChanged'] = (info) => { + // Filter out "changed" elements that are not visible on the screen AND have been already tracked + const visibleItems = info.changed.filter((entry) => entry.isViewable) - const onViewableItemsChanged: TrackableFlatListProps['onViewableItemsChanged'] = (info) => { - // Filter out "changed" elements that are not visible on the screen AND have been already tracked - const visibleItems = info.changed.filter((entry) => entry.isViewable) + // Early return if no new viewed elements available + if (!visibleItems.length) return - // Early return if no new viewed elements available - if (!visibleItems.length) return + // If "trackOnce" is false, we loop through all "visibleItems" and trigger tracking callback for all array elements + if (!trackOnce) { + visibleItems.forEach(({ item }) => trackItem(item)) + return + } - // If "trackOnce" is false, we loop through all "visibleItems" and trigger tracking callback for all array elements - if (!trackOnce) { - visibleItems.forEach(({ item }) => trackItem(item)) - return - } + // If "trackOnce" is true, we save already tracked items and trigger tracking callback only for the new ones + setTrackedItems((prevTrackedItems) => { + const newVisibleItems = visibleItems.reduce((acc, { item }) => { + const id = listItemIdentifier(item) - // If "trackOnce" is true, we save already tracked items and trigger tracking callback only for the new ones - setTrackedItems((prevTrackedItems) => { - const newVisibleItems = visibleItems.reduce((acc, { item }) => { - const id = listItemIdentifier(item) + // Send the tracking event for the item if it hasn't been tracked yet + if (!prevTrackedItems[item[id]]) { + trackItem(item) + } - // Send the tracking event for the item if it hasn't been tracked yet - if (!prevTrackedItems[item[id]]) { - trackItem(item) - } + return { + ...acc, + [item[id]]: true, + } + }, {}) + // Update state with the new tracked items return { - ...acc, - [item[id]]: true, + ...prevTrackedItems, + ...newVisibleItems, } - }, {}) + }) + } - // Update state with the new tracked items - return { - ...prevTrackedItems, - ...newVisibleItems, - } - }) - } + // To precisely know when to trigger "onListIsInView", we need to calculate "belowFoldThreshold". + // "itemVisiblePercentThreshold" should be considered while calculating this value. + const onListLayout = (event: LayoutChangeEvent) => { + const listHeight = event.nativeEvent.layout.height + const itemVisiblePercentThreshold = viewabilityConfig.itemVisiblePercentThreshold + const listBelowFoldThreshold = Math.round((listHeight * itemVisiblePercentThreshold) / 100) - // To precisely know when to trigger "onListIsInView", we need to calculate "belowFoldThreshold". - // "itemVisiblePercentThreshold" should be considered while calculating this value. - const onListLayout = (event: LayoutChangeEvent) => { - const listHeight = event.nativeEvent.layout.height - const itemVisiblePercentThreshold = viewabilityConfig.itemVisiblePercentThreshold - const listBelowFoldThreshold = Math.round((listHeight * itemVisiblePercentThreshold) / 100) + setListBelowFoldThreshold(listBelowFoldThreshold) + } - setListBelowFoldThreshold(listBelowFoldThreshold) - } + // By default, "onViewableItemsChanged" is triggered on first render for all initially visible elements. + // To prevent that, we need to wait until the list actually appears on the screen for the first time. + // After tracking is initialized, we can hide ScrollBasedCallbackInvocation to avoid unnecessary scroll event listeners. + const onListIsInView = () => { + listRef.current?.recordInteraction() + setIsListTrackingInitialized(true) + } - // By default, "onViewableItemsChanged" is triggered on first render for all initially visible elements. - // To prevent that, we need to wait until the list actually appears on the screen for the first time. - // After tracking is initialized, we can hide ScrollBasedCallbackInvocation to avoid unnecessary scroll event listeners. - const onListIsInView = () => { - list.current?.recordInteraction() - setIsListTrackingInitialized(true) + // According to RN, the following callback cannot be updated after the initial render, therefore we keep deps as [] + // eslint-disable-next-line react-hooks/exhaustive-deps + const onViewableItemsChangedCallback = useCallback(onViewableItemsChanged, [onViewableItemsChanged]) + + return ( + <> + {!isListTrackingInitialized ? ( + <ScrollBasedCallbackInvocation + callback={onListIsInView} + belowFoldThreshold={listBelowFoldThreshold} + placeholder={<></>} + /> + ) : null} + {withFlashList ? ( + <FlashList + {...(restProps as FlashListProps<TrackableFlatListItem>)} + ref={listRef as React.RefObject<FlashList<TrackableFlatListItem>>} + onLayout={onListLayout} + viewabilityConfig={viewabilityConfig} + onViewableItemsChanged={onViewableItemsChangedCallback} + estimatedItemSize={estimatedItemSize} + /> + ) : ( + <FlatList + {...restProps} + ref={listRef as React.RefObject<FlatList<TrackableFlatListItem>>} + onLayout={onListLayout} + viewabilityConfig={viewabilityConfig} + onViewableItemsChanged={onViewableItemsChangedCallback} + /> + )} + </> + ) } +) - // According to RN, the following callback cannot be updated after the initial render, therefore we keep deps as [] - // eslint-disable-next-line react-hooks/exhaustive-deps - const onViewableItemsChangedCallback = useCallback(onViewableItemsChanged, []) - - return ( - <> - {!isListTrackingInitialized ? ( - <ScrollBasedCallbackInvocation - callback={onListIsInView} - belowFoldThreshold={listBelowFoldThreshold} - placeholder={<></>} - /> - ) : null} - {withFlashList ? ( - <FlashList - {...(restProps as FlashListProps<TrackableFlatListItem>)} - ref={list as React.RefObject<FlashList<TrackableFlatListItem>>} - onLayout={onListLayout} - viewabilityConfig={viewabilityConfig} - onViewableItemsChanged={onViewableItemsChangedCallback} - estimatedItemSize={estimatedItemSize} - /> - ) : ( - <FlatList - {...restProps} - ref={list as React.RefObject<FlatList>} - onLayout={onListLayout} - viewabilityConfig={viewabilityConfig} - onViewableItemsChanged={onViewableItemsChangedCallback} - /> - )} - </> - ) -} +TrackableFlatList.displayName = 'TrackableFlatList' export default TrackableFlatList diff --git a/core/tracking/TrackableFlatList.web.tsx b/core/tracking/TrackableFlatList.web.tsx index ff1335ff3f..964aa51bb6 100644 --- a/core/tracking/TrackableFlatList.web.tsx +++ b/core/tracking/TrackableFlatList.web.tsx @@ -1,10 +1,10 @@ -import React, { type PropsWithChildren, ReactNode } from 'react' -import { IntersectionObserverProps, useInView } from 'react-intersection-observer' -import { FlatList, ListRenderItem } from 'react-native' +import React, { type PropsWithChildren, type ReactNode, useRef } from 'react' +import { type IntersectionObserverProps, useInView } from 'react-intersection-observer' +import { FlatList, type ListRenderItem } from 'react-native' import { - TrackableFlatListItem as TrackableFlatListItemType, - TrackableFlatListProps, + type TrackableFlatListItem as TrackableFlatListItemType, + type TrackableFlatListProps, defaultViewabilityConfig, } from '@holi/core/tracking/TrackableFlatList.shared' import useTracking from '@holi/core/tracking/hooks/useTracking' @@ -47,6 +47,8 @@ const TrackableFlatList = ({ renderItem, ...restProps }: TrackableFlatListProps) => { + const flatListRef = useRef<FlatList<TrackableFlatListItemType>>(null) + const { track } = useTracking() const onInView = (item: TrackableFlatListItemType) => { @@ -65,7 +67,13 @@ const TrackableFlatList = ({ </TrackableFlatListItem> ) - return <FlatList {...restProps} renderItem={extendedRenderItem} /> + return ( + <FlatList + {...restProps} + ref={flatListRef} + renderItem={extendedRenderItem as ListRenderItem<TrackableFlatListItemType>} + /> + ) } export default TrackableFlatList -- GitLab