diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index 32e81191c0..8b9c0c7f45 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -51,6 +51,11 @@ */ - (void)applyStartAnimationState; +#if TARGET_OS_TV +- (void)handleAnimatePressIn; +- (void)handleAnimatePressOut; +#endif // TARGET_OS_TV + /** * Resets all transient press/animation state so the button can be safely reused * by Fabric view recycling. Cancels pending press-out blocks, removes in-flight diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index af27a5a3d0..468576db7c 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -68,6 +68,44 @@ - (instancetype)initWithFrame:(CGRect)frame return self; } +#if TARGET_OS_TV +- (void)emitPressInEvent +{ + if (!_buttonView.userEnabled) { + return; + } + + [_buttonView sendActionsForControlEvents:UIControlEventTouchDown]; +} + +- (void)emitPressOutEvent +{ + if (!_buttonView.userEnabled) { + return; + } + + [_buttonView sendActionsForControlEvents:UIControlEventTouchUpInside]; +} + +- (void)animatePressIn +{ + if (!_buttonView.userEnabled) { + return; + } + + [_buttonView handleAnimatePressIn]; +} + +- (void)animatePressOut +{ + if (!_buttonView.userEnabled) { + return; + } + + [_buttonView handleAnimatePressOut]; +} +#endif // TARGET_OS_TV + - (void)prepareForRecycle { [self.layer removeAnimationForKey:@"transform"]; diff --git a/packages/react-native-gesture-handler/src/components/utils.ts b/packages/react-native-gesture-handler/src/components/utils.ts index 21e59d020a..4b64015b86 100644 --- a/packages/react-native-gesture-handler/src/components/utils.ts +++ b/packages/react-native-gesture-handler/src/components/utils.ts @@ -1,5 +1,27 @@ +import { Platform } from 'react-native'; + import type { BaseGesture, GestureRef } from '../handlers/gestures/gesture'; +type TVProps = { + focusable?: boolean | undefined; + isTVSelectable?: boolean | undefined; +}; + +/** + * Gesture Handler buttons render the native button component directly, bypassing RN's `` — + * the only place the `focusable` prop is translated into `isTVSelectable`, which actually drives + * tvOS focusability. + */ +export function getTVProps(props: TVProps): TVProps { + if (!Platform.isTV) { + return {}; + } + + return { + isTVSelectable: props.focusable ?? props.isTVSelectable ?? true, + }; +} + export type RelationPropName = | 'simultaneousWithExternalGesture' | 'requireExternalGestureToFail' diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx index 4e61917ff2..e07cd9df60 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx @@ -2,6 +2,7 @@ import React, { useRef } from 'react'; import { Animated, Platform, StyleSheet } from 'react-native'; import GestureHandlerButton from '../../components/GestureHandlerButton'; +import { getTVProps } from '../../components/utils'; import createNativeWrapper from '../createNativeWrapper'; import type { NativeHandlerData } from '../hooks/gestures/native/NativeTypes'; import type { GestureEndEvent, GestureEvent } from '../types'; @@ -95,10 +96,13 @@ export const BaseButton = (props: BaseButtonProps) => { props.onFinalize?.(e); }; + const tvProps = getTVProps(rest); + return ( { }, [cancelDelayedPress, cancelLongPress]); const handlePressIn = useCallback( - (event: PressableEvent) => { + (event: PressableEvent, skipBoundsCheck = false) => { if ( + !skipBoundsCheck && !isTouchWithinInset( dimensions.current, normalizedHitSlop, @@ -306,6 +308,14 @@ const Pressable = (props: PressableProps) => { } }, onBegin: () => { + if (Platform.isTV) { + // tvOS drives this native gesture from the focus-engine Select press. + // The press state machine is touch-based and never + // receives LONG_PRESS_TOUCHES_DOWN here, so bypass it and drive the press handlers directly. + // A focus-driven press has no coordinates, so skip the hit-slop bounds check entirely. + handlePressIn(viewCenterToPressableEvent(dimensions.current), true); + return; + } if (Platform.OS === 'android' && isScreenReaderEnabled) { stateMachine.handleEvent( StateMachineEvent.NATIVE_BEGIN, @@ -316,8 +326,7 @@ const Pressable = (props: PressableProps) => { stateMachine.handleEvent(StateMachineEvent.NATIVE_BEGIN); }, onActivate: () => { - if (Platform.OS !== 'android') { - // Native.onActivate is broken with Android + hitSlop + if (!Platform.isTV && Platform.OS !== 'android') { stateMachine.handleEvent(StateMachineEvent.NATIVE_START); } }, @@ -327,6 +336,16 @@ const Pressable = (props: PressableProps) => { if (Platform.OS === 'web') { return; } + + if (Platform.isTV) { + handlePressOut( + viewCenterToPressableEvent(dimensions.current), + !event.canceled + ); + handleFinalize(); + return; + } + stateMachine.handleEvent( event.canceled ? StateMachineEvent.CANCEL : StateMachineEvent.FINALIZE ); @@ -373,10 +392,13 @@ const Pressable = (props: PressableProps) => { [onLayout] ); + const tvProps = getTVProps(remainingProps); + return ( { } : TRANSPARENT_RIPPLE; + const tvProps = getTVProps(rest); + return (