Skip to content

Commit

Permalink
Merge pull request #38161 from software-mansion-labs/fix-chatlist-scroll
Browse files Browse the repository at this point in the history
[Wave 8] [Ideal Nav] Save scroll position on HOME screen
  • Loading branch information
Hayata Suenaga authored Mar 26, 2024
2 parents bdc624c + 830fb27 commit e0d66e8
Show file tree
Hide file tree
Showing 6 changed files with 216 additions and 5 deletions.
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.
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

0 comments on commit e0d66e8

Please sign in to comment.