Skip to content

Commit

Permalink
Merge pull request #48615 from bernhardoj/fix/48036-animate-pay-button
Browse files Browse the repository at this point in the history
Animate settlement button when pay and trigger a haptic feedback
  • Loading branch information
roryabraham authored Sep 13, 2024
2 parents 0f2de40 + eb00272 commit 49b0051
Show file tree
Hide file tree
Showing 8 changed files with 249 additions and 126 deletions.
2 changes: 2 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ const CONST = {
},
// Multiplier for gyroscope animation in order to make it a bit more subtle
ANIMATION_GYROSCOPE_VALUE: 0.4,
ANIMATION_PAY_BUTTON_DURATION: 200,
ANIMATION_PAY_BUTTON_HIDE_DELAY: 1000,
BACKGROUND_IMAGE_TRANSITION_DURATION: 1000,
SCREEN_TRANSITION_END_TIMEOUT: 1000,
ARROW_HIDE_DELAY: 3000,
Expand Down
5 changes: 5 additions & 0 deletions src/components/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ type ButtonProps = Partial<ChildrenProps> & {
/** Additional styles to add after local styles. Applied to Pressable portion of button */
style?: StyleProp<ViewStyle>;

/** Additional styles to add to the component when it's disabled */
disabledStyle?: StyleProp<ViewStyle>;

/** Additional button styles. Specific to the OpacityView of the button */
innerStyles?: StyleProp<ViewStyle>;

Expand Down Expand Up @@ -206,6 +209,7 @@ function Button(
enterKeyEventListenerPriority = 0,

style = [],
disabledStyle,
innerStyles = [],
textStyles = [],
textHoverStyles = [],
Expand Down Expand Up @@ -381,6 +385,7 @@ function Button(
danger && !isDisabled ? styles.buttonDangerHovered : undefined,
hoverStyles,
]}
disabledStyle={disabledStyle}
id={id}
accessibilityLabel={accessibilityLabel}
role={CONST.ROLE.BUTTON}
Expand Down
2 changes: 2 additions & 0 deletions src/components/ButtonWithDropdownMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function ButtonWithDropdownMenu<IValueType>({
menuHeaderText = '',
customText,
style,
disabledStyle,
buttonSize = CONST.DROPDOWN_BUTTON_SIZE.MEDIUM,
anchorAlignment = {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
Expand Down Expand Up @@ -157,6 +158,7 @@ function ButtonWithDropdownMenu<IValueType>({
pressOnEnter={pressOnEnter}
isDisabled={isDisabled || !!options[0].disabled}
style={[styles.w100, style]}
disabledStyle={disabledStyle}
isLoading={isLoading}
text={selectedItem.text}
onPress={(event) => onPress(event, options[0].value)}
Expand Down
3 changes: 3 additions & 0 deletions src/components/ButtonWithDropdownMenu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ type ButtonWithDropdownMenuProps<TValueType> = {
/** Additional styles to add to the component */
style?: StyleProp<ViewStyle>;

/** Additional styles to add to the component when it's disabled */
disabledStyle?: StyleProp<ViewStyle>;

/** Menu options to display */
/** e.g. [{text: 'Pay with Expensify', icon: Wallet}] */
options: Array<DropdownOption<TValueType>>;
Expand Down
18 changes: 14 additions & 4 deletions src/components/ReportActionItem/ReportPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import truncate from 'lodash/truncate';
import React, {useMemo, useState} from 'react';
import React, {useCallback, useMemo, useState} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
Expand All @@ -12,7 +12,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu';
import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu';
import SettlementButton from '@components/SettlementButton';
import AnimatedSettlementButton from '@components/SettlementButton/AnimatedSettlementButton';
import {showContextMenuForReport} from '@components/ShowContextMenuContext';
import Text from '@components/Text';
import useDelegateUserDetails from '@hooks/useDelegateUserDetails';
Expand All @@ -23,6 +23,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import ControlSelection from '@libs/ControlSelection';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import HapticFeedback from '@libs/HapticFeedback';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReceiptUtils from '@libs/ReceiptUtils';
Expand Down Expand Up @@ -136,6 +137,7 @@ function ReportPreview({
[transactions, iouReportID, action],
);

const [isPaidAnimationRunning, setIsPaidAnimationRunning] = useState(false);
const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false);
const [requestType, setRequestType] = useState<ActionHandledType>();
const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(iouReport, policy);
Expand Down Expand Up @@ -196,6 +198,7 @@ function ReportPreview({
const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails();
const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false);

const stopAnimation = useCallback(() => setIsPaidAnimationRunning(false), []);
const confirmPayment = (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => {
if (!type) {
return;
Expand All @@ -207,6 +210,8 @@ function ReportPreview({
} else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) {
setIsHoldMenuVisible(true);
} else if (chatReport && iouReport) {
setIsPaidAnimationRunning(true);
HapticFeedback.longPress();
if (ReportUtils.isInvoiceReport(iouReport)) {
IOU.payInvoice(type, chatReport, iouReport, payAsBusiness);
} else {
Expand Down Expand Up @@ -306,7 +311,10 @@ function ReportPreview({

const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport);

const shouldShowPayButton = useMemo(() => IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions), [iouReport, chatReport, policy, allTransactions]);
const shouldShowPayButton = useMemo(
() => isPaidAnimationRunning || IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions),
[isPaidAnimationRunning, iouReport, chatReport, policy, allTransactions],
);

const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy), [iouReport, policy]);

Expand Down Expand Up @@ -473,7 +481,9 @@ function ReportPreview({
</View>
</View>
{shouldShowSettlementButton && (
<SettlementButton
<AnimatedSettlementButton
isPaidAnimationRunning={isPaidAnimationRunning}
onAnimationFinish={stopAnimation}
formattedAmount={getSettlementAmount() ?? ''}
currency={iouReport?.currency}
policyID={policyID}
Expand Down
93 changes: 93 additions & 0 deletions src/components/SettlementButton/AnimatedSettlementButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React, {useCallback, useEffect} from 'react';
import Animated, {runOnJS, useAnimatedStyle, useSharedValue, withDelay, withTiming} from 'react-native-reanimated';
import Text from '@components/Text';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import SettlementButton from '.';
import type SettlementButtonProps from './types';

type AnimatedSettlementButtonProps = SettlementButtonProps & {
isPaidAnimationRunning: boolean;
onAnimationFinish: () => void;
};

function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, isDisabled, ...settlementButtonProps}: AnimatedSettlementButtonProps) {
const styles = useThemeStyles();
const buttonScale = useSharedValue(1);
const buttonOpacity = useSharedValue(1);
const paymentCompleteTextScale = useSharedValue(0);
const paymentCompleteTextOpacity = useSharedValue(1);
const height = useSharedValue<number>(variables.componentSizeNormal);
const buttonStyles = useAnimatedStyle(() => ({
transform: [{scale: buttonScale.value}],
opacity: buttonOpacity.value,
}));
const paymentCompleteTextStyles = useAnimatedStyle(() => ({
transform: [{scale: paymentCompleteTextScale.value}],
opacity: paymentCompleteTextOpacity.value,
position: 'absolute',
alignSelf: 'center',
}));
const containerStyles = useAnimatedStyle(() => ({
height: height.value,
justifyContent: 'center',
overflow: 'hidden',
}));
const buttonDisabledStyle = isPaidAnimationRunning
? {
opacity: 1,
...styles.cursorDefault,
}
: undefined;

const resetAnimation = useCallback(() => {
// eslint-disable-next-line react-compiler/react-compiler
buttonScale.value = 1;
buttonOpacity.value = 1;
paymentCompleteTextScale.value = 0;
paymentCompleteTextOpacity.value = 1;
height.value = variables.componentSizeNormal;
}, [buttonScale, buttonOpacity, paymentCompleteTextScale, paymentCompleteTextOpacity, height]);

useEffect(() => {
if (!isPaidAnimationRunning) {
resetAnimation();
return;
}
// eslint-disable-next-line react-compiler/react-compiler
buttonScale.value = withTiming(0, {duration: CONST.ANIMATION_PAY_BUTTON_DURATION});
buttonOpacity.value = withTiming(0, {duration: CONST.ANIMATION_PAY_BUTTON_DURATION});
paymentCompleteTextScale.value = withTiming(1, {duration: CONST.ANIMATION_PAY_BUTTON_DURATION});

// Wait for the above animation + 1s delay before hiding the component
const totalDelay = CONST.ANIMATION_PAY_BUTTON_DURATION + CONST.ANIMATION_PAY_BUTTON_HIDE_DELAY;
height.value = withDelay(
totalDelay,
withTiming(0, {duration: CONST.ANIMATION_PAY_BUTTON_DURATION}, () => runOnJS(onAnimationFinish)()),
);
paymentCompleteTextOpacity.value = withDelay(totalDelay, withTiming(0, {duration: CONST.ANIMATION_PAY_BUTTON_DURATION}));
}, [isPaidAnimationRunning, onAnimationFinish, buttonOpacity, buttonScale, height, paymentCompleteTextOpacity, paymentCompleteTextScale, resetAnimation]);

return (
<Animated.View style={containerStyles}>
{isPaidAnimationRunning && (
<Animated.View style={paymentCompleteTextStyles}>
<Text style={[styles.buttonMediumText]}>Payment complete</Text>
</Animated.View>
)}
<Animated.View style={buttonStyles}>
<SettlementButton
// eslint-disable-next-line react/jsx-props-no-spreading
{...settlementButtonProps}
isDisabled={isPaidAnimationRunning || isDisabled}
disabledStyle={buttonDisabledStyle}
/>
</Animated.View>
</Animated.View>
);
}

AnimatedSettlementButton.displayName = 'AnimatedSettlementButton';

export default AnimatedSettlementButton;
Loading

0 comments on commit 49b0051

Please sign in to comment.