Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.swmansion.gesturehandler.react

import android.content.Context
import android.view.accessibility.AccessibilityManager
import com.facebook.jni.HybridData
import com.facebook.proguard.annotations.DoNotStrip
import com.facebook.react.bridge.JSApplicationIllegalArgumentException
Expand Down Expand Up @@ -129,6 +131,15 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) :
isReanimatedAvailable = isAvailable
}

@ReactMethod
override fun isScreenReaderEnabled(): Boolean {
val accessibilityManager = reactApplicationContext.getSystemService(
Context.ACCESSIBILITY_SERVICE,
) as AccessibilityManager?

return accessibilityManager?.isTouchExplorationEnabled ?: false
}
Comment on lines +136 to +141
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we already have this defined in some utils or extensions file?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


@DoNotStrip
@Suppress("unused")
fun setGestureHandlerState(handlerTag: Int, newState: Int) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ - (void)setGestureStateSync:(int)state forHandler:(int)handlerTag
}
}

- (NSNumber *)isScreenReaderEnabled
{
return @(UIAccessibilityIsVoiceOverRunning());
}

#pragma mark-- Batch handling

- (void)addOperationBlock:(GestureHandlerOperation)operation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
}

// @ts-ignore Types should be HTMLElement or React.Component
NodeManager.getHandler(handlerTag).init(newView, propsRef, actionType);

Check warning on line 57 in packages/react-native-gesture-handler/src/RNGestureHandlerModule.web.ts

View workflow job for this annotation

GitHub Actions / check

Unsafe call of an `any` typed value
},
detachGestureHandler(handlerTag: number) {
if (shouldPreventDrop) {
Expand Down Expand Up @@ -92,4 +92,7 @@
setReanimatedAvailable(_isAvailable: boolean) {
// No-op on web
},
isScreenReaderEnabled() {
return false;
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,7 @@ export default {
flushOperations() {
// NO-OP
},
isScreenReaderEnabled() {
// NO-OP
},
Comment on lines +59 to +61
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isScreenReaderEnabled() is declared to return a boolean but this Windows stub currently returns undefined (no return statement). That can break callers like isScreenReaderEnabled() in src/utils.ts (cache becomes undefined) and may cause incorrect branching. Return an explicit boolean (likely false) to keep behavior predictable on Windows.

Copilot uses AI. Check for mistakes.
};
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
isTouchWithinInset,
} from './utils';
import { PressabilityDebugView } from '../../handlers/PressabilityDebugView';
import { INT32_MAX, isTestEnv } from '../../utils';
import { INT32_MAX, isScreenReaderEnabled, isTestEnv } from '../../utils';
import {
applyRelationProp,
RelationPropName,
Expand Down Expand Up @@ -259,7 +259,7 @@ const LegacyPressable = (props: LegacyPressableProps) => {
);
})
.onTouchesUp(() => {
if (Platform.OS === 'android') {
if (Platform.OS === 'android' && !isScreenReaderEnabled()) {
// Prevents potential soft-locks
stateMachine.reset();
handleFinalize();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Platform } from 'react-native';
import { PressableEvent } from './PressableProps';
import { StateDefinition } from './StateMachine';
import { isScreenReaderEnabled } from '../../utils';

export enum StateMachineEvent {
NATIVE_BEGIN = 'nativeBegin',
Expand Down Expand Up @@ -29,6 +30,25 @@ function getAndroidStatesConfig(
];
}

function getAndroidAccessibilityStatesConfig(
handlePressIn: (event: PressableEvent) => void,
handlePressOut: (event: PressableEvent) => void
) {
return [
{
eventName: StateMachineEvent.LONG_PRESS_TOUCHES_DOWN,
callback: handlePressIn,
},
{
eventName: StateMachineEvent.NATIVE_BEGIN,
},
{
eventName: StateMachineEvent.FINALIZE,
callback: handlePressOut,
},
];
}

function getIosStatesConfig(
handlePressIn: (event: PressableEvent) => void,
handlePressOut: (event: PressableEvent) => void
Expand Down Expand Up @@ -112,6 +132,9 @@ export function getStatesConfig(
handlePressOut: (event: PressableEvent) => void
): StateDefinition[] {
if (Platform.OS === 'android') {
if (isScreenReaderEnabled()) {
return getAndroidAccessibilityStatesConfig(handlePressIn, handlePressOut);
}
return getAndroidStatesConfig(handlePressIn, handlePressOut);
Comment on lines 134 to 138
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getStatesConfig selects the Android state-machine configuration based on the screen reader status at the time this function is called. In both Pressable implementations, the state machine config is set once in a useEffect that does not re-run when the screen reader status changes, so toggling TalkBack while the app is running can leave Pressable using the wrong event-order config until remount. Consider wiring screen-reader status as reactive state (subscribe in the component and re-run setStates when it changes) or making getStatesConfig accept the current status as a parameter from the component.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

} else if (Platform.OS === 'ios') {
return getIosStatesConfig(handlePressIn, handlePressOut);
Expand Down
2 changes: 2 additions & 0 deletions packages/react-native-gesture-handler/src/mocks/module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const flushOperations = NOOP;
const configureRelations = NOOP;
const setReanimatedAvailable = NOOP;
const install = NOOP;
const isScreenReaderEnabled = NOOP;

export default {
attachGestureHandler,
Expand All @@ -22,4 +23,5 @@ export default {
setReanimatedAvailable,
flushOperations,
install,
isScreenReaderEnabled,
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface Spec extends TurboModule {
dropGestureHandler: (handlerTag: Double) => void;
flushOperations: () => void;
setReanimatedAvailable: (isAvailable: boolean) => void;
isScreenReaderEnabled: () => boolean;
}

export default TurboModuleRegistry.getEnforcing<Spec>('RNGestureHandlerModule');
16 changes: 16 additions & 0 deletions packages/react-native-gesture-handler/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { AccessibilityInfo } from 'react-native';
import RNGestureHandlerModule from './RNGestureHandlerModule';

export function toArray<T>(object: T | T[]): T[] {
if (!Array.isArray(object)) {
return [object];
Expand Down Expand Up @@ -93,3 +96,16 @@ export function deepEqual(obj1: any, obj2: any) {
}

export const INT32_MAX = 2 ** 31 - 1;

let isScreenReaderEnabledCache: boolean | null = null;

AccessibilityInfo.addEventListener('screenReaderChanged', () => {
isScreenReaderEnabledCache = RNGestureHandlerModule.isScreenReaderEnabled();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need a new method in the module when there's one in React Native, and you can also just get the value from the event here?

});

export function isScreenReaderEnabled(): boolean {
if (isScreenReaderEnabledCache === null) {
isScreenReaderEnabledCache = RNGestureHandlerModule.isScreenReaderEnabled();
}
return isScreenReaderEnabledCache;
Comment on lines +106 to +110
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isScreenReaderEnabled() calls RNGestureHandlerModule.isScreenReaderEnabled() on first use, but the Jest mock used in jestSetup.js (src/mocks/module.tsx) doesn’t currently define this new method. Any tests (or consumer test code) that call into this utility (directly or via Pressable on Android) will throw TypeError: ... isScreenReaderEnabled is not a function. Add a default mock implementation (e.g. returning false) to the mocked module to keep the test environment consistent with the new public API.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { GestureDetector } from '../detectors';
import { PureNativeButton } from './GestureButtons';

import { PressabilityDebugView } from '../../handlers/PressabilityDebugView';
import { INT32_MAX, isTestEnv } from '../../utils';
import { INT32_MAX, isScreenReaderEnabled, isTestEnv } from '../../utils';

const DEFAULT_LONG_PRESS_DURATION = 500;
const IS_TEST_ENV = isTestEnv();
Expand Down Expand Up @@ -257,7 +257,7 @@ const Pressable = (props: PressableProps) => {
);
},
onTouchesUp: () => {
if (Platform.OS === 'android') {
if (Platform.OS === 'android' && !isScreenReaderEnabled()) {
// Prevents potential soft-locks
stateMachine.reset();
handleFinalize();
Expand Down
Loading