rn-pdf-king 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +5 -0
- package/README.md +148 -0
- package/android/build.gradle +55 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/rnpdfking/PdfKing.kt +693 -0
- package/android/src/main/java/expo/modules/rnpdfking/RnPdfKingModule.kt +163 -0
- package/android/src/main/java/expo/modules/rnpdfking/RnPdfKingView.kt +184 -0
- package/build/PdfDocument.d.ts +19 -0
- package/build/PdfDocument.d.ts.map +1 -0
- package/build/PdfDocument.js +81 -0
- package/build/PdfDocument.js.map +1 -0
- package/build/PdfPage.d.ts +24 -0
- package/build/PdfPage.d.ts.map +1 -0
- package/build/PdfPage.js +13 -0
- package/build/PdfPage.js.map +1 -0
- package/build/RnPdfKing.types.d.ts +48 -0
- package/build/RnPdfKing.types.d.ts.map +1 -0
- package/build/RnPdfKing.types.js +2 -0
- package/build/RnPdfKing.types.js.map +1 -0
- package/build/RnPdfKingModule.d.ts +13 -0
- package/build/RnPdfKingModule.d.ts.map +1 -0
- package/build/RnPdfKingModule.js +4 -0
- package/build/RnPdfKingModule.js.map +1 -0
- package/build/RnPdfKingModule.web.d.ts +13 -0
- package/build/RnPdfKingModule.web.d.ts.map +1 -0
- package/build/RnPdfKingModule.web.js +21 -0
- package/build/RnPdfKingModule.web.js.map +1 -0
- package/build/RnPdfKingView.d.ts +4 -0
- package/build/RnPdfKingView.d.ts.map +1 -0
- package/build/RnPdfKingView.js +7 -0
- package/build/RnPdfKingView.js.map +1 -0
- package/build/RnPdfKingView.web.d.ts +4 -0
- package/build/RnPdfKingView.web.d.ts.map +1 -0
- package/build/RnPdfKingView.web.js +7 -0
- package/build/RnPdfKingView.web.js.map +1 -0
- package/build/ZoomableList.d.ts +37 -0
- package/build/ZoomableList.d.ts.map +1 -0
- package/build/ZoomableList.js +289 -0
- package/build/ZoomableList.js.map +1 -0
- package/build/ZoomablePage.d.ts +10 -0
- package/build/ZoomablePage.d.ts.map +1 -0
- package/build/ZoomablePage.js +15 -0
- package/build/ZoomablePage.js.map +1 -0
- package/build/ZoomablePdfPage.d.ts +10 -0
- package/build/ZoomablePdfPage.d.ts.map +1 -0
- package/build/ZoomablePdfPage.js +17 -0
- package/build/ZoomablePdfPage.js.map +1 -0
- package/build/index.d.ts +8 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +10 -0
- package/build/index.js.map +1 -0
- package/build/zoom/constants.d.ts +36 -0
- package/build/zoom/constants.d.ts.map +1 -0
- package/build/zoom/constants.js +36 -0
- package/build/zoom/constants.js.map +1 -0
- package/build/zoom/index.d.ts +255 -0
- package/build/zoom/index.d.ts.map +1 -0
- package/build/zoom/index.js +783 -0
- package/build/zoom/index.js.map +1 -0
- package/build/zoom/utils.d.ts +55 -0
- package/build/zoom/utils.d.ts.map +1 -0
- package/build/zoom/utils.js +66 -0
- package/build/zoom/utils.js.map +1 -0
- package/bun.lock +2217 -0
- package/expo-module.config.json +9 -0
- package/ios/RnPdfKing.podspec +29 -0
- package/ios/RnPdfKingModule.swift +48 -0
- package/ios/RnPdfKingView.swift +38 -0
- package/package.json +45 -0
- package/src/PdfDocument.tsx +115 -0
- package/src/PdfPage.tsx +57 -0
- package/src/RnPdfKing.types.ts +32 -0
- package/src/RnPdfKingModule.ts +15 -0
- package/src/RnPdfKingModule.web.ts +24 -0
- package/src/RnPdfKingView.tsx +11 -0
- package/src/RnPdfKingView.web.tsx +15 -0
- package/src/ZoomableList.tsx +438 -0
- package/src/ZoomablePage.tsx +31 -0
- package/src/ZoomablePdfPage.tsx +34 -0
- package/src/index.ts +9 -0
- package/src/zoom/constants.ts +40 -0
- package/src/zoom/index.tsx +1267 -0
- package/src/zoom/utils.ts +96 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
import React, { useCallback, useMemo } from 'react';
|
|
2
|
+
import { View, } from 'react-native';
|
|
3
|
+
import { Gesture, GestureDetector, GestureHandlerRootView, State, } from 'react-native-gesture-handler';
|
|
4
|
+
import Animated, { Easing, runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedValue, withDecay, withSpring, withTiming, scrollTo, } from 'react-native-reanimated';
|
|
5
|
+
import { ANIMATION_DURATION, MAX_SCALE, TAP_MAX_DELTA, DOUBLE_TAP_SCALE, } from './constants'; // Allow over-zoom by 50%
|
|
6
|
+
import { clamp } from './utils';
|
|
7
|
+
import { StyleSheet } from 'react-native';
|
|
8
|
+
const styles = StyleSheet.create({
|
|
9
|
+
container: {
|
|
10
|
+
flex: 1,
|
|
11
|
+
overflow: 'hidden',
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
// Rubber band factor for over-scroll/over-zoom
|
|
15
|
+
const RUBBER_BAND_FACTOR = 0.55;
|
|
16
|
+
const MIN_OVER_SCALE = 0.5; // Allow zooming out to 50% for rubber band
|
|
17
|
+
// Apple Photos spring animation config
|
|
18
|
+
// Uses critically damped spring (dampingRatio ≈ 1) with fast response
|
|
19
|
+
// Reference: iOS UISpringTimingParameters defaults
|
|
20
|
+
const SPRING_CONFIG = {
|
|
21
|
+
damping: 20,
|
|
22
|
+
stiffness: 250,
|
|
23
|
+
mass: 0.5,
|
|
24
|
+
overshootClamping: false,
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Apple Photos-style zoom gesture hook
|
|
28
|
+
*
|
|
29
|
+
* Key principles from Apple Photos:
|
|
30
|
+
* 1. Transform order: translate first, then scale (scale around center)
|
|
31
|
+
* 2. Focal point stays under finger during pinch
|
|
32
|
+
* 3. Rubber band effect when over-zooming or at boundaries
|
|
33
|
+
* 4. Smooth spring animations for snap-back
|
|
34
|
+
* 5. Momentum-based panning with boundary bounce
|
|
35
|
+
*/
|
|
36
|
+
export function useZoomGesture(props = {}) {
|
|
37
|
+
const { animationFunction = withTiming, animationConfig, doubleTapConfig, minScale = 1, maxScale = MAX_SCALE, enableGallerySwipe = false, parentScrollRef, currentIndex = 0, itemWidth = 0, onPanningUpdate, onPanningStart, onPanningEnd, onPanningStarted, onPinchingStarted, onPinchingStopped, disableVerticalPan = false, parentAnimatedScrollRef, } = props;
|
|
38
|
+
// Boolean flag for worklet (refs can't be passed to worklets)
|
|
39
|
+
const hasParentScroll = !!parentScrollRef && itemWidth > 0;
|
|
40
|
+
// ============== STATE ==============
|
|
41
|
+
// Scale state - single source of truth
|
|
42
|
+
const scale = useSharedValue(1);
|
|
43
|
+
const savedScale = useSharedValue(1);
|
|
44
|
+
// Translation state (in screen coordinates)
|
|
45
|
+
const translateX = useSharedValue(0);
|
|
46
|
+
const translateY = useSharedValue(0);
|
|
47
|
+
const savedTranslateX = useSharedValue(0);
|
|
48
|
+
const savedTranslateY = useSharedValue(0);
|
|
49
|
+
// Container and content dimensions
|
|
50
|
+
const containerDimensions = useSharedValue({ width: 0, height: 0 });
|
|
51
|
+
const contentDimensions = useSharedValue({ width: 1, height: 1 });
|
|
52
|
+
// Pinch gesture state
|
|
53
|
+
const pinchFocalX = useSharedValue(0);
|
|
54
|
+
const pinchFocalY = useSharedValue(0);
|
|
55
|
+
const isPinching = useSharedValue(false);
|
|
56
|
+
// Pan gesture state for rubber band effect
|
|
57
|
+
const isPanning = useSharedValue(false);
|
|
58
|
+
// Edge swipe state for Apple Photos-style gallery navigation
|
|
59
|
+
const isAtLeftEdge = useSharedValue(false);
|
|
60
|
+
const isAtRightEdge = useSharedValue(false);
|
|
61
|
+
const panStartX = useSharedValue(0);
|
|
62
|
+
const accumulatedOverflow = useSharedValue(0); // Track overflow for snap decision
|
|
63
|
+
const virtualTranslationX = useSharedValue(0);
|
|
64
|
+
const virtualTranslationY = useSharedValue(0);
|
|
65
|
+
const startScrollY = useSharedValue(0);
|
|
66
|
+
const currentScrollY = useSharedValue(0);
|
|
67
|
+
// Tracking state
|
|
68
|
+
const isZoomedIn = useSharedValue(false);
|
|
69
|
+
const zoomGestureLastTime = useSharedValue(0);
|
|
70
|
+
// ============== HELPERS ==============
|
|
71
|
+
const withAnimation = useCallback((toValue, config) => {
|
|
72
|
+
'worklet';
|
|
73
|
+
return animationFunction(toValue, {
|
|
74
|
+
duration: ANIMATION_DURATION,
|
|
75
|
+
easing: Easing.out(Easing.cubic),
|
|
76
|
+
...config,
|
|
77
|
+
...animationConfig,
|
|
78
|
+
});
|
|
79
|
+
}, [animationFunction, animationConfig]);
|
|
80
|
+
/**
|
|
81
|
+
* Calculate the maximum translation bounds for a given scale
|
|
82
|
+
* This ensures the content edges don't go past the container edges
|
|
83
|
+
*
|
|
84
|
+
* Apple Photos algorithm:
|
|
85
|
+
* - Content is centered in container
|
|
86
|
+
* - Translation bounds = half of how much scaled content exceeds container
|
|
87
|
+
* - When content fits inside container, bounds = 0 (no panning allowed)
|
|
88
|
+
*
|
|
89
|
+
* IMPORTANT: Use actual contentDimensions from onLayoutContent, not calculated
|
|
90
|
+
* aspect-fit size. Layout system may round dimensions differently than our math.
|
|
91
|
+
*/
|
|
92
|
+
const getTranslateBounds = useCallback((currentScale) => {
|
|
93
|
+
'worklet';
|
|
94
|
+
const container = containerDimensions.value;
|
|
95
|
+
// Use actual measured content dimensions, not calculated aspect-fit size
|
|
96
|
+
// This ensures bounds match exactly what's rendered on screen
|
|
97
|
+
const content = contentDimensions.value;
|
|
98
|
+
// Scaled content dimensions
|
|
99
|
+
const scaledWidth = content.width * currentScale;
|
|
100
|
+
const scaledHeight = content.height * currentScale;
|
|
101
|
+
// How much the scaled content exceeds the container
|
|
102
|
+
// When scaledSize <= containerSize, excess = 0 (content fits, no panning)
|
|
103
|
+
// When scaledSize > containerSize, excess = scaledSize - containerSize
|
|
104
|
+
const excessWidth = Math.max(0, scaledWidth - container.width);
|
|
105
|
+
const excessHeight = Math.max(0, scaledHeight - container.height);
|
|
106
|
+
// Max translation = half the excess (content can pan from edge to edge)
|
|
107
|
+
// Subtract small padding to ensure content always overlaps edges
|
|
108
|
+
// This prevents subpixel gaps from floating-point rounding
|
|
109
|
+
const safetyPadding = 1;
|
|
110
|
+
return {
|
|
111
|
+
maxX: Math.max(0, Math.floor(excessWidth / 2) - safetyPadding),
|
|
112
|
+
maxY: Math.max(0, Math.floor(excessHeight / 2) - safetyPadding),
|
|
113
|
+
};
|
|
114
|
+
}, [containerDimensions, contentDimensions]);
|
|
115
|
+
/**
|
|
116
|
+
* Clamp translation to valid bounds
|
|
117
|
+
*/
|
|
118
|
+
const clampTranslation = useCallback((tx, ty, currentScale) => {
|
|
119
|
+
'worklet';
|
|
120
|
+
const bounds = getTranslateBounds(currentScale);
|
|
121
|
+
return {
|
|
122
|
+
x: clamp(tx, -bounds.maxX, bounds.maxX),
|
|
123
|
+
y: clamp(ty, -bounds.maxY, bounds.maxY),
|
|
124
|
+
};
|
|
125
|
+
}, [getTranslateBounds]);
|
|
126
|
+
/**
|
|
127
|
+
* Apply rubber band effect to a value that's outside bounds
|
|
128
|
+
* Apple Photos uses this for smooth over-scroll feeling
|
|
129
|
+
*/
|
|
130
|
+
const rubberBand = useCallback((value, min, max, dimension) => {
|
|
131
|
+
'worklet';
|
|
132
|
+
if (value < min) {
|
|
133
|
+
const overscroll = min - value;
|
|
134
|
+
return min - (1 - (1 / ((overscroll * RUBBER_BAND_FACTOR / dimension) + 1))) * dimension;
|
|
135
|
+
}
|
|
136
|
+
if (value > max) {
|
|
137
|
+
const overscroll = value - max;
|
|
138
|
+
return max + (1 - (1 / ((overscroll * RUBBER_BAND_FACTOR / dimension) + 1))) * dimension;
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}, []);
|
|
142
|
+
/**
|
|
143
|
+
* Apply rubber band to translation during gesture
|
|
144
|
+
*/
|
|
145
|
+
const applyRubberBandTranslation = useCallback((tx, ty, currentScale) => {
|
|
146
|
+
'worklet';
|
|
147
|
+
const container = containerDimensions.value;
|
|
148
|
+
const bounds = getTranslateBounds(currentScale);
|
|
149
|
+
return {
|
|
150
|
+
x: rubberBand(tx, -bounds.maxX, bounds.maxX, container.width),
|
|
151
|
+
y: rubberBand(ty, -bounds.maxY, bounds.maxY, container.height),
|
|
152
|
+
};
|
|
153
|
+
}, [containerDimensions, getTranslateBounds, rubberBand]);
|
|
154
|
+
/**
|
|
155
|
+
* Apply boundary constraints with spring animation (rubber band effect)
|
|
156
|
+
*/
|
|
157
|
+
const applyBoundaryConstraints = useCallback((targetScale, animate = true) => {
|
|
158
|
+
'worklet';
|
|
159
|
+
const clampedScale = clamp(targetScale, minScale, maxScale);
|
|
160
|
+
const { x: clampedX, y: clampedY } = clampTranslation(translateX.value, translateY.value, clampedScale);
|
|
161
|
+
if (animate) {
|
|
162
|
+
// Apple uses spring animation for snap-back
|
|
163
|
+
// Using gentle spring config to avoid excessive bounce (fix for #51)
|
|
164
|
+
scale.value = withSpring(clampedScale, SPRING_CONFIG);
|
|
165
|
+
translateX.value = withSpring(clampedX, SPRING_CONFIG);
|
|
166
|
+
translateY.value = withSpring(clampedY, SPRING_CONFIG);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
scale.value = clampedScale;
|
|
170
|
+
translateX.value = clampedX;
|
|
171
|
+
translateY.value = clampedY;
|
|
172
|
+
}
|
|
173
|
+
savedScale.value = clampedScale;
|
|
174
|
+
savedTranslateX.value = clampedX;
|
|
175
|
+
savedTranslateY.value = clampedY;
|
|
176
|
+
isZoomedIn.value = clampedScale > minScale;
|
|
177
|
+
}, [
|
|
178
|
+
scale,
|
|
179
|
+
translateX,
|
|
180
|
+
translateY,
|
|
181
|
+
savedScale,
|
|
182
|
+
savedTranslateX,
|
|
183
|
+
savedTranslateY,
|
|
184
|
+
isZoomedIn,
|
|
185
|
+
clampTranslation,
|
|
186
|
+
minScale,
|
|
187
|
+
maxScale,
|
|
188
|
+
]);
|
|
189
|
+
// ============== ZOOM ACTIONS ==============
|
|
190
|
+
/**
|
|
191
|
+
* Zoom in to a point (double-tap)
|
|
192
|
+
* Apple Photos behavior: zoom to 2x (or configured scale) centered on tap point
|
|
193
|
+
*/
|
|
194
|
+
const zoomIn = useCallback((focalX, focalY) => {
|
|
195
|
+
'worklet';
|
|
196
|
+
const container = containerDimensions.value;
|
|
197
|
+
const targetScale = doubleTapConfig?.defaultScale
|
|
198
|
+
?? doubleTapConfig?.minZoomScale
|
|
199
|
+
?? DOUBLE_TAP_SCALE;
|
|
200
|
+
const clampedTargetScale = clamp(targetScale, doubleTapConfig?.minZoomScale ?? minScale, doubleTapConfig?.maxZoomScale ?? maxScale);
|
|
201
|
+
// Container center
|
|
202
|
+
const centerX = container.width / 2;
|
|
203
|
+
const centerY = container.height / 2;
|
|
204
|
+
// Current state
|
|
205
|
+
const currentScale = scale.value;
|
|
206
|
+
const currentTx = translateX.value;
|
|
207
|
+
const currentTy = translateY.value;
|
|
208
|
+
// Focal point offset from center (in screen coords)
|
|
209
|
+
const focalOffsetX = focalX - centerX;
|
|
210
|
+
const focalOffsetY = focalY - centerY;
|
|
211
|
+
// Calculate new translation to keep focal point stationary
|
|
212
|
+
// The focal point in content space: (focalOffset - translate) / scale
|
|
213
|
+
// After zoom: newTranslate = focalOffset - contentPoint * newScale
|
|
214
|
+
const contentPointX = (focalOffsetX - currentTx) / currentScale;
|
|
215
|
+
const contentPointY = (focalOffsetY - currentTy) / currentScale;
|
|
216
|
+
let newTx = focalOffsetX - contentPointX * clampedTargetScale;
|
|
217
|
+
let newTy = focalOffsetY - contentPointY * clampedTargetScale;
|
|
218
|
+
// Clamp to bounds
|
|
219
|
+
const clamped = clampTranslation(newTx, newTy, clampedTargetScale);
|
|
220
|
+
newTx = clamped.x;
|
|
221
|
+
newTy = clamped.y;
|
|
222
|
+
// Animate
|
|
223
|
+
scale.value = withAnimation(clampedTargetScale);
|
|
224
|
+
translateX.value = withAnimation(newTx);
|
|
225
|
+
translateY.value = withAnimation(newTy);
|
|
226
|
+
savedScale.value = clampedTargetScale;
|
|
227
|
+
savedTranslateX.value = newTx;
|
|
228
|
+
savedTranslateY.value = newTy;
|
|
229
|
+
isZoomedIn.value = true;
|
|
230
|
+
}, [
|
|
231
|
+
containerDimensions,
|
|
232
|
+
scale,
|
|
233
|
+
translateX,
|
|
234
|
+
translateY,
|
|
235
|
+
savedScale,
|
|
236
|
+
savedTranslateX,
|
|
237
|
+
savedTranslateY,
|
|
238
|
+
isZoomedIn,
|
|
239
|
+
doubleTapConfig,
|
|
240
|
+
withAnimation,
|
|
241
|
+
clampTranslation,
|
|
242
|
+
minScale,
|
|
243
|
+
maxScale,
|
|
244
|
+
]);
|
|
245
|
+
/**
|
|
246
|
+
* Zoom out to minimum scale
|
|
247
|
+
*/
|
|
248
|
+
const zoomOut = useCallback(() => {
|
|
249
|
+
'worklet';
|
|
250
|
+
scale.value = withAnimation(minScale);
|
|
251
|
+
translateX.value = withAnimation(0);
|
|
252
|
+
translateY.value = withAnimation(0);
|
|
253
|
+
savedScale.value = minScale;
|
|
254
|
+
savedTranslateX.value = 0;
|
|
255
|
+
savedTranslateY.value = 0;
|
|
256
|
+
isZoomedIn.value = false;
|
|
257
|
+
}, [
|
|
258
|
+
scale,
|
|
259
|
+
translateX,
|
|
260
|
+
translateY,
|
|
261
|
+
savedScale,
|
|
262
|
+
savedTranslateX,
|
|
263
|
+
savedTranslateY,
|
|
264
|
+
isZoomedIn,
|
|
265
|
+
withAnimation,
|
|
266
|
+
minScale,
|
|
267
|
+
]);
|
|
268
|
+
/**
|
|
269
|
+
* Handle double tap
|
|
270
|
+
*/
|
|
271
|
+
const onDoubleTap = useCallback((x, y) => {
|
|
272
|
+
'worklet';
|
|
273
|
+
if (isZoomedIn.value)
|
|
274
|
+
zoomOut();
|
|
275
|
+
else
|
|
276
|
+
zoomIn(x, y);
|
|
277
|
+
}, [isZoomedIn, zoomIn, zoomOut]);
|
|
278
|
+
// ============== LAYOUT HANDLERS ==============
|
|
279
|
+
const onLayout = useCallback(({ nativeEvent: { layout: { width, height } } }) => {
|
|
280
|
+
containerDimensions.value = { width, height };
|
|
281
|
+
}, [containerDimensions]);
|
|
282
|
+
const onLayoutContent = useCallback(({ nativeEvent: { layout: { width, height } } }) => {
|
|
283
|
+
contentDimensions.value = { width, height };
|
|
284
|
+
}, [contentDimensions]);
|
|
285
|
+
// ============== GESTURE HANDLERS ==============
|
|
286
|
+
const updateZoomGestureLastTime = useCallback(() => {
|
|
287
|
+
'worklet';
|
|
288
|
+
zoomGestureLastTime.value = Date.now();
|
|
289
|
+
}, [zoomGestureLastTime]);
|
|
290
|
+
// Callback for scrolling parent from worklet
|
|
291
|
+
// Compatible with FlatList/ScrollView from react-native, react-native-gesture-handler,
|
|
292
|
+
// and react-native-reanimated (Animated.FlatList/ScrollView)
|
|
293
|
+
const scrollParent = useCallback((offset, animated = false) => {
|
|
294
|
+
if (!parentScrollRef?.current)
|
|
295
|
+
return;
|
|
296
|
+
const ref = parentScrollRef.current;
|
|
297
|
+
// Duck-typing to support all FlatList/ScrollView implementations
|
|
298
|
+
if (ref.scrollToOffset)
|
|
299
|
+
ref.scrollToOffset({ offset, animated });
|
|
300
|
+
else if (ref.scrollTo)
|
|
301
|
+
ref.scrollTo({ x: offset, animated });
|
|
302
|
+
}, [parentScrollRef]);
|
|
303
|
+
// Delayed zoom reset after snap animation completes
|
|
304
|
+
const resetZoomDelayed = useCallback((delay = 300) => {
|
|
305
|
+
setTimeout(() => {
|
|
306
|
+
scale.value = withSpring(minScale, SPRING_CONFIG);
|
|
307
|
+
translateX.value = withSpring(0, SPRING_CONFIG);
|
|
308
|
+
translateY.value = withSpring(0, SPRING_CONFIG);
|
|
309
|
+
savedScale.value = minScale;
|
|
310
|
+
savedTranslateX.value = 0;
|
|
311
|
+
savedTranslateY.value = 0;
|
|
312
|
+
isZoomedIn.value = false;
|
|
313
|
+
}, delay);
|
|
314
|
+
}, [
|
|
315
|
+
scale,
|
|
316
|
+
translateX,
|
|
317
|
+
translateY,
|
|
318
|
+
savedScale,
|
|
319
|
+
savedTranslateX,
|
|
320
|
+
savedTranslateY,
|
|
321
|
+
isZoomedIn,
|
|
322
|
+
minScale,
|
|
323
|
+
]);
|
|
324
|
+
const zoomGesture = useMemo(() => {
|
|
325
|
+
// ========== DOUBLE TAP ==========
|
|
326
|
+
const tapGesture = Gesture.Tap()
|
|
327
|
+
.numberOfTaps(2)
|
|
328
|
+
.maxDeltaX(TAP_MAX_DELTA)
|
|
329
|
+
.maxDeltaY(TAP_MAX_DELTA)
|
|
330
|
+
.onEnd((event) => {
|
|
331
|
+
'worklet';
|
|
332
|
+
updateZoomGestureLastTime();
|
|
333
|
+
onDoubleTap(event.x, event.y);
|
|
334
|
+
});
|
|
335
|
+
// ========== PAN GESTURE ==========
|
|
336
|
+
// Apple Photos: 1 finger when zoomed in, 2 fingers when at 1x
|
|
337
|
+
// With enableGallerySwipe + parentScrollRef: seamless edge scrolling
|
|
338
|
+
const panGesture = Gesture.Pan()
|
|
339
|
+
.manualActivation(true)
|
|
340
|
+
.onTouchesDown((e) => {
|
|
341
|
+
'worklet';
|
|
342
|
+
// Store initial touch position and edge state
|
|
343
|
+
if (e.numberOfTouches >= 1) {
|
|
344
|
+
const bounds = getTranslateBounds(scale.value);
|
|
345
|
+
const edgeThreshold = 2;
|
|
346
|
+
// Check current edge state
|
|
347
|
+
// At left edge: translateX is at maxX (content shifted right, showing left of image)
|
|
348
|
+
// At right edge: translateX is at -maxX (content shifted left, showing right of image)
|
|
349
|
+
isAtLeftEdge.value = translateX.value >= bounds.maxX - edgeThreshold;
|
|
350
|
+
isAtRightEdge.value = translateX.value <= -bounds.maxX + edgeThreshold;
|
|
351
|
+
panStartX.value = e.allTouches[0].x;
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
.onTouchesMove((e, state) => {
|
|
355
|
+
'worklet';
|
|
356
|
+
if (e.state === State.ACTIVE)
|
|
357
|
+
return; // Already activated
|
|
358
|
+
if ([State.UNDETERMINED, State.BEGAN].includes(e.state)) {
|
|
359
|
+
const zoomed = scale.value > minScale + 0.01; // Small threshold to avoid float issues
|
|
360
|
+
// 2 finger pan always works (for pinch-pan combo)
|
|
361
|
+
if (e.numberOfTouches === 2) {
|
|
362
|
+
state.activate();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// Not zoomed - don't activate (let parent handle)
|
|
366
|
+
if (!zoomed) {
|
|
367
|
+
state.fail();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// Zoomed with 1 finger
|
|
371
|
+
// If we have parentScrollRef - always activate, we'll handle scrolling ourselves
|
|
372
|
+
if (enableGallerySwipe && hasParentScroll) {
|
|
373
|
+
state.activate();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// Legacy mode: check for edge swipe
|
|
377
|
+
if (enableGallerySwipe && e.numberOfTouches === 1) {
|
|
378
|
+
const touch = e.allTouches[0];
|
|
379
|
+
const deltaX = touch.x - panStartX.value;
|
|
380
|
+
const bounds = getTranslateBounds(scale.value);
|
|
381
|
+
const absDeltaX = Math.abs(deltaX);
|
|
382
|
+
// If no horizontal panning is possible, let parent handle
|
|
383
|
+
if (bounds.maxX === 0) {
|
|
384
|
+
state.fail();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// Wait for sufficient movement before deciding
|
|
388
|
+
const decisionThreshold = 5;
|
|
389
|
+
if (absDeltaX < decisionThreshold)
|
|
390
|
+
return; // Not enough movement yet, don't decide
|
|
391
|
+
// Check if swiping beyond edge
|
|
392
|
+
// At left edge and swiping right -> let parent handle (go to prev image)
|
|
393
|
+
// At right edge and swiping left -> let parent handle (go to next image)
|
|
394
|
+
if (isAtLeftEdge.value && deltaX > 0) {
|
|
395
|
+
state.fail();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (isAtRightEdge.value && deltaX < 0) {
|
|
399
|
+
state.fail();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Activate for normal zoomed panning
|
|
404
|
+
state.activate();
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
.onStart(() => {
|
|
408
|
+
'worklet';
|
|
409
|
+
updateZoomGestureLastTime();
|
|
410
|
+
isPanning.value = true;
|
|
411
|
+
accumulatedOverflow.value = 0; // Reset overflow tracking
|
|
412
|
+
virtualTranslationX.value = 0;
|
|
413
|
+
virtualTranslationY.value = 0;
|
|
414
|
+
startScrollY.value = currentScrollY.value;
|
|
415
|
+
// Save current position
|
|
416
|
+
savedTranslateX.value = translateX.value;
|
|
417
|
+
savedTranslateY.value = translateY.value;
|
|
418
|
+
if (onPanningStart)
|
|
419
|
+
runOnJS(onPanningStart)();
|
|
420
|
+
if (onPanningStarted)
|
|
421
|
+
runOnJS(onPanningStarted)();
|
|
422
|
+
})
|
|
423
|
+
.onUpdate((event) => {
|
|
424
|
+
'worklet';
|
|
425
|
+
const bounds = getTranslateBounds(scale.value);
|
|
426
|
+
// Calculate new translation
|
|
427
|
+
let newTx = savedTranslateX.value + event.translationX;
|
|
428
|
+
const newTy = savedTranslateY.value + event.translationY;
|
|
429
|
+
// Apple Photos seamless scrolling with parentScrollRef
|
|
430
|
+
if (enableGallerySwipe && hasParentScroll) {
|
|
431
|
+
// Calculate overflow (how much we're trying to go past the edge)
|
|
432
|
+
let overflow = 0;
|
|
433
|
+
if (newTx > bounds.maxX) {
|
|
434
|
+
// Trying to go past left edge (swiping right)
|
|
435
|
+
overflow = newTx - bounds.maxX;
|
|
436
|
+
newTx = bounds.maxX;
|
|
437
|
+
}
|
|
438
|
+
else if (newTx < -bounds.maxX) {
|
|
439
|
+
// Trying to go past right edge (swiping left)
|
|
440
|
+
overflow = newTx + bounds.maxX; // negative value
|
|
441
|
+
newTx = -bounds.maxX;
|
|
442
|
+
}
|
|
443
|
+
// If there's overflow, scroll the parent FlatList
|
|
444
|
+
if (overflow !== 0) {
|
|
445
|
+
accumulatedOverflow.value = overflow;
|
|
446
|
+
const targetOffset = currentIndex * itemWidth - overflow;
|
|
447
|
+
// Scroll parent without animation for smooth tracking
|
|
448
|
+
runOnJS(scrollParent)(targetOffset, false);
|
|
449
|
+
// Lock vertical movement while scrolling parent
|
|
450
|
+
translateX.value = newTx;
|
|
451
|
+
if (onPanningUpdate)
|
|
452
|
+
runOnJS(onPanningUpdate)({
|
|
453
|
+
x: translateX.value,
|
|
454
|
+
y: translateY.value,
|
|
455
|
+
translationX: event.translationX,
|
|
456
|
+
translationY: event.translationY,
|
|
457
|
+
velocityX: event.velocityX,
|
|
458
|
+
velocityY: event.velocityY
|
|
459
|
+
});
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
accumulatedOverflow.value = 0;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// Apply rubber band to both axes (handles local panning even if gallery swipe is enabled)
|
|
467
|
+
const rubber = applyRubberBandTranslation(newTx, newTy, scale.value);
|
|
468
|
+
translateX.value = rubber.x;
|
|
469
|
+
if (!disableVerticalPan)
|
|
470
|
+
translateY.value = rubber.y;
|
|
471
|
+
virtualTranslationX.value = event.translationX;
|
|
472
|
+
virtualTranslationY.value = event.translationY;
|
|
473
|
+
if (parentAnimatedScrollRef && scale.value > 1) {
|
|
474
|
+
scrollTo(parentAnimatedScrollRef, 0, startScrollY.value - event.translationY, false);
|
|
475
|
+
}
|
|
476
|
+
})
|
|
477
|
+
.onEnd((event) => {
|
|
478
|
+
'worklet';
|
|
479
|
+
updateZoomGestureLastTime();
|
|
480
|
+
isPanning.value = false;
|
|
481
|
+
const currentScale = scale.value;
|
|
482
|
+
const bounds = getTranslateBounds(currentScale);
|
|
483
|
+
// Handle snap for parent scroll (Apple Photos behavior)
|
|
484
|
+
if (enableGallerySwipe && hasParentScroll && accumulatedOverflow.value !== 0) {
|
|
485
|
+
const overflow = accumulatedOverflow.value;
|
|
486
|
+
const velocity = event.velocityX;
|
|
487
|
+
const snapThreshold = itemWidth * 0.3; // 30% of item width
|
|
488
|
+
// Determine if we should snap to next/prev or back to current
|
|
489
|
+
// Snap to next/prev if: overflow > threshold OR high velocity in same direction
|
|
490
|
+
const shouldSnapToNext = overflow < -snapThreshold || (overflow < 0 && velocity < -500);
|
|
491
|
+
const shouldSnapToPrev = overflow > snapThreshold || (overflow > 0 && velocity > 500);
|
|
492
|
+
if (shouldSnapToNext) {
|
|
493
|
+
// Snap to next image - scroll to next index
|
|
494
|
+
const nextOffset = (currentIndex + 1) * itemWidth;
|
|
495
|
+
runOnJS(scrollParent)(nextOffset, true);
|
|
496
|
+
// Reset zoom after snap animation completes
|
|
497
|
+
runOnJS(resetZoomDelayed)(300);
|
|
498
|
+
}
|
|
499
|
+
else if (shouldSnapToPrev) {
|
|
500
|
+
// Snap to previous image - scroll to prev index
|
|
501
|
+
const prevOffset = (currentIndex - 1) * itemWidth;
|
|
502
|
+
runOnJS(scrollParent)(prevOffset, true);
|
|
503
|
+
// Reset zoom after snap animation completes
|
|
504
|
+
runOnJS(resetZoomDelayed)(300);
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
// Snap back to current image
|
|
508
|
+
const currentOffset = currentIndex * itemWidth;
|
|
509
|
+
runOnJS(scrollParent)(currentOffset, true);
|
|
510
|
+
}
|
|
511
|
+
accumulatedOverflow.value = 0;
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
// Check if we're outside bounds
|
|
515
|
+
const currentTx = translateX.value;
|
|
516
|
+
const currentTy = translateY.value;
|
|
517
|
+
const isOutOfBoundsX = currentTx < -bounds.maxX || currentTx > bounds.maxX;
|
|
518
|
+
const isOutOfBoundsY = currentTy < -bounds.maxY || currentTy > bounds.maxY;
|
|
519
|
+
if (isOutOfBoundsX || (isOutOfBoundsY && !disableVerticalPan)) {
|
|
520
|
+
// Spring back to bounds with gentle animation (fix for #51)
|
|
521
|
+
translateX.value = withSpring(clamp(currentTx, -bounds.maxX, bounds.maxX), SPRING_CONFIG);
|
|
522
|
+
if (!disableVerticalPan) {
|
|
523
|
+
translateY.value = withSpring(clamp(currentTy, -bounds.maxY, bounds.maxY), SPRING_CONFIG);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
else if (!disableVerticalPan) {
|
|
527
|
+
// Apply momentum with clamping (decay with rubber band)
|
|
528
|
+
translateX.value = withDecay({
|
|
529
|
+
velocity: event.velocityX,
|
|
530
|
+
clamp: [-bounds.maxX, bounds.maxX],
|
|
531
|
+
rubberBandEffect: true,
|
|
532
|
+
rubberBandFactor: 0.6,
|
|
533
|
+
});
|
|
534
|
+
translateY.value = withDecay({
|
|
535
|
+
velocity: event.velocityY,
|
|
536
|
+
clamp: [-bounds.maxY, bounds.maxY],
|
|
537
|
+
rubberBandEffect: true,
|
|
538
|
+
rubberBandFactor: 0.6,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
// Vertical momentum for parent scroll
|
|
543
|
+
virtualTranslationY.value = withDecay({
|
|
544
|
+
velocity: event.velocityY,
|
|
545
|
+
});
|
|
546
|
+
translateX.value = withDecay({
|
|
547
|
+
velocity: event.velocityX,
|
|
548
|
+
clamp: [-bounds.maxX, bounds.maxX],
|
|
549
|
+
rubberBandEffect: true,
|
|
550
|
+
rubberBandFactor: 0.6,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
// Update saved values
|
|
554
|
+
savedTranslateX.value = clamp(savedTranslateX.value + event.translationX, -bounds.maxX, bounds.maxX);
|
|
555
|
+
savedTranslateY.value = clamp(savedTranslateY.value + event.translationY, -bounds.maxY, bounds.maxY);
|
|
556
|
+
if (onPanningEnd)
|
|
557
|
+
runOnJS(onPanningEnd)();
|
|
558
|
+
})
|
|
559
|
+
.onTouchesCancelled(() => {
|
|
560
|
+
'worklet';
|
|
561
|
+
isPanning.value = false;
|
|
562
|
+
})
|
|
563
|
+
.minDistance(0)
|
|
564
|
+
.minPointers(1)
|
|
565
|
+
.maxPointers(2);
|
|
566
|
+
// ========== PINCH GESTURE ==========
|
|
567
|
+
// Apple Photos: dynamic focal point tracking during pinch
|
|
568
|
+
const pinchGesture = Gesture.Pinch()
|
|
569
|
+
.onTouchesDown((e, state) => {
|
|
570
|
+
'worklet';
|
|
571
|
+
// Immediately activate pinch when 2 fingers touch
|
|
572
|
+
// This prevents horizontal FlatList from stealing the gesture on Android
|
|
573
|
+
if (e.numberOfTouches === 2)
|
|
574
|
+
state.activate();
|
|
575
|
+
})
|
|
576
|
+
.onStart((event) => {
|
|
577
|
+
'worklet';
|
|
578
|
+
updateZoomGestureLastTime();
|
|
579
|
+
isPinching.value = true;
|
|
580
|
+
// Save current state
|
|
581
|
+
savedScale.value = scale.value;
|
|
582
|
+
savedTranslateX.value = translateX.value;
|
|
583
|
+
savedTranslateY.value = translateY.value;
|
|
584
|
+
// Save initial focal point
|
|
585
|
+
pinchFocalX.value = event.focalX;
|
|
586
|
+
pinchFocalY.value = event.focalY;
|
|
587
|
+
if (onPinchingStarted)
|
|
588
|
+
runOnJS(onPinchingStarted)();
|
|
589
|
+
})
|
|
590
|
+
.onUpdate((event) => {
|
|
591
|
+
'worklet';
|
|
592
|
+
const container = containerDimensions.value;
|
|
593
|
+
const centerX = container.width / 2;
|
|
594
|
+
const centerY = container.height / 2;
|
|
595
|
+
// New scale with rubber band limits
|
|
596
|
+
let newScale = savedScale.value * event.scale;
|
|
597
|
+
// Apply rubber band to scale
|
|
598
|
+
if (newScale < minScale) {
|
|
599
|
+
// Rubber band for zoom out below minScale
|
|
600
|
+
const overZoom = minScale - newScale;
|
|
601
|
+
newScale = minScale - overZoom * RUBBER_BAND_FACTOR;
|
|
602
|
+
newScale = Math.max(newScale, minScale * MIN_OVER_SCALE);
|
|
603
|
+
}
|
|
604
|
+
else if (newScale > maxScale) {
|
|
605
|
+
// Rubber band for zoom in above max
|
|
606
|
+
const overZoom = newScale - maxScale;
|
|
607
|
+
newScale = maxScale + overZoom * RUBBER_BAND_FACTOR;
|
|
608
|
+
newScale = Math.min(newScale, maxScale * 1.5);
|
|
609
|
+
}
|
|
610
|
+
// Dynamic focal point - Apple Photos updates focal point during gesture
|
|
611
|
+
// This makes the gesture feel more natural when fingers move
|
|
612
|
+
const currentFocalX = event.focalX;
|
|
613
|
+
const currentFocalY = event.focalY;
|
|
614
|
+
// Blend between initial and current focal point
|
|
615
|
+
// This creates smoother behavior than pure dynamic tracking
|
|
616
|
+
const focalBlend = 0.3; // 30% tracking of finger movement
|
|
617
|
+
const effectiveFocalX = pinchFocalX.value + (currentFocalX - pinchFocalX.value) * focalBlend;
|
|
618
|
+
const effectiveFocalY = pinchFocalY.value + (currentFocalY - pinchFocalY.value) * focalBlend;
|
|
619
|
+
// Focal point offset from container center
|
|
620
|
+
const focalOffsetX = effectiveFocalX - centerX;
|
|
621
|
+
const focalOffsetY = effectiveFocalY - centerY;
|
|
622
|
+
// Apple Photos focal point algorithm
|
|
623
|
+
const scaleRatio = newScale / savedScale.value;
|
|
624
|
+
const newTx = focalOffsetX * (1 - scaleRatio) + savedTranslateX.value * scaleRatio;
|
|
625
|
+
const newTy = focalOffsetY * (1 - scaleRatio) + savedTranslateY.value * scaleRatio;
|
|
626
|
+
// Apply directly (rubber band already applied to scale)
|
|
627
|
+
scale.value = newScale;
|
|
628
|
+
translateX.value = newTx;
|
|
629
|
+
translateY.value = newTy;
|
|
630
|
+
})
|
|
631
|
+
.onEnd(() => {
|
|
632
|
+
'worklet';
|
|
633
|
+
updateZoomGestureLastTime();
|
|
634
|
+
isPinching.value = false;
|
|
635
|
+
// Check previous zoom state before applying constraints
|
|
636
|
+
const wasZoomed = isZoomedIn.value;
|
|
637
|
+
// Apply boundary constraints with spring animation
|
|
638
|
+
applyBoundaryConstraints(scale.value, true);
|
|
639
|
+
// Update isZoomedIn state
|
|
640
|
+
const finalScale = clamp(scale.value, minScale, maxScale);
|
|
641
|
+
const isNowZoomed = finalScale > minScale;
|
|
642
|
+
if (wasZoomed !== isNowZoomed)
|
|
643
|
+
isZoomedIn.value = isNowZoomed;
|
|
644
|
+
if (onPinchingStopped)
|
|
645
|
+
runOnJS(onPinchingStopped)();
|
|
646
|
+
});
|
|
647
|
+
return Gesture.Simultaneous(tapGesture, panGesture, pinchGesture);
|
|
648
|
+
}, [
|
|
649
|
+
updateZoomGestureLastTime,
|
|
650
|
+
onDoubleTap,
|
|
651
|
+
scale,
|
|
652
|
+
translateX,
|
|
653
|
+
translateY,
|
|
654
|
+
savedScale,
|
|
655
|
+
savedTranslateX,
|
|
656
|
+
savedTranslateY,
|
|
657
|
+
pinchFocalX,
|
|
658
|
+
pinchFocalY,
|
|
659
|
+
containerDimensions,
|
|
660
|
+
isPinching,
|
|
661
|
+
isPanning,
|
|
662
|
+
getTranslateBounds,
|
|
663
|
+
applyBoundaryConstraints,
|
|
664
|
+
applyRubberBandTranslation,
|
|
665
|
+
minScale,
|
|
666
|
+
maxScale,
|
|
667
|
+
isZoomedIn,
|
|
668
|
+
enableGallerySwipe,
|
|
669
|
+
isAtLeftEdge,
|
|
670
|
+
isAtRightEdge,
|
|
671
|
+
panStartX,
|
|
672
|
+
hasParentScroll,
|
|
673
|
+
currentIndex,
|
|
674
|
+
itemWidth,
|
|
675
|
+
scrollParent,
|
|
676
|
+
accumulatedOverflow,
|
|
677
|
+
resetZoomDelayed,
|
|
678
|
+
onPanningUpdate,
|
|
679
|
+
onPanningStart,
|
|
680
|
+
onPanningEnd,
|
|
681
|
+
disableVerticalPan,
|
|
682
|
+
parentAnimatedScrollRef,
|
|
683
|
+
]);
|
|
684
|
+
// Handle momentum scrolling on UI thread
|
|
685
|
+
useAnimatedReaction(() => virtualTranslationY.value, (val) => {
|
|
686
|
+
if (parentAnimatedScrollRef && scale.value > 1 && !isPanning.value) {
|
|
687
|
+
scrollTo(parentAnimatedScrollRef, 0, startScrollY.value - val, false);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
// ============== ANIMATED STYLE ==============
|
|
691
|
+
// Transform order: translate first, then scale
|
|
692
|
+
// This means scale happens around the center of the View
|
|
693
|
+
//
|
|
694
|
+
// Apple Photos rendering approach:
|
|
695
|
+
// - Use exact floating-point values for smooth animations
|
|
696
|
+
// - The content View should have explicit dimensions matching aspect ratio
|
|
697
|
+
// - overflow: hidden on container clips any subpixel overflow
|
|
698
|
+
const contentContainerAnimatedStyle = useAnimatedStyle(() => ({
|
|
699
|
+
transform: [
|
|
700
|
+
{ translateX: translateX.value },
|
|
701
|
+
{ translateY: translateY.value },
|
|
702
|
+
{ scale: scale.value },
|
|
703
|
+
],
|
|
704
|
+
}));
|
|
705
|
+
return {
|
|
706
|
+
zoomGesture,
|
|
707
|
+
contentContainerAnimatedStyle,
|
|
708
|
+
onLayout,
|
|
709
|
+
onLayoutContent,
|
|
710
|
+
zoomOut: () => {
|
|
711
|
+
'worklet';
|
|
712
|
+
zoomOut();
|
|
713
|
+
},
|
|
714
|
+
isZoomedIn,
|
|
715
|
+
zoomGestureLastTime,
|
|
716
|
+
scale,
|
|
717
|
+
virtualTranslationX,
|
|
718
|
+
virtualTranslationY,
|
|
719
|
+
translateX,
|
|
720
|
+
translateY,
|
|
721
|
+
onScroll: (e) => {
|
|
722
|
+
'worklet';
|
|
723
|
+
currentScrollY.value = e.nativeEvent.contentOffset.y;
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Zoom component that provides pinch, pan, and double-tap gestures for zooming content
|
|
729
|
+
* Implements Apple Photos-style zoom behavior
|
|
730
|
+
*
|
|
731
|
+
* @example
|
|
732
|
+
* ```tsx
|
|
733
|
+
* <Zoom
|
|
734
|
+
* doubleTapConfig={{
|
|
735
|
+
* defaultScale: 2,
|
|
736
|
+
* minZoomScale: 1,
|
|
737
|
+
* maxZoomScale: 5,
|
|
738
|
+
* }}
|
|
739
|
+
* >
|
|
740
|
+
* <Image source={{ uri: 'https://example.com/image.jpg' }} />
|
|
741
|
+
* </Zoom>
|
|
742
|
+
* ```
|
|
743
|
+
*/
|
|
744
|
+
export default function Zoom(props) {
|
|
745
|
+
const { style, contentContainerStyle, children, onZoomChange, onZoomStateChange, onPanningUpdate, onPanningStart, onPanningEnd, disableVerticalPan, ...rest } = props;
|
|
746
|
+
const { zoomGesture, onLayout, onLayoutContent, contentContainerAnimatedStyle, scale, isZoomedIn, virtualTranslationX, virtualTranslationY, translateX: transX, translateY: transY, } = useZoomGesture({ ...rest, onPanningUpdate, onPanningStart, onPanningEnd, disableVerticalPan });
|
|
747
|
+
// Bridge panning updates to JS callback
|
|
748
|
+
useAnimatedReaction(() => ({
|
|
749
|
+
tx: virtualTranslationX.value,
|
|
750
|
+
ty: virtualTranslationY.value,
|
|
751
|
+
}), (current, previous) => {
|
|
752
|
+
if (onPanningUpdate && (current.tx !== previous?.tx || current.ty !== previous?.ty)) {
|
|
753
|
+
runOnJS(onPanningUpdate)({
|
|
754
|
+
x: transX.value,
|
|
755
|
+
y: transY.value,
|
|
756
|
+
translationX: current.tx,
|
|
757
|
+
translationY: current.ty,
|
|
758
|
+
velocityX: 0,
|
|
759
|
+
velocityY: 0,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}, [onPanningUpdate, transX, transY]);
|
|
763
|
+
// Bridge scale changes to JS callback if provided
|
|
764
|
+
useAnimatedReaction(() => scale.value, (currentScale, previousScale) => {
|
|
765
|
+
if (onZoomChange && currentScale !== previousScale)
|
|
766
|
+
runOnJS(onZoomChange)(currentScale);
|
|
767
|
+
}, [onZoomChange]);
|
|
768
|
+
// Bridge zoom state changes to JS callback if provided
|
|
769
|
+
useAnimatedReaction(() => isZoomedIn.value, (currentIsZoomed, previousIsZoomed) => {
|
|
770
|
+
if (onZoomStateChange && currentIsZoomed !== previousIsZoomed)
|
|
771
|
+
runOnJS(onZoomStateChange)(currentIsZoomed);
|
|
772
|
+
}, [onZoomStateChange]);
|
|
773
|
+
return (<GestureHandlerRootView style={[styles.container, style]}>
|
|
774
|
+
<GestureDetector gesture={zoomGesture}>
|
|
775
|
+
<View style={styles.container} onLayout={onLayout} collapsable={false}>
|
|
776
|
+
<Animated.View style={[contentContainerAnimatedStyle, contentContainerStyle]} onLayout={onLayoutContent}>
|
|
777
|
+
{children}
|
|
778
|
+
</Animated.View>
|
|
779
|
+
</View>
|
|
780
|
+
</GestureDetector>
|
|
781
|
+
</GestureHandlerRootView>);
|
|
782
|
+
}
|
|
783
|
+
//# sourceMappingURL=index.js.map
|