Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
fa598a6
reanimated
Dec 24, 2025
d1c21ee
set up physical device test
Dec 24, 2025
9945d2b
Merge branch 'thomas/no-use-before-define' into thomas/reanimated
Dec 24, 2025
7c76154
doc fix
Dec 24, 2025
fac493f
fix crashes
Dec 24, 2025
8604c3c
onTransformWorklet works
Dec 24, 2025
6c2adfe
fix pageSheet modal breaking coordinates by removing originalPageX/Y …
Dec 25, 2025
b7b10af
fix missing gesture center logic
Dec 25, 2025
4427fcb
fix dx, dy
Dec 25, 2025
ebcb7b8
fix accessing props from worklets
Dec 25, 2025
77c9c80
refactor: clean up unused imports and simplify StaticPin component
Dec 25, 2025
d4fe8c0
fix longPress getting called on pinch
Dec 25, 2025
490ae19
add test case for modal mode
Dec 25, 2025
ff5b6c3
Zoomable context
elliottkember Dec 25, 2025
8809509
Woo - no animated value in the consumer
elliottkember Dec 25, 2025
3601884
Add a nice inverted zoom style
elliottkember Dec 26, 2025
fbab873
ConstantSizeMarker
elliottkember Dec 26, 2025
44f0a85
Rename
elliottkember Dec 26, 2025
eea8cb1
unzoomStyle
elliottkember Dec 26, 2025
1019845
pan responder hooks
Dec 29, 2025
341414d
runonjs (#154)
elliottkember Dec 29, 2025
f893a93
ref export
Dec 29, 2025
71fdaf9
Merge remote-tracking branch 'origin/thomas/reanimated' into thomas/r…
Dec 29, 2025
efdd4aa
unnecessary useAnimatedStyle
Dec 29, 2025
7846d04
onZoomEnd
Dec 30, 2025
74070c3
FixedSize
elliottkember Jan 4, 2026
e37a705
Revert math.max change in favour of separate PR
elliottkember Jan 5, 2026
8c6b27f
Just expose inverseZoom, we don't need an animated style for a transform
elliottkember Jan 5, 2026
86f67ca
inverseZoomStyle
elliottkember Jan 5, 2026
4a2d93f
Separate context file
elliottkember Jan 5, 2026
a327775
Merge branch 'thomas/reanimated' into elliott/reanimated-context
elliottkember Jan 5, 2026
591db01
Bugbot
elliottkember Jan 6, 2026
d23d301
Forgot a file
elliottkember Jan 6, 2026
5193634
Merge pull request #153 from openspacelabs/elliott/reanimated-context
elliottkember Jan 6, 2026
4545946
useZoomableViewContext
elliottkember Jan 6, 2026
f61f498
Forgot to export
elliottkember Jan 6, 2026
678b1c2
Try it without the -worklet suffix and with a Worklet type (which tru…
elliottkember Jan 6, 2026
1540cb2
Bump version
elliottkember Jan 6, 2026
bcb7bba
Merge remote-tracking branch 'origin/master' into thomas/reanimated
Apr 13, 2026
468bd2d
Strip lib/ build artifacts made redundant by #158
Apr 13, 2026
29e1e33
Merge remote-tracking branch 'origin/thomas/no-use-before-define' int…
Apr 19, 2026
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
44 changes: 18 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
| zoomStep | number | How much zoom should be applied on double tap | 0.5 |
| pinchToZoomInSensitivity | number | the level of resistance (sensitivity) to zoom in (0 - 10) - higher is less sensitive | 1 |
| pinchToZoomOutSensitivity | number | the level of resistance (sensitivity) to zoom out (0 - 10) - higher is less sensitive | 1 |
| movementSensibility | number | how resistant should shifting the view around be? (0.5 - 5) - higher is less sensitive | 1 |
| movementSensitivity | number | how resistant should shifting the view around be? (0.5 - 5) - higher is less sensitive | 1 |
| initialOffsetX | number | The horizontal offset the image should start at | 0 |
| initialOffsetY | number | The vertical offset the image should start at | 0 |
| contentHeight | number | Specify if you want to treat the height of the **centered** content inside the zoom subject as the zoom subject's height | undefined |
Expand All @@ -189,19 +189,15 @@

These events can be used to work with data after specific events.

| name | description | params | expected return |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| onTransform | Will be called when the transformation configuration (zoom level and offset) changes | zoomableViewEventObject | void |
| onDoubleTapBefore | Will be called at the start of a double tap | event, gestureState, zoomableViewEventObject | void |
| onDoubleTapAfter | Will be called at the end of a double tap | event, gestureState, zoomableViewEventObject | void |
| onShiftingBefore | Will be called when user taps and moves the view, but before our view movement work kicks in (so this is the place to interrupt movement, if you need to) | event, gestureState, zoomableViewEventObject | {boolean} if this returns true, ZoomableView will not process the shift, otherwise it will |
| onShiftingAfter | Will be called when user taps and moves the view, but after the values have changed already | event, gestureState, zoomableViewEventObject | void |
| onShiftingEnd | Will be called when user stops a tap and move gesture | event, gestureState, zoomableViewEventObject | void |
| onZoomBefore | Will be called while the user pinches the screen, but before our zoom work kicks in (so this is the place to interrupt zooming, if you need to) | event, gestureState, zoomableViewEventObject | {boolean} if this returns true, ZoomableView will not process the pinch, otherwise it will |
| onZoomAfter | Will be called while the user pinches the screen, but after the values have changed already | event, gestureState, zoomableViewEventObject | {boolean} if this returns true, ZoomableView will not process the pinch, otherwise it will |
| onZoomEnd | Will be called after pinchzooming has ended | event, gestureState, zoomableViewEventObject | {boolean} if this returns true, ZoomableView will not process the pinch, otherwise it will |
| onLongPress | Will be called after the user pressed on the image for a while | event, gestureState | void |
| onLayout | Like `View`'s `onLayout`, but different in that it syncs with this component's internal state and returns a fake sythentic event | Like `View`'s `onLayout` but the synthetic event is fake | void |
| name | description | params | expected return |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | --------------- |
| onTransform | Will be called when the transformation configuration (zoom level and offset) changes | zoomableViewEventObject | void |
| onDoubleTapBefore | Will be called at the start of a double tap | event, zoomableViewEventObject | void |
| onDoubleTapAfter | Will be called at the end of a double tap | event, zoomableViewEventObject | void |
| onShiftingEnd | Will be called when user stops a tap and move gesture | event, zoomableViewEventObject | void |
| onZoomEnd | Will be called after pinchzooming has ended | event, zoomableViewEventObject | void |
| onLongPress | Will be called after the user pressed on the image for a while | event | void |
| onLayout | Like `View`'s `onLayout`, but different in that it syncs with this component's internal state and returns a fake sythentic event | Like `View`'s `onLayout` but the synthetic event is fake | void |

#### Methods

Expand Down Expand Up @@ -266,20 +262,18 @@

#### Pan Responder Hooks

Sometimes you need to change deeper level behavior, so we prepared these panresponder hooks for you.
`react-native-gesture-handler` is now used instead of the built-in PanResponder. As such, we have removed some hooks that
are no longer supported and made the rest backward compatible.

| name | description | params | expected return |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------- |
| onStartShouldSetPanResponder | description | event, gestureState, zoomableViewEventObject, baseComponentResult | {boolean} whether panresponder should be set or not |
| onPanResponderGrant | description | event, gestureState, zoomableViewEventObject | void |
| onPanResponderEnd | Will be called when gesture ends (more accurately, on pan responder "release") | event, gestureState, zoomableViewEventObject | void |
| onPanResponderTerminate | Will be called when the gesture is force-interrupted by another handler | event, gestureState, zoomableViewEventObject | void |
| onPanResponderTerminationRequest | Callback asking whether the gesture should be interrupted by another handler (**iOS only** due to https://github.com/facebook/react-native/issues/27778, https://github.com/facebook/react-native/issues/5696, ...) | event, gestureState, zoomableViewEventObject | void |
| onPanResponderMove | Will be called when user moves while touching | event, gestureState, zoomableViewEventObject | void |
| onShouldBlockNativeResponder | Returns whether this component should block native components from becoming the JS responder | event, gestureState, zoomableViewEventObject | boolean |
| name | description | params | expected return |
| ------------------------- | ------------------------------------------------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------- |
| onPanResponderGrant | description | event, zoomableViewEventObject | void |
| onPanResponderEnd | Will be called when gesture ends (more accurately, on pan responder "release") | event, zoomableViewEventObject | void |
| onPanResponderTerminate | Will be called when the gesture is force-interrupted by another handler | event, zoomableViewEventObject | void |
| onPanResponderMoveWorklet | Will be called when user moves while touching | event, zoomableViewEventObject | {boolean} if true is returned, pinch and shift operations will not be processed |

### zoomableViewEventObject

Check warning on line 276 in README.md

View check run for this annotation

Claude / Claude Code Review

README documents wrong prop name: onPanResponderMoveWorklet vs onPanResponderMove

The README Pan Responder Hooks table documents the prop as `onPanResponderMoveWorklet` (line 273), but the actual prop defined in `src/typings/index.ts` is `onPanResponderMove`. Any developer following the README who passes `onPanResponderMoveWorklet={myWorklet}` will have the callback silently ignored — movement interception will be completely non-functional with no TypeScript error to signal the mistake.
Comment on lines +270 to 276
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The README Pan Responder Hooks table documents the prop as onPanResponderMoveWorklet (line 273), but the actual prop defined in src/typings/index.ts is onPanResponderMove. Any developer following the README who passes onPanResponderMoveWorklet={myWorklet} will have the callback silently ignored — movement interception will be completely non-functional with no TypeScript error to signal the mistake.

Extended reasoning...

What the bug is and how it manifests

The README Pan Responder Hooks table (README.md, line 273) documents the move interception callback as onPanResponderMoveWorklet. However, looking at src/typings/index.ts, the prop is declared as onPanResponderMove (typed as Worklet<(event: GestureTouchEvent, zoomableViewEventObject: ZoomableViewEvent) => boolean>). The Worklet<T> alias is defined as type Worklet<T extends (...args: any[]) => any> = T, meaning it is structurally identical to T — it adds no nominal distinction that TypeScript can use to flag the mismatch.

The specific code path that triggers it

A developer reads the README table, sees onPanResponderMoveWorklet, and writes:

<ReactNativeZoomableView onPanResponderMoveWorklet={myWorklet} />

Internally, _handlePanResponderMove checks onPanResponderMove?.(e, _getZoomableViewEventObject()). Since the prop that was actually passed is onPanResponderMoveWorklet (not a recognized key in ReactNativeZoomableViewProps), the callback is never invoked.

Why existing code doesn't prevent it

TypeScript's excess property checking only triggers in direct object literals passed inline. In React JSX, props are passed as an object literal, so TypeScript would flag an unknown prop... unless the component's prop type has an index signature, or the user passes via spread. More importantly, Worklet<T> = T means even if someone mistakenly thought onPanResponderMoveWorklet was a valid prop name, there is no nominal type difference to catch it. In practice, the JSX compiler will simply drop the unrecognized prop into the component, where it is never read.

What the impact would be

Any developer who uses the README as their primary reference for the Pan Responder Hooks API (its intended purpose) and tries to intercept move events will find that their callback is never called. Pinch and shift operations cannot be intercepted. There is no runtime error, no warning, and no TypeScript error in typical usage — the feature is just silently absent.

How to fix it

Change line 273 of README.md from onPanResponderMoveWorklet to onPanResponderMove to match the actual prop name declared in src/typings/index.ts.

Step-by-step proof

  1. Developer reads README.md Pan Responder Hooks table at line 273, sees prop name onPanResponderMoveWorklet.
  2. Developer adds onPanResponderMoveWorklet={(e, eventObj) => { 'worklet'; return true; }} to their <ReactNativeZoomableView>.
  3. TypeScript compiles without error (excess property checking may not fire in JSX spread scenarios; Worklet<T>=T provides no type guard).
  4. At runtime, _handlePanResponderMove evaluates onPanResponderMove?.(e, ...) — this is undefined because the prop was passed under the wrong name.
  5. The early-return guard is never triggered; movement interception never occurs regardless of what the worklet returns.
  6. The developer has no indication anything is wrong — the component renders and pans normally, the callback just never fires.

The zoomableViewEventObject object is attached to every event and represents the current state of our zoomable view.

```
Expand All @@ -289,8 +283,6 @@
offsetY: number, // current offset top
originalHeight: number, // original height of the zoom subject
originalWidth: number, // original width of the zoom subject
originalPageX: number, // original absolute X of the zoom subject
originalPageY: number, // original absolite Y of the zoom subject
}
```

Expand Down
79 changes: 59 additions & 20 deletions example/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { ReactNativeZoomableView } from '@openspacelabs/react-native-zoomable-view';
import {
FixedSize,
ReactNativeZoomableView,
ReactNativeZoomableViewRef,
} from '@openspacelabs/react-native-zoomable-view';
import { debounce } from 'lodash';
import React, { useCallback, useRef, useState } from 'react';
import { Animated, Button, Image, Text, View } from 'react-native';
import React, { ReactNode, useCallback, useRef, useState } from 'react';
import {
Alert,
Button,
Image,
Modal,
Text,
View,
ViewProps,
} from 'react-native';
import { scheduleOnRN } from 'react-native-worklets';

Check failure on line 17 in example/App.tsx

View workflow job for this annotation

GitHub Actions / checks

Cannot find module 'react-native-worklets' or its corresponding type declarations.

import { applyContainResizeMode } from '../src/helper/coordinateConversion';
import { styles } from './style';
Expand All @@ -13,10 +26,24 @@
const stringifyPoint = (point?: { x: number; y: number }) =>
point ? `${Math.round(point.x)}, ${Math.round(point.y)}` : 'Off map';

const PageSheetModal = ({
children,
style,
}: {
children: ReactNode;
style?: ViewProps['style'];
}) => {
return (
<Modal animationType="slide" presentationStyle="pageSheet">
<View style={style}>{children}</View>
</Modal>
);
};

export default function App() {
const zoomAnimatedValue = useRef(new Animated.Value(1)).current;
const scale = Animated.divide(1, zoomAnimatedValue);
const ref = useRef<ReactNativeZoomableViewRef>(null);
const [showMarkers, setShowMarkers] = useState(true);
const [modal, setModal] = useState(false);
const [size, setSize] = useState<{ width: number; height: number }>({
width: 0,
height: 0,
Expand All @@ -36,8 +63,10 @@
const staticPinPosition = { x: size.width / 2, y: size.height / 2 };
const { size: contentSize } = applyContainResizeMode(imageSize, size);

const Wrapper = modal ? PageSheetModal : View;

return (
<View style={styles.container}>
<Wrapper style={styles.container}>
<Text>ReactNativeZoomableView</Text>
<View
style={styles.box}
Expand All @@ -46,35 +75,36 @@
}}
>
<ReactNativeZoomableView
ref={ref}
debug
onLongPress={() => {
Alert.alert('Long press detected');
}}
// Where to put the pin in the content view
staticPinPosition={staticPinPosition}
// Callback that returns the position of the pin
// on the actual source image
onStaticPinPositionChange={debouncedUpdatePin}
onStaticPinPositionMove={debouncedUpdateMovePin}
onStaticPinPositionMove={(position) => {
'worklet';
scheduleOnRN(debouncedUpdateMovePin, position);
}}
maxZoom={30}
// Give these to the zoomable view so it can apply the boundaries around the actual content.
// Need to make sure the content is actually centered and the width and height are
// measured when it's rendered naturally. Not the intrinsic sizes.
contentWidth={contentSize?.width ?? 0}
contentHeight={contentSize?.height ?? 0}
zoomAnimatedValue={zoomAnimatedValue}
>
<View style={styles.contents}>
<Image style={styles.img} source={{ uri }} />

{showMarkers &&
(['20%', '40%', '60%', '80%'] as const).map((left) =>
(['20%', '40%', '60%', '80%'] as const).map((top) => (
<Animated.View
key={`${left}x${top}`}
// These markers will move and zoom with the image, but will retain their size
// because of the scale transformation.
style={[
styles.marker,
{ left, top, transform: [{ scale }] },
]}
/>
[20, 40, 60, 80].map((left) =>
[20, 40, 60, 80].map((top) => (
<FixedSize left={left} top={top} key={`${left}x${top}`}>
<View style={styles.marker} />
</FixedSize>
))
)}
</View>
Expand All @@ -88,6 +118,15 @@
setShowMarkers((value) => !value);
}}
/>
</View>

<Button
// Toggle modal to test if zoomable view works correctly in modal,
// where pull-down-to-close gesture can interfere with pan gestures.
title={`Toggle Modal Mode`}
onPress={() => {
setModal((value) => !value);
}}
/>
</Wrapper>
Comment on lines +122 to +130
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Clever! I think this should exhibit the same behaviour as the react-navigation / react-native-screens pull to close, but I might add the routing library and some formSheet / pageSheet routes just to test the theory

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🥳 Looks good with expo-router, which uses native-stack under the hood!

image

);
}
1 change: 1 addition & 0 deletions example/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module.exports = function (api) {
},
},
],
'react-native-reanimated/plugin',
],
};
};
Loading
Loading