diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2d04410ee852..907c97b1e520 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -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, diff --git a/src/components/AnimatedSubmitButton/index.tsx b/src/components/AnimatedSubmitButton/index.tsx new file mode 100644 index 000000000000..aabe97dca511 --- /dev/null +++ b/src/components/AnimatedSubmitButton/index.tsx @@ -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) { + 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(gap); + const height = useSharedValue(variables.componentSizeNormal); + const [canShow, setCanShow] = useState(true); + const [minWidth, setMinWidth] = useState(0); + const [isShowingLoading, setIsShowingLoading] = useState(false); + const viewRef = useRef(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); + + 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 ( + + {isAnimationRunning && canShow && ( + { + viewRef.current = el as HTMLElement | null; + }} + exiting={buttonAnimation} + > +