diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt index a700cb065b..7d033288f7 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt @@ -14,10 +14,8 @@ import com.facebook.react.views.swiperefresh.ReactSwipeRefreshLayout import com.facebook.react.views.text.ReactTextView import com.facebook.react.views.textinput.ReactEditText import com.facebook.react.views.view.ReactViewGroup -import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager import com.swmansion.gesturehandler.react.RNGestureHandlerRootHelper import com.swmansion.gesturehandler.react.events.eventbuilders.NativeGestureHandlerEventDataBuilder -import com.swmansion.gesturehandler.react.isScreenReaderOn class NativeViewGestureHandler : GestureHandler() { override val isContinuous = true @@ -121,17 +119,6 @@ class NativeViewGestureHandler : GestureHandler() { override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) { val view = view!! - - val isTouchExplorationEnabled = view.context.isScreenReaderOn() - - if (view is RNGestureHandlerButtonViewManager.ButtonViewGroup && isTouchExplorationEnabled) { - // Fix for: https://github.com/software-mansion/react-native-gesture-handler/issues/2808 - // When TalkBack is enabled, events are often not being sent to the orchestrator for processing. - // Instead, states will be changed directly by an alternative mechanism added in this PR: - // https://github.com/software-mansion/react-native-gesture-handler/pull/2234 - return - } - if (event.actionMasked == MotionEvent.ACTION_UP) { if (state == STATE_UNDETERMINED && !hook.canBegin(event)) { cancel() diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index a487bb6237..b79d38c81b 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -27,6 +27,7 @@ import androidx.core.view.children import androidx.interpolator.view.animation.FastOutSlowInInterpolator import com.facebook.react.R import com.facebook.react.bridge.Dynamic +import com.facebook.react.bridge.ReadableArray import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.LengthPercentage @@ -347,6 +348,7 @@ class RNGestureHandlerButtonViewManager : super.onAfterUpdateTransaction(view) view.updateBackground() + view.updateLongPressAccessibility() } override fun getDelegate(): ViewManagerDelegate? = mDelegate @@ -434,6 +436,23 @@ class RNGestureHandlerButtonViewManager : invalidate() } + fun updateLongPressAccessibility() { + val hasLongPress = hasLongPressAccessibilityAction() + setOnLongClickListener(if (hasLongPress) dummyLongClickListener else null) + isLongClickable = hasLongPress + } + + private fun hasLongPressAccessibilityAction(): Boolean { + val actions = getTag(R.id.accessibility_actions) as? ReadableArray ?: return false + for (i in 0 until actions.size()) { + if (actions.getMap(i)?.getString("name") == "longpress") { + return true + } + } + + return false + } + override fun setBackgroundColor(color: Int) { BackgroundStyleApplicator.setBackgroundColor(this, color) } @@ -879,7 +898,8 @@ class RNGestureHandlerButtonViewManager : // don't preform click when a child button is pressed (mainly to prevent sound effect of // a parent button from playing) return if (!isChildTouched()) { - if (context.isScreenReaderOn()) { + // Don't activate native handlers when isPressed is true (motion events are passing through) + if (context.isScreenReaderOn() && !isPressed) { RNGestureHandlerRootView.findGestureHandlerRootView(this)?.activateNativeHandlers(this) } else if (receivedKeyEvent) { RNGestureHandlerRootView.findGestureHandlerRootView(this)?.activateNativeHandlers(this) @@ -936,7 +956,14 @@ class RNGestureHandlerButtonViewManager : var resolveOutValue = TypedValue() var touchResponder: ButtonViewGroup? = null var soundResponder: ButtonViewGroup? = null - var dummyClickListener = OnClickListener { } + val dummyClickListener = OnClickListener { } + val dummyLongClickListener = OnLongClickListener { view -> + if (view.context.isScreenReaderOn()) { + view.performAccessibilityAction(AccessibilityNodeInfo.ACTION_LONG_CLICK, null) + } else { + false + } + } } } diff --git a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx index 103f4d5f52..f57abed808 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx @@ -34,6 +34,7 @@ import { gestureTouchToPressableEvent, isTouchWithinInset, numberAsInset, + viewCenterToPressableEvent, } from './utils'; const DEFAULT_LONG_PRESS_DURATION = 500; @@ -301,6 +302,13 @@ const LegacyPressable = (props: LegacyPressableProps) => { } }) .onBegin(() => { + if (Platform.OS === 'android' && isScreenReaderEnabled) { + stateMachine.handleEvent( + StateMachineEvent.NATIVE_BEGIN, + viewCenterToPressableEvent(dimensions.current) + ); + return; + } stateMachine.handleEvent(StateMachineEvent.NATIVE_BEGIN); }) .onStart(() => { diff --git a/packages/react-native-gesture-handler/src/components/Pressable/StateMachine.tsx b/packages/react-native-gesture-handler/src/components/Pressable/StateMachine.tsx index b65491e2f2..a0c51255bb 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/StateMachine.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/StateMachine.tsx @@ -3,6 +3,7 @@ import type { PressableEvent } from './PressableProps'; export interface StateDefinition { eventName: string; callback?: (event: PressableEvent) => void; + optional?: boolean; } class PressableStateMachine { @@ -30,9 +31,23 @@ class PressableStateMachine { return; } - const step = this.states[this.currentStepIndex]; this.eventPayload = eventPayload || this.eventPayload; + // Skip past optional steps that don't match the incoming event + while ( + this.currentStepIndex < this.states.length && + this.states[this.currentStepIndex].eventName !== eventName && + this.states[this.currentStepIndex].optional + ) { + this.currentStepIndex++; + } + + if (this.currentStepIndex >= this.states.length) { + this.reset(); + return; + } + + const step = this.states[this.currentStepIndex]; if (step.eventName !== eventName) { if (this.currentStepIndex > 0) { // retry with position at index 0 diff --git a/packages/react-native-gesture-handler/src/components/Pressable/stateDefinitions.ts b/packages/react-native-gesture-handler/src/components/Pressable/stateDefinitions.ts index 650f6a78d9..9b7d4d39eb 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/stateDefinitions.ts +++ b/packages/react-native-gesture-handler/src/components/Pressable/stateDefinitions.ts @@ -36,11 +36,12 @@ function getAndroidAccessibilityStatesConfig( ) { return [ { - eventName: StateMachineEvent.LONG_PRESS_TOUCHES_DOWN, + eventName: StateMachineEvent.NATIVE_BEGIN, callback: handlePressIn, }, { - eventName: StateMachineEvent.NATIVE_BEGIN, + eventName: StateMachineEvent.LONG_PRESS_TOUCHES_DOWN, + optional: true, }, { eventName: StateMachineEvent.FINALIZE, diff --git a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts index ec83e8b5d8..eb5e3e314c 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts +++ b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts @@ -142,10 +142,47 @@ const gestureTouchToPressableEvent = ( }; }; +const viewCenterToPressableEvent = ( + dimensions: PressableDimensions +): PressableEvent => { + const timestamp = Date.now(); + const targetId = 0; + const centerX = dimensions.width / 2; + const centerY = dimensions.height / 2; + + const pressEvent: InnerPressableEvent = { + identifier: 0, + locationX: centerX, + locationY: centerY, + pageX: centerX, + pageY: centerY, + target: targetId, + timestamp, + touches: [], + changedTouches: [], + }; + + return { + nativeEvent: { + touches: [pressEvent], + changedTouches: [pressEvent], + identifier: 0, + locationX: centerX, + locationY: centerY, + pageX: centerX, + pageY: centerY, + target: targetId, + timestamp, + force: undefined, + }, + }; +}; + export { addInsets, gestureToPressableEvent, gestureTouchToPressableEvent, isTouchWithinInset, numberAsInset, + viewCenterToPressableEvent, }; 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 e1ccae5b0f..375255e86f 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -29,6 +29,7 @@ import { gestureTouchToPressableEvent, isTouchWithinInset, numberAsInset, + viewCenterToPressableEvent, } from '../../components/Pressable/utils'; import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; import { useIsScreenReaderEnabled } from '../../useIsScreenReaderEnabled'; @@ -305,6 +306,13 @@ const Pressable = (props: PressableProps) => { } }, onBegin: () => { + if (Platform.OS === 'android' && isScreenReaderEnabled) { + stateMachine.handleEvent( + StateMachineEvent.NATIVE_BEGIN, + viewCenterToPressableEvent(dimensions.current) + ); + return; + } stateMachine.handleEvent(StateMachineEvent.NATIVE_BEGIN); }, onActivate: () => {