Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5c04cf8
animate submit button on report preview
allgandalf Jul 12, 2025
03af3b3
animate submit button on report / expense view page
allgandalf Jul 12, 2025
1489419
fix comments and imports
allgandalf Jul 12, 2025
443a5db
fix comments and remove redundant usage
allgandalf Jul 12, 2025
2791bee
add comments to props
allgandalf Jul 12, 2025
3273791
fix esLint
allgandalf Jul 12, 2025
2e46b63
Merge branch 'Expensify:main' into issue/55064
allgandalf Jul 13, 2025
0b8d132
Merge branch 'Expensify:main' into issue/55064
allgandalf Jul 15, 2025
938d8b5
fix width when showing loading indicator
allgandalf Jul 16, 2025
0389e5e
Merge branch 'main' into issue/55064
allgandalf Jul 16, 2025
96ab6fa
Merge branch 'Expensify:main' into issue/55064
allgandalf Jul 17, 2025
56253f9
Merge branch 'Expensify:main' into issue/55064
allgandalf Jul 23, 2025
8f02af9
Merge branch 'main' into issue/55064
allgandalf Jul 30, 2025
4f92a50
Update src/components/AnimatedSubmitButton/index.tsx
allgandalf Jul 30, 2025
d3eca11
Merge branch 'Expensify:main' into issue/55064
allgandalf Jul 31, 2025
1bb0f74
fix reviewer comments
allgandalf Jul 31, 2025
fea44e0
fix typefailure
allgandalf Jul 31, 2025
891579d
fix prettier
allgandalf Jul 31, 2025
926c016
Merge branch 'Expensify:main' into issue/55064
allgandalf Jul 31, 2025
f75d54a
Merge branch 'main' into issue/55064
allgandalf Aug 12, 2025
4ce0c27
Merge branch 'Expensify:main' into issue/55064
allgandalf Aug 14, 2025
3d458d4
apply reviewer suggestions to code
allgandalf Aug 14, 2025
38513db
fix prettier
allgandalf Aug 14, 2025
3fe37d7
Merge branch 'Expensify:main' into issue/55064
allgandalf Aug 18, 2025
a644db6
Merge branch 'Expensify:main' into issue/55064
allgandalf Aug 19, 2025
21499af
address design feedback
allgandalf Aug 19, 2025
388c32b
Merge branch 'Expensify:main' into issue/55064
allgandalf Aug 20, 2025
fd540cc
Merge branch 'main' into issue/55064
allgandalf Sep 8, 2025
3ca2d27
Merge branch 'main' into issue/55064
allgandalf Sep 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,12 @@ const CONST = {
// Multiplier for gyroscope animation in order to make it a bit more subtle
ANIMATION_GYROSCOPE_VALUE: 0.4,
ANIMATION_PAID_DURATION: 200,
ANIMATION_SUBMIT_DURATION: 200,
ANIMATION_SUBMIT_LOADING_STATE_DURATION: 1000,
ANIMATION_SUBMIT_SUBMITTED_STATE_VISIBLE_DURATION: 1500,
ANIMATION_PAID_CHECKMARK_DELAY: 300,
ANIMATION_THUMBS_UP_DURATION: 250,
ANIMATION_SUBMITTED_DURATION: 250,
ANIMATION_THUMBS_UP_DELAY: 200,
ANIMATION_PAID_BUTTON_HIDE_DELAY: 300,
BACKGROUND_IMAGE_TRANSITION_DURATION: 1000,
Expand Down
139 changes: 139 additions & 0 deletions src/components/AnimatedSubmitButton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import Animated, {Keyframe, runOnJS, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';

type AnimatedSubmitButtonProps = {
// Whether to show the success state
success: boolean | undefined;

// Text to show on the button
text: string;

// Function to call when the button is pressed
onPress: () => void;

// Whether the animation is running
isSubmittingAnimationRunning: boolean;

// Function to call when the animation finishes
onAnimationFinish: () => void;
};

function AnimatedSubmitButton({success, text, onPress, isSubmittingAnimationRunning, onAnimationFinish}: AnimatedSubmitButtonProps) {
Comment thread
luacmartins marked this conversation as resolved.
const styles = useThemeStyles();
const {translate} = useLocalize();
const isAnimationRunning = isSubmittingAnimationRunning;
const buttonDuration = isSubmittingAnimationRunning ? CONST.ANIMATION_SUBMIT_DURATION : CONST.ANIMATION_SUBMITTED_DURATION;
const gap = styles.expenseAndReportPreviewTextButtonContainer.gap;
const buttonMarginTop = useSharedValue<number>(gap);
const height = useSharedValue<number>(variables.componentSizeNormal);
const [canShow, setCanShow] = useState(true);
const [minWidth, setMinWidth] = useState<number>(0);
const [isShowingLoading, setIsShowingLoading] = useState(false);
const viewRef = useRef<HTMLElement | null>(null);

const containerStyles = useAnimatedStyle(() => ({
height: height.get(),
justifyContent: 'center',
}));

const stretchOutY = useCallback(() => {
'worklet';

if (canShow) {
runOnJS(onAnimationFinish)();
return;
}
height.set(withTiming(0, {duration: buttonDuration}, () => runOnJS(onAnimationFinish)()));
}, [buttonDuration, height, onAnimationFinish, canShow]);

const buttonAnimation = useMemo(
() =>
new Keyframe({
from: {
opacity: 1,
transform: [{scale: 1}],
},
to: {
opacity: 0,
transform: [{scale: 0}],
},
})
.duration(buttonDuration)
.withCallback(stretchOutY),
[buttonDuration, stretchOutY],
);
const icon = isAnimationRunning ? Expensicons.Send : null;

useEffect(() => {
if (!isAnimationRunning) {
setMinWidth(0);
setCanShow(true);
setIsShowingLoading(false);
height.set(variables.componentSizeNormal);
buttonMarginTop.set(0);
return;
}

setMinWidth(viewRef.current?.getBoundingClientRect?.().width ?? 0);
setIsShowingLoading(true);

const timer = setTimeout(() => {
setIsShowingLoading(false);
}, CONST.ANIMATION_SUBMIT_LOADING_STATE_DURATION);
Comment thread
allgandalf marked this conversation as resolved.

return () => clearTimeout(timer);
}, [buttonMarginTop, gap, height, isAnimationRunning]);

useEffect(() => {
if (!isAnimationRunning || isShowingLoading) {
return;
}

const timer = setTimeout(() => setCanShow(false), CONST.ANIMATION_SUBMIT_SUBMITTED_STATE_VISIBLE_DURATION);

return () => clearTimeout(timer);
}, [isAnimationRunning, isShowingLoading]);

// eslint-disable-next-line react-compiler/react-compiler
const showLoading = isShowingLoading || (!viewRef.current && isAnimationRunning);

return (
<Animated.View style={[containerStyles, {minWidth}]}>
{isAnimationRunning && canShow && (
<Animated.View
ref={(el) => {
viewRef.current = el as HTMLElement | null;
}}
exiting={buttonAnimation}
>
<Button
success={success}
text={showLoading ? text : translate('common.submitted')}
isLoading={showLoading}
icon={!showLoading ? icon : undefined}
isDisabled
shouldStayNormalOnDisable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Honestly, I don't like this approach too much. Is it possible to use PressableWithoutFeedback here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

PressableWithoutFeedback is not used in the Button component :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

what's wrong with this approach btw?, what do you suggest instead exactly ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We leverage isDisabled behavior, but we don't want to use the disabled style.
@luacmartins, what do you think about @allgandalf's approach? If that's okay with you, then I'm fine with it.

what do you suggest instead exactly ?

We use PressableWithoutFeedback, and style its child as a button 👀

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I agree with @hoangzinh. I think using PressableWithoutFeedback would make more sense here.

/>
</Animated.View>
)}
{!isAnimationRunning && (
<Button
success={success}
text={text}
onPress={onPress}
icon={icon}
/>
)}
</Animated.View>
);
}

AnimatedSubmitButton.displayName = 'AnimatedSubmitButton';

export default AnimatedSubmitButton;
35 changes: 23 additions & 12 deletions src/components/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ type ButtonProps = Partial<ChildrenProps> & {
* Reference to the outer element.
*/
ref?: ForwardedRef<View>;

/**
* Whether the button should stay visually normal even when disabled.
*/
shouldStayNormalOnDisable?: boolean;
};

type KeyboardShortcutComponentProps = Pick<ButtonProps, 'isDisabled' | 'isLoading' | 'onPress' | 'pressOnEnter' | 'allowBubble' | 'enterKeyEventListenerPriority' | 'isPressOnEnterActive'>;
Expand Down Expand Up @@ -271,6 +276,7 @@ function Button({
isNested = false,
secondLineText = '',
shouldBlendOpacity = false,
shouldStayNormalOnDisable = false,
ref,
...rest
}: ButtonProps) {
Expand Down Expand Up @@ -389,8 +395,8 @@ function Button({
StyleUtils.getButtonStyleWithIcon(styles, small, medium, large, !!icon, !!(text?.length > 0), shouldShowRightIcon),
success ? styles.buttonSuccess : undefined,
danger ? styles.buttonDanger : undefined,
isDisabled ? styles.buttonOpacityDisabled : undefined,
isDisabled && !danger && !success ? styles.buttonDisabled : undefined,
isDisabled && !shouldStayNormalOnDisable ? styles.buttonOpacityDisabled : undefined,
isDisabled && !danger && !success && !shouldStayNormalOnDisable ? styles.buttonDisabled : undefined,
shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
text && shouldShowRightIcon ? styles.alignItemsStretch : undefined,
Expand All @@ -413,6 +419,7 @@ function Button({
styles,
success,
text,
shouldStayNormalOnDisable,
],
);

Expand Down Expand Up @@ -483,28 +490,32 @@ function Button({
shouldBlendOpacity={shouldBlendOpacity}
disabled={isLoading || isDisabled}
wrapperStyle={[
isDisabled ? {...styles.cursorDisabled, ...styles.noSelect} : {},
isDisabled && !shouldStayNormalOnDisable ? {...styles.cursorDisabled, ...styles.noSelect} : {},
styles.buttonContainer,
shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
style,
]}
style={buttonContainerStyles}
isNested={isNested}
hoverStyle={[
shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined,
success && !isDisabled ? styles.buttonSuccessHovered : undefined,
danger && !isDisabled ? styles.buttonDangerHovered : undefined,
hoverStyles,
]}
disabledStyle={disabledStyle}
hoverStyle={
!isDisabled || !shouldStayNormalOnDisable
? [
shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined,
success && !isDisabled ? styles.buttonSuccessHovered : undefined,
danger && !isDisabled ? styles.buttonDangerHovered : undefined,
hoverStyles,
]
: []
}
disabledStyle={!shouldStayNormalOnDisable ? disabledStyle : undefined}
id={id}
testID={testID}
accessibilityLabel={accessibilityLabel}
role={getButtonRole(isNested)}
hoverDimmingValue={1}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
onHoverIn={!isDisabled || !shouldStayNormalOnDisable ? () => setIsHovered(true) : undefined}
onHoverOut={!isDisabled || !shouldStayNormalOnDisable ? () => setIsHovered(false) : undefined}
>
{shouldBlendOpacity && <View style={[StyleSheet.absoluteFill, buttonBlendForegroundStyle]} />}
{renderContent()}
Expand Down
26 changes: 13 additions & 13 deletions src/components/MoneyReportHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import type * as OnyxTypes from '@src/types/onyx';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type IconAsset from '@src/types/utils/IconAsset';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
import AnimatedSubmitButton from './AnimatedSubmitButton';
import BrokenConnectionDescription from './BrokenConnectionDescription';
import Button from './Button';
import ButtonWithDropdownMenu from './ButtonWithDropdownMenu';
Expand Down Expand Up @@ -262,7 +263,8 @@ function MoneyReportHeader({

const [exportModalStatus, setExportModalStatus] = useState<ExportType | null>(null);

const {isPaidAnimationRunning, isApprovedAnimationRunning, startAnimation, stopAnimation, startApprovedAnimation} = usePaymentAnimations();
const {isPaidAnimationRunning, isApprovedAnimationRunning, isSubmittingAnimationRunning, startAnimation, stopAnimation, startApprovedAnimation, startSubmittingAnimation} =
usePaymentAnimations();
const styles = useThemeStyles();
const theme = useTheme();
const {translate} = useLocalize();
Expand Down Expand Up @@ -555,13 +557,6 @@ function MoneyReportHeader({
}, [dismissedHoldUseExplanation, isLoadingHoldUseExplained, isOnHold]);

const primaryAction = useMemo(() => {
// It's necessary to allow payment animation to finish before button is changed
if (isPaidAnimationRunning || isApprovedAnimationRunning) {
return CONST.REPORT.PRIMARY_ACTIONS.PAY;
}
if (!moneyRequestReport) {
return '';
}
return getReportPrimaryAction({
report: moneyRequestReport,
chatReport,
Expand All @@ -572,18 +567,20 @@ function MoneyReportHeader({
reportActions,
isChatReportArchived,
invoiceReceiverPolicy,
isPaidAnimationRunning,
isSubmittingAnimationRunning,
});
}, [
isPaidAnimationRunning,
isApprovedAnimationRunning,
isSubmittingAnimationRunning,
moneyRequestReport,
reportNameValuePairs,
policy,
chatReport,
transactions,
violations,
policy,
reportNameValuePairs,
reportActions,
isChatReportArchived,
chatReport,
invoiceReceiverPolicy,
]);

Expand Down Expand Up @@ -758,13 +755,14 @@ function MoneyReportHeader({

const primaryActionsImplementation = {
[CONST.REPORT.PRIMARY_ACTIONS.SUBMIT]: (
<Button
<AnimatedSubmitButton
success
text={translate('common.submit')}
onPress={() => {
if (!moneyRequestReport) {
return;
}
startSubmittingAnimation();
submitReport(moneyRequestReport);
if (currentSearchQueryJSON) {
search({
Expand All @@ -775,6 +773,8 @@ function MoneyReportHeader({
});
}
}}
isSubmittingAnimationRunning={isSubmittingAnimationRunning}
onAnimationFinish={stopAnimation}
/>
),
[CONST.REPORT.PRIMARY_ACTIONS.APPROVE]: (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, {useCallback, useContext, useDeferredValue, useEffect, useMemo, us
import {ActivityIndicator, FlatList, View} from 'react-native';
import type {ListRenderItemInfo, ViewToken} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withDelay, withSpring, withTiming} from 'react-native-reanimated';
import AnimatedSubmitButton from '@components/AnimatedSubmitButton';
import Button from '@components/Button';
import {getButtonRole} from '@components/Button/utils';
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
Expand Down Expand Up @@ -138,7 +139,8 @@ function MoneyRequestReportPreviewContent({
[transactions, iouReportID, action],
);

const {isPaidAnimationRunning, isApprovedAnimationRunning, stopAnimation, startAnimation, startApprovedAnimation} = usePaymentAnimations();
const {isPaidAnimationRunning, isApprovedAnimationRunning, isSubmittingAnimationRunning, stopAnimation, startAnimation, startApprovedAnimation, startSubmittingAnimation} =
usePaymentAnimations();
const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false);
const [requestType, setRequestType] = useState<ActionHandledType>();
const [paymentType, setPaymentType] = useState<PaymentMethodType>();
Expand Down Expand Up @@ -312,7 +314,7 @@ function MoneyRequestReportPreviewContent({
const totalAmountStyle = shouldUseNarrowLayout ? [styles.flexColumnReverse, styles.alignItemsStretch] : [styles.flexRow, styles.alignItemsCenter];

useEffect(() => {
if (!isPaidAnimationRunning || isApprovedAnimationRunning) {
if (!isPaidAnimationRunning || isApprovedAnimationRunning || isSubmittingAnimationRunning) {
return;
}

Expand Down Expand Up @@ -454,12 +456,17 @@ function MoneyRequestReportPreviewContent({
}, [iouReportID]);

const reportPreviewAction = useMemo(() => {
// It's necessary to allow payment animation to finish before button is changed
if (isPaidAnimationRunning || isApprovedAnimationRunning) {
return CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY;
}
return getReportPreviewAction(violations, isIouReportArchived || isChatReportArchived, iouReport, policy, transactions, invoiceReceiverPolicy);
}, [isPaidAnimationRunning, isApprovedAnimationRunning, violations, isIouReportArchived, isChatReportArchived, iouReport, policy, transactions, invoiceReceiverPolicy]);
return getReportPreviewAction(
violations,
isIouReportArchived || isChatReportArchived,
iouReport,
policy,
transactions,
invoiceReceiverPolicy,
isPaidAnimationRunning,
isSubmittingAnimationRunning,
);
}, [isPaidAnimationRunning, isSubmittingAnimationRunning, violations, iouReport, policy, transactions, isIouReportArchived, invoiceReceiverPolicy, isChatReportArchived]);

const addExpenseDropdownOptions = useMemo(
() => [
Expand Down Expand Up @@ -499,10 +506,15 @@ function MoneyRequestReportPreviewContent({

const reportPreviewActions = {
[CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT]: (
<Button
<AnimatedSubmitButton
success={isWaitingForSubmissionFromCurrentUser}
text={translate('common.submit')}
onPress={() => submitReport(iouReport)}
text={translate('iou.submitAmount', {amount: getTotalAmountForIOUReportPreviewButton(iouReport, policy, reportPreviewAction)})}
onPress={() => {
startSubmittingAnimation();
submitReport(iouReport);
}}
isSubmittingAnimationRunning={isSubmittingAnimationRunning}
onAnimationFinish={stopAnimation}
/>
),
[CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE]: (
Expand Down
Loading
Loading