diff --git a/src/components/ScrollOffsetContextProvider.tsx b/src/components/ScrollOffsetContextProvider.tsx index f051497606d48..36af23156eac2 100644 --- a/src/components/ScrollOffsetContextProvider.tsx +++ b/src/components/ScrollOffsetContextProvider.tsx @@ -22,6 +22,12 @@ type ScrollOffsetContextValue = { /** Get scroll index value for given screen */ getScrollIndex: (route: PlatformStackRouteProp) => number | undefined; + /** Save the measured average item length for a given screen's list */ + saveAverageItemLength: (route: PlatformStackRouteProp, length: number) => void; + + /** Get the saved average item length for a given screen's list */ + getAverageItemLength: (route: PlatformStackRouteProp) => number | undefined; + /** Clean scroll offsets of screen that aren't anymore in the state */ cleanStaleScrollOffsets: (state: State) => void; }; @@ -36,6 +42,8 @@ const defaultValue: ScrollOffsetContextValue = { getScrollOffset: () => undefined, saveScrollIndex: () => {}, getScrollIndex: () => undefined, + saveAverageItemLength: () => {}, + getAverageItemLength: () => undefined, cleanStaleScrollOffsets: () => {}, }; @@ -62,6 +70,7 @@ function getKey(route: PlatformStackRouteProp | NavigationPartial function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProps) { const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); const scrollOffsetsRef = useRef>({}); + const averageItemLengthsRef = useRef>({}); const previousPriorityMode = usePrevious(priorityMode); useEffect(() => { @@ -73,6 +82,7 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp for (const key of Object.keys(scrollOffsetsRef.current)) { if (key.includes(SCREENS.INBOX) || key.includes(SCREENS.SEARCH.ROOT)) { delete scrollOffsetsRef.current[key]; + delete averageItemLengthsRef.current[key]; } } }, [priorityMode, previousPriorityMode]); @@ -95,12 +105,13 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp } delete scrollOffsetsRef.current[key]; + delete averageItemLengthsRef.current[key]; } }, []); const cleanStaleScrollOffsets: ScrollOffsetContextValue['cleanStaleScrollOffsets'] = useCallback( (state) => { - const sidebarRoutes = state.routes.filter((route) => isSidebarScreenName(route.name)); + const sidebarRoutes = state.routes.filter((route) => isSidebarScreenName(route.name) || route.name === SCREENS.WORKSPACES_LIST); const existingScreenKeys = new Set(sidebarRoutes.map(getKey)); const focusedRoute = findFocusedRoute(state); @@ -133,6 +144,17 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp return scrollOffsetsRef.current[getKey(route)]; }, []); + const saveAverageItemLength: ScrollOffsetContextValue['saveAverageItemLength'] = useCallback((route, length) => { + averageItemLengthsRef.current[getKey(route)] = length; + }, []); + + const getAverageItemLength: ScrollOffsetContextValue['getAverageItemLength'] = useCallback((route) => { + if (!averageItemLengthsRef.current) { + return; + } + return averageItemLengthsRef.current[getKey(route)]; + }, []); + const contextValue = useMemo( (): ScrollOffsetContextValue => ({ saveScrollOffset, @@ -140,8 +162,10 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp cleanStaleScrollOffsets, saveScrollIndex, getScrollIndex, + saveAverageItemLength, + getAverageItemLength, }), - [saveScrollOffset, getScrollOffset, cleanStaleScrollOffsets, saveScrollIndex, getScrollIndex], + [saveScrollOffset, getScrollOffset, cleanStaleScrollOffsets, saveScrollIndex, getScrollIndex, saveAverageItemLength, getAverageItemLength], ); return {children}; diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index e3ab960890321..9ff660cbb9fd9 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,6 +1,6 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {Str} from 'expensify-common'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; import {FlatList, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -19,6 +19,8 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {PressableWithoutFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; +import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; +import type {ScrollViewProps} from '@components/ScrollView'; import SearchBar from '@components/SearchBar'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; @@ -40,6 +42,7 @@ import useSearchResults from '@hooks/useSearchResults'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolationOfWorkspace from '@hooks/useTransactionViolationOfWorkspace'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {isConnectionInProgress} from '@libs/actions/connections'; import {close} from '@libs/actions/Modal'; import {clearWorkspaceOwnerChangeFlow, isApprover as isApproverUserAction, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member'; @@ -68,6 +71,7 @@ import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButt import {isSubscriptionTypeOfInvoicing, shouldCalculateBillNewDot as shouldCalculateBillNewDotFn} from '@libs/SubscriptionUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import type {AvatarSource} from '@libs/UserAvatarUtils'; +import variables from '@styles/variables'; import {setNameValuePair} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -132,6 +136,13 @@ function WorkspacesListPage() { const icons = useMemoizedLazyExpensifyIcons(['Building', 'Exit', 'Copy', 'Star', 'Trashcan', 'Transfer', 'FallbackWorkspaceAvatar']); const theme = useTheme(); const styles = useThemeStyles(); + + // Fallback estimated height (in px) for a workspace row: avatar + vertical padding (styles.p5 top + bottom) + bottom margin (styles.mb2). + // Used to calculate initialNumToRender when no measured height is available yet. + const rowMarginBottom = (styles.mb2 as Partial>)?.marginBottom ?? 0; + const rowPaddingVertical = (styles.p5 as Partial>)?.padding ?? 0; + const estimatedItemHeight = variables.avatarSizeNormal + rowPaddingVertical * 2 + rowMarginBottom; + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Building', 'Exit', 'Copy', 'Star', 'Trashcan', 'Transfer', 'Plus', 'FallbackWorkspaceAvatar']); const {translate, localeCompare} = useLocalize(); useDocumentTitle(translate('common.workspaces')); @@ -187,13 +198,40 @@ function WorkspacesListPage() { const policyToDelete = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDToDelete}`]; + const {saveScrollOffset, getScrollOffset, saveAverageItemLength, getAverageItemLength} = useContext(ScrollOffsetContext); + const {windowHeight} = useWindowDimensions(); + const onScroll = useCallback>( + (e) => { + // If the layout measurement is 0, it means the list 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 flatlistRef = useRef(null); + const measuredItemHeight = useRef(getAverageItemLength(route) ?? 0); + + useLayoutEffect(() => { + const scrollOffset = getScrollOffset(route); + if (!scrollOffset || !flatlistRef.current) { + return; + } + flatlistRef.current.scrollToOffset({ + offset: scrollOffset, + animated: false, + }); + }, [getScrollOffset, route]); + // We need this to update translation for deleting a workspace when it has third party card feeds or expensify card assigned. const workspaceAccountID = policyToDelete?.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; const [cardFeeds, , defaultCardFeeds] = useCardFeeds(policyIDToDelete); const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, { selector: filterInactiveCards, }); - const flatlistRef = useRef(null); const [lastAccessedWorkspacePolicyID] = useOnyx(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID); const hasCardFeedOrExpensifyCard = @@ -436,48 +474,61 @@ function WorkspacesListPage() { } return ( - { + const height = e.nativeEvent.layout.height; + if (height <= 0) { + return; + } + if (height !== getAverageItemLength(route)) { + measuredItemHeight.current = height; + saveAverageItemLength(route, height); + } + }} > - - {({hovered}) => ( - - )} - - + + {({hovered}) => ( + + )} + + + ); }; @@ -683,6 +734,21 @@ function WorkspacesListPage() { shouldShowDomainsSection && !domains.length ? [{listItemType: 'domains-empty-state' as const}] : [], ].flat(); + // Compute initialNumToRender: render enough items to cover the saved scroll offset so + // useLayoutEffect can restore position before first paint. Uses measured row height when + // available (persisted across navigations via ScrollOffsetContext), otherwise falls back to an estimate. + const savedScrollOffset = getScrollOffset(route) ?? 0; + const computedInitialNumToRender = (() => { + if (savedScrollOffset <= 0 || data.length === 0) { + return undefined; + } + // eslint-disable-next-line react-hooks/refs -- Reading the local measured height during render is intentional; the value is an optimization hint for initialNumToRender. + const localMeasured = measuredItemHeight.current > 0 ? measuredItemHeight.current : undefined; + const itemHeight = localMeasured ?? getAverageItemLength(route) ?? estimatedItemHeight; + const viewportItems = Math.ceil(windowHeight / itemHeight); + return Math.min(Math.ceil(savedScrollOffset / itemHeight) + viewportItems, data.length); + })(); + // eslint-disable-next-line react/no-unused-prop-types const renderItem = ({item, index}: {item: WorkspaceOrDomainListItem; index: number}) => { switch (item.listItemType) { @@ -765,6 +831,8 @@ function WorkspacesListPage() { ListHeaderComponent={listHeaderComponent} keyboardShouldPersistTaps="handled" contentContainerStyle={styles.pb20} + onScroll={onScroll} + initialNumToRender={computedInitialNumToRender} /> )}