/** * Native event handling - uses RNGH when available, falls back to responder system */ import { composeEventHandlers } from '@hanzogui/helpers' import { getGestureHandler } from '@hanzogui/native' import React, { useRef } from 'react' import { Platform, View } from 'react-native' import { useMainThreadPressEvents } from './helpers/mainThreadPressEvents' import type { StaticConfig, GuiComponentStateRef } from './types' // web events not used on native export function getWebEvents() { return {} } export function useEvents( events: any, viewProps: any, stateRef: { current: GuiComponentStateRef }, staticConfig: StaticConfig, isHOC?: boolean, isInsideNativeMenu?: boolean, debugName?: string | null ) { // focus/blur events always attached directly if (events) { if (events.onFocus) { viewProps['onFocus'] = events.onFocus } if (events.onBlur) { viewProps['onBlur'] = events.onBlur } } const hasPressEvents = // its stable and always on if you have in/out/regular events?.onPress const hasAnyPressCallbacks = Boolean( events?.onPress || events?.onPressIn || events?.onPressOut || events?.onLongPress ) const gh = getGestureHandler() // track if we ever had press events to avoid re-parenting / hooks issues if (hasPressEvents) { stateRef.current.hasHadEvents = true } // avoid hooks/reparenting const everEnabled = Boolean(hasPressEvents || stateRef.current.hasHadEvents) const isUsingRNGH = gh.isEnabled // NOW handle early returns (after all hooks are called) // THESE BRANCHES ARE NEVER CHANGING RENDER-TO-RENDER // input special case - TextInput needs press events attached directly (not via RNGH) if (staticConfig.isInput) { if (events) { const { onPressIn, onPressOut, onPress } = events const inputEvents: any = { onPressIn, onPressOut: onPressOut || onPress, } if (onPressOut && onPress) { // only supports onPressIn and onPressOut so combine them inputEvents.onPressOut = composeEventHandlers(onPress, onPressOut) } Object.assign(viewProps, inputEvents) } // inputs don't use gesture handler return null } // HOC special case - pass press events to the inner component instead of wrapping // HOC components may return null which crashes GestureDetector (it tries to access // _internalInstanceHandle on a null native view). By passing events down, the inner // component handles gesture detection at its own level. // // Composite component special case - when styled() wraps a non-Gui component // (e.g. React.forwardRef), the elementType becomes that composite component. // GestureDetector/responder wrapping around a composite component breaks during // re-renders triggered by pressStyle state changes (the gesture/responder loses // attachment to the native view through the composite layers). Pass events as props // so they flow through to the inner native View. const isCompositeComponent = !isHOC && staticConfig.Component && typeof staticConfig.Component !== 'string' if (isHOC || isCompositeComponent) { if (events) { const { onPressIn, onPressOut, onPress, onLongPress, delayLongPress } = events Object.assign(viewProps, { onPressIn, onPressOut, onPress, onLongPress, delayLongPress, }) } // HOCs and composite components don't use gesture handler at this level return null } // rngh path - logic (hooks already called above) if (isUsingRNGH) { // rngh path - hooks const callbacksRef = useRef(isUsingRNGH ? {} : null) const gestureRef = useRef(null) if (everEnabled) { // store callbacks in refs so gesture doesn't need to be recreated on every render callbacksRef.current = hasPressEvents ? { onPressIn: events.onPressIn, onPressOut: events.onPressOut, onPress: events.onPress, onLongPress: events.onLongPress, } : {} // only create gesture once, callbacks are read from ref if (!gestureRef.current) { if (isInsideNativeMenu) { // Inside native menus on Android: use Manual gesture with manualActivation // so it never goes ACTIVE (which would send ACTION_CANCEL to MenuView). // Press callbacks fire via onTouchesDown/Up instead. const { Gesture } = gh.state const manual = Gesture.Manual() .runOnJS(true) .manualActivation(true) .onTouchesDown(() => { callbacksRef.current.onPressIn?.({}) }) .onTouchesUp(() => { callbacksRef.current.onPress?.({}) callbacksRef.current.onPressOut?.({}) }) .onTouchesCancelled(() => { callbacksRef.current.onPressOut?.({}) }) gestureRef.current = manual } else { gestureRef.current = gh.createPressGesture({ debugName, onPressIn: (e: any) => callbacksRef.current.onPressIn?.(e), onPressOut: (e: any) => callbacksRef.current.onPressOut?.(e), onPress: (e: any) => callbacksRef.current.onPress?.(e), onLongPress: (e: any) => callbacksRef.current.onLongPress?.(e), delayLongPress: events?.delayLongPress, hitSlop: viewProps.hitSlop, }) } } // TODO update viewProps.hitSlop / events.delayLongPress! return gestureRef.current } return null } useMainThreadPressEvents(events, viewProps, hasPressEvents, debugName) return null } export function wrapWithGestureDetector( content: any, gesture: any, stateRef: { current: GuiComponentStateRef }, isHOC?: boolean, isCompositeComponent?: boolean ) { // Skip wrapping for HOC and composite components - they pass press events // to the inner component via props instead of using GestureDetector if (isHOC || isCompositeComponent) { return content } const gh = getGestureHandler() const { GestureDetector, Gesture } = gh.state // avoid re-parenting: only wrap if we ever had press events const shouldWrap = stateRef.current.hasHadEvents if (!GestureDetector || !shouldWrap) { return content } // use actual gesture or no-op Manual gesture to maintain tree structure const gestureToUse = gesture || Gesture?.Manual() if (!gestureToUse) { return content } const detector = React.createElement( GestureDetector, { gesture: gestureToUse }, content ) // wrap in a responder-claiming View OUTSIDE the GestureDetector. // this blocks parent RN Pressable/TouchableOpacity from firing when // a press lands on this component, without causing the RNGH deadlock // that happens when responder claims are applied to a view inside // the gesture-managed subtree (RNGH intercepts UIManager.setJSResponder // globally — when the claimant is one of its own gesture targets it // creates a coordination conflict, especially at scale on first mount). return React.createElement( View, { collapsable: false, // display: contents keeps the wrapper transparent to layout (new arch / // Fabric) so it doesn't become an extra flex child and shift siblings. style: responderWrapperStyle, onStartShouldSetResponder: responderClaim, onResponderTerminationRequest: responderDeny, }, detector ) } const responderClaim = () => true const responderDeny = () => false const responderWrapperStyle = { display: 'contents' } as const