Skip to content

Commit cc145d6

Browse files
authored
Merge pull request Expensify#72338 from callstack-internal/perf/report-transaction-rows
Remove children from AnimatedCollapsible if isExpanded false
2 parents af76aaf + 90a0e23 commit cc145d6

File tree

6 files changed

+360
-272
lines changed

6 files changed

+360
-272
lines changed

src/CONST/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1647,6 +1647,7 @@ const CONST = {
16471647
// we set this height threshold to 30dpi, since gesture bars will never be taller than that. (Samsung & Huawei: ~14-15dpi)
16481648
NAVIGATION_BAR_ANDROID_SOFT_KEYS_MINIMUM_HEIGHT_THRESHOLD: 30,
16491649
TRANSACTION: {
1650+
RESULTS_PAGE_SIZE: 20,
16501651
DEFAULT_MERCHANT: 'Expense',
16511652
UNKNOWN_MERCHANT: 'Unknown Merchant',
16521653
PARTIAL_TRANSACTION_MERCHANT: '(none)',

src/components/AnimatedCollapsible/index.tsx

Lines changed: 45 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import React, {useEffect, useRef} from 'react';
1+
import React, {useEffect} from 'react';
22
import type {ReactNode} from 'react';
33
import {View} from 'react-native';
44
import type {StyleProp, ViewStyle} from 'react-native';
5-
import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated';
5+
import Animated, {runOnJS, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated';
66
import Icon from '@components/Icon';
77
import * as Expensicons from '@components/Icon/Expensicons';
88
import {easing} from '@components/Modal/ReanimatedModal/utils';
@@ -47,61 +47,43 @@ function AnimatedCollapsible({isExpanded, children, header, duration = 300, styl
4747
const theme = useTheme();
4848
const styles = useThemeStyles();
4949
const contentHeight = useSharedValue(0);
50-
const isAnimating = useSharedValue(false);
51-
const hasExpanded = useSharedValue(false);
52-
const isExpandedFirstTime = useRef(false);
50+
const hasExpanded = useSharedValue(isExpanded);
51+
const [isRendered, setIsRendered] = React.useState(isExpanded);
5352

5453
useEffect(() => {
55-
if (!isExpanded && !isExpandedFirstTime.current) {
56-
return;
54+
hasExpanded.set(isExpanded);
55+
if (isExpanded) {
56+
setIsRendered(true);
5757
}
58-
if (isExpandedFirstTime.current) {
59-
hasExpanded.set(true);
60-
} else {
61-
isExpandedFirstTime.current = true;
58+
}, [isExpanded, hasExpanded]);
59+
60+
const animatedHeight = useDerivedValue(() => {
61+
if (!contentHeight.get()) {
62+
return 0;
6263
}
63-
}, [hasExpanded, isExpanded]);
64-
65-
// Animation for content height and opacity
66-
const derivedHeight = useDerivedValue(() => {
67-
const targetHeight = isExpanded ? contentHeight.get() : 0;
68-
return withTiming(
69-
targetHeight,
70-
{
71-
duration,
72-
easing,
73-
},
74-
(finished) => {
75-
if (!finished) {
76-
return;
77-
}
78-
isAnimating.set(false);
79-
},
80-
);
81-
});
8264

83-
const derivedOpacity = useDerivedValue(() => {
84-
const targetOpacity = isExpanded ? 1 : 0;
85-
isAnimating.set(true);
86-
return withTiming(targetOpacity, {
87-
duration,
88-
easing,
65+
const target = hasExpanded.get() ? contentHeight.get() : 0;
66+
67+
return withTiming(target, {duration, easing}, (finished) => {
68+
if (!finished || target) {
69+
return;
70+
}
71+
runOnJS(setIsRendered)(false);
8972
});
90-
});
73+
}, []);
9174

92-
const contentAnimatedStyle = useAnimatedStyle(() => {
93-
if (!isExpanded && !hasExpanded.get()) {
94-
return {
95-
height: 0,
96-
opacity: 0,
97-
overflow: 'hidden',
98-
};
75+
const animatedOpacity = useDerivedValue(() => {
76+
if (!contentHeight.get()) {
77+
return 0;
9978
}
10079

80+
return withTiming(hasExpanded.get() ? 1 : 0, {duration, easing});
81+
});
82+
83+
const contentAnimatedStyle = useAnimatedStyle(() => {
10184
return {
102-
height: !hasExpanded.get() ? undefined : derivedHeight.get(),
103-
opacity: derivedOpacity.get(),
104-
overflow: isAnimating.get() ? 'hidden' : 'visible',
85+
height: animatedHeight.get(),
86+
opacity: animatedOpacity.get(),
10587
};
10688
});
10789

@@ -126,22 +108,22 @@ function AnimatedCollapsible({isExpanded, children, header, duration = 300, styl
126108
</PressableWithFeedback>
127109
</View>
128110
<Animated.View style={[contentAnimatedStyle, contentStyle]}>
129-
<View
130-
onLayout={(e) => {
131-
if (!e.nativeEvent.layout.height) {
132-
return;
133-
}
134-
if (!isExpanded) {
135-
hasExpanded.set(true);
136-
}
137-
contentHeight.set(e.nativeEvent.layout.height);
138-
}}
139-
>
140-
<View style={[styles.pv2, styles.ph3]}>
141-
<View style={[styles.borderBottom]} />
142-
</View>
143-
{children}
144-
</View>
111+
{isExpanded || isRendered ? (
112+
<Animated.View
113+
style={styles.stickToTop}
114+
onLayout={(e) => {
115+
const height = e.nativeEvent.layout.height;
116+
if (height) {
117+
contentHeight.set(height);
118+
}
119+
}}
120+
>
121+
<View style={[styles.pv2, styles.ph3]}>
122+
<View style={[styles.borderBottom]} />
123+
</View>
124+
{children}
125+
</Animated.View>
126+
) : null}
145127
</Animated.View>
146128
</View>
147129
);
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import React, {useCallback, useContext, useMemo} from 'react';
2+
import {View} from 'react-native';
3+
import ActivityIndicator from '@components/ActivityIndicator';
4+
import Button from '@components/Button';
5+
import OfflineWithFeedback from '@components/OfflineWithFeedback';
6+
import {useSearchContext} from '@components/Search/SearchContext';
7+
import type {SearchColumnType} from '@components/Search/types';
8+
import SearchTableHeader, {getExpenseHeaders} from '@components/SelectionListWithSections/SearchTableHeader';
9+
import type {ListItem, TransactionGroupListExpandedProps, TransactionListItemType} from '@components/SelectionListWithSections/types';
10+
import Text from '@components/Text';
11+
import TransactionItemRow from '@components/TransactionItemRow';
12+
import {WideRHPContext} from '@components/WideRHPContextProvider';
13+
import useLocalize from '@hooks/useLocalize';
14+
import useResponsiveLayout from '@hooks/useResponsiveLayout';
15+
import useTheme from '@hooks/useTheme';
16+
import useThemeStyles from '@hooks/useThemeStyles';
17+
import {getReportIDForTransaction} from '@libs/MoneyRequestReportUtils';
18+
import Navigation from '@libs/Navigation/Navigation';
19+
import {getReportAction} from '@libs/ReportActionsUtils';
20+
import {createAndOpenSearchTransactionThread, getColumnsToShow} from '@libs/SearchUIUtils';
21+
import {getTransactionViolations} from '@libs/TransactionUtils';
22+
import {setActiveTransactionThreadIDs} from '@userActions/TransactionThreadNavigation';
23+
import CONST from '@src/CONST';
24+
import ROUTES from '@src/ROUTES';
25+
26+
function TransactionGroupListExpanded<TItem extends ListItem>({
27+
transactionsQueryJSON,
28+
showTooltip,
29+
canSelectMultiple,
30+
onCheckboxPress,
31+
columns,
32+
groupBy,
33+
accountID,
34+
isOffline,
35+
violations,
36+
areAllOptionalColumnsHidden: areAllOptionalColumnsHiddenProp,
37+
transactions,
38+
transactionsVisibleLimit,
39+
setTransactionsVisibleLimit,
40+
isEmpty,
41+
isGroupByReports,
42+
transactionsSnapshot,
43+
shouldDisplayEmptyView,
44+
searchTransactions,
45+
isInSingleTransactionReport,
46+
}: TransactionGroupListExpandedProps<TItem>) {
47+
const theme = useTheme();
48+
const styles = useThemeStyles();
49+
const {translate} = useLocalize();
50+
const {currentSearchHash} = useSearchContext();
51+
const transactionsSnapshotMetadata = useMemo(() => {
52+
return transactionsSnapshot?.search;
53+
}, [transactionsSnapshot]);
54+
55+
const visibleTransactions = useMemo(() => {
56+
if (isGroupByReports) {
57+
return transactions.slice(0, transactionsVisibleLimit);
58+
}
59+
return transactions;
60+
}, [transactions, transactionsVisibleLimit, isGroupByReports]);
61+
62+
const currentColumns = useMemo(() => {
63+
if (isGroupByReports) {
64+
return columns ?? [];
65+
}
66+
if (!transactionsSnapshot?.data) {
67+
return [];
68+
}
69+
const columnsToShow = getColumnsToShow(accountID, transactionsSnapshot?.data, false, transactionsSnapshot?.search.type === CONST.SEARCH.DATA_TYPES.TASK);
70+
71+
return (Object.keys(columnsToShow) as SearchColumnType[]).filter((col) => columnsToShow[col]);
72+
}, [accountID, columns, isGroupByReports, transactionsSnapshot?.data, transactionsSnapshot?.search.type]);
73+
74+
const areAllOptionalColumnsHidden = useMemo(() => {
75+
if (isGroupByReports) {
76+
return areAllOptionalColumnsHiddenProp ?? false;
77+
}
78+
const canBeMissingColumns = getExpenseHeaders(groupBy)
79+
.filter((header) => header.canBeMissing)
80+
.map((header) => header.columnName);
81+
return canBeMissingColumns.every((column) => !currentColumns.includes(column));
82+
}, [areAllOptionalColumnsHiddenProp, currentColumns, groupBy, isGroupByReports]);
83+
84+
// Currently only the transaction report groups have transactions where the empty view makes sense
85+
const shouldDisplayShowMoreButton = isGroupByReports ? transactions.length > transactionsVisibleLimit : !!transactionsSnapshotMetadata?.hasMoreResults && !isOffline;
86+
const currentOffset = transactionsSnapshotMetadata?.offset ?? 0;
87+
const shouldShowLoadingOnSearch = !!(!transactions?.length && transactionsSnapshotMetadata?.isLoading) || currentOffset > 0;
88+
const shouldDisplayLoadingIndicator = !isGroupByReports && !!transactionsSnapshotMetadata?.isLoading && shouldShowLoadingOnSearch;
89+
const {isLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
90+
91+
const {amountColumnSize, dateColumnSize, taxAmountColumnSize} = useMemo(() => {
92+
const isAmountColumnWide = transactions.some((transaction) => transaction.isAmountColumnWide);
93+
const isTaxAmountColumnWide = transactions.some((transaction) => transaction.isTaxAmountColumnWide);
94+
const shouldShowYearForSomeTransaction = transactions.some((transaction) => transaction.shouldShowYear);
95+
return {
96+
amountColumnSize: isAmountColumnWide ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL,
97+
taxAmountColumnSize: isTaxAmountColumnWide ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL,
98+
dateColumnSize: shouldShowYearForSomeTransaction ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL,
99+
};
100+
}, [transactions]);
101+
102+
const {markReportIDAsExpense} = useContext(WideRHPContext);
103+
const openReportInRHP = (transactionItem: TransactionListItemType) => {
104+
const backTo = Navigation.getActiveRoute();
105+
const reportID = getReportIDForTransaction(transactionItem);
106+
107+
const navigateToTransactionThread = () => {
108+
if (transactionItem.transactionThreadReportID === CONST.REPORT.UNREPORTED_REPORT_ID) {
109+
const iouAction = getReportAction(transactionItem.report?.reportID, transactionItem.moneyRequestReportActionID);
110+
createAndOpenSearchTransactionThread(transactionItem, iouAction, currentSearchHash, backTo);
111+
return;
112+
}
113+
markReportIDAsExpense(reportID);
114+
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, backTo}));
115+
};
116+
117+
// The arrow navigation in RHP is only allowed for group-by:reports
118+
if (!isGroupByReports) {
119+
navigateToTransactionThread();
120+
return;
121+
}
122+
123+
const siblingTransactionThreadIDs = transactions.map(getReportIDForTransaction);
124+
125+
// When opening the transaction thread in RHP we need to find every other ID for the rest of transactions
126+
// to display prev/next arrows in RHP for navigation
127+
setActiveTransactionThreadIDs(siblingTransactionThreadIDs).then(() => {
128+
// If we're trying to open a transaction without a transaction thread, let's create the thread and navigate the user
129+
navigateToTransactionThread();
130+
});
131+
};
132+
133+
const onShowMoreButtonPress = useCallback(() => {
134+
if (isGroupByReports) {
135+
setTransactionsVisibleLimit((currentPageSize) => currentPageSize + CONST.TRANSACTION.RESULTS_PAGE_SIZE);
136+
} else if (!isOffline && transactionsQueryJSON) {
137+
searchTransactions(CONST.SEARCH.RESULTS_PAGE_SIZE);
138+
}
139+
}, [isGroupByReports, isOffline, transactionsQueryJSON, setTransactionsVisibleLimit, searchTransactions]);
140+
141+
if (shouldDisplayEmptyView) {
142+
return (
143+
<View style={[styles.alignItemsCenter, styles.justifyContentCenter, styles.mnh13]}>
144+
<Text
145+
style={[styles.textLabelSupporting]}
146+
numberOfLines={1}
147+
>
148+
{translate('search.moneyRequestReport.emptyStateTitle')}
149+
</Text>
150+
</View>
151+
);
152+
}
153+
154+
return (
155+
<>
156+
{isLargeScreenWidth && (
157+
<View
158+
style={[styles.searchListHeaderContainerStyle, styles.groupSearchListTableContainerStyle, styles.bgTransparent, styles.pl9, isGroupByReports ? styles.pr10 : styles.pr3]}
159+
>
160+
<SearchTableHeader
161+
canSelectMultiple
162+
type={CONST.SEARCH.DATA_TYPES.EXPENSE}
163+
onSortPress={() => {}}
164+
sortOrder={undefined}
165+
sortBy={undefined}
166+
shouldShowYear={dateColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE}
167+
isAmountColumnWide={amountColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE}
168+
isTaxAmountColumnWide={taxAmountColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE}
169+
shouldShowSorting={false}
170+
columns={currentColumns}
171+
areAllOptionalColumnsHidden={areAllOptionalColumnsHidden ?? false}
172+
groupBy={groupBy}
173+
/>
174+
</View>
175+
)}
176+
{visibleTransactions.map((transaction) => (
177+
<OfflineWithFeedback
178+
pendingAction={transaction.pendingAction}
179+
key={transaction.transactionID}
180+
>
181+
<TransactionItemRow
182+
report={transaction.report}
183+
transactionItem={transaction}
184+
violations={getTransactionViolations(transaction, violations)}
185+
isSelected={!!transaction.isSelected}
186+
dateColumnSize={dateColumnSize}
187+
amountColumnSize={amountColumnSize}
188+
taxAmountColumnSize={taxAmountColumnSize}
189+
shouldShowTooltip={showTooltip}
190+
shouldUseNarrowLayout={!isLargeScreenWidth}
191+
shouldShowCheckbox={!!canSelectMultiple}
192+
onCheckboxPress={() => onCheckboxPress?.(transaction as unknown as TItem)}
193+
columns={currentColumns}
194+
onButtonPress={() => {
195+
openReportInRHP(transaction);
196+
}}
197+
style={[styles.noBorderRadius, shouldUseNarrowLayout ? [styles.p3, styles.pt2] : [styles.ph3, styles.pv1Half], isGroupByReports && styles.pr10]}
198+
isReportItemChild
199+
isInSingleTransactionReport={isInSingleTransactionReport}
200+
areAllOptionalColumnsHidden={areAllOptionalColumnsHidden}
201+
/>
202+
</OfflineWithFeedback>
203+
))}
204+
{shouldDisplayShowMoreButton && !shouldDisplayLoadingIndicator && (
205+
<View style={[styles.w100, styles.flexRow, isLargeScreenWidth && styles.pl10]}>
206+
<Button
207+
text={translate('common.showMore')}
208+
onPress={onShowMoreButtonPress}
209+
link
210+
shouldUseDefaultHover={false}
211+
isNested
212+
medium
213+
innerStyles={[styles.ph3]}
214+
textStyles={[styles.fontSizeNormal]}
215+
/>
216+
</View>
217+
)}
218+
{shouldDisplayLoadingIndicator && (
219+
<View style={[isLargeScreenWidth && styles.pl10, styles.pt3, isEmpty && styles.pb3]}>
220+
<ActivityIndicator
221+
color={theme.spinner}
222+
size={25}
223+
style={[styles.pl3, !isEmpty && styles.alignItemsStart]}
224+
/>
225+
</View>
226+
)}
227+
</>
228+
);
229+
}
230+
231+
TransactionGroupListExpanded.displayName = 'TransactionGroupListExpanded';
232+
233+
export default TransactionGroupListExpanded;

0 commit comments

Comments
 (0)