Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
be2519e
refactor(ReanimatedModal): follow Rules of React, compile with React …
roryabraham May 13, 2026
2e0c129
test(ReanimatedModal): add regression tests for transition state machine
roryabraham May 13, 2026
1651cd0
fix(ReanimatedModal): stabilize back handler with useEffectEvent
roryabraham May 13, 2026
27fe5c7
fix(ReanimatedModal): replace isContainerOpen bool with four-state enum
roryabraham May 13, 2026
d7914a6
refactor(ReanimatedModal): move handle lifecycle to animation callbacks
roryabraham May 14, 2026
ffc7abe
refactor: move useAnimationTransition to src/hooks
roryabraham May 14, 2026
d32c60b
Remove unnecessary useCallback
roryabraham May 14, 2026
761ed77
refactor(ReanimatedModal): extract useOnValueChange utility hook
roryabraham May 14, 2026
7fa3582
docs(useOnValueChange): expand JSDoc with usage guidance
roryabraham May 14, 2026
6fe42b9
Don't nest useEffectEvent
roryabraham May 14, 2026
6d187c1
refactor(ReanimatedModal): rename onBackButtonPressHandler to tryClose
roryabraham May 14, 2026
20c6428
Fix prettier
roryabraham May 14, 2026
3398d76
fix(types): make swipeThreshold optional in ReanimatedModalProps
roryabraham May 14, 2026
4195eaa
fix(types): make children optional in ReanimatedModalProps
roryabraham May 14, 2026
5525272
Merge remote-tracking branch 'origin/main' into Rory-ReanimatedModal-v2
roryabraham May 14, 2026
d62eb4d
fix: initialize modalState to 'open' when mounted with isVisible=true
roryabraham May 14, 2026
c311c94
refactor(ReanimatedModal): compile Container, Backdrop & GestureHandl…
roryabraham May 14, 2026
67effd7
fix(lint): remove unused jsx-props-no-spreading disable directive
roryabraham May 14, 2026
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
2 changes: 0 additions & 2 deletions config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,6 @@
"../../src/components/Modal/BaseModal.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/Modal/Global/ConfirmModalWrapper.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1
"../../src/components/Modal/ReanimatedModal/Container/index.web.tsx" "react-hooks/refs" 1
"../../src/components/Modal/ReanimatedModal/index.tsx" "@typescript-eslint/no-deprecated/InteractionManager.clearInteractionHandle" 1
"../../src/components/Modal/ReanimatedModal/index.tsx" "@typescript-eslint/no-deprecated/InteractionManager.createInteractionHandle" 1
"../../src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1
"../../src/components/MoneyReportHeaderModals.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1
"../../src/components/MoneyRequestAmountInput.tsx" "react-hooks/immutability" 2
Expand Down
38 changes: 23 additions & 15 deletions src/components/Modal/ReanimatedModal/Backdrop/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React from 'react';
import Animated, {Keyframe} from 'react-native-reanimated';
import Animated, {Keyframe, ReduceMotion} from 'react-native-reanimated';
import type {BackdropProps} from '@components/Modal/ReanimatedModal/types';
import {getModalInAnimation, getModalOutAnimation} from '@components/Modal/ReanimatedModal/utils';
import {PressableWithoutFeedback} from '@components/Pressable';
import useAnimationTransition from '@hooks/useAnimationTransition';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
Expand All @@ -18,19 +19,14 @@ function Backdrop({
}: BackdropProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {onAnimationComplete} = useAnimationTransition();

const Entering = new Keyframe(getModalInAnimation('fadeIn')).duration(animationInTiming);
const Exiting = new Keyframe(getModalOutAnimation('fadeOut')).duration(animationOutTiming);

const BackdropOverlay = (
<Animated.View
entering={Entering}
exiting={Exiting}
style={[styles.modalBackdrop, {opacity: backdropOpacity}, style]}
>
{!!customBackdrop && customBackdrop}
</Animated.View>
);
const Entering = new Keyframe(getModalInAnimation('fadeIn'))
.duration(animationInTiming)
// ReduceMotion.Never ensures the callback fires even when system motion is reduced
.reduceMotion(ReduceMotion.Never)
.withCallback(onAnimationComplete);
const Exiting = new Keyframe(getModalOutAnimation('fadeOut')).duration(animationOutTiming).reduceMotion(ReduceMotion.Never).withCallback(onAnimationComplete);

if (!customBackdrop) {
return (
Expand All @@ -40,12 +36,24 @@ function Backdrop({
onPressIn={onBackdropPress}
sentryLabel={CONST.SENTRY_LABEL.REANIMATED_MODAL.BACKDROP}
>
{BackdropOverlay}
<Animated.View
entering={Entering}
exiting={Exiting}
style={[styles.modalBackdrop, {opacity: backdropOpacity}, style]}
/>
</PressableWithoutFeedback>
);
}

return BackdropOverlay;
return (
<Animated.View
entering={Entering}
exiting={Exiting}
style={[styles.modalBackdrop, {opacity: backdropOpacity}, style]}
>
{customBackdrop}
</Animated.View>
);
}

export default Backdrop;
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {PropsWithChildren} from 'react';
import React, {useMemo} from 'react';
import React from 'react';
import type {GestureStateChangeEvent, GestureType, PanGestureHandlerEventPayload} from 'react-native-gesture-handler';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import {useSharedValue} from 'react-native-reanimated';
Expand Down Expand Up @@ -52,18 +52,14 @@ function hasSwipeEnded(
function GestureHandler({swipeDirection, onSwipeComplete, swipeThreshold = 100, children}: PropsWithChildren<GestureHandlerProps>) {
const initialTranslationX = useSharedValue(0);
const initialTranslationY = useSharedValue(0);
const panGesture: GestureType = useMemo(
() =>
Gesture.Pan()
.onStart((e) => {
initialTranslationX.set(e.translationX);
initialTranslationY.set(e.translationY);
})
.onEnd((e) => {
hasSwipeEnded(e, {x: initialTranslationX.get(), y: initialTranslationY.get()}, swipeThreshold, swipeDirection, onSwipeComplete);
}),
[initialTranslationX, initialTranslationY, onSwipeComplete, swipeDirection, swipeThreshold],
);
const panGesture: GestureType = Gesture.Pan()
.onStart((e) => {
initialTranslationX.set(e.translationX);
initialTranslationY.set(e.translationY);
})
.onEnd((e) => {
hasSwipeEnded(e, {x: initialTranslationX.get(), y: initialTranslationY.get()}, swipeThreshold, swipeDirection, onSwipeComplete);
});

if (!swipeDirection || !swipeDirection?.length || !onSwipeComplete) {
return children;
Expand Down
33 changes: 14 additions & 19 deletions src/components/Modal/ReanimatedModal/Container/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React, {useMemo} from 'react';
import React from 'react';
import {View} from 'react-native';
import Animated, {Keyframe} from 'react-native-reanimated';
import {scheduleOnRN} from 'react-native-worklets';
import type ReanimatedModalProps from '@components/Modal/ReanimatedModal/types';
import type {ContainerProps} from '@components/Modal/ReanimatedModal/types';
import {getModalInAnimation, getModalOutAnimation} from '@components/Modal/ReanimatedModal/utils';
import useAnimationTransition from '@hooks/useAnimationTransition';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import GestureHandler from './GestureHandler';
Expand All @@ -22,28 +22,23 @@ function Container({
swipeDirection,
swipeThreshold = 100,
...props
}: Partial<ReanimatedModalProps> & ContainerProps) {
}: ContainerProps) {
const styles = useThemeStyles();
const {onAnimationComplete} = useAnimationTransition();

const Entering = useMemo(() => {
const AnimationIn = new Keyframe(getModalInAnimation(animationIn));
const Entering = new Keyframe(getModalInAnimation(animationIn)).duration(animationInTiming).withCallback(() => {
'worklet';

return AnimationIn.duration(animationInTiming).withCallback(() => {
'worklet';
scheduleOnRN(onOpenCallBack);
scheduleOnRN(onAnimationComplete);
});

scheduleOnRN(onOpenCallBack);
});
}, [animationIn, animationInTiming, onOpenCallBack]);
const Exiting = new Keyframe(getModalOutAnimation(animationOut)).duration(animationOutTiming).withCallback(() => {
'worklet';

const Exiting = useMemo(() => {
const AnimationOut = new Keyframe(getModalOutAnimation(animationOut));

return AnimationOut.duration(animationOutTiming).withCallback(() => {
'worklet';

scheduleOnRN(onCloseCallBack);
});
}, [animationOutTiming, onCloseCallBack, animationOut]);
scheduleOnRN(onCloseCallBack);
scheduleOnRN(onAnimationComplete);
});

return (
<View
Expand Down
37 changes: 18 additions & 19 deletions src/components/Modal/ReanimatedModal/Container/index.web.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, {useEffect, useMemo, useRef} from 'react';
import React, {useEffect} from 'react';
import Animated, {Keyframe, ReduceMotion, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import type ReanimatedModalProps from '@components/Modal/ReanimatedModal/types';
import type {ContainerProps} from '@components/Modal/ReanimatedModal/types';
import {easing, getModalInAnimationStyle, getModalOutAnimation} from '@components/Modal/ReanimatedModal/utils';
import useAnimationTransition from '@hooks/useAnimationTransition';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';

Expand All @@ -18,14 +19,10 @@ function Container({
...props
}: ReanimatedModalProps & ContainerProps) {
const styles = useThemeStyles();
const onCloseCallbackRef = useRef(onCloseCallBack);
const {onAnimationComplete} = useAnimationTransition();
const initProgress = useSharedValue(0);
const isInitiated = useSharedValue(false);

useEffect(() => {
onCloseCallbackRef.current = onCloseCallBack;
}, [onCloseCallBack]);

useEffect(() => {
if (isInitiated.get()) {
return;
Expand All @@ -41,24 +38,26 @@ function Container({
// we enable the animations to make sure they are called
reduceMotion: ReduceMotion.Never,
},
onOpenCallBack,
() => {
onOpenCallBack();
onAnimationComplete();
},
),
);
}, [animationInTiming, onOpenCallBack, initProgress, isInitiated]);
}, [animationInTiming, onOpenCallBack, onAnimationComplete, initProgress, isInitiated]);

// instead of an entering transition since keyframe animations break keyboard on mWeb Chrome (#62799)
const animatedStyles = useAnimatedStyle(() => getModalInAnimationStyle(animationIn)(initProgress.get()), [initProgress]);
const animatedStyles = useAnimatedStyle(() => getModalInAnimationStyle(animationIn)(initProgress.get()), [animationIn, initProgress]);

const Exiting = useMemo(
() =>
new Keyframe(getModalOutAnimation(animationOut))
.duration(animationOutTiming)
.withCallback(() => onCloseCallbackRef.current())
// on web the callbacks are not called when animations are disabled with the reduced motion setting on
// we enable the animations to make sure they are called
.reduceMotion(ReduceMotion.Never),
[animationOutTiming, animationOut],
);
const Exiting = new Keyframe(getModalOutAnimation(animationOut))
.duration(animationOutTiming)
.withCallback(() => {
onCloseCallBack();
onAnimationComplete();
})
// on web the callbacks are not called when animations are disabled with the reduced motion setting on
// we enable the animations to make sure they are called
.reduceMotion(ReduceMotion.Never);

return (
<Animated.View
Expand Down
Loading
Loading