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 @@ -17,6 +17,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
28 changes: 27 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} from 'react';
import React, {memo, useCallback, useContext, useRef} from 'react';
import {StyleSheet, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
import withCurrentReportID from '@components/withCurrentReportID';
import usePermissions from '@hooks/usePermissions';
import useThemeStyles from '@hooks/useThemeStyles';
Expand Down Expand Up @@ -34,6 +37,10 @@ function LHNOptionsList({
onFirstItemRendered = () => {},
reportIDsWithErrors = {},
}: LHNOptionsListProps) {
const {saveScrollOffset, scrollToOffset} = useContext(ScrollOffsetContext);
const flashListRef = useRef<FlashList<string>>(null);
const route = useRoute();

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

Expand Down Expand Up @@ -116,9 +123,26 @@ function LHNOptionsList({
],
);

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(() => {
scrollToOffset(route, flashListRef);
}, [route, flashListRef, scrollToOffset]);

return (
<View style={style ?? styles.flex1}>
<FlashList
ref={flashListRef}
indicatorStyle="white"
keyboardShouldPersistTaps="always"
contentContainerStyle={StyleSheet.flatten(contentContainerStyles)}
Expand All @@ -129,6 +153,8 @@ function LHNOptionsList({
estimatedItemSize={optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight}
extraData={[currentReportID]}
showsVerticalScrollIndicator={false}
onLayout={onLayout}
onScroll={onScroll}
/>
</View>
);
Expand Down
93 changes: 93 additions & 0 deletions src/components/ScrollOffsetContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type {ParamListBase, RouteProp} from '@react-navigation/native';
import type {FlashList} from '@shopify/flash-list';
import React, {createContext, useCallback, useMemo, useRef} from 'react';
import type {NavigationPartialRoute, State} from '@libs/Navigation/types';
import NAVIGATORS from '@src/NAVIGATORS';

type ScrollOffsetContextValue = {
/** Save scroll offset of flashlist on given screen */
saveScrollOffset: (route: RouteProp<ParamListBase>, scrollOffset: number) => void;

/** Scroll to saved offset of given screen */
scrollToOffset: (route: RouteProp<ParamListBase>, flashListRef: React.RefObject<FlashList<string>>) => void;

/** Clean scroll offsets of screen that aren't anymore in the state */
cleanStaleScrollOffsets: (state: State) => void;
};

type ScrollOffsetContextProviderProps = {
/** Actual content wrapped by this component */
children: React.ReactNode;
};

const defaultValue: ScrollOffsetContextValue = {
saveScrollOffset: () => {},
scrollToOffset: () => {},
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`;
}

export default function ScrollOffsetContextProvider(props: ScrollOffsetContextProviderProps) {
const scrollOffsetsRef = useRef<Record<string, number>>({});

const saveScrollOffset: ScrollOffsetContextValue['saveScrollOffset'] = useCallback((route, scrollOffset) => {
scrollOffsetsRef.current[getKey(route)] = scrollOffset;
}, []);

const scrollToOffset: ScrollOffsetContextValue['scrollToOffset'] = useCallback((route, flashListRef) => {
const offset = scrollOffsetsRef.current[getKey(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});
});
}, []);

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

return <ScrollOffsetContext.Provider value={contextValue}>{props.children}</ScrollOffsetContext.Provider>;
}

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 useFlipper from '@hooks/useFlipper';
Expand Down Expand Up @@ -63,6 +64,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
useFlipper(navigationRef);
const firstRenderRef = useRef(true);
const theme = useTheme();
const {cleanStaleScrollOffsets} = useContext(ScrollOffsetContext);

const currentReportIDValue = useCurrentReportID();
const {isSmallScreenWidth} = useWindowDimensions();
Expand Down Expand Up @@ -125,6 +127,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
Loading