From 556de6b2ce66dd62d878afe4c143df215c1a2071 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Bert?=
<63123542+m-bert@users.noreply.github.com>
Date: Mon, 13 Apr 2026 15:03:47 +0200
Subject: [PATCH 1/5] [Web] Fix `Pinch` and `Rotation` (#4078)
## Description
I've noticed that `Pinch` and `Rotation` behave weirdly when it comes to
ending gesture on web. I've took a look and fixed some minor issues.
> [!NOTE]
> This PR may introduce mismatch between `android` and `web` versions of
`Rotation`. I still think that this is how it is supposed to work, so we
may want to [make required changes to `android` as
well](https://github.com/software-mansion/react-native-gesture-handler/pull/4079)
(or drop this PR if you prefer to).
### Pinch
I've noticed that `Pinch` is not reset when pointers are placed on view
and then released. This was because for some reason `Pinch` simply
ignored finished states if it had not activated earlier.
### Rotation
`Rotation`, always finalized with `true`. This was because we always
tried to move it to `end` state. Our internal event handler hadn't been
sending `onDeactivate`, but `true` was still passed into `onFinalize`.
## Test plan
Tested on Transformations example and code below:
```tsx
import { StyleSheet, View } from 'react-native';
import {
GestureDetector,
GestureHandlerRootView,
usePinchGesture,
useRotationGesture,
} from 'react-native-gesture-handler';
export default function App() {
const g = useRotationGesture({
onBegin: () => console.log('onBegin'),
onActivate: () => console.log('onActivate'),
onUpdate: (e) => console.log('onUpdate', e.rotation),
onDeactivate: () => console.log('onDeactivate'),
onFinalize: (_, s) => console.log('onFinalize', s),
});
return (
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
box: {
width: 200,
height: 200,
backgroundColor: 'blue',
borderRadius: 12,
},
});
```
---
.../src/web/detectors/RotationGestureDetector.ts | 12 +++++-------
.../src/web/handlers/PinchGestureHandler.ts | 7 +++----
.../src/web/handlers/RotationGestureHandler.ts | 16 +++++-----------
3 files changed, 13 insertions(+), 22 deletions(-)
diff --git a/packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts b/packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts
index 79ffd6936b..c294d6f0c8 100644
--- a/packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts
+++ b/packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts
@@ -75,12 +75,11 @@ export default class RotationGestureDetector
}
private finish(): void {
- if (!this.isInProgress) {
- return;
+ if (this.isInProgress) {
+ this.isInProgress = false;
+ this.keyPointers = [NaN, NaN];
}
- this.isInProgress = false;
- this.keyPointers = [NaN, NaN];
this.onRotationEnd(this);
}
@@ -138,9 +137,8 @@ export default class RotationGestureDetector
break;
case EventTypes.UP:
- if (this.isInProgress) {
- this.finish();
- }
+ this.finish();
+
break;
}
diff --git a/packages/react-native-gesture-handler/src/web/handlers/PinchGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/PinchGestureHandler.ts
index 9242f50995..05e4584d52 100644
--- a/packages/react-native-gesture-handler/src/web/handlers/PinchGestureHandler.ts
+++ b/packages/react-native-gesture-handler/src/web/handlers/PinchGestureHandler.ts
@@ -80,12 +80,11 @@ export default class PinchGestureHandler extends GestureHandler {
protected onPointerUp(event: AdaptedEvent): void {
super.onPointerUp(event);
this.tracker.removeFromTracker(event.pointerId);
- if (this.state !== State.ACTIVE) {
- return;
- }
- this.scaleGestureDetector.onTouchEvent(event, this.tracker);
if (this.state === State.ACTIVE) {
+ // We don't have to call it in the else branch as it would simply return `true`.
+ this.scaleGestureDetector.onTouchEvent(event, this.tracker);
+
this.end();
} else {
this.fail();
diff --git a/packages/react-native-gesture-handler/src/web/handlers/RotationGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/RotationGestureHandler.ts
index 194cd44986..a192140e0a 100644
--- a/packages/react-native-gesture-handler/src/web/handlers/RotationGestureHandler.ts
+++ b/packages/react-native-gesture-handler/src/web/handlers/RotationGestureHandler.ts
@@ -37,7 +37,11 @@ export default class RotationGestureHandler extends GestureHandler {
return true;
},
onRotationEnd: (_detector: RotationGestureDetector): void => {
- this.end();
+ if (this.state === State.ACTIVE) {
+ this.end();
+ } else {
+ this.fail();
+ }
},
};
@@ -128,16 +132,6 @@ export default class RotationGestureHandler extends GestureHandler {
super.onPointerUp(event);
this.tracker.removeFromTracker(event.pointerId);
this.rotationGestureDetector.onTouchEvent(event, this.tracker);
-
- if (this.state !== State.ACTIVE) {
- return;
- }
-
- if (this.state === State.ACTIVE) {
- this.end();
- } else {
- this.fail();
- }
}
protected onPointerRemove(event: AdaptedEvent): void {
From 3cdb51b2fcbe2cf1671c28b4fd0d83d6ceff46ed Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Bert?=
<63123542+m-bert@users.noreply.github.com>
Date: Mon, 13 Apr 2026 12:40:25 +0200
Subject: [PATCH 2/5] [Android] Correct `Rotation` handler (#4079)
## Description
Follow up for #4078
I've noticed that when `Rotation` does not activate, but pointers are
released, `onFinalize` is called with `true`. This happens because we
try to move it to `END` state, but our internal logic does not send
`onDeactivate`.
Code in `onHandle` that I removed was effectively dead, as `ACTION_UP`
was handled in rotation detector - the only possible branch was `else`
statement which fails handler if only one pointer was present.
## Test plan
Tested on Transformations example and the following
code:
```tsx
import { StyleSheet, View } from 'react-native';
import {
GestureDetector,
GestureHandlerRootView,
usePinchGesture,
useRotationGesture,
} from 'react-native-gesture-handler';
export default function App() {
const g = useRotationGesture({
onBegin: () => console.log('onBegin'),
onActivate: () => console.log('onActivate'),
onUpdate: (e) => console.log('onUpdate', e.rotation),
onDeactivate: () => console.log('onDeactivate'),
onFinalize: (_, s) => console.log('onFinalize', s),
});
return (
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
box: {
width: 200,
height: 200,
backgroundColor: 'blue',
borderRadius: 12,
},
});
```
---
.../core/RotationGestureHandler.kt | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/RotationGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/RotationGestureHandler.kt
index 2d660f6d4d..657f05cef3 100644
--- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/RotationGestureHandler.kt
+++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/RotationGestureHandler.kt
@@ -35,7 +35,11 @@ class RotationGestureHandler : GestureHandler() {
override fun onRotationBegin(detector: RotationGestureDetector) = true
override fun onRotationEnd(detector: RotationGestureDetector) {
- end()
+ if (state == STATE_ACTIVE) {
+ end()
+ } else {
+ fail()
+ }
}
}
@@ -56,12 +60,11 @@ class RotationGestureHandler : GestureHandler() {
anchorX = point.x
anchorY = point.y
}
- if (sourceEvent.actionMasked == MotionEvent.ACTION_UP) {
- if (state == STATE_ACTIVE) {
- end()
- } else {
- fail()
- }
+
+ // ACTION_UP is already handled in rotationGestureDetector.onTouchEvent (and effectively in onRotationEnd)
+ // if more than one pointer was used
+ if (sourceEvent.actionMasked == MotionEvent.ACTION_UP && state == STATE_BEGAN) {
+ fail()
}
}
From a516d768bd557a8ff24fb61e12d4f098025579b8 Mon Sep 17 00:00:00 2001
From: Aleksandr Litinskii
Date: Tue, 14 Apr 2026 18:20:40 +0300
Subject: [PATCH 3/5] SwipeableProps from RefObject to Ref (#3759)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Fix ReanimatedSwipeable ref type to support ref callbacks
## Description
This PR fixes the `ref` prop type in `ReanimatedSwipeableProps` to
support both ref objects and ref callbacks, improving TypeScript
compatibility and developer experience.
## Changes
- Changed `ref?: React.RefObject` to `ref?:
React.Ref` in `ReanimatedSwipeableProps`
interface
## Motivation
The current implementation only supports `React.RefObject` which limits
developers to using `useRef()` hook. By changing to `React.Ref`, the
component now supports:
1. **Ref objects** (`useRef()`)
2. **Ref callbacks** (function refs)
This change aligns with React's best practices and provides more
flexibility for developers when working with refs.
## Example Usage
### Before (only ref objects)
```tsx
const swipeableRef = useRef(null);
{/* content */}
```
### After (supports both ref objects and callbacks)
```tsx
// Ref object
const swipeableRef = useRef(null);
// Ref callback
const handleRef = (ref: SwipeableMethods | null) => {
// Custom logic
};
{/* content */}
{/* content */}
```
## Type Safety
This change maintains full TypeScript type safety while expanding the
supported ref patterns. The `React.Ref` type is a union type that
includes:
- `React.RefObject`
- `React.RefCallback`
## Breaking Changes
None. This is a backward-compatible change that only expands the
supported ref types.
## Testing
- [x] Existing tests pass
- [x] TypeScript compilation succeeds
- [x] Both ref object and ref callback patterns work correctly
## Related Issues
Fixes the limitation where developers could only use `useRef()` with
ReanimatedSwipeable components.
---------
Co-authored-by: Michał
Co-authored-by: Michał Bert <63123542+m-bert@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.../components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
index f81877d1b2..0c0e517e0d 100644
--- a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
+++ b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
@@ -13,7 +13,7 @@ export interface SwipeableProps {
/**
*
*/
- ref?: React.RefObject;
+ ref?: React.Ref;
/**
* Sets a `testID` property, allowing for querying `ReanimatedSwipeable` for it in tests.
From a312b3a912e19ac9d5a1059fedf1024f43171eeb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Bert?=
<63123542+m-bert@users.noreply.github.com>
Date: Wed, 22 Apr 2026 13:54:36 +0200
Subject: [PATCH 4/5] Do not check for `worklets` if babel plugin was not used
(#4107)
## Description
In some environments babel plugin is not used (e.g. it can be omitted
when using `Next.js`). Our detector checks if callbacks are `worklets`
by checking `__workletHash` field. However, if plugin wasn't used, this
will be `undefined`. In that case, our warning will wrongly suggest to
use `runOnJS`. This may be misleading, if plugin was skipped on `web`,
but used on native platforms.
In this PR, we check if plugin was applied by checking empty worklet
function. If it has `__workletHash` field it means that plugin was used.
If it wasn't, we won't suggest using `runOnJS`.
## Test plan
Confirmed by the original reporter
---
.../src/handlers/gestures/GestureDetector/utils.ts | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts
index 0bf2471311..fc713189b2 100644
--- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts
+++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts
@@ -36,6 +36,16 @@ export const ALLOWED_PROPS = [
...nativeViewGestureHandlerProps,
];
+// In some environments (e.g. `Next.js`) Reanimated Babel plugin might not be used.
+// In that case we would wrongly suggest to add `runOnJS` to gesture configuration, even if the user doesn't use worklets at all.
+// To prevent this, we check whether the plugin is enabled by defining a worklet and checking if the `__workletHash` property is available.
+function emptyWorklet() {
+ 'worklet';
+}
+
+// @ts-expect-error if `emptyWorklet` is a worklet, `__workletHash` will be available, if not then the check will return false.
+const wasBabelPluginEnabled = emptyWorklet.__workletHash !== undefined;
+
function convertToHandlerTag(ref: GestureRef): number {
if (typeof ref === 'number') {
return ref;
@@ -105,7 +115,7 @@ export function checkGestureCallbacksForWorklets(gesture: GestureType) {
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 && !isTestEnv()) {
+ if (areAllNotWorklets && wasBabelPluginEnabled && !isTestEnv()) {
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.`
From b1ad90ad9514f157691283d1c8d02058440b89b5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andrzej=20Antoni=20Kwa=C5=9Bniewski?=
<81448793+akwasniewski@users.noreply.github.com>
Date: Tue, 24 Mar 2026 11:38:28 +0100
Subject: [PATCH 5/5] [android] fix talkback on pressable (#4017)
When TalkBack is activated, pressable cannot be clicked on android. This
PR fixes the issue.
The reason was that Pressable requires a specific order of events. This
order of events is hardcoded and found empirically. However, with
talback enabled the order of events is different, I added a function
which queries native side whether or not talback is enabled and adjusts
expected event order accordingly.
Moreover with talback enabled longPress gets both onPointerUp and
onPointerDown before native side receives its events, thus we must not
reset the state machine state on onPointerUp.
Tested on the following example:
```tsx
import React from 'react';
import { Text, StyleSheet, View } from 'react-native';
import { Pressable, GestureHandlerRootView } from 'react-native-gesture-handler'
const PressableExample = () => {
const [count, setCount] = React.useState(0);
return (
setCount((c) => c + 1)}>
{count}
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
pressable: {
alignItems: 'center', justifyContent: 'center', backgroundColor: 'green', width: 100, height: 100
},
wrapperCustom: {
borderRadius: 8,
padding: 16,
minWidth: 150,
alignItems: 'center',
},
text: {
fontSize: 18,
color: 'white',
fontWeight: '600',
},
});
export default PressableExample;
```
---
.../src/components/Pressable/Pressable.tsx | 14 ++++++---
.../components/Pressable/stateDefinitions.ts | 26 ++++++++++++++--
.../src/useIsScreenReaderEnabled.ts | 31 +++++++++++++++++++
3 files changed, 65 insertions(+), 6 deletions(-)
create mode 100644 packages/react-native-gesture-handler/src/useIsScreenReaderEnabled.ts
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 db956d916f..c9985eb8a0 100644
--- a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx
+++ b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx
@@ -37,6 +37,7 @@ import {
} from '../utils';
import { getStatesConfig, StateMachineEvent } from './stateDefinitions';
import { PressableStateMachine } from './StateMachine';
+import { useIsScreenReaderEnabled } from '../../useIsScreenReaderEnabled';
const DEFAULT_LONG_PRESS_DURATION = 500;
const IS_TEST_ENV = isTestEnv();
@@ -202,11 +203,16 @@ const Pressable = (props: PressableProps) => {
);
const stateMachine = useMemo(() => new PressableStateMachine(), []);
+ const isScreenReaderEnabled = useIsScreenReaderEnabled();
useEffect(() => {
- const configuration = getStatesConfig(handlePressIn, handlePressOut);
+ const configuration = getStatesConfig(
+ handlePressIn,
+ handlePressOut,
+ isScreenReaderEnabled
+ );
stateMachine.setStates(configuration);
- }, [handlePressIn, handlePressOut, stateMachine]);
+ }, [handlePressIn, handlePressOut, stateMachine, isScreenReaderEnabled]);
const hoverInTimeout = useRef(null);
const hoverOutTimeout = useRef(null);
@@ -259,7 +265,7 @@ const Pressable = (props: PressableProps) => {
);
})
.onTouchesUp(() => {
- if (Platform.OS === 'android') {
+ if (Platform.OS === 'android' && !isScreenReaderEnabled) {
// Prevents potential soft-locks
stateMachine.reset();
handleFinalize();
@@ -280,7 +286,7 @@ const Pressable = (props: PressableProps) => {
handleFinalize();
}
}),
- [stateMachine, handleFinalize, handlePressOut]
+ [stateMachine, handleFinalize, handlePressOut, isScreenReaderEnabled]
);
// RNButton is placed inside ButtonGesture to enable Android's ripple and to capture non-propagating events
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 55279211cd..af4fb93057 100644
--- a/packages/react-native-gesture-handler/src/components/Pressable/stateDefinitions.ts
+++ b/packages/react-native-gesture-handler/src/components/Pressable/stateDefinitions.ts
@@ -29,6 +29,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
@@ -109,10 +128,13 @@ function getUniversalStatesConfig(
export function getStatesConfig(
handlePressIn: (event: PressableEvent) => void,
- handlePressOut: (event: PressableEvent) => void
+ handlePressOut: (event: PressableEvent) => void,
+ screenReaderActive: boolean
): StateDefinition[] {
if (Platform.OS === 'android') {
- return getAndroidStatesConfig(handlePressIn, handlePressOut);
+ return screenReaderActive
+ ? getAndroidAccessibilityStatesConfig(handlePressIn, handlePressOut)
+ : getAndroidStatesConfig(handlePressIn, handlePressOut);
} else if (Platform.OS === 'ios') {
return getIosStatesConfig(handlePressIn, handlePressOut);
} else if (Platform.OS === 'web') {
diff --git a/packages/react-native-gesture-handler/src/useIsScreenReaderEnabled.ts b/packages/react-native-gesture-handler/src/useIsScreenReaderEnabled.ts
new file mode 100644
index 0000000000..4d7dba945b
--- /dev/null
+++ b/packages/react-native-gesture-handler/src/useIsScreenReaderEnabled.ts
@@ -0,0 +1,31 @@
+import { useEffect, useState } from 'react';
+import { AccessibilityInfo } from 'react-native';
+
+export function useIsScreenReaderEnabled() {
+ const [isEnabled, setIsEnabled] = useState(false);
+
+ useEffect(() => {
+ const checkStatus = async () => {
+ try {
+ const res = await AccessibilityInfo.isScreenReaderEnabled();
+ setIsEnabled(res);
+ } catch (error) {
+ console.warn('Could not read accessibility info: defaulting to false');
+ }
+ };
+
+ checkStatus();
+
+ const listener = AccessibilityInfo.addEventListener(
+ 'screenReaderChanged',
+ (enabled) => {
+ setIsEnabled(enabled);
+ }
+ );
+
+ return () => {
+ listener.remove();
+ };
+ }, []);
+ return isEnabled;
+}