diff --git a/src/App.tsx b/src/App.tsx index a2d353a026af..76f9198e97b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import {LocaleContextProvider} from './components/LocaleContextProvider'; import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; import SafeArea from './components/SafeArea'; +import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider'; import ThemeIllustrationsProvider from './components/ThemeIllustrationsProvider'; import ThemeProvider from './components/ThemeProvider'; import ThemeStylesProvider from './components/ThemeStylesProvider'; @@ -69,6 +70,7 @@ function App({url}: AppProps) { KeyboardStateProvider, PopoverContextProvider, CurrentReportIDContextProvider, + ScrollOffsetContextProvider, ReportAttachmentsProvider, PickerStateProvider, EnvironmentProvider, diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 07a2cb4b71ee..04573c8bccac 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -1,9 +1,13 @@ +import {useRoute} from '@react-navigation/native'; +import type {FlashListProps} from '@shopify/flash-list'; import {FlashList} from '@shopify/flash-list'; import type {ReactElement} from 'react'; -import React, {memo, useCallback, useMemo} from 'react'; +import React, {memo, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import usePermissions from '@hooks/usePermissions'; +import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import variables from '@styles/variables'; @@ -31,6 +35,10 @@ function LHNOptionsList({ transactionViolations = {}, onFirstItemRendered = () => {}, }: LHNOptionsListProps) { + const {saveScrollOffset, getScrollOffset} = useContext(ScrollOffsetContext); + const flashListRef = useRef>(null); + const route = useRoute(); + const styles = useThemeStyles(); const {canUseViolations} = usePermissions(); @@ -111,9 +119,53 @@ function LHNOptionsList({ const extraData = useMemo(() => [reportActions, reports, policy, personalDetails, data.length], [reportActions, reports, policy, personalDetails, data.length]); + const previousOptionMode = usePrevious(optionMode); + + useEffect(() => { + if (previousOptionMode === null || previousOptionMode === optionMode || !flashListRef.current) { + return; + } + + if (!flashListRef.current) { + return; + } + + // If the option mode changes want to scroll to the top of the list because rendered items will have different height. + flashListRef.current.scrollToOffset({offset: 0}); + }, [previousOptionMode, optionMode]); + + const onScroll = useCallback['onScroll']>>( + (e) => { + // If the layout measurement is 0, it means the flashlist is not displayed but the onScroll may be triggered with offset value 0. + // We should ignore this case. + if (e.nativeEvent.layoutMeasurement.height === 0) { + return; + } + saveScrollOffset(route, e.nativeEvent.contentOffset.y); + }, + [route, saveScrollOffset], + ); + + const onLayout = useCallback(() => { + const offset = getScrollOffset(route); + + if (!(offset && flashListRef.current)) { + return; + } + + // We need to use requestAnimationFrame to make sure it will scroll properly on iOS. + requestAnimationFrame(() => { + if (!(offset && flashListRef.current)) { + return; + } + flashListRef.current.scrollToOffset({offset}); + }); + }, [route, flashListRef, getScrollOffset]); + return ( ); diff --git a/src/components/ScrollOffsetContextProvider.tsx b/src/components/ScrollOffsetContextProvider.tsx new file mode 100644 index 000000000000..d7815d7a65a0 --- /dev/null +++ b/src/components/ScrollOffsetContextProvider.tsx @@ -0,0 +1,109 @@ +import type {ParamListBase, RouteProp} from '@react-navigation/native'; +import React, {createContext, useCallback, useEffect, useMemo, useRef} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import usePrevious from '@hooks/usePrevious'; +import type {NavigationPartialRoute, State} from '@libs/Navigation/types'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {PriorityMode} from '@src/types/onyx'; + +type ScrollOffsetContextValue = { + /** Save scroll offset of flashlist on given screen */ + saveScrollOffset: (route: RouteProp, scrollOffset: number) => void; + + /** Get scroll offset value for given screen */ + getScrollOffset: (route: RouteProp) => number | undefined; + + /** Clean scroll offsets of screen that aren't anymore in the state */ + cleanStaleScrollOffsets: (state: State) => void; +}; + +type ScrollOffsetContextProviderOnyxProps = { + /** The chat priority mode */ + priorityMode: PriorityMode; +}; + +type ScrollOffsetContextProviderProps = ScrollOffsetContextProviderOnyxProps & { + /** Actual content wrapped by this component */ + children: React.ReactNode; +}; + +const defaultValue: ScrollOffsetContextValue = { + saveScrollOffset: () => {}, + getScrollOffset: () => undefined, + cleanStaleScrollOffsets: () => {}, +}; + +const ScrollOffsetContext = createContext(defaultValue); + +/** This function is prepared to work with HOME screens. May need modification if we want to handle other types of screens. */ +function getKey(route: RouteProp | NavigationPartialRoute): string { + if (route.params && 'policyID' in route.params && typeof route.params.policyID === 'string') { + return `${route.name}-${route.params.policyID}`; + } + return `${route.name}-global`; +} + +function ScrollOffsetContextProvider({children, priorityMode}: ScrollOffsetContextProviderProps) { + const scrollOffsetsRef = useRef>({}); + const previousPriorityMode = usePrevious(priorityMode); + + useEffect(() => { + if (previousPriorityMode === null || previousPriorityMode === priorityMode) { + return; + } + + // If the priority mode changes, we need to clear the scroll offsets for the home screens because it affects the size of the elements and scroll positions wouldn't be correct. + for (const key of Object.keys(scrollOffsetsRef.current)) { + if (key.includes(SCREENS.HOME)) { + delete scrollOffsetsRef.current[key]; + } + } + }, [priorityMode, previousPriorityMode]); + + const saveScrollOffset: ScrollOffsetContextValue['saveScrollOffset'] = useCallback((route, scrollOffset) => { + scrollOffsetsRef.current[getKey(route)] = scrollOffset; + }, []); + + const getScrollOffset: ScrollOffsetContextValue['getScrollOffset'] = useCallback((route) => { + if (!scrollOffsetsRef.current) { + return; + } + return scrollOffsetsRef.current[getKey(route)]; + }, []); + + const cleanStaleScrollOffsets: ScrollOffsetContextValue['cleanStaleScrollOffsets'] = useCallback((state) => { + const bottomTabNavigator = state.routes.find((route) => route.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR); + if (bottomTabNavigator?.state && 'routes' in bottomTabNavigator.state) { + const bottomTabNavigatorRoutes = bottomTabNavigator.state.routes; + const scrollOffsetkeysOfExistingScreens = bottomTabNavigatorRoutes.map((route) => getKey(route)); + for (const key of Object.keys(scrollOffsetsRef.current)) { + if (!scrollOffsetkeysOfExistingScreens.includes(key)) { + delete scrollOffsetsRef.current[key]; + } + } + } + }, []); + + const contextValue = useMemo( + (): ScrollOffsetContextValue => ({ + saveScrollOffset, + getScrollOffset, + cleanStaleScrollOffsets, + }), + [saveScrollOffset, getScrollOffset, cleanStaleScrollOffsets], + ); + + return {children}; +} + +export default withOnyx({ + priorityMode: { + key: ONYXKEYS.NVP_PRIORITY_MODE, + }, +})(ScrollOffsetContextProvider); + +export {ScrollOffsetContext}; + +export type {ScrollOffsetContextProviderProps, ScrollOffsetContextValue}; diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 39efd8203c75..506eae2bdfd2 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -1,6 +1,7 @@ import type {NavigationState} from '@react-navigation/native'; import {DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-navigation/native'; -import React, {useEffect, useMemo, useRef} from 'react'; +import React, {useContext, useEffect, useMemo, useRef} from 'react'; +import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useCurrentReportID from '@hooks/useCurrentReportID'; import useTheme from '@hooks/useTheme'; @@ -61,6 +62,7 @@ function parseAndLogRoute(state: NavigationState) { function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: NavigationRootProps) { const firstRenderRef = useRef(true); const theme = useTheme(); + const {cleanStaleScrollOffsets} = useContext(ScrollOffsetContext); const currentReportIDValue = useCurrentReportID(); const {isSmallScreenWidth} = useWindowDimensions(); @@ -123,6 +125,9 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N setActiveWorkspaceID(activeWorkspaceID); }, 0); parseAndLogRoute(state); + + // We want to clean saved scroll offsets for screens that aren't anymore in the state. + cleanStaleScrollOffsets(state); }; return ( diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 307fa9ffcce6..73bd031b5978 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -1,5 +1,7 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; +import {useRoute} from '@react-navigation/native'; +import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, ScrollView as RNScrollView, ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; @@ -13,6 +15,7 @@ import MenuItem from '@components/MenuItem'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {PressableWithFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; +import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; @@ -437,6 +440,35 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa ); + const {saveScrollOffset, getScrollOffset} = useContext(ScrollOffsetContext); + const route = useRoute(); + const scrollViewRef = useRef(null); + + const onScroll = useCallback>( + (e) => { + // If the layout measurement is 0, it means the flashlist is not displayed but the onScroll may be triggered with offset value 0. + // We should ignore this case. + if (e.nativeEvent.layoutMeasurement.height === 0) { + return; + } + saveScrollOffset(route, e.nativeEvent.contentOffset.y); + }, + [route, saveScrollOffset], + ); + + const [isAfterOnLayout, setIsAfterOnLayout] = useState(false); + + const onLayout = useCallback(() => { + const scrollOffset = getScrollOffset(route); + setIsAfterOnLayout(true); + if (!scrollOffset || !scrollViewRef.current) { + return; + } + scrollViewRef.current.scrollTo({y: scrollOffset, animated: false}); + }, [getScrollOffset, route]); + + const scrollOffset = getScrollOffset(route); + return ( - + {headerContent} {accountMenuItems} {workspaceMenuItems} diff --git a/tests/utils/LHNTestUtils.tsx b/tests/utils/LHNTestUtils.tsx index 44bfcd46d399..f6eee590313b 100644 --- a/tests/utils/LHNTestUtils.tsx +++ b/tests/utils/LHNTestUtils.tsx @@ -39,6 +39,7 @@ jest.mock('@react-navigation/native', (): typeof Navigation => { const actualNav = jest.requireActual('@react-navigation/native'); return { ...actualNav, + useRoute: jest.fn(), useFocusEffect: jest.fn(), useIsFocused: () => ({ navigate: mockedNavigate,