Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
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 @@ -159,7 +159,7 @@ These options can be used to limit and change the zoom behavior.
| 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 | 3 |
| 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.9 |
| movementSensitivity | number | how resistant should shifting the view around be? (0.5 - 5) - higher is less sensitive | 1.9 |
| 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 @@ -182,19 +182,15 @@ These optional props can be used to keep a "static" pin in the centre of the scr

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 @@ -259,17 +255,15 @@ export default function App() {

#### 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

Expand All @@ -282,8 +276,6 @@ The zoomableViewEventObject object is attached to every event and represents the
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';

import { applyContainResizeMode } from '../src/helper/coordinateConversion';
import { styles } from './style';
Expand All @@ -13,10 +26,24 @@ const imageSize = { width: kittenSize, height: kittenSize };
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 @@ export default function App() {
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 @@ export default function App() {
}}
>
<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 @@ export default function App() {
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