diff --git a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx new file mode 100644 index 0000000000000..8574fb21a1b2f --- /dev/null +++ b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx @@ -0,0 +1,95 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useEffect, useRef} from 'react'; +import type {FlatList as RNFlatList} from 'react-native'; +import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; +import FlatList from '..'; +import type {BaseFlatListWithScrollKeyProps} from './types'; + +/** + * FlatList component that handles initial scroll key. + */ +function BaseFlatListWithScrollKey(props: BaseFlatListWithScrollKeyProps, ref: ForwardedRef) { + const { + shouldEnableAutoScrollToTopThreshold, + initialScrollKey, + data, + onStartReached, + renderItem, + keyExtractor, + onViewableItemsChanged, + onContentSizeChange, + onScrollBeginDrag, + onWheel, + onTouchStartCapture, + ...rest + } = props; + const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, handleRenderItem, listRef} = useFlatListScrollKey({ + data, + keyExtractor, + initialScrollKey, + inverted: false, + onStartReached, + shouldEnableAutoScrollToTopThreshold, + renderItem, + ref, + }); + + const isLoadingData = useRef(true); + const isInitialDataRef = useRef(isInitialData); + // Determine whether the user has interacted with the FlatList, + // ensuring that handleStartReached is only triggered within onViewableItemsChanged after user interaction. + const hasUserInteractedRef = useRef(false); + + useEffect(() => { + isInitialDataRef.current = isInitialData; + + if (!isLoadingData.current || data.length > displayedData.length) { + return; + } + + isLoadingData.current = false; + }, [data.length, displayedData.length, isInitialData]); + + return ( + onContentSizeChange?.(width, height, isInitialData)} + onViewableItemsChanged={(info) => { + onViewableItemsChanged?.(info); + + if (!hasUserInteractedRef.current || isInitialDataRef.current || !isLoadingData.current || info.viewableItems.length <= 0 || info.viewableItems.at(0)?.index !== 0) { + return; + } + handleStartReached({distanceFromStart: 0}); + }} + onScrollBeginDrag={(e) => { + onScrollBeginDrag?.(e); + hasUserInteractedRef.current = true; + }} + onWheel={(e) => { + onWheel?.(e); + hasUserInteractedRef.current = true; + }} + onTouchStartCapture={(e) => { + onTouchStartCapture?.(e); + hasUserInteractedRef.current = true; + }} + /> + ); +} + +BaseFlatListWithScrollKey.displayName = 'BaseFlatListWithScrollKey'; + +export default forwardRef(BaseFlatListWithScrollKey); diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx new file mode 100644 index 0000000000000..ec38ec31e1c34 --- /dev/null +++ b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx @@ -0,0 +1,66 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useRef} from 'react'; +import type {LayoutChangeEvent, FlatList as RNFlatList} from 'react-native'; +import mergeRefs from '@libs/mergeRefs'; +import BaseFlatListWithScrollKey from './BaseFlatListWithScrollKey'; +import type {FlatListWithScrollKeyProps} from './types'; + +/** + * FlatList component that handles initial scroll key. + */ +function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { + const {initialScrollKey, onLayout, onContentSizeChange, ...rest} = props; + + const flatListHeight = useRef(0); + const shouldScrollToEndRef = useRef(false); + const listRef = useRef(null); + + const onLayoutInner = useCallback( + (event: LayoutChangeEvent) => { + onLayout?.(event); + + flatListHeight.current = event.nativeEvent.layout.height; + }, + [onLayout], + ); + + const onContentSizeChangeInner = useCallback( + (w: number, h: number, isInitialData?: boolean) => { + onContentSizeChange?.(w, h); + + if (!initialScrollKey) { + return; + } + // Since the ListHeaderComponent is only rendered after the data has finished rendering, iOS locks the entire current viewport. + // As a result, the viewport does not automatically scroll down to fill the gap at the bottom. + // We will check during the initial render (isInitialData === true). If the content height is less than the layout height, + // it means there is a gap at the bottom. + // Then, once the render is complete (isInitialData === false), we will manually scroll to the bottom. + if (shouldScrollToEndRef.current) { + requestAnimationFrame(() => { + listRef.current?.scrollToEnd(); + }); + shouldScrollToEndRef.current = false; + } + if (h < flatListHeight.current && isInitialData) { + shouldScrollToEndRef.current = true; + } + }, + [onContentSizeChange, initialScrollKey], + ); + + return ( + + ); +} + +FlatListWithScrollKey.displayName = 'FlatListWithScrollKey'; + +export default forwardRef(FlatListWithScrollKey); diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx new file mode 100644 index 0000000000000..1f228527c541e --- /dev/null +++ b/src/components/FlatList/FlatListWithScrollKey/index.tsx @@ -0,0 +1,22 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef} from 'react'; +import type {FlatList as RNFlatList} from 'react-native'; +import BaseFlatListWithScrollKey from './BaseFlatListWithScrollKey'; +import type {FlatListWithScrollKeyProps} from './types'; + +/** + * FlatList component that handles initial scroll key. + */ +function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { + return ( + + ); +} + +FlatListWithScrollKey.displayName = 'FlatListWithScrollKey'; + +export default forwardRef(FlatListWithScrollKey); diff --git a/src/components/FlatList/FlatListWithScrollKey/types.ts b/src/components/FlatList/FlatListWithScrollKey/types.ts new file mode 100644 index 0000000000000..0c6211fdc4727 --- /dev/null +++ b/src/components/FlatList/FlatListWithScrollKey/types.ts @@ -0,0 +1,14 @@ +import type {FlatListProps, ListRenderItem} from 'react-native'; + +type BaseFlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex' | 'onContentSizeChange'> & { + data: T[]; + initialScrollKey?: string | null | undefined; + keyExtractor: (item: T, index: number) => string; + shouldEnableAutoScrollToTopThreshold?: boolean; + renderItem: ListRenderItem; + onContentSizeChange?: (contentWidth: number, contentHeight: number, isInitialData?: boolean) => void; +}; + +type FlatListWithScrollKeyProps = Omit, 'onContentSizeChange'> & Pick, 'onContentSizeChange'>; + +export type {FlatListWithScrollKeyProps, BaseFlatListWithScrollKeyProps}; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index af1eeb21ead2f..3a880b638fd25 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -1,10 +1,8 @@ import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList, ScrollViewProps} from 'react-native'; +import React from 'react'; +import type {FlatListProps, ListRenderItem, FlatList as RNFlatList} from 'react-native'; import FlatList from '@components/FlatList'; -import usePrevious from '@hooks/usePrevious'; -import getInitialPaginationSize from './getInitialPaginationSize'; -import RenderTaskQueue from './RenderTaskQueue'; +import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; // Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 function defaultKeyExtractor(item: T | {key: string} | {id: string}, index: number): string { @@ -28,108 +26,17 @@ type BaseInvertedFlatListProps = Omit, 'data' | 'renderItem' shouldDisableVisibleContentPosition?: boolean; }; -const AUTOSCROLL_TO_TOP_THRESHOLD = 250; - function BaseInvertedFlatList({ref, ...props}: BaseInvertedFlatListProps) { const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor = defaultKeyExtractor, ...rest} = props; - // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. - // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more - // previous items, until everything is rendered. We also progressively render new data that is added at the start of the - // list to make sure `maintainVisibleContentPosition` works as expected. - const [currentDataId, setCurrentDataId] = useState(() => { - if (initialScrollKey) { - return initialScrollKey; - } - return null; - }); - const [isInitialData, setIsInitialData] = useState(true); - const [isQueueRendering, setIsQueueRendering] = useState(false); - - const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); - const displayedData = useMemo(() => { - if (currentDataIndex <= 0) { - return data; - } - return data.slice(Math.max(0, currentDataIndex - (isInitialData ? 0 : getInitialPaginationSize))); - }, [currentDataIndex, data, isInitialData]); - - const isLoadingData = data.length > displayedData.length; - const wasLoadingData = usePrevious(isLoadingData); - const dataIndexDifference = data.length - displayedData.length; - - // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. - const renderQueue = useMemo(() => new RenderTaskQueue(setIsQueueRendering), []); - useEffect(() => { - return () => { - renderQueue.cancel(); - }; - }, [renderQueue]); - - renderQueue.setHandler((info) => { - if (!isLoadingData) { - onStartReached?.(info); - } - setIsInitialData(false); - const firstDisplayedItem = displayedData.at(0); - setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); - }); - - const handleStartReached = useCallback( - (info: {distanceFromStart: number}) => { - renderQueue.add(info); - }, - [renderQueue], - ); - - const handleRenderItem = useCallback( - ({item, index, separators}: ListRenderItemInfo) => { - // Adjust the index passed here so it matches the original data. - return renderItem({item, index: index + dataIndexDifference, separators}); - }, - [renderItem, dataIndexDifference], - ); - - const maintainVisibleContentPosition = useMemo(() => { - if (!initialScrollKey && (!isInitialData || !isQueueRendering)) { - return undefined; - } - - const config: ScrollViewProps['maintainVisibleContentPosition'] = { - // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, - }; - - if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { - config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; - } - - return config; - }, [initialScrollKey, isInitialData, isQueueRendering, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); - - const listRef = useRef(null); - useImperativeHandle(ref, () => { - // If we're trying to scroll at the start of the list we need to make sure to - // render all items. - const scrollToOffsetFn: RNFlatList['scrollToOffset'] = (params) => { - if (params.offset === 0) { - setCurrentDataId(null); - } - requestAnimationFrame(() => { - listRef.current?.scrollToOffset(params); - }); - }; - - return new Proxy( - {}, - { - get: (_target, prop) => { - if (prop === 'scrollToOffset') { - return scrollToOffsetFn; - } - return listRef.current?.[prop as keyof RNFlatList]; - }, - }, - ) as RNFlatList; + const {displayedData, maintainVisibleContentPosition, handleStartReached, handleRenderItem, listRef} = useFlatListScrollKey({ + data, + keyExtractor, + initialScrollKey, + inverted: true, + onStartReached, + shouldEnableAutoScrollToTopThreshold, + renderItem, + ref, }); return ( @@ -151,6 +58,4 @@ BaseInvertedFlatList.displayName = 'BaseInvertedFlatList'; export default BaseInvertedFlatList; -export {AUTOSCROLL_TO_TOP_THRESHOLD}; - export type {BaseInvertedFlatListProps}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index ac77aa36ffece..906f30888613e 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -13,13 +13,13 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import Checkbox from '@components/Checkbox'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; -import FlatList from '@components/FlatList'; -import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInvertedFlatList'; +import FlatListWithScrollKey from '@components/FlatList/FlatListWithScrollKey'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import ScrollView from '@components/ScrollView'; import {useSearchContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; +import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@hooks/useFlatListScrollKey'; import useLoadReportActions from '@hooks/useLoadReportActions'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -32,6 +32,7 @@ import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {queueExportSearchWithTemplate} from '@libs/actions/Search'; import DateUtils from '@libs/DateUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -57,6 +58,7 @@ import {isTransactionPendingDelete} from '@libs/TransactionUtils'; import Visibility from '@libs/Visibility'; import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostFullScreenRoute'; import FloatingMessageCounter from '@pages/home/report/FloatingMessageCounter'; +import getInitialNumToRender from '@pages/home/report/getInitialNumReportActionsToRender'; import ReportActionsListItemRenderer from '@pages/home/report/ReportActionsListItemRenderer'; import shouldDisplayNewMarkerOnReportAction from '@pages/home/report/shouldDisplayNewMarkerOnReportAction'; import useReportUnreadMessageScrollTracking from '@pages/home/report/useReportUnreadMessageScrollTracking'; @@ -77,7 +79,6 @@ import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyS */ const EmptyParentReportActionForTransactionThread = undefined; -const INITIAL_NUM_TO_RENDER = 20; // Amount of time to wait until all list items should be rendered and scrollToEnd will behave well const DELAY_FOR_SCROLLING_TO_END = 100; @@ -640,6 +641,22 @@ function MoneyRequestReportActionsList({ const isSelectAllChecked = selectedTransactionIDs.length > 0 && selectedTransactionIDs.length === transactionsWithoutPendingDelete.length; // Wrapped into useCallback to stabilize children re-renders const keyExtractor = useCallback((item: OnyxTypes.ReportAction) => item.reportActionID, []); + + const {windowHeight} = useWindowDimensions(); + /** + * Calculates the ideal number of report actions to render in the first render, based on the screen height and on + * the height of the smallest report action possible. + */ + const initialNumToRender = useMemo((): number | undefined => { + const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom + variables.fontSizeNormalHeight; + const availableHeight = windowHeight - (CONST.CHAT_FOOTER_MIN_HEIGHT + variables.contentHeaderHeight); + const numToRender = Math.ceil(availableHeight / minimumReportActionHeight); + if (linkedReportActionID) { + return getInitialNumToRender(numToRender); + } + return numToRender || undefined; + }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]); + return ( ) : ( - : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState removeClippedSubviews={false} + initialScrollKey={linkedReportActionID} /> )} diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts new file mode 100644 index 0000000000000..fb3e1b490b0b0 --- /dev/null +++ b/src/hooks/useFlatListScrollKey.ts @@ -0,0 +1,212 @@ +import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import type {ForwardedRef} from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList, ScrollView} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import {View} from 'react-native'; +import RenderTaskQueue from '@components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue'; +import type {ScrollViewProps} from '@components/ScrollView'; +import getPlatform from '@libs/getPlatform'; +import getInitialPaginationSize from '@src/components/InvertedFlatList/BaseInvertedFlatList/getInitialPaginationSize'; +import CONST from '@src/CONST'; +import usePrevious from './usePrevious'; + +type FlatListScrollKeyProps = { + data: T[]; + keyExtractor: (item: T, index: number) => string; + initialScrollKey: string | null | undefined; + inverted: boolean; + onStartReached?: ((info: {distanceFromStart: number}) => void) | null; + shouldEnableAutoScrollToTopThreshold?: boolean; + renderItem: ListRenderItem; + ref?: ForwardedRef; +}; + +const AUTOSCROLL_TO_TOP_THRESHOLD = 250; + +export default function useFlatListScrollKey({ + data, + keyExtractor, + initialScrollKey, + onStartReached, + inverted, + shouldEnableAutoScrollToTopThreshold, + renderItem, + ref, +}: FlatListScrollKeyProps) { + // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. + // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more + // previous items, until everything is rendered. We also progressively render new data that is added at the start of the + // list to make sure `maintainVisibleContentPosition` works as expected. + const [currentDataId, setCurrentDataId] = useState(() => { + if (initialScrollKey) { + return initialScrollKey; + } + return null; + }); + const currentDataIndex = useMemo(() => (currentDataId === null ? -1 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); + const [isInitialData, setIsInitialData] = useState(currentDataIndex >= 0); + const [isQueueRendering, setIsQueueRendering] = useState(false); + + // On the web platform, when data.length === 1, `maintainVisibleContentPosition` does not work. + // Therefore, we need to duplicate the data to ensure data.length >= 2 + const shouldDuplicateData = useMemo( + () => !inverted && data.length === 1 && isInitialData && (getPlatform() === CONST.PLATFORM.WEB || getPlatform() === CONST.PLATFORM.DESKTOP), + [data.length, inverted, isInitialData], + ); + + const displayedData = useMemo(() => { + if (shouldDuplicateData) { + return [{...data.at(0), reportActionID: '0'} as T, ...data]; + } + if (currentDataIndex <= 0) { + return data; + } + // If data.length > 1 and highlighted item is the last element, there will be a bug that does not trigger the `onStartReached` event. + // So we will need to return at least the last 2 elements in this case. + const offset = !inverted && currentDataIndex === data.length - 1 ? 1 : 0; + // We always render the list from the highlighted item to the end of the list because: + // - With an inverted FlatList, items are rendered from bottom to top, + // so the highlighted item stays at the bottom and within the visible viewport. + // - With a non-inverted (base) FlatList, items are rendered from top to bottom, + // making the highlighted item appear at the top of the list. + // Then, `maintainVisibleContentPosition` ensures the highlighted item remains in place + // as the rest of the items are appended. + return data.slice(Math.max(0, currentDataIndex - (isInitialData ? offset : getInitialPaginationSize))); + }, [currentDataIndex, data, inverted, isInitialData, shouldDuplicateData]); + + const isLoadingData = data.length > displayedData.length; + const wasLoadingData = usePrevious(isLoadingData); + const dataIndexDifference = data.length - displayedData.length; + + // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. + const renderQueue = useMemo(() => new RenderTaskQueue(setIsQueueRendering), []); + useEffect(() => { + return () => { + renderQueue.cancel(); + }; + }, [renderQueue]); + + renderQueue.setHandler((info) => { + if (!isLoadingData) { + onStartReached?.(info); + } + setIsInitialData(false); + const firstDisplayedItem = displayedData.at(0); + setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, Math.max(0, currentDataIndex)) : null); + }); + + const handleStartReached = useCallback( + (info: {distanceFromStart: number}) => { + renderQueue.add(info); + }, + [renderQueue], + ); + + useEffect(() => { + // In cases where the data is empty on the initial render, `handleStartReached` will never be triggered. + // We'll manually invoke it in this scenario. + if (inverted || data.length > 0) { + return; + } + handleStartReached({distanceFromStart: 0}); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, []); + + const [shouldPreserveVisibleContentPosition, setShouldPreserveVisibleContentPosition] = useState(true); + const maintainVisibleContentPosition = useMemo(() => { + if ((!initialScrollKey && (!isInitialData || !isQueueRendering)) || !shouldPreserveVisibleContentPosition) { + return undefined; + } + + const config: ScrollViewProps['maintainVisibleContentPosition'] = { + // This needs to be 1 to avoid using loading views as anchors. + minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, + }; + + if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { + config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; + } + + return config; + }, [initialScrollKey, isInitialData, isQueueRendering, shouldPreserveVisibleContentPosition, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); + + const handleRenderItem = useCallback( + ({item, index, separators}: ListRenderItemInfo) => { + // Adjust the index passed here so it matches the original data. + if (shouldDuplicateData && index === 1) { + return React.createElement(View, {style: {opacity: 0}}, renderItem({item, index: index + dataIndexDifference, separators})); + } + + return renderItem({item, index: index + dataIndexDifference, separators}); + }, + [shouldDuplicateData, renderItem, dataIndexDifference], + ); + + useEffect(() => { + if (inverted || isInitialData || isQueueRendering) { + return; + } + + // Unlike an inverted FlatList, a non-inverted FlatList can have data.length === 0, + // which causes the initial value of `minIndexForVisible` to be 0. + // When data.length increases and `minIndexForVisible` updates accordingly, + // it can lead to a crash due to inconsistent rendering behavior. + // Additionally, keeping `minIndexForVisible` at 1 may cause the scroll offset to shift + // when the height of the ListHeaderComponent changes, as FlatList tries to keep items within the visible viewport. + requestAnimationFrame(() => { + setShouldPreserveVisibleContentPosition(false); + }); + }, [inverted, isInitialData, isQueueRendering]); + + const listRef = useRef(null); + useImperativeHandle(ref, () => { + // If we're trying to scroll at the start of the list we need to make sure to + // render all items. + const scrollToOffsetFn: RNFlatList['scrollToOffset'] = (params) => { + if (params.offset === 0) { + setCurrentDataId(null); + } + requestAnimationFrame(() => { + listRef.current?.scrollToOffset(params); + }); + }; + + const scrollToEndFn: RNFlatList['scrollToEnd'] = (params) => { + const scrollViewRef = listRef.current?.getNativeScrollRef(); + // Try to scroll on underlying scrollView if available, fallback to usual listRef + if (scrollViewRef && 'scrollToEnd' in scrollViewRef) { + (scrollViewRef as ScrollView).scrollToEnd({animated: !!params?.animated}); + return; + } + listRef.current?.scrollToEnd(params); + }; + + return new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'scrollToOffset') { + return scrollToOffsetFn; + } + if (prop === 'scrollToEnd') { + return scrollToEndFn; + } + return listRef.current?.[prop as keyof RNFlatList]; + }, + }, + ) as RNFlatList; + }); + + return { + handleStartReached, + setCurrentDataId, + displayedData, + maintainVisibleContentPosition, + isInitialData, + handleRenderItem, + listRef, + }; +} + +export {AUTOSCROLL_TO_TOP_THRESHOLD}; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index c0c4068c8c3b6..76bf078d0afac 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -9,10 +9,10 @@ import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import InvertedFlatList from '@components/InvertedFlatList'; -import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInvertedFlatList'; import {PersonalDetailsContext, usePersonalDetails} from '@components/OnyxListItemProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@hooks/useFlatListScrollKey'; import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import useLocalize from '@hooks/useLocalize'; import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus';