diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index aba80c8a8f..ae7a7717a5 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -1,13 +1,19 @@ import { render, renderHook } from '@testing-library/react-native'; -import { act } from 'react'; +import { act, createRef } from 'react'; +import type { View } from 'react-native'; import GestureHandlerRootView from '../components/GestureHandlerRootView'; import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; import { State } from '../State'; -import { RectButton, Touchable } from '../v3/components'; +import { Pressable, RectButton, Touchable } from '../v3/components'; +import { setAndForwardAnimatableRef } from '../v3/components/animatableRef'; import { usePanGesture } from '../v3/hooks/gestures'; import type { SingleGesture } from '../v3/types'; +type AnimatableViewRef = View & { + getAnimatableRef?: () => View | null; +}; + describe('[API v3] Hooks', () => { test('Pan gesture', () => { const onBegin = jest.fn(); @@ -34,6 +40,66 @@ describe('[API v3] Hooks', () => { }); describe('[API v3] Components', () => { + test('Pressable exposes the native button as an animatable ref', () => { + const ref = createRef(); + + render( + + + + ); + + expect(ref.current?.getAnimatableRef?.()).toBe(ref.current); + }); + + test('Pressable forwards function refs on mount and unmount', () => { + const ref = jest.fn(); + + const { unmount } = render( + + + + ); + + expect(ref).toHaveBeenCalledWith(expect.anything()); + + unmount(); + + expect(ref).toHaveBeenLastCalledWith(null); + }); + + test('Pressable animatable refs stay bound to their host instance', () => { + const ref = createRef(); + const renderPressable = (key: string) => ( + + + + ); + const { rerender } = render(renderPressable('first')); + const firstRef = ref.current; + + rerender(renderPressable('second')); + const secondRef = ref.current; + + expect(firstRef?.getAnimatableRef?.()).toBe(firstRef); + expect(secondRef?.getAnimatableRef?.()).toBe(secondRef); + }); + + test('setAndForwardAnimatableRef preserves an existing animatable ref', () => { + const localRef = createRef(); + const forwardedRef = createRef(); + const existingAnimatableRef = jest.fn(); + const hostRef = { + getAnimatableRef: existingAnimatableRef, + } as unknown as AnimatableViewRef; + + setAndForwardAnimatableRef(localRef, forwardedRef, hostRef); + + expect(localRef.current).toBe(hostRef); + expect(forwardedRef.current).toBe(hostRef); + expect(hostRef.getAnimatableRef).toBe(existingAnimatableRef); + }); + test('Rect Button', () => { const pressFn = jest.fn(); @@ -61,6 +127,51 @@ describe('[API v3] Components', () => { }); describe('Touchable', () => { + test('exposes the native button as an animatable ref', () => { + const ref = createRef(); + + render( + + + + ); + + expect(ref.current?.getAnimatableRef?.()).toBe(ref.current); + }); + + test('forwards function refs on mount and unmount', () => { + const ref = jest.fn(); + + const { unmount } = render( + + + + ); + + expect(ref).toHaveBeenCalledWith(expect.anything()); + + unmount(); + + expect(ref).toHaveBeenLastCalledWith(null); + }); + + test('animatable refs stay bound to their host instance', () => { + const ref = createRef(); + const renderTouchable = (key: string) => ( + + + + ); + const { rerender } = render(renderTouchable('first')); + const firstRef = ref.current; + + rerender(renderTouchable('second')); + const secondRef = ref.current; + + expect(firstRef?.getAnimatableRef?.()).toBe(firstRef); + expect(secondRef?.getAnimatableRef?.()).toBe(secondRef); + }); + test('calls onPress on successful press', () => { const pressFn = jest.fn(); diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx index fd9d267316..d8ccd21800 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -42,6 +42,7 @@ import { useNativeGesture, useSimultaneousGestures, } from '../hooks'; +import { setAndForwardAnimatableRef } from './animatableRef'; import { PureNativeButton } from './GestureButtons'; const DEFAULT_LONG_PRESS_DURATION = 500; @@ -72,6 +73,7 @@ const Pressable = (props: PressableProps) => { simultaneousWith, requireToFail, block, + ref, ...remainingProps } = props; @@ -81,10 +83,19 @@ const Pressable = (props: PressableProps) => { const pressDelayTimeoutRef = useRef(null); const isOnPressAllowed = useRef(true); const isCurrentlyPressed = useRef(false); + const buttonRef = useRef | null>( + null + ); const dimensions = useRef({ width: 0, height: 0, }); + const setButtonRef = useCallback( + (button: React.ComponentRef | null) => { + setAndForwardAnimatableRef(buttonRef, ref, button); + }, + [ref] + ); const normalizedHitSlop: Insets = useMemo( () => @@ -399,6 +410,7 @@ const Pressable = (props: PressableProps) => { { const longPressTimeout = useRef | undefined>( undefined ); + const buttonRef = useRef | null>(null); + const setButtonRef = useCallback( + (button: React.ComponentRef | null) => { + setAndForwardAnimatableRef(buttonRef, ref, button); + }, + [ref] + ); const wrappedLongPress = useCallback(() => { longPressDetected.current = true; @@ -218,7 +228,7 @@ export const Touchable = (props: TouchableProps) => { {...tvProps} {...rippleProps} {...resolvedDurations} - ref={ref ?? null} + ref={setButtonRef} enabled={!disabled} defaultOpacity={defaultOpacity} defaultUnderlayOpacity={defaultUnderlayOpacity} diff --git a/packages/react-native-gesture-handler/src/v3/components/animatableRef.ts b/packages/react-native-gesture-handler/src/v3/components/animatableRef.ts new file mode 100644 index 0000000000..8d399aabb4 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/animatableRef.ts @@ -0,0 +1,24 @@ +import type React from 'react'; + +type AnimatableRef = T & { + getAnimatableRef?: () => T | null; +}; + +export function setAndForwardAnimatableRef( + localRef: React.MutableRefObject, + forwardedRef: React.Ref | undefined, + ref: T | null +) { + localRef.current = ref; + + const animatableRef = ref as AnimatableRef | null; + if (animatableRef && !animatableRef.getAnimatableRef) { + animatableRef.getAnimatableRef = () => ref; + } + + if (typeof forwardedRef === 'function') { + forwardedRef(ref); + } else if (forwardedRef) { + (forwardedRef as React.MutableRefObject).current = ref; + } +}