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
40 changes: 39 additions & 1 deletion src/components/LHNOptionsList/LHNOptionsList.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
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, 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 useThemeStyles from '@hooks/useThemeStyles';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
Expand Down Expand Up @@ -31,6 +34,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 +118,38 @@ function LHNOptionsList({

const extraData = useMemo(() => [reportActions, reports, policy, personalDetails], [reportActions, reports, policy, personalDetails]);

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 +160,8 @@ function LHNOptionsList({
estimatedItemSize={optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight}
extraData={extraData}
showsVerticalScrollIndicator={false}
onLayout={onLayout}
onScroll={onScroll}
/>
</View>
);
Expand Down
114 changes: 114 additions & 0 deletions src/components/ScrollOffsetContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type {ParamListBase, RouteProp} from '@react-navigation/native';
import React, {createContext, useCallback, useEffect, useMemo, useRef} from 'react';
import {withOnyx} from 'react-native-onyx';
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 = useRef<PriorityMode>(priorityMode);

useEffect(() => {
if (previousPriorityMode.current === priorityMode) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use usePrevious hook to compare with prev props value.

Btw, is this reasonable approach to reset scroll of HOME screen here? This is general context provider.
I thought of handling in LHNOptionsList.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like having this in one place rather than scattering it in different files makes it easier to understand. But I am open to hear different opinions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, I am fine with current approach.

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];
}
}

previousPriorityMode.current = priorityMode;
}, [priorityMode]);

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];
}
}
}
}, []);

/**
* The context this component exposes to child components
* @returns currentReportID to share between central pane and LHN
*/
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