diff --git a/src/handlers/gestureHandlerCommon.ts b/src/handlers/gestureHandlerCommon.ts index aaac5b8b98..57425615fd 100644 --- a/src/handlers/gestureHandlerCommon.ts +++ b/src/handlers/gestureHandlerCommon.ts @@ -45,7 +45,7 @@ export const baseGestureHandlerProps = [ 'onHandlerStateChange', ] as const; -export const baseGestureHandlerWithMonitorProps = [ +export const baseGestureHandlerWithDetectorProps = [ ...commonProps, 'needsPointerData', 'manualActivation', diff --git a/src/handlers/gestures/GestureDetector.tsx b/src/handlers/gestures/GestureDetector.tsx deleted file mode 100644 index 45d927c230..0000000000 --- a/src/handlers/gestures/GestureDetector.tsx +++ /dev/null @@ -1,889 +0,0 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; -import { - GestureType, - HandlerCallbacks, - BaseGesture, - GestureRef, - CALLBACK_TYPE, -} from './gesture'; -import { Reanimated, SharedValue } from './reanimatedWrapper'; -import { registerHandler, unregisterHandler } from '../handlersRegistry'; -import RNGestureHandlerModule from '../../RNGestureHandlerModule'; -import { - baseGestureHandlerWithMonitorProps, - filterConfig, - findNodeHandle, - GestureTouchEvent, - GestureUpdateEvent, - GestureStateChangeEvent, - HandlerStateChangeEvent, - scheduleFlushOperations, - UserSelect, - TouchAction, -} from '../gestureHandlerCommon'; -import { - GestureStateManager, - GestureStateManagerType, -} from './gestureStateManager'; -import { flingGestureHandlerProps } from '../FlingGestureHandler'; -import { forceTouchGestureHandlerProps } from '../ForceTouchGestureHandler'; -import { longPressGestureHandlerProps } from '../LongPressGestureHandler'; -import { - panGestureHandlerProps, - panGestureHandlerCustomNativeProps, -} from '../PanGestureHandler'; -import { tapGestureHandlerProps } from '../TapGestureHandler'; -import { hoverGestureHandlerProps } from './hoverGesture'; -import { State } from '../../State'; -import { TouchEventType } from '../../TouchEventType'; -import { ComposedGesture } from './gestureComposition'; -import { ActionType } from '../../ActionType'; -import { isFabric, isJestEnv, tagMessage } from '../../utils'; -import { getReactNativeVersion } from '../../getReactNativeVersion'; -import { getShadowNodeFromRef } from '../../getShadowNodeFromRef'; -import { Platform } from 'react-native'; -import type RNGestureHandlerModuleWeb from '../../RNGestureHandlerModule.web'; -import { onGestureHandlerEvent } from './eventReceiver'; -import { RNRenderer } from '../../RNRenderer'; -import { isNewWebImplementationEnabled } from '../../EnableNewWebImplementation'; -import { nativeViewGestureHandlerProps } from '../NativeViewGestureHandler'; -import GestureHandlerRootViewContext from '../../GestureHandlerRootViewContext'; -import { ghQueueMicrotask } from '../../ghQueueMicrotask'; - -declare const global: { - isFormsStackingContext: (node: unknown) => boolean | null; // JSI function -}; - -const ALLOWED_PROPS = [ - ...baseGestureHandlerWithMonitorProps, - ...tapGestureHandlerProps, - ...panGestureHandlerProps, - ...panGestureHandlerCustomNativeProps, - ...longPressGestureHandlerProps, - ...forceTouchGestureHandlerProps, - ...flingGestureHandlerProps, - ...hoverGestureHandlerProps, - ...nativeViewGestureHandlerProps, -]; - -export type GestureConfigReference = { - config: GestureType[]; - animatedEventHandler: unknown; - animatedHandlers: SharedValue< - HandlerCallbacks>[] | null - > | null; - firstExecution: boolean; - useReanimatedHook: boolean; -}; - -function convertToHandlerTag(ref: GestureRef): number { - if (typeof ref === 'number') { - return ref; - } else if (ref instanceof BaseGesture) { - return ref.handlerTag; - } else { - // @ts-ignore in this case it should be a ref either to gesture object or - // a gesture handler component, in both cases handlerTag property exists - return ref.current?.handlerTag ?? -1; - } -} - -function extractValidHandlerTags(interactionGroup: GestureRef[] | undefined) { - return ( - interactionGroup?.map(convertToHandlerTag)?.filter((tag) => tag > 0) ?? [] - ); -} - -function dropHandlers(preparedGesture: GestureConfigReference) { - for (const handler of preparedGesture.config) { - RNGestureHandlerModule.dropGestureHandler(handler.handlerTag); - - unregisterHandler(handler.handlerTag, handler.config.testId); - } - - scheduleFlushOperations(); -} - -function checkGestureCallbacksForWorklets(gesture: GestureType) { - // if a gesture is explicitly marked to run on the JS thread there is no need to check - // if callbacks are worklets as the user is aware they will be ran on the JS thread - if (gesture.config.runOnJS) { - return; - } - - const areSomeNotWorklets = gesture.handlers.isWorklet.includes(false); - const areSomeWorklets = gesture.handlers.isWorklet.includes(true); - - // if some of the callbacks are worklets and some are not, and the gesture is not - // explicitly marked with `.runOnJS(true)` show an error - if (areSomeNotWorklets && areSomeWorklets) { - console.error( - tagMessage( - `Some of the callbacks in the gesture are worklets and some are not. Either make sure that all calbacks are marked as 'worklet' if you wish to run them on the UI thread or use '.runOnJS(true)' modifier on the gesture explicitly to run all callbacks on the JS thread.` - ) - ); - } -} - -interface WebEventHandler { - onGestureHandlerEvent: (event: HandlerStateChangeEvent) => void; - onGestureHandlerStateChange?: ( - event: HandlerStateChangeEvent - ) => void; -} - -interface AttachHandlersConfig { - preparedGesture: GestureConfigReference; - gestureConfig: ComposedGesture | GestureType; - gesture: GestureType[]; - viewTag: number; - webEventHandlersRef: React.RefObject; - mountedRef: React.RefObject; -} - -function attachHandlers({ - preparedGesture, - gestureConfig, - gesture, - viewTag, - webEventHandlersRef, - mountedRef, -}: AttachHandlersConfig) { - if (!preparedGesture.firstExecution) { - gestureConfig.initialize(); - } else { - preparedGesture.firstExecution = false; - } - - // use queueMicrotask to extract handlerTags, because all refs should be initialized - // when it's ran - ghQueueMicrotask(() => { - if (!mountedRef.current) { - return; - } - gestureConfig.prepare(); - }); - - for (const handler of gesture) { - checkGestureCallbacksForWorklets(handler); - RNGestureHandlerModule.createGestureHandler( - handler.handlerName, - handler.handlerTag, - filterConfig(handler.config, ALLOWED_PROPS) - ); - - registerHandler(handler.handlerTag, handler, handler.config.testId); - } - - // use queueMicrotask to extract handlerTags, because all refs should be initialized - // when it's ran - ghQueueMicrotask(() => { - if (!mountedRef.current) { - return; - } - for (const handler of gesture) { - let requireToFail: number[] = []; - if (handler.config.requireToFail) { - requireToFail = extractValidHandlerTags(handler.config.requireToFail); - } - - let simultaneousWith: number[] = []; - if (handler.config.simultaneousWith) { - simultaneousWith = extractValidHandlerTags( - handler.config.simultaneousWith - ); - } - - let blocksHandlers: number[] = []; - if (handler.config.blocksHandlers) { - blocksHandlers = extractValidHandlerTags(handler.config.blocksHandlers); - } - - RNGestureHandlerModule.updateGestureHandler( - handler.handlerTag, - filterConfig(handler.config, ALLOWED_PROPS, { - simultaneousHandlers: simultaneousWith, - waitFor: requireToFail, - blocksHandlers: blocksHandlers, - }) - ); - } - - scheduleFlushOperations(); - }); - - preparedGesture.config = gesture; - - for (const gesture of preparedGesture.config) { - const actionType = gesture.shouldUseReanimated - ? ActionType.REANIMATED_WORKLET - : ActionType.JS_FUNCTION_NEW_API; - - if (Platform.OS === 'web') { - ( - RNGestureHandlerModule.attachGestureHandler as typeof RNGestureHandlerModuleWeb.attachGestureHandler - )( - gesture.handlerTag, - viewTag, - ActionType.JS_FUNCTION_OLD_API, // ignored on web - webEventHandlersRef - ); - } else { - RNGestureHandlerModule.attachGestureHandler( - gesture.handlerTag, - viewTag, - actionType - ); - } - } - - if (preparedGesture.animatedHandlers) { - const isAnimatedGesture = (g: GestureType) => g.shouldUseReanimated; - - preparedGesture.animatedHandlers.value = gesture - .filter(isAnimatedGesture) - .map((g) => g.handlers) as unknown as HandlerCallbacks< - Record - >[]; - } -} - -function updateHandlers( - preparedGesture: GestureConfigReference, - gestureConfig: ComposedGesture | GestureType, - gesture: GestureType[], - mountedRef: React.RefObject -) { - gestureConfig.prepare(); - - for (let i = 0; i < gesture.length; i++) { - const handler = preparedGesture.config[i]; - checkGestureCallbacksForWorklets(handler); - - // only update handlerTag when it's actually different, it may be the same - // if gesture config object is wrapped with useMemo - if (gesture[i].handlerTag !== handler.handlerTag) { - gesture[i].handlerTag = handler.handlerTag; - gesture[i].handlers.handlerTag = handler.handlerTag; - } - } - - // use queueMicrotask to extract handlerTags, because when it's ran, all refs should be updated - // and handlerTags in BaseGesture references should be updated in the loop above (we need to wait - // in case of external relations) - ghQueueMicrotask(() => { - if (!mountedRef.current) { - return; - } - for (let i = 0; i < gesture.length; i++) { - const handler = preparedGesture.config[i]; - - handler.config = gesture[i].config; - handler.handlers = gesture[i].handlers; - - const requireToFail = extractValidHandlerTags( - handler.config.requireToFail - ); - - const simultaneousWith = extractValidHandlerTags( - handler.config.simultaneousWith - ); - - RNGestureHandlerModule.updateGestureHandler( - handler.handlerTag, - filterConfig(handler.config, ALLOWED_PROPS, { - simultaneousHandlers: simultaneousWith, - waitFor: requireToFail, - }) - ); - - registerHandler(handler.handlerTag, handler, handler.config.testId); - } - - if (preparedGesture.animatedHandlers) { - const previousHandlersValue = - preparedGesture.animatedHandlers.value ?? []; - const newHandlersValue = preparedGesture.config - .filter((g) => g.shouldUseReanimated) // ignore gestures that shouldn't run on UI - .map((g) => g.handlers) as unknown as HandlerCallbacks< - Record - >[]; - - // if amount of gesture configs changes, we need to update the callbacks in shared value - let shouldUpdateSharedValue = - previousHandlersValue.length !== newHandlersValue.length; - - if (!shouldUpdateSharedValue) { - // if the amount is the same, we need to check if any of the configs inside has changed - for (let i = 0; i < newHandlersValue.length; i++) { - if ( - // we can use the `gestureId` prop as it's unique for every config instance - newHandlersValue[i].gestureId !== previousHandlersValue[i].gestureId - ) { - shouldUpdateSharedValue = true; - break; - } - } - } - - if (shouldUpdateSharedValue) { - preparedGesture.animatedHandlers.value = newHandlersValue; - } - } - - scheduleFlushOperations(); - }); -} - -function needsToReattach( - preparedGesture: GestureConfigReference, - gesture: GestureType[] -) { - if (gesture.length !== preparedGesture.config.length) { - return true; - } - for (let i = 0; i < gesture.length; i++) { - if ( - gesture[i].handlerName !== preparedGesture.config[i].handlerName || - gesture[i].shouldUseReanimated !== - preparedGesture.config[i].shouldUseReanimated - ) { - return true; - } - } - - return false; -} - -function isStateChangeEvent( - event: GestureUpdateEvent | GestureStateChangeEvent | GestureTouchEvent -): event is GestureStateChangeEvent { - 'worklet'; - // @ts-ignore Yes, the oldState prop is missing on GestureTouchEvent, that's the point - return event.oldState != null; -} - -function isTouchEvent( - event: GestureUpdateEvent | GestureStateChangeEvent | GestureTouchEvent -): event is GestureTouchEvent { - 'worklet'; - return event.eventType != null; -} - -function getHandler( - type: CALLBACK_TYPE, - gesture: HandlerCallbacks> -) { - 'worklet'; - switch (type) { - case CALLBACK_TYPE.BEGAN: - return gesture.onBegin; - case CALLBACK_TYPE.START: - return gesture.onStart; - case CALLBACK_TYPE.UPDATE: - return gesture.onUpdate; - case CALLBACK_TYPE.CHANGE: - return gesture.onChange; - case CALLBACK_TYPE.END: - return gesture.onEnd; - case CALLBACK_TYPE.FINALIZE: - return gesture.onFinalize; - case CALLBACK_TYPE.TOUCHES_DOWN: - return gesture.onTouchesDown; - case CALLBACK_TYPE.TOUCHES_MOVE: - return gesture.onTouchesMove; - case CALLBACK_TYPE.TOUCHES_UP: - return gesture.onTouchesUp; - case CALLBACK_TYPE.TOUCHES_CANCELLED: - return gesture.onTouchesCancelled; - } -} - -function touchEventTypeToCallbackType( - eventType: TouchEventType -): CALLBACK_TYPE { - 'worklet'; - switch (eventType) { - case TouchEventType.TOUCHES_DOWN: - return CALLBACK_TYPE.TOUCHES_DOWN; - case TouchEventType.TOUCHES_MOVE: - return CALLBACK_TYPE.TOUCHES_MOVE; - case TouchEventType.TOUCHES_UP: - return CALLBACK_TYPE.TOUCHES_UP; - case TouchEventType.TOUCHES_CANCELLED: - return CALLBACK_TYPE.TOUCHES_CANCELLED; - } - return CALLBACK_TYPE.UNDEFINED; -} - -function runWorklet( - type: CALLBACK_TYPE, - gesture: HandlerCallbacks>, - event: GestureStateChangeEvent | GestureUpdateEvent | GestureTouchEvent, - ...args: any[] -) { - 'worklet'; - const handler = getHandler(type, gesture); - if (gesture.isWorklet[type]) { - // @ts-ignore Logic below makes sure the correct event is send to the - // correct handler. - handler?.(event, ...args); - } else if (handler) { - console.warn(tagMessage('Animated gesture callback must be a worklet')); - } -} - -function useAnimatedGesture( - preparedGesture: GestureConfigReference, - needsRebuild: boolean -) { - if (!Reanimated) { - return; - } - - // Hooks are called conditionally, but the condition is whether the - // react-native-reanimated is installed, which shouldn't change while running - // eslint-disable-next-line react-hooks/rules-of-hooks - const sharedHandlersCallbacks = Reanimated.useSharedValue< - HandlerCallbacks>[] | null - >(null); - - // eslint-disable-next-line react-hooks/rules-of-hooks - const lastUpdateEvent = Reanimated.useSharedValue< - (GestureUpdateEvent | undefined)[] - >([]); - - // not every gesture needs a state controller, init them lazily - const stateControllers: GestureStateManagerType[] = []; - - const callback = ( - event: GestureStateChangeEvent | GestureUpdateEvent | GestureTouchEvent - ) => { - 'worklet'; - - const currentCallback = sharedHandlersCallbacks.value; - if (!currentCallback) { - return; - } - - for (let i = 0; i < currentCallback.length; i++) { - const gesture = currentCallback[i]; - - if (event.handlerTag === gesture.handlerTag) { - if (isStateChangeEvent(event)) { - if ( - event.oldState === State.UNDETERMINED && - event.state === State.BEGAN - ) { - runWorklet(CALLBACK_TYPE.BEGAN, gesture, event); - } else if ( - (event.oldState === State.BEGAN || - event.oldState === State.UNDETERMINED) && - event.state === State.ACTIVE - ) { - runWorklet(CALLBACK_TYPE.START, gesture, event); - lastUpdateEvent.value[gesture.handlerTag] = undefined; - } else if ( - event.oldState !== event.state && - event.state === State.END - ) { - if (event.oldState === State.ACTIVE) { - runWorklet(CALLBACK_TYPE.END, gesture, event, true); - } - runWorklet(CALLBACK_TYPE.FINALIZE, gesture, event, true); - } else if ( - (event.state === State.FAILED || event.state === State.CANCELLED) && - event.state !== event.oldState - ) { - if (event.oldState === State.ACTIVE) { - runWorklet(CALLBACK_TYPE.END, gesture, event, false); - } - runWorklet(CALLBACK_TYPE.FINALIZE, gesture, event, false); - } - } else if (isTouchEvent(event)) { - if (!stateControllers[i]) { - stateControllers[i] = GestureStateManager.create(event.handlerTag); - } - - if (event.eventType !== TouchEventType.UNDETERMINED) { - runWorklet( - touchEventTypeToCallbackType(event.eventType), - gesture, - event, - stateControllers[i] - ); - } - } else { - runWorklet(CALLBACK_TYPE.UPDATE, gesture, event); - - if (gesture.onChange && gesture.changeEventCalculator) { - runWorklet( - CALLBACK_TYPE.CHANGE, - gesture, - gesture.changeEventCalculator?.( - event, - lastUpdateEvent.value[gesture.handlerTag] - ) - ); - - lastUpdateEvent.value[gesture.handlerTag] = event; - } - } - } - } - }; - - // eslint-disable-next-line react-hooks/rules-of-hooks - const event = Reanimated.useEvent( - callback, - ['onGestureHandlerStateChange', 'onGestureHandlerEvent'], - needsRebuild - ); - - preparedGesture.animatedEventHandler = event; - preparedGesture.animatedHandlers = sharedHandlersCallbacks; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function validateDetectorChildren(ref: any) { - // finds the first native view under the Wrap component and traverses the fiber tree upwards - // to check whether there is more than one native view as a pseudo-direct child of GestureDetector - // i.e. this is not ok: - // Wrap - // | - // / \ - // / \ - // / \ - // / \ - // NativeView NativeView - // - // but this is fine: - // Wrap - // | - // NativeView - // | - // / \ - // / \ - // / \ - // / \ - // NativeView NativeView - if (__DEV__ && Platform.OS !== 'web') { - const REACT_NATIVE_VERSION = getReactNativeVersion(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const wrapType = - REACT_NATIVE_VERSION.minor > 63 || REACT_NATIVE_VERSION.major > 0 - ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - ref._reactInternals.elementType - : // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - ref._reactInternalFiber.elementType; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - let instance = - RNRenderer.findHostInstance_DEPRECATED( - ref - )._internalFiberInstanceHandleDEV; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - while (instance && instance.elementType !== wrapType) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (instance.sibling) { - throw new Error( - 'GestureDetector has more than one native view as its children. This can happen if you are using a custom component that renders multiple views, like React.Fragment. You should wrap content of GestureDetector with a or .' - ); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - instance = instance.return; - } - } -} - -const applyUserSelectProp = ( - userSelect: UserSelect, - gesture: ComposedGesture | GestureType -): void => { - for (const g of gesture.toGestureArray()) { - g.config.userSelect = userSelect; - } -}; - -const applyEnableContextMenuProp = ( - enableContextMenu: boolean, - gesture: ComposedGesture | GestureType -): void => { - for (const g of gesture.toGestureArray()) { - g.config.enableContextMenu = enableContextMenu; - } -}; - -const applyTouchActionProp = ( - touchAction: TouchAction, - gesture: ComposedGesture | GestureType -): void => { - for (const g of gesture.toGestureArray()) { - g.config.touchAction = touchAction; - } -}; - -interface GestureDetectorProps { - /** - * A gesture object containing the configuration and callbacks. - * Can be any of: - * - base gestures (`Tap`, `Pan`, ...) - * - `ComposedGesture` (`Race`, `Simultaneous`, `Exclusive`) - */ - gesture: ComposedGesture | GestureType; - children?: React.ReactNode; - - /** - * #### Web only - * This parameter allows to specify which `userSelect` property should be applied to underlying view. - * Possible values are `"none" | "auto" | "text"`. Default value is set to `"none"`. - */ - userSelect?: UserSelect; - /** - * #### Web only - * Specifies whether context menu should be enabled after clicking on underlying view with right mouse button. - * Default value is set to `false`. - */ - enableContextMenu?: boolean; - /** - * #### Web only - * This parameter allows to specify which `touchAction` property should be applied to underlying view. - * Supports all CSS touch-action values (e.g. `"none"`, `"pan-y"`). Default value is set to `"none"`. - */ - touchAction?: TouchAction; -} -interface GestureDetectorState { - firstRender: boolean; - viewRef: React.Component | null; - previousViewTag: number; - forceReattach: boolean; -} - -/** - * `GestureDetector` is responsible for creating and updating native gesture handlers based on the config of provided gesture. - * - * ### Props - * - `gesture` - * - `userSelect` (**Web only**) - * - `enableContextMenu` (**Web only**) - * - `touchAction` (**Web only**) - * - * ### Remarks - * - Gesture Detector will use first native view in its subtree to recognize gestures, however if this view is used only to group its children it may get automatically collapsed. - * - Using the same instance of a gesture across multiple Gesture Detectors is not possible. - * - * @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/gesture-detector - */ -export const GestureDetector = (props: GestureDetectorProps) => { - const rootViewContext = useContext(GestureHandlerRootViewContext); - if (__DEV__ && !rootViewContext && !isJestEnv() && Platform.OS !== 'web') { - throw new Error( - 'GestureDetector must be used as a descendant of GestureHandlerRootView. Otherwise the gestures will not be recognized. See https://docs.swmansion.com/react-native-gesture-handler/docs/installation for more details.' - ); - } - - const gestureConfig = props.gesture; - - if (props.userSelect) { - applyUserSelectProp(props.userSelect, gestureConfig); - } - - if (props.enableContextMenu !== undefined) { - applyEnableContextMenuProp(props.enableContextMenu, gestureConfig); - } - - if (props.touchAction !== undefined) { - applyTouchActionProp(props.touchAction, gestureConfig); - } - - const gesture = gestureConfig.toGestureArray(); - const useReanimatedHook = gesture.some((g) => g.shouldUseReanimated); - - // store state in ref to prevent unnecessary renders - const state = useRef({ - firstRender: true, - viewRef: null, - previousViewTag: -1, - forceReattach: false, - }).current; - const mountedRef = useRef(false); - const webEventHandlersRef = useRef({ - onGestureHandlerEvent: (e: HandlerStateChangeEvent) => { - onGestureHandlerEvent(e.nativeEvent); - }, - onGestureHandlerStateChange: isNewWebImplementationEnabled() - ? (e: HandlerStateChangeEvent) => { - onGestureHandlerEvent(e.nativeEvent); - } - : undefined, - }); - - const [renderState, setRenderState] = useState(false); - function forceRender() { - setRenderState(!renderState); - } - - const preparedGesture = React.useRef({ - config: gesture, - animatedEventHandler: null, - animatedHandlers: null, - firstExecution: true, - useReanimatedHook: useReanimatedHook, - }).current; - - if (useReanimatedHook !== preparedGesture.useReanimatedHook) { - throw new Error( - tagMessage( - 'You cannot change the thread the callbacks are ran on while the app is running' - ) - ); - } - - function onHandlersUpdate(skipConfigUpdate?: boolean) { - // if the underlying view has changed we need to reattach handlers to the new view - const viewTag = findNodeHandle(state.viewRef) as number; - const forceReattach = viewTag !== state.previousViewTag; - - if (forceReattach || needsToReattach(preparedGesture, gesture)) { - validateDetectorChildren(state.viewRef); - dropHandlers(preparedGesture); - attachHandlers({ - preparedGesture, - gestureConfig, - gesture, - webEventHandlersRef, - viewTag, - mountedRef, - }); - - state.previousViewTag = viewTag; - state.forceReattach = forceReattach; - if (forceReattach) { - forceRender(); - } - } else if (!skipConfigUpdate) { - updateHandlers(preparedGesture, gestureConfig, gesture, mountedRef); - } - } - - // Reanimated event should be rebuilt only when gestures are reattached, otherwise - // config update will be enough as all necessary items are stored in shared values anyway - const needsToRebuildReanimatedEvent = - preparedGesture.firstExecution || - needsToReattach(preparedGesture, gesture) || - state.forceReattach; - - state.forceReattach = false; - - if (preparedGesture.firstExecution) { - gestureConfig.initialize(); - } - - if (useReanimatedHook) { - // Whether animatedGesture or gesture is used shouldn't change while the app is running - // eslint-disable-next-line react-hooks/rules-of-hooks - useAnimatedGesture(preparedGesture, needsToRebuildReanimatedEvent); - } - - useEffect(() => { - const viewTag = findNodeHandle(state.viewRef) as number; - state.firstRender = true; - mountedRef.current = true; - - validateDetectorChildren(state.viewRef); - - attachHandlers({ - preparedGesture, - gestureConfig, - gesture, - webEventHandlersRef, - viewTag, - mountedRef, - }); - - return () => { - mountedRef.current = false; - dropHandlers(preparedGesture); - }; - }, []); - - useEffect(() => { - if (!state.firstRender) { - onHandlersUpdate(); - } else { - state.firstRender = false; - } - }, [props]); - - const refFunction = (ref: unknown) => { - if (ref !== null) { - // @ts-ignore Just setting the view ref - state.viewRef = ref; - - // if it's the first render, also set the previousViewTag to prevent reattaching gestures when not needed - if (state.previousViewTag === -1) { - state.previousViewTag = findNodeHandle(state.viewRef) as number; - } - - // pass true as `skipConfigUpdate`, here we only want to trigger the eventual reattaching of handlers - // in case the view has changed, while config update would be handled be the `useEffect` above - onHandlersUpdate(true); - - if (isFabric() && global.isFormsStackingContext) { - const node = getShadowNodeFromRef(ref); - if (global.isFormsStackingContext(node) === false) { - console.error( - tagMessage( - 'GestureDetector has received a child that may get view-flattened. ' + - '\nTo prevent it from misbehaving you need to wrap the child with a ``.' - ) - ); - } - } - } - }; - - if (useReanimatedHook) { - return ( - - {props.children} - - ); - } else { - return {props.children}; - } -}; - -class Wrap extends React.Component<{ - onGestureHandlerEvent?: unknown; - // implicit `children` prop has been removed in @types/react^18.0.0 - children?: React.ReactNode; -}> { - render() { - try { - // I don't think that fighting with types over such a simple function is worth it - // The only thing it does is add 'collapsable: false' to the child component - // to make sure it is in the native view hierarchy so the detector can find - // correct viewTag to attach to. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const child: any = React.Children.only(this.props.children); - return React.cloneElement( - child, - { collapsable: false }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - child.props.children - ); - } catch (e) { - throw new Error( - tagMessage( - `GestureDetector got more than one view as a child. If you want the gesture to work on multiple views, wrap them with a common parent and attach the gesture to that view.` - ) - ); - } - } -} - -const AnimatedWrap = Reanimated?.default?.createAnimatedComponent(Wrap) ?? Wrap; diff --git a/src/handlers/gestures/GestureDetector/Wrap.tsx b/src/handlers/gestures/GestureDetector/Wrap.tsx new file mode 100644 index 0000000000..bbd3878594 --- /dev/null +++ b/src/handlers/gestures/GestureDetector/Wrap.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Reanimated } from '../reanimatedWrapper'; +import { tagMessage } from '../../../utils'; + +export class Wrap extends React.Component<{ + onGestureHandlerEvent?: unknown; + // implicit `children` prop has been removed in @types/react^18.0.0 + children?: React.ReactNode; +}> { + render() { + try { + // I don't think that fighting with types over such a simple function is worth it + // The only thing it does is add 'collapsable: false' to the child component + // to make sure it is in the native view hierarchy so the detector can find + // correct viewTag to attach to. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const child: any = React.Children.only(this.props.children); + return React.cloneElement( + child, + { collapsable: false }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + child.props.children + ); + } catch (e) { + throw new Error( + tagMessage( + `GestureDetector got more than one view as a child. If you want the gesture to work on multiple views, wrap them with a common parent and attach the gesture to that view.` + ) + ); + } + } +} + +export const AnimatedWrap = + Reanimated?.default?.createAnimatedComponent(Wrap) ?? Wrap; diff --git a/src/handlers/gestures/GestureDetector/attachHandlers.ts b/src/handlers/gestures/GestureDetector/attachHandlers.ts new file mode 100644 index 0000000000..0ded87eb4e --- /dev/null +++ b/src/handlers/gestures/GestureDetector/attachHandlers.ts @@ -0,0 +1,112 @@ +import React from 'react'; +import { GestureType, HandlerCallbacks } from '../gesture'; +import { registerHandler } from '../../handlersRegistry'; +import RNGestureHandlerModule from '../../../RNGestureHandlerModule'; +import { + filterConfig, + scheduleFlushOperations, +} from '../../gestureHandlerCommon'; +import { ComposedGesture } from '../gestureComposition'; +import { ActionType } from '../../../ActionType'; +import { Platform } from 'react-native'; +import type RNGestureHandlerModuleWeb from '../../../RNGestureHandlerModule.web'; +import { ghQueueMicrotask } from '../../../ghQueueMicrotask'; +import { AttachedGestureState, WebEventHandler } from './types'; +import { + extractGestureRelations, + checkGestureCallbacksForWorklets, + ALLOWED_PROPS, +} from './utils'; + +interface AttachHandlersConfig { + preparedGesture: AttachedGestureState; + gestureConfig: ComposedGesture | GestureType; + gesturesToAttach: GestureType[]; + viewTag: number; + webEventHandlersRef: React.RefObject; +} + +export function attachHandlers({ + preparedGesture, + gestureConfig, + gesturesToAttach, + viewTag, + webEventHandlersRef, +}: AttachHandlersConfig) { + gestureConfig.initialize(); + + // use queueMicrotask to extract handlerTags, because all refs should be initialized + // when it's ran + ghQueueMicrotask(() => { + if (!preparedGesture.isMounted) { + return; + } + gestureConfig.prepare(); + }); + + for (const handler of gesturesToAttach) { + checkGestureCallbacksForWorklets(handler); + RNGestureHandlerModule.createGestureHandler( + handler.handlerName, + handler.handlerTag, + filterConfig(handler.config, ALLOWED_PROPS) + ); + + registerHandler(handler.handlerTag, handler, handler.config.testId); + } + + // use queueMicrotask to extract handlerTags, because all refs should be initialized + // when it's ran + ghQueueMicrotask(() => { + if (!preparedGesture.isMounted) { + return; + } + for (const handler of gesturesToAttach) { + RNGestureHandlerModule.updateGestureHandler( + handler.handlerTag, + filterConfig( + handler.config, + ALLOWED_PROPS, + extractGestureRelations(handler) + ) + ); + } + + scheduleFlushOperations(); + }); + + for (const gesture of gesturesToAttach) { + const actionType = gesture.shouldUseReanimated + ? ActionType.REANIMATED_WORKLET + : ActionType.JS_FUNCTION_NEW_API; + + if (Platform.OS === 'web') { + ( + RNGestureHandlerModule.attachGestureHandler as typeof RNGestureHandlerModuleWeb.attachGestureHandler + )( + gesture.handlerTag, + viewTag, + ActionType.JS_FUNCTION_OLD_API, // ignored on web + webEventHandlersRef + ); + } else { + RNGestureHandlerModule.attachGestureHandler( + gesture.handlerTag, + viewTag, + actionType + ); + } + } + + preparedGesture.attachedGestures = gesturesToAttach; + + if (preparedGesture.animatedHandlers) { + const isAnimatedGesture = (g: GestureType) => g.shouldUseReanimated; + + preparedGesture.animatedHandlers.value = gesturesToAttach + .filter(isAnimatedGesture) + .map((g) => g.handlers) as unknown as HandlerCallbacks< + Record + >[]; + } +} diff --git a/src/handlers/gestures/GestureDetector/dropHandlers.ts b/src/handlers/gestures/GestureDetector/dropHandlers.ts new file mode 100644 index 0000000000..94e9a2081b --- /dev/null +++ b/src/handlers/gestures/GestureDetector/dropHandlers.ts @@ -0,0 +1,14 @@ +import { unregisterHandler } from '../../handlersRegistry'; +import RNGestureHandlerModule from '../../../RNGestureHandlerModule'; +import { scheduleFlushOperations } from '../../gestureHandlerCommon'; +import { AttachedGestureState } from './types'; + +export function dropHandlers(preparedGesture: AttachedGestureState) { + for (const handler of preparedGesture.attachedGestures) { + RNGestureHandlerModule.dropGestureHandler(handler.handlerTag); + + unregisterHandler(handler.handlerTag, handler.config.testId); + } + + scheduleFlushOperations(); +} diff --git a/src/handlers/gestures/GestureDetector/index.tsx b/src/handlers/gestures/GestureDetector/index.tsx new file mode 100644 index 0000000000..dea678b57c --- /dev/null +++ b/src/handlers/gestures/GestureDetector/index.tsx @@ -0,0 +1,185 @@ +/* eslint-disable react/no-unused-prop-types */ +import React, { useContext, useEffect, useMemo, useRef } from 'react'; +import { GestureType } from '../gesture'; +import { + findNodeHandle, + UserSelect, + TouchAction, +} from '../../gestureHandlerCommon'; +import { ComposedGesture } from '../gestureComposition'; +import { isJestEnv } from '../../../utils'; +import { Platform } from 'react-native'; + +import GestureHandlerRootViewContext from '../../../GestureHandlerRootViewContext'; +import { AttachedGestureState, GestureDetectorState } from './types'; +import { useAnimatedGesture } from './useAnimatedGesture'; +import { attachHandlers } from './attachHandlers'; +import { needsToReattach } from './needsToReattach'; +import { dropHandlers } from './dropHandlers'; +import { useWebEventHandlers } from './utils'; +import { Wrap, AnimatedWrap } from './Wrap'; +import { useDetectorUpdater } from './useDetectorUpdater'; +import { useViewRefHandler } from './useViewRefHandler'; + +function propagateDetectorConfig( + props: GestureDetectorProps, + gesture: ComposedGesture | GestureType +) { + const keysToPropagate: (keyof GestureDetectorProps)[] = [ + 'userSelect', + 'enableContextMenu', + 'touchAction', + ]; + + for (const key of keysToPropagate) { + const value = props[key]; + if (value === undefined) { + continue; + } + + for (const g of gesture.toGestureArray()) { + const config = g.config as { [key: string]: unknown }; + config[key] = value; + } + } +} + +interface GestureDetectorProps { + children?: React.ReactNode; + /** + * A gesture object containing the configuration and callbacks. + * Can be any of: + * - base gestures (`Tap`, `Pan`, ...) + * - `ComposedGesture` (`Race`, `Simultaneous`, `Exclusive`) + */ + gesture: ComposedGesture | GestureType; + /** + * #### Web only + * This parameter allows to specify which `userSelect` property should be applied to underlying view. + * Possible values are `"none" | "auto" | "text"`. Default value is set to `"none"`. + */ + userSelect?: UserSelect; + /** + * #### Web only + * Specifies whether context menu should be enabled after clicking on underlying view with right mouse button. + * Default value is set to `false`. + */ + enableContextMenu?: boolean; + /** + * #### Web only + * This parameter allows to specify which `touchAction` property should be applied to underlying view. + * Supports all CSS touch-action values (e.g. `"none"`, `"pan-y"`). Default value is set to `"none"`. + */ + touchAction?: TouchAction; +} + +/** + * `GestureDetector` is responsible for creating and updating native gesture handlers based on the config of provided gesture. + * + * ### Props + * - `gesture` + * - `userSelect` (**Web only**) + * - `enableContextMenu` (**Web only**) + * - `touchAction` (**Web only**) + * + * ### Remarks + * - Gesture Detector will use first native view in its subtree to recognize gestures, however if this view is used only to group its children it may get automatically collapsed. + * - Using the same instance of a gesture across multiple Gesture Detectors is not possible. + * + * @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/gesture-detector + */ +export const GestureDetector = (props: GestureDetectorProps) => { + const rootViewContext = useContext(GestureHandlerRootViewContext); + if (__DEV__ && !rootViewContext && !isJestEnv() && Platform.OS !== 'web') { + throw new Error( + 'GestureDetector must be used as a descendant of GestureHandlerRootView. Otherwise the gestures will not be recognized. See https://docs.swmansion.com/react-native-gesture-handler/docs/installation for more details.' + ); + } + + // Gesture config should be wrapped with useMemo to prevent unnecessary re-renders + const gestureConfig = props.gesture; + propagateDetectorConfig(props, gestureConfig); + + const gesturesToAttach = useMemo( + () => gestureConfig.toGestureArray(), + [gestureConfig] + ); + const shouldUseReanimated = gesturesToAttach.some( + (g) => g.shouldUseReanimated + ); + + const webEventHandlersRef = useWebEventHandlers(); + // store state in ref to prevent unnecessary renders + const state = useRef({ + firstRender: true, + viewRef: null, + previousViewTag: -1, + forceRebuildReanimatedEvent: false, + }).current; + + const preparedGesture = React.useRef({ + attachedGestures: [], + animatedEventHandler: null, + animatedHandlers: null, + shouldUseReanimated: shouldUseReanimated, + isMounted: false, + }).current; + + const updateAttachedGestures = useDetectorUpdater( + state, + preparedGesture, + gesturesToAttach, + gestureConfig, + webEventHandlersRef + ); + + const refHandler = useViewRefHandler(state, updateAttachedGestures); + + // Reanimated event should be rebuilt only when gestures are reattached, otherwise + // config update will be enough as all necessary items are stored in shared values anyway + const needsToRebuildReanimatedEvent = + state.firstRender || + state.forceRebuildReanimatedEvent || + needsToReattach(preparedGesture, gesturesToAttach); + state.forceRebuildReanimatedEvent = false; + + useAnimatedGesture(preparedGesture, needsToRebuildReanimatedEvent); + + useEffect(() => { + const viewTag = findNodeHandle(state.viewRef) as number; + preparedGesture.isMounted = true; + + attachHandlers({ + preparedGesture, + gestureConfig, + gesturesToAttach, + webEventHandlersRef, + viewTag, + }); + + return () => { + preparedGesture.isMounted = false; + dropHandlers(preparedGesture); + }; + }, []); + + useEffect(() => { + if (!state.firstRender) { + updateAttachedGestures(); + } else { + state.firstRender = false; + } + }, [props]); + + if (shouldUseReanimated) { + return ( + + {props.children} + + ); + } else { + return {props.children}; + } +}; diff --git a/src/handlers/gestures/GestureDetector/needsToReattach.ts b/src/handlers/gestures/GestureDetector/needsToReattach.ts new file mode 100644 index 0000000000..d755f15464 --- /dev/null +++ b/src/handlers/gestures/GestureDetector/needsToReattach.ts @@ -0,0 +1,27 @@ +import { GestureType } from '../gesture'; +import { AttachedGestureState } from './types'; + +// Checks whether the gesture should be reattached to the view, this will happen when: +// - The number of gestures in the preparedGesture is different than the number of gestures in the gesture +// - The handlerName is different in any of the gestures +// - At least one of the gestures changed the thread it runs on +export function needsToReattach( + preparedGesture: AttachedGestureState, + newGestures: GestureType[] +) { + if (newGestures.length !== preparedGesture.attachedGestures.length) { + return true; + } + for (let i = 0; i < newGestures.length; i++) { + if ( + newGestures[i].handlerName !== + preparedGesture.attachedGestures[i].handlerName || + newGestures[i].shouldUseReanimated !== + preparedGesture.attachedGestures[i].shouldUseReanimated + ) { + return true; + } + } + + return false; +} diff --git a/src/handlers/gestures/GestureDetector/types.ts b/src/handlers/gestures/GestureDetector/types.ts new file mode 100644 index 0000000000..3b161ad137 --- /dev/null +++ b/src/handlers/gestures/GestureDetector/types.ts @@ -0,0 +1,32 @@ +import { GestureType, HandlerCallbacks } from '../gesture'; +import { SharedValue } from '../reanimatedWrapper'; +import { HandlerStateChangeEvent } from '../../gestureHandlerCommon'; + +export interface AttachedGestureState { + // Array of gestures that should be attached to the view under that gesture detector + attachedGestures: GestureType[]; + // Event handler for the gesture, returned by `useEvent` from Reanimated + animatedEventHandler: unknown; + // Shared value that's responsible for transferring the callbacks to the UI thread handler + animatedHandlers: SharedValue< + HandlerCallbacks>[] | null + > | null; + // Whether `useAnimatedGesture` should be called inside detector + shouldUseReanimated: boolean; + // Whether the GestureDetector is mounted + isMounted: boolean; +} + +export interface GestureDetectorState { + firstRender: boolean; + viewRef: React.Component | null; + previousViewTag: number; + forceRebuildReanimatedEvent: boolean; +} + +export interface WebEventHandler { + onGestureHandlerEvent: (event: HandlerStateChangeEvent) => void; + onGestureHandlerStateChange?: ( + event: HandlerStateChangeEvent + ) => void; +} diff --git a/src/handlers/gestures/GestureDetector/updateHandlers.ts b/src/handlers/gestures/GestureDetector/updateHandlers.ts new file mode 100644 index 0000000000..a8508d7f0d --- /dev/null +++ b/src/handlers/gestures/GestureDetector/updateHandlers.ts @@ -0,0 +1,94 @@ +import { GestureType, HandlerCallbacks } from '../gesture'; +import { registerHandler } from '../../handlersRegistry'; +import RNGestureHandlerModule from '../../../RNGestureHandlerModule'; +import { + filterConfig, + scheduleFlushOperations, +} from '../../gestureHandlerCommon'; +import { ComposedGesture } from '../gestureComposition'; +import { ghQueueMicrotask } from '../../../ghQueueMicrotask'; +import { AttachedGestureState } from './types'; +import { + extractGestureRelations, + checkGestureCallbacksForWorklets, + ALLOWED_PROPS, +} from './utils'; + +export function updateHandlers( + preparedGesture: AttachedGestureState, + gestureConfig: ComposedGesture | GestureType, + newGestures: GestureType[] +) { + gestureConfig.prepare(); + + for (let i = 0; i < newGestures.length; i++) { + const handler = preparedGesture.attachedGestures[i]; + checkGestureCallbacksForWorklets(handler); + + // only update handlerTag when it's actually different, it may be the same + // if gesture config object is wrapped with useMemo + if (newGestures[i].handlerTag !== handler.handlerTag) { + newGestures[i].handlerTag = handler.handlerTag; + newGestures[i].handlers.handlerTag = handler.handlerTag; + } + } + + // use queueMicrotask to extract handlerTags, because when it's ran, all refs should be updated + // and handlerTags in BaseGesture references should be updated in the loop above (we need to wait + // in case of external relations) + ghQueueMicrotask(() => { + if (!preparedGesture.isMounted) { + return; + } + for (let i = 0; i < newGestures.length; i++) { + const handler = preparedGesture.attachedGestures[i]; + + handler.config = newGestures[i].config; + handler.handlers = newGestures[i].handlers; + + RNGestureHandlerModule.updateGestureHandler( + handler.handlerTag, + filterConfig( + handler.config, + ALLOWED_PROPS, + extractGestureRelations(handler) + ) + ); + + registerHandler(handler.handlerTag, handler, handler.config.testId); + } + + if (preparedGesture.animatedHandlers) { + const previousHandlersValue = + preparedGesture.animatedHandlers.value ?? []; + const newHandlersValue = preparedGesture.attachedGestures + .filter((g) => g.shouldUseReanimated) // ignore gestures that shouldn't run on UI + .map((g) => g.handlers) as unknown as HandlerCallbacks< + Record + >[]; + + // if amount of gesture configs changes, we need to update the callbacks in shared value + let shouldUpdateSharedValue = + previousHandlersValue.length !== newHandlersValue.length; + + if (!shouldUpdateSharedValue) { + // if the amount is the same, we need to check if any of the configs inside has changed + for (let i = 0; i < newHandlersValue.length; i++) { + if ( + // we can use the `gestureId` prop as it's unique for every config instance + newHandlersValue[i].gestureId !== previousHandlersValue[i].gestureId + ) { + shouldUpdateSharedValue = true; + break; + } + } + } + + if (shouldUpdateSharedValue) { + preparedGesture.animatedHandlers.value = newHandlersValue; + } + } + + scheduleFlushOperations(); + }); +} diff --git a/src/handlers/gestures/GestureDetector/useAnimatedGesture.ts b/src/handlers/gestures/GestureDetector/useAnimatedGesture.ts new file mode 100644 index 0000000000..c1cce09307 --- /dev/null +++ b/src/handlers/gestures/GestureDetector/useAnimatedGesture.ts @@ -0,0 +1,206 @@ +import { HandlerCallbacks, CALLBACK_TYPE } from '../gesture'; +import { Reanimated } from '../reanimatedWrapper'; +import { + GestureTouchEvent, + GestureUpdateEvent, + GestureStateChangeEvent, +} from '../../gestureHandlerCommon'; +import { + GestureStateManager, + GestureStateManagerType, +} from '../gestureStateManager'; +import { State } from '../../../State'; +import { TouchEventType } from '../../../TouchEventType'; +import { tagMessage } from '../../../utils'; +import { AttachedGestureState } from './types'; + +function getHandler( + type: CALLBACK_TYPE, + gesture: HandlerCallbacks> +) { + 'worklet'; + switch (type) { + case CALLBACK_TYPE.BEGAN: + return gesture.onBegin; + case CALLBACK_TYPE.START: + return gesture.onStart; + case CALLBACK_TYPE.UPDATE: + return gesture.onUpdate; + case CALLBACK_TYPE.CHANGE: + return gesture.onChange; + case CALLBACK_TYPE.END: + return gesture.onEnd; + case CALLBACK_TYPE.FINALIZE: + return gesture.onFinalize; + case CALLBACK_TYPE.TOUCHES_DOWN: + return gesture.onTouchesDown; + case CALLBACK_TYPE.TOUCHES_MOVE: + return gesture.onTouchesMove; + case CALLBACK_TYPE.TOUCHES_UP: + return gesture.onTouchesUp; + case CALLBACK_TYPE.TOUCHES_CANCELLED: + return gesture.onTouchesCancelled; + } +} + +function touchEventTypeToCallbackType( + eventType: TouchEventType +): CALLBACK_TYPE { + 'worklet'; + switch (eventType) { + case TouchEventType.TOUCHES_DOWN: + return CALLBACK_TYPE.TOUCHES_DOWN; + case TouchEventType.TOUCHES_MOVE: + return CALLBACK_TYPE.TOUCHES_MOVE; + case TouchEventType.TOUCHES_UP: + return CALLBACK_TYPE.TOUCHES_UP; + case TouchEventType.TOUCHES_CANCELLED: + return CALLBACK_TYPE.TOUCHES_CANCELLED; + } + return CALLBACK_TYPE.UNDEFINED; +} + +function runWorklet( + type: CALLBACK_TYPE, + gesture: HandlerCallbacks>, + event: GestureStateChangeEvent | GestureUpdateEvent | GestureTouchEvent, + ...args: unknown[] +) { + 'worklet'; + const handler = getHandler(type, gesture); + if (gesture.isWorklet[type]) { + // @ts-ignore Logic below makes sure the correct event is send to the + // correct handler. + handler?.(event, ...args); + } else if (handler) { + console.warn(tagMessage('Animated gesture callback must be a worklet')); + } +} + +function isStateChangeEvent( + event: GestureUpdateEvent | GestureStateChangeEvent | GestureTouchEvent +): event is GestureStateChangeEvent { + 'worklet'; + // @ts-ignore Yes, the oldState prop is missing on GestureTouchEvent, that's the point + return event.oldState != null; +} + +function isTouchEvent( + event: GestureUpdateEvent | GestureStateChangeEvent | GestureTouchEvent +): event is GestureTouchEvent { + 'worklet'; + return event.eventType != null; +} + +export function useAnimatedGesture( + preparedGesture: AttachedGestureState, + needsRebuild: boolean +) { + if (!Reanimated) { + return; + } + + // Hooks are called conditionally, but the condition is whether the + // react-native-reanimated is installed, which shouldn't change while running + // eslint-disable-next-line react-hooks/rules-of-hooks + const sharedHandlersCallbacks = Reanimated.useSharedValue< + HandlerCallbacks>[] | null + >(null); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const lastUpdateEvent = Reanimated.useSharedValue< + (GestureUpdateEvent | undefined)[] + >([]); + + // not every gesture needs a state controller, init them lazily + const stateControllers: GestureStateManagerType[] = []; + + const callback = ( + event: GestureStateChangeEvent | GestureUpdateEvent | GestureTouchEvent + ) => { + 'worklet'; + + const currentCallback = sharedHandlersCallbacks.value; + if (!currentCallback) { + return; + } + + for (let i = 0; i < currentCallback.length; i++) { + const gesture = currentCallback[i]; + + if (event.handlerTag !== gesture.handlerTag) { + continue; + } + + if (isStateChangeEvent(event)) { + if ( + event.oldState === State.UNDETERMINED && + event.state === State.BEGAN + ) { + runWorklet(CALLBACK_TYPE.BEGAN, gesture, event); + } else if ( + (event.oldState === State.BEGAN || + event.oldState === State.UNDETERMINED) && + event.state === State.ACTIVE + ) { + runWorklet(CALLBACK_TYPE.START, gesture, event); + lastUpdateEvent.value[gesture.handlerTag] = undefined; + } else if ( + event.oldState !== event.state && + event.state === State.END + ) { + if (event.oldState === State.ACTIVE) { + runWorklet(CALLBACK_TYPE.END, gesture, event, true); + } + runWorklet(CALLBACK_TYPE.FINALIZE, gesture, event, true); + } else if ( + (event.state === State.FAILED || event.state === State.CANCELLED) && + event.state !== event.oldState + ) { + if (event.oldState === State.ACTIVE) { + runWorklet(CALLBACK_TYPE.END, gesture, event, false); + } + runWorklet(CALLBACK_TYPE.FINALIZE, gesture, event, false); + } + } else if (isTouchEvent(event)) { + if (!stateControllers[i]) { + stateControllers[i] = GestureStateManager.create(event.handlerTag); + } + + if (event.eventType !== TouchEventType.UNDETERMINED) { + runWorklet( + touchEventTypeToCallbackType(event.eventType), + gesture, + event, + stateControllers[i] + ); + } + } else { + runWorklet(CALLBACK_TYPE.UPDATE, gesture, event); + + if (gesture.onChange && gesture.changeEventCalculator) { + runWorklet( + CALLBACK_TYPE.CHANGE, + gesture, + gesture.changeEventCalculator?.( + event, + lastUpdateEvent.value[gesture.handlerTag] + ) + ); + + lastUpdateEvent.value[gesture.handlerTag] = event; + } + } + } + }; + + // eslint-disable-next-line react-hooks/rules-of-hooks + const event = Reanimated.useEvent( + callback, + ['onGestureHandlerStateChange', 'onGestureHandlerEvent'], + needsRebuild + ); + + preparedGesture.animatedEventHandler = event; + preparedGesture.animatedHandlers = sharedHandlersCallbacks; +} diff --git a/src/handlers/gestures/GestureDetector/useDetectorUpdater.ts b/src/handlers/gestures/GestureDetector/useDetectorUpdater.ts new file mode 100644 index 0000000000..dbbb362ddd --- /dev/null +++ b/src/handlers/gestures/GestureDetector/useDetectorUpdater.ts @@ -0,0 +1,69 @@ +import React, { useCallback } from 'react'; +import { GestureType } from '../gesture'; +import { findNodeHandle } from '../../gestureHandlerCommon'; +import { ComposedGesture } from '../gestureComposition'; + +import { + AttachedGestureState, + GestureDetectorState, + WebEventHandler, +} from './types'; +import { attachHandlers } from './attachHandlers'; +import { updateHandlers } from './updateHandlers'; +import { needsToReattach } from './needsToReattach'; +import { dropHandlers } from './dropHandlers'; +import { useForceRender, validateDetectorChildren } from './utils'; + +// Returns a function that's responsible for updating the attached gestures +// If the view has changed, it will reattach the handlers to the new view +// If the view remains the same, it will update the handlers with the new config +export function useDetectorUpdater( + state: GestureDetectorState, + preparedGesture: AttachedGestureState, + gesturesToAttach: GestureType[], + gestureConfig: ComposedGesture | GestureType, + webEventHandlersRef: React.RefObject +) { + const forceRender = useForceRender(); + const updateAttachedGestures = useCallback( + // skipConfigUpdate is used to prevent unnecessary updates when only checking if the view has changed + (skipConfigUpdate?: boolean) => { + // if the underlying view has changed we need to reattach handlers to the new view + const viewTag = findNodeHandle(state.viewRef) as number; + const didUnderlyingViewChange = viewTag !== state.previousViewTag; + + if ( + didUnderlyingViewChange || + needsToReattach(preparedGesture, gesturesToAttach) + ) { + validateDetectorChildren(state.viewRef); + dropHandlers(preparedGesture); + attachHandlers({ + preparedGesture, + gestureConfig, + gesturesToAttach, + webEventHandlersRef, + viewTag, + }); + + if (didUnderlyingViewChange) { + state.previousViewTag = viewTag; + state.forceRebuildReanimatedEvent = true; + forceRender(); + } + } else if (!skipConfigUpdate) { + updateHandlers(preparedGesture, gestureConfig, gesturesToAttach); + } + }, + [ + forceRender, + gestureConfig, + gesturesToAttach, + preparedGesture, + state, + webEventHandlersRef, + ] + ); + + return updateAttachedGestures; +} diff --git a/src/handlers/gestures/GestureDetector/useViewRefHandler.ts b/src/handlers/gestures/GestureDetector/useViewRefHandler.ts new file mode 100644 index 0000000000..363295ffd5 --- /dev/null +++ b/src/handlers/gestures/GestureDetector/useViewRefHandler.ts @@ -0,0 +1,53 @@ +import { findNodeHandle } from '../../gestureHandlerCommon'; + +import { isFabric, tagMessage } from '../../../utils'; +import { getShadowNodeFromRef } from '../../../getShadowNodeFromRef'; + +import { GestureDetectorState } from './types'; +import React, { useCallback } from 'react'; + +declare const global: { + isFormsStackingContext: (node: unknown) => boolean | null; // JSI function +}; + +// Ref handler for the Wrap component attached under the GestureDetector. +// It's responsible for setting the viewRef on the state and triggering the reattaching of handlers +// if the view has changed. +export function useViewRefHandler( + state: GestureDetectorState, + updateAttachedGestures: (skipConfigUpdate?: boolean) => void +) { + const refHandler = useCallback( + (ref: React.Component | null) => { + if (ref === null) { + return; + } + + state.viewRef = ref; + + // if it's the first render, also set the previousViewTag to prevent reattaching gestures when not needed + if (state.previousViewTag === -1) { + state.previousViewTag = findNodeHandle(state.viewRef) as number; + } + + // Pass true as `skipConfigUpdate`. Here we only want to trigger the eventual reattaching of handlers + // in case the view has changed. If the view doesn't change, the update will be handled by detector. + updateAttachedGestures(true); + + if (__DEV__ && isFabric() && global.isFormsStackingContext) { + const node = getShadowNodeFromRef(ref); + if (global.isFormsStackingContext(node) === false) { + console.error( + tagMessage( + 'GestureDetector has received a child that may get view-flattened. ' + + '\nTo prevent it from misbehaving you need to wrap the child with a ``.' + ) + ); + } + } + }, + [state, updateAttachedGestures] + ); + + return refHandler; +} diff --git a/src/handlers/gestures/GestureDetector/utils.ts b/src/handlers/gestures/GestureDetector/utils.ts new file mode 100644 index 0000000000..9ff719ce28 --- /dev/null +++ b/src/handlers/gestures/GestureDetector/utils.ts @@ -0,0 +1,185 @@ +import { Platform } from 'react-native'; + +import { tagMessage } from '../../../utils'; +import { GestureRef, BaseGesture, GestureType } from '../gesture'; + +import { flingGestureHandlerProps } from '../../FlingGestureHandler'; +import { forceTouchGestureHandlerProps } from '../../ForceTouchGestureHandler'; +import { longPressGestureHandlerProps } from '../../LongPressGestureHandler'; +import { + panGestureHandlerProps, + panGestureHandlerCustomNativeProps, +} from '../../PanGestureHandler'; +import { tapGestureHandlerProps } from '../../TapGestureHandler'; +import { hoverGestureHandlerProps } from '../hoverGesture'; +import { nativeViewGestureHandlerProps } from '../../NativeViewGestureHandler'; +import { + HandlerStateChangeEvent, + baseGestureHandlerWithDetectorProps, +} from '../../gestureHandlerCommon'; +import { isNewWebImplementationEnabled } from '../../../EnableNewWebImplementation'; +import { getReactNativeVersion } from '../../../getReactNativeVersion'; +import { RNRenderer } from '../../../RNRenderer'; +import { useCallback, useRef, useState } from 'react'; +import { Reanimated } from '../reanimatedWrapper'; +import { onGestureHandlerEvent } from '../eventReceiver'; +import { WebEventHandler } from './types'; + +export const ALLOWED_PROPS = [ + ...baseGestureHandlerWithDetectorProps, + ...tapGestureHandlerProps, + ...panGestureHandlerProps, + ...panGestureHandlerCustomNativeProps, + ...longPressGestureHandlerProps, + ...forceTouchGestureHandlerProps, + ...flingGestureHandlerProps, + ...hoverGestureHandlerProps, + ...nativeViewGestureHandlerProps, +]; + +function convertToHandlerTag(ref: GestureRef): number { + if (typeof ref === 'number') { + return ref; + } else if (ref instanceof BaseGesture) { + return ref.handlerTag; + } else { + // @ts-ignore in this case it should be a ref either to gesture object or + // a gesture handler component, in both cases handlerTag property exists + return ref.current?.handlerTag ?? -1; + } +} + +function extractValidHandlerTags(interactionGroup: GestureRef[] | undefined) { + return ( + interactionGroup?.map(convertToHandlerTag)?.filter((tag) => tag > 0) ?? [] + ); +} + +export function extractGestureRelations(gesture: GestureType) { + const requireToFail = extractValidHandlerTags(gesture.config.requireToFail); + const simultaneousWith = extractValidHandlerTags( + gesture.config.simultaneousWith + ); + const blocksHandlers = extractValidHandlerTags(gesture.config.blocksHandlers); + + return { + waitFor: requireToFail, + simultaneousHandlers: simultaneousWith, + blocksHandlers: blocksHandlers, + }; +} + +export function checkGestureCallbacksForWorklets(gesture: GestureType) { + if (!__DEV__) { + return; + } + // if a gesture is explicitly marked to run on the JS thread there is no need to check + // if callbacks are worklets as the user is aware they will be ran on the JS thread + if (gesture.config.runOnJS) { + return; + } + + const areSomeNotWorklets = gesture.handlers.isWorklet.includes(false); + const areSomeWorklets = gesture.handlers.isWorklet.includes(true); + + // if some of the callbacks are worklets and some are not, and the gesture is not + // explicitly marked with `.runOnJS(true)` show an error + if (areSomeNotWorklets && areSomeWorklets) { + console.error( + tagMessage( + `Some of the callbacks in the gesture are worklets and some are not. Either make sure that all calbacks are marked as 'worklet' if you wish to run them on the UI thread or use '.runOnJS(true)' modifier on the gesture explicitly to run all callbacks on the JS thread.` + ) + ); + } + + if (Reanimated === undefined) { + // if Reanimated is not available, we can't run worklets, so we shouldn't show the warning + return; + } + + const areAllNotWorklets = !areSomeWorklets && areSomeNotWorklets; + // if none of the callbacks are worklets and the gesture is not explicitly marked with + // `.runOnJS(true)` show a warning + if (areAllNotWorklets) { + console.warn( + tagMessage( + `None of the callbacks in the gesture are worklets. If you wish to run them on the JS thread use '.runOnJS(true)' modifier on the gesture to make this explicit. Otherwise, mark the callbacks as 'worklet' to run them on the UI thread.` + ) + ); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function validateDetectorChildren(ref: any) { + // finds the first native view under the Wrap component and traverses the fiber tree upwards + // to check whether there is more than one native view as a pseudo-direct child of GestureDetector + // i.e. this is not ok: + // Wrap + // | + // / \ + // / \ + // / \ + // / \ + // NativeView NativeView + // + // but this is fine: + // Wrap + // | + // NativeView + // | + // / \ + // / \ + // / \ + // / \ + // NativeView NativeView + if (__DEV__ && Platform.OS !== 'web') { + const REACT_NATIVE_VERSION = getReactNativeVersion(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const wrapType = + REACT_NATIVE_VERSION.minor > 63 || REACT_NATIVE_VERSION.major > 0 + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + ref._reactInternals.elementType + : // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + ref._reactInternalFiber.elementType; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + let instance = + RNRenderer.findHostInstance_DEPRECATED( + ref + )._internalFiberInstanceHandleDEV; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + while (instance && instance.elementType !== wrapType) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (instance.sibling) { + throw new Error( + 'GestureDetector has more than one native view as its children. This can happen if you are using a custom component that renders multiple views, like React.Fragment. You should wrap content of GestureDetector with a or .' + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + instance = instance.return; + } + } +} + +export function useForceRender() { + const [renderState, setRenderState] = useState(false); + const forceRender = useCallback(() => { + setRenderState(!renderState); + }, [renderState, setRenderState]); + + return forceRender; +} + +export function useWebEventHandlers() { + return useRef({ + onGestureHandlerEvent: (e: HandlerStateChangeEvent) => { + onGestureHandlerEvent(e.nativeEvent); + }, + onGestureHandlerStateChange: isNewWebImplementationEnabled() + ? (e: HandlerStateChangeEvent) => { + onGestureHandlerEvent(e.nativeEvent); + } + : undefined, + }); +} diff --git a/src/handlers/gestures/gestureComposition.ts b/src/handlers/gestures/gestureComposition.ts index cea793aa1a..d790c9c926 100644 --- a/src/handlers/gestures/gestureComposition.ts +++ b/src/handlers/gestures/gestureComposition.ts @@ -29,6 +29,8 @@ export class ComposedGesture extends Gesture { if (gesture instanceof BaseGesture) { const newConfig = { ...gesture.config }; + // no need to extend `blocksHandlers` here, because it's not changed in composition + // the same effect is achieved by reversing the order of 2 gestures in `Exclusive` newConfig.simultaneousWith = extendRelation( newConfig.simultaneousWith, simultaneousGestures diff --git a/src/handlers/gestures/gestureStateManager.ts b/src/handlers/gestures/gestureStateManager.ts index 7dee92bbfe..b052e9d310 100644 --- a/src/handlers/gestures/gestureStateManager.ts +++ b/src/handlers/gestures/gestureStateManager.ts @@ -24,7 +24,9 @@ function create(handlerTag: number): GestureStateManagerType { begin: () => { 'worklet'; if (REANIMATED_AVAILABLE) { - setGestureState(handlerTag, State.BEGAN); + // When Reanimated is available, setGestureState should be defined + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + setGestureState!(handlerTag, State.BEGAN); } else { console.warn(warningMessage); } @@ -33,7 +35,9 @@ function create(handlerTag: number): GestureStateManagerType { activate: () => { 'worklet'; if (REANIMATED_AVAILABLE) { - setGestureState(handlerTag, State.ACTIVE); + // When Reanimated is available, setGestureState should be defined + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + setGestureState!(handlerTag, State.ACTIVE); } else { console.warn(warningMessage); } @@ -42,7 +46,9 @@ function create(handlerTag: number): GestureStateManagerType { fail: () => { 'worklet'; if (REANIMATED_AVAILABLE) { - setGestureState(handlerTag, State.FAILED); + // When Reanimated is available, setGestureState should be defined + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + setGestureState!(handlerTag, State.FAILED); } else { console.warn(warningMessage); } @@ -51,7 +57,9 @@ function create(handlerTag: number): GestureStateManagerType { end: () => { 'worklet'; if (REANIMATED_AVAILABLE) { - setGestureState(handlerTag, State.END); + // When Reanimated is available, setGestureState should be defined + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + setGestureState!(handlerTag, State.END); } else { console.warn(warningMessage); } diff --git a/src/handlers/gestures/reanimatedWrapper.ts b/src/handlers/gestures/reanimatedWrapper.ts index 6ef7f7dedb..67771b08ca 100644 --- a/src/handlers/gestures/reanimatedWrapper.ts +++ b/src/handlers/gestures/reanimatedWrapper.ts @@ -9,23 +9,25 @@ export interface SharedValue { value: T; } -let Reanimated: { - default: { - // Slightly modified definition copied from 'react-native-reanimated' - // eslint-disable-next-line @typescript-eslint/ban-types - createAnimatedComponent

( - component: ComponentClass

, - options?: unknown - ): ComponentClass

; - }; - useEvent: ( - callback: (event: GestureUpdateEvent | GestureStateChangeEvent) => void, - events: string[], - rebuild: boolean - ) => unknown; - useSharedValue: (value: T) => SharedValue; - setGestureState: (handlerTag: number, newState: number) => void; -}; +let Reanimated: + | { + default: { + // Slightly modified definition copied from 'react-native-reanimated' + // eslint-disable-next-line @typescript-eslint/ban-types + createAnimatedComponent

( + component: ComponentClass

, + options?: unknown + ): ComponentClass

; + }; + useEvent: ( + callback: (event: GestureUpdateEvent | GestureStateChangeEvent) => void, + events: string[], + rebuild: boolean + ) => unknown; + useSharedValue: (value: T) => SharedValue; + setGestureState: (handlerTag: number, newState: number) => void; + } + | undefined; try { Reanimated = require('react-native-reanimated');