Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Wave 8] [Ideal Nav] Save scroll position on HOME screen #38161

Merged
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,6 +70,7 @@ function App({url}: AppProps) {
KeyboardStateProvider,
PopoverContextProvider,
CurrentReportIDContextProvider,
ScrollOffsetContextProvider,
ReportAttachmentsProvider,
PickerStateProvider,
EnvironmentProvider,
Expand Down
56 changes: 55 additions & 1 deletion src/components/LHNOptionsList/LHNOptionsList.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -31,6 +35,10 @@ function LHNOptionsList({
transactionViolations = {},
onFirstItemRendered = () => {},
}: LHNOptionsListProps) {
const {saveScrollOffset, getScrollOffset} = useContext(ScrollOffsetContext);
const flashListRef = useRef<FlashList<string>>(null);
const route = useRoute();

const styles = useThemeStyles();
const {canUseViolations} = usePermissions();

Expand Down Expand Up @@ -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.
hayata-suenaga marked this conversation as resolved.
Show resolved Hide resolved
flashListRef.current.scrollToOffset({offset: 0});
}, [previousOptionMode, optionMode]);

const onScroll = useCallback<NonNullable<FlashListProps<string>['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 (
<View style={style ?? styles.flex1}>
<FlashList
ref={flashListRef}
indicatorStyle="white"
keyboardShouldPersistTaps="always"
contentContainerStyle={StyleSheet.flatten(contentContainerStyles)}
Expand All @@ -124,6 +176,8 @@ function LHNOptionsList({
estimatedItemSize={optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight}
extraData={extraData}
showsVerticalScrollIndicator={false}
onLayout={onLayout}
onScroll={onScroll}
/>
</View>
);
Expand Down
109 changes: 109 additions & 0 deletions src/components/ScrollOffsetContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<ParamListBase>, scrollOffset: number) => void;

/** Get scroll offset value for given screen */
getScrollOffset: (route: RouteProp<ParamListBase>) => 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<ScrollOffsetContextValue>(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<ParamListBase> | 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<Record<string, number>>({});
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 <ScrollOffsetContext.Provider value={contextValue}>{children}</ScrollOffsetContext.Provider>;
}

export default withOnyx<ScrollOffsetContextProviderProps, ScrollOffsetContextProviderOnyxProps>({
priorityMode: {
key: ONYXKEYS.NVP_PRIORITY_MODE,
},
})(ScrollOffsetContextProvider);

export {ScrollOffsetContext};

export type {ScrollOffsetContextProviderProps, ScrollOffsetContextValue};
7 changes: 6 additions & 1 deletion src/libs/Navigation/NavigationRoot.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 (
Expand Down
46 changes: 43 additions & 3 deletions src/pages/settings/InitialSettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -437,14 +440,51 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa
</View>
);

const {saveScrollOffset, getScrollOffset} = useContext(ScrollOffsetContext);
const route = useRoute();
const scrollViewRef = useRef<RNScrollView>(null);

const onScroll = useCallback<NonNullable<ScrollViewProps['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 [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 (
<ScreenWrapper
style={[styles.w100, styles.pb0]}
includePaddingTop={false}
includeSafeAreaPaddingBottom={false}
testID={InitialSettingsPage.displayName}
>
<ScrollView style={[styles.w100, styles.pt4]}>
<ScrollView
ref={scrollViewRef}
onLayout={onLayout}
onScroll={onScroll}
scrollEventThrottle={16}
// We use marginTop to prevent glitching on the initial frame that renders before scrollTo.
contentContainerStyle={[!isAfterOnLayout && !!scrollOffset && {marginTop: -scrollOffset}]}
style={[styles.w100, styles.pt4]}
>
{headerContent}
{accountMenuItems}
{workspaceMenuItems}
Expand Down
1 change: 1 addition & 0 deletions tests/utils/LHNTestUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading