cpk-ui 0.5.1 → 0.5.2
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/components/uis/PinchZoom/PinchZoom.d.ts +87 -0
- package/components/uis/PinchZoom/PinchZoom.d.ts.map +1 -0
- package/components/uis/PinchZoom/PinchZoom.js +255 -0
- package/index.d.ts +1 -0
- package/index.d.ts.map +1 -1
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/components/uis/PinchZoom/PinchZoom.stories.tsx +266 -0
- package/src/components/uis/PinchZoom/PinchZoom.test.tsx +113 -0
- package/src/components/uis/PinchZoom/PinchZoom.tsx +424 -0
- package/src/index.tsx +1 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { PropsWithChildren } from 'react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import type { ViewStyle } from 'react-native';
|
|
4
|
+
import { Animated } from 'react-native';
|
|
5
|
+
export type PinchZoomProps = PropsWithChildren<{
|
|
6
|
+
/**
|
|
7
|
+
* Custom view style.
|
|
8
|
+
* @warning Passing `transform` in style will disable pinch-zoom functionality.
|
|
9
|
+
* Use a wrapper View for custom transforms instead.
|
|
10
|
+
*/
|
|
11
|
+
style?: ViewStyle;
|
|
12
|
+
children?: any;
|
|
13
|
+
/** Callback fired when zoom scale changes */
|
|
14
|
+
onScaleChanged?(value: number): void;
|
|
15
|
+
/** Callback fired when content position changes */
|
|
16
|
+
onTranslateChanged?(valueXY: {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
}): void;
|
|
20
|
+
/** Callback fired after gesture animation completes (decay or snap-back) */
|
|
21
|
+
onRelease?(): void;
|
|
22
|
+
/**
|
|
23
|
+
* Allow unrestricted overflow on specific axes.
|
|
24
|
+
* When true, allows overflow. When false/undefined, clamps to bounds.
|
|
25
|
+
*/
|
|
26
|
+
allowEmpty?: {
|
|
27
|
+
x?: boolean;
|
|
28
|
+
y?: boolean;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Auto-snap to bounds after release.
|
|
32
|
+
* @default true
|
|
33
|
+
*/
|
|
34
|
+
fixOverflowAfterRelease?: boolean;
|
|
35
|
+
/** Test ID for component testing */
|
|
36
|
+
testID?: string;
|
|
37
|
+
}>;
|
|
38
|
+
export interface PinchZoomRef {
|
|
39
|
+
animatedValue: {
|
|
40
|
+
scale: Animated.Value;
|
|
41
|
+
translate: Animated.ValueXY;
|
|
42
|
+
};
|
|
43
|
+
setValues(_: {
|
|
44
|
+
scale?: number;
|
|
45
|
+
translate?: {
|
|
46
|
+
x: number;
|
|
47
|
+
y: number;
|
|
48
|
+
};
|
|
49
|
+
}): void;
|
|
50
|
+
}
|
|
51
|
+
declare const PinchZoomRoot: React.ForwardRefExoticComponent<{
|
|
52
|
+
/**
|
|
53
|
+
* Custom view style.
|
|
54
|
+
* @warning Passing `transform` in style will disable pinch-zoom functionality.
|
|
55
|
+
* Use a wrapper View for custom transforms instead.
|
|
56
|
+
*/
|
|
57
|
+
style?: ViewStyle;
|
|
58
|
+
children?: any;
|
|
59
|
+
/** Callback fired when zoom scale changes */
|
|
60
|
+
onScaleChanged?(value: number): void;
|
|
61
|
+
/** Callback fired when content position changes */
|
|
62
|
+
onTranslateChanged?(valueXY: {
|
|
63
|
+
x: number;
|
|
64
|
+
y: number;
|
|
65
|
+
}): void;
|
|
66
|
+
/** Callback fired after gesture animation completes (decay or snap-back) */
|
|
67
|
+
onRelease?(): void;
|
|
68
|
+
/**
|
|
69
|
+
* Allow unrestricted overflow on specific axes.
|
|
70
|
+
* When true, allows overflow. When false/undefined, clamps to bounds.
|
|
71
|
+
*/
|
|
72
|
+
allowEmpty?: {
|
|
73
|
+
x?: boolean;
|
|
74
|
+
y?: boolean;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Auto-snap to bounds after release.
|
|
78
|
+
* @default true
|
|
79
|
+
*/
|
|
80
|
+
fixOverflowAfterRelease?: boolean;
|
|
81
|
+
/** Test ID for component testing */
|
|
82
|
+
testID?: string;
|
|
83
|
+
} & {
|
|
84
|
+
children?: React.ReactNode | undefined;
|
|
85
|
+
} & React.RefAttributes<PinchZoomRef>>;
|
|
86
|
+
export { PinchZoomRoot as PinchZoom };
|
|
87
|
+
//# sourceMappingURL=PinchZoom.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PinchZoom.d.ts","sourceRoot":"","sources":["../../../../src/components/uis/PinchZoom/PinchZoom.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,iBAAiB,EAAM,MAAM,OAAO,CAAC;AAClD,OAAO,KAMN,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAIV,SAAS,EACV,MAAM,cAAc,CAAC;AACtB,OAAO,EAAC,QAAQ,EAAe,MAAM,cAAc,CAAC;AAEpD,MAAM,MAAM,cAAc,GAAG,iBAAiB,CAAC;IAC7C;;;;OAIG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,QAAQ,CAAC,EAAE,GAAG,CAAC;IACf,6CAA6C;IAC7C,cAAc,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,mDAAmD;IACnD,kBAAkB,CAAC,CAAC,OAAO,EAAE;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAC,GAAG,IAAI,CAAC;IAC3D,4EAA4E;IAC5E,SAAS,CAAC,IAAI,IAAI,CAAC;IACnB;;;OAGG;IACH,UAAU,CAAC,EAAE;QAAC,CAAC,CAAC,EAAE,OAAO,CAAC;QAAC,CAAC,CAAC,EAAE,OAAO,CAAA;KAAC,CAAC;IACxC;;;OAGG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,oCAAoC;IACpC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC,CAAC;AAEH,MAAM,WAAW,YAAY;IAC3B,aAAa,EAAE;QAAC,KAAK,EAAE,QAAQ,CAAC,KAAK,CAAC;QAAC,SAAS,EAAE,QAAQ,CAAC,OAAO,CAAA;KAAC,CAAC;IACpE,SAAS,CAAC,CAAC,EAAE;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE;YAAC,CAAC,EAAE,MAAM,CAAC;YAAC,CAAC,EAAE,MAAM,CAAA;SAAC,CAAA;KAAC,GAAG,IAAI,CAAC;CAC1E;AAsXD,QAAA,MAAM,aAAa;IApZjB;;;;OAIG;YACK,SAAS;eACN,GAAG;IACd,6CAA6C;2BACtB,MAAM,GAAG,IAAI;IACpC,mDAAmD;iCACtB;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAC,GAAG,IAAI;IAC1D,4EAA4E;kBAC9D,IAAI;IAClB;;;OAGG;iBACU;QAAC,CAAC,CAAC,EAAE,OAAO,CAAC;QAAC,CAAC,CAAC,EAAE,OAAO,CAAA;KAAC;IACvC;;;OAGG;8BACuB,OAAO;IACjC,oCAAoC;aAC3B,MAAM;;;sCA4XwD,CAAC;AAE1E,OAAO,EAAC,aAAa,IAAI,SAAS,EAAC,CAAC"}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from 'react';
|
|
3
|
+
import { Animated, PanResponder } from 'react-native';
|
|
4
|
+
function getDistanceFromTouches(touches) {
|
|
5
|
+
const [touch1, touch2] = touches;
|
|
6
|
+
return Math.sqrt((touch1.pageX - touch2.pageX) ** 2 + (touch1.pageY - touch2.pageY) ** 2);
|
|
7
|
+
}
|
|
8
|
+
function getRelativeTouchesCenterPosition(touches, layout, transformCache) {
|
|
9
|
+
const pageX = (touches[0].pageX + touches[1].pageX) / 2 - layout.width / 2 - layout.pageX;
|
|
10
|
+
const pageY = (touches[0].pageY + touches[1].pageY) / 2 -
|
|
11
|
+
layout.height / 2 -
|
|
12
|
+
layout.pageY;
|
|
13
|
+
return {
|
|
14
|
+
locationX: (pageX - transformCache.translateX) / transformCache.scale,
|
|
15
|
+
locationY: (pageY - transformCache.translateY) / transformCache.scale,
|
|
16
|
+
pageX,
|
|
17
|
+
pageY,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function PinchZoom({ style, children, onScaleChanged, onRelease, onTranslateChanged, allowEmpty, fixOverflowAfterRelease = true, testID = 'pinch-zoom-container', }, ref) {
|
|
21
|
+
const containerView = useRef(null);
|
|
22
|
+
const scale = useRef(new Animated.Value(1)).current;
|
|
23
|
+
const translate = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current;
|
|
24
|
+
const transformCache = useRef({
|
|
25
|
+
scale: 1,
|
|
26
|
+
translateX: 0,
|
|
27
|
+
translateY: 0,
|
|
28
|
+
}).current;
|
|
29
|
+
const lastTransform = useRef({ scale: 1, translateX: 0, translateY: 0 });
|
|
30
|
+
const initialDistance = useRef(undefined);
|
|
31
|
+
const initialTouchesCenter = useRef(undefined);
|
|
32
|
+
const layout = useRef(undefined);
|
|
33
|
+
const decayingTranslateAnimation = useRef(undefined);
|
|
34
|
+
const isResponderActive = useRef(false);
|
|
35
|
+
const movingVelocity = useRef(undefined);
|
|
36
|
+
containerView.current?.measure((x, y, width, height, pageX, pageY) => {
|
|
37
|
+
layout.current = { width, height, pageX, pageY };
|
|
38
|
+
});
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
scale.addListener(({ value }) => {
|
|
41
|
+
transformCache.scale = value;
|
|
42
|
+
onScaleChanged?.(value);
|
|
43
|
+
});
|
|
44
|
+
const id = translate.addListener(({ x, y }) => {
|
|
45
|
+
if (decayingTranslateAnimation.current &&
|
|
46
|
+
layout.current &&
|
|
47
|
+
!isResponderActive.current) {
|
|
48
|
+
const overflowX = !allowEmpty?.x &&
|
|
49
|
+
Math.abs(x) > ((transformCache.scale - 1) * layout.current.width) / 2;
|
|
50
|
+
const overflowY = !allowEmpty?.y &&
|
|
51
|
+
Math.abs(y) >
|
|
52
|
+
((transformCache.scale - 1) * layout.current.height) / 2;
|
|
53
|
+
if (overflowX || overflowY) {
|
|
54
|
+
decayingTranslateAnimation.current?.stop();
|
|
55
|
+
decayingTranslateAnimation.current = undefined;
|
|
56
|
+
translate.setValue({
|
|
57
|
+
x: overflowX
|
|
58
|
+
? (Math.sign(x) *
|
|
59
|
+
(transformCache.scale - 1) *
|
|
60
|
+
layout.current.width) /
|
|
61
|
+
2
|
|
62
|
+
: x,
|
|
63
|
+
y: overflowY
|
|
64
|
+
? (Math.sign(y) *
|
|
65
|
+
(transformCache.scale - 1) *
|
|
66
|
+
layout.current.height) /
|
|
67
|
+
2
|
|
68
|
+
: y,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
transformCache.translateX = x;
|
|
73
|
+
transformCache.translateY = y;
|
|
74
|
+
onTranslateChanged?.({ x, y });
|
|
75
|
+
});
|
|
76
|
+
return () => {
|
|
77
|
+
scale.removeAllListeners();
|
|
78
|
+
translate.removeListener(id);
|
|
79
|
+
};
|
|
80
|
+
}, [
|
|
81
|
+
onScaleChanged,
|
|
82
|
+
onTranslateChanged,
|
|
83
|
+
scale,
|
|
84
|
+
transformCache,
|
|
85
|
+
translate,
|
|
86
|
+
allowEmpty?.x,
|
|
87
|
+
allowEmpty?.y,
|
|
88
|
+
]);
|
|
89
|
+
const [panResponder, setPanResponder] = useState();
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
setPanResponder(PanResponder.create({
|
|
92
|
+
onMoveShouldSetPanResponder: () => true,
|
|
93
|
+
onPanResponderGrant: ({ nativeEvent }) => {
|
|
94
|
+
isResponderActive.current = true;
|
|
95
|
+
movingVelocity.current = undefined;
|
|
96
|
+
if (decayingTranslateAnimation.current) {
|
|
97
|
+
decayingTranslateAnimation.current.stop();
|
|
98
|
+
}
|
|
99
|
+
const { touches } = nativeEvent;
|
|
100
|
+
lastTransform.current = { ...transformCache };
|
|
101
|
+
initialDistance.current = undefined;
|
|
102
|
+
if (layout.current != null) {
|
|
103
|
+
if (touches.length === 2) {
|
|
104
|
+
initialDistance.current = getDistanceFromTouches(touches);
|
|
105
|
+
initialTouchesCenter.current = getRelativeTouchesCenterPosition(touches, layout.current, transformCache);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
initialDistance.current = undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
onPanResponderMove: ({ nativeEvent }, gestureState) => {
|
|
113
|
+
const { touches } = nativeEvent;
|
|
114
|
+
if (layout.current == null) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (movingVelocity.current) {
|
|
118
|
+
movingVelocity.current = {
|
|
119
|
+
x: (movingVelocity.current.x + gestureState.vx) / 2,
|
|
120
|
+
y: (movingVelocity.current.y + gestureState.vy) / 2,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
movingVelocity.current = { x: gestureState.vx, y: gestureState.vy };
|
|
125
|
+
}
|
|
126
|
+
if (touches.length === 2) {
|
|
127
|
+
if (initialDistance.current &&
|
|
128
|
+
initialTouchesCenter.current &&
|
|
129
|
+
layout.current) {
|
|
130
|
+
const newScale = Math.max(1, (getDistanceFromTouches(touches) / initialDistance.current) *
|
|
131
|
+
lastTransform.current.scale);
|
|
132
|
+
const { pageX, pageY } = getRelativeTouchesCenterPosition(touches, layout.current, transformCache);
|
|
133
|
+
scale.setValue(newScale);
|
|
134
|
+
const newTranslateX = pageX - initialTouchesCenter.current.locationX * newScale;
|
|
135
|
+
const newTranslateY = pageY - initialTouchesCenter.current.locationY * newScale;
|
|
136
|
+
translate.setValue({
|
|
137
|
+
x: allowEmpty?.x
|
|
138
|
+
? newTranslateX
|
|
139
|
+
: Math.min(Math.abs(newTranslateX), ((newScale - 1) * layout.current.width) / 2) * Math.sign(newTranslateX),
|
|
140
|
+
y: allowEmpty?.y
|
|
141
|
+
? newTranslateY
|
|
142
|
+
: Math.min(Math.abs(newTranslateY), ((newScale - 1) * layout.current.height) / 2) * Math.sign(newTranslateY),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
initialDistance.current = getDistanceFromTouches(touches);
|
|
147
|
+
initialTouchesCenter.current = getRelativeTouchesCenterPosition(touches, layout.current, transformCache);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (touches.length === 1) {
|
|
151
|
+
if (initialDistance.current) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const newTranslateX = lastTransform.current.translateX + gestureState.dx;
|
|
155
|
+
const newTranslateY = lastTransform.current.translateY + gestureState.dy;
|
|
156
|
+
translate.setValue({
|
|
157
|
+
x: newTranslateX,
|
|
158
|
+
y: newTranslateY,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
onPanResponderRelease: () => {
|
|
163
|
+
isResponderActive.current = false;
|
|
164
|
+
if (layout.current == null) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const overflowX = !allowEmpty?.x &&
|
|
168
|
+
Math.abs(transformCache.translateX) >
|
|
169
|
+
((transformCache.scale - 1) * layout.current.width) / 2;
|
|
170
|
+
const overflowY = !allowEmpty?.y &&
|
|
171
|
+
Math.abs(transformCache.translateY) >
|
|
172
|
+
((transformCache.scale - 1) * layout.current.height) / 2;
|
|
173
|
+
if (overflowX || overflowY) {
|
|
174
|
+
if (!fixOverflowAfterRelease) {
|
|
175
|
+
onRelease?.();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
decayingTranslateAnimation.current?.stop();
|
|
179
|
+
decayingTranslateAnimation.current = undefined;
|
|
180
|
+
const toValue = {
|
|
181
|
+
x: overflowX
|
|
182
|
+
? (Math.sign(transformCache.translateX) *
|
|
183
|
+
(transformCache.scale - 1) *
|
|
184
|
+
layout.current.width) /
|
|
185
|
+
2
|
|
186
|
+
: transformCache.translateX,
|
|
187
|
+
y: overflowY
|
|
188
|
+
? (Math.sign(transformCache.translateY) *
|
|
189
|
+
(transformCache.scale - 1) *
|
|
190
|
+
layout.current.height) /
|
|
191
|
+
2
|
|
192
|
+
: transformCache.translateY,
|
|
193
|
+
};
|
|
194
|
+
Animated.timing(translate, {
|
|
195
|
+
toValue,
|
|
196
|
+
duration: 100,
|
|
197
|
+
useNativeDriver: true,
|
|
198
|
+
}).start(() => {
|
|
199
|
+
onRelease?.();
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
decayingTranslateAnimation.current = Animated.decay(translate, {
|
|
204
|
+
velocity: movingVelocity.current ?? { x: 0, y: 0 },
|
|
205
|
+
useNativeDriver: true,
|
|
206
|
+
});
|
|
207
|
+
decayingTranslateAnimation.current.start(() => {
|
|
208
|
+
decayingTranslateAnimation.current = undefined;
|
|
209
|
+
onRelease?.();
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
}));
|
|
214
|
+
}, [
|
|
215
|
+
allowEmpty?.x,
|
|
216
|
+
allowEmpty?.y,
|
|
217
|
+
fixOverflowAfterRelease,
|
|
218
|
+
onRelease,
|
|
219
|
+
scale,
|
|
220
|
+
transformCache,
|
|
221
|
+
translate,
|
|
222
|
+
]);
|
|
223
|
+
useImperativeHandle(ref, () => ({
|
|
224
|
+
animatedValue: { scale, translate },
|
|
225
|
+
setValues: (values) => {
|
|
226
|
+
values.scale != null && scale.setValue(values.scale);
|
|
227
|
+
values.translate != null && translate.setValue(values.translate);
|
|
228
|
+
},
|
|
229
|
+
}));
|
|
230
|
+
// Warn if style.transform is provided, as it will disable pinch-zoom
|
|
231
|
+
if (__DEV__ && style?.transform) {
|
|
232
|
+
console.warn('PinchZoom: passing transform in style prop will disable pinch-zoom functionality. ' +
|
|
233
|
+
'Use a wrapper View for custom transforms instead.');
|
|
234
|
+
}
|
|
235
|
+
return (_jsx(Animated.View
|
|
236
|
+
// @ts-ignore - ref type issue with NativeMethods
|
|
237
|
+
, {
|
|
238
|
+
// @ts-ignore - ref type issue with NativeMethods
|
|
239
|
+
ref: (pinchViewRef) => {
|
|
240
|
+
containerView.current = pinchViewRef;
|
|
241
|
+
}, style: [
|
|
242
|
+
style,
|
|
243
|
+
style?.transform
|
|
244
|
+
? {}
|
|
245
|
+
: {
|
|
246
|
+
transform: [
|
|
247
|
+
{ translateX: translate.x },
|
|
248
|
+
{ translateY: translate.y },
|
|
249
|
+
{ scale },
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
], testID: testID, ...(panResponder?.panHandlers || {}), children: children }));
|
|
253
|
+
}
|
|
254
|
+
const PinchZoomRoot = forwardRef(PinchZoom);
|
|
255
|
+
export { PinchZoomRoot as PinchZoom };
|
package/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * from './components/uis/Hr/Hr';
|
|
|
13
13
|
export * from './components/uis/Icon/Icon';
|
|
14
14
|
export * from './components/uis/IconButton/IconButton';
|
|
15
15
|
export * from './components/uis/LoadingIndicator/LoadingIndicator';
|
|
16
|
+
export * from './components/uis/PinchZoom/PinchZoom';
|
|
16
17
|
export * from './components/uis/Rating/Rating';
|
|
17
18
|
export * from './components/uis/RadioGroup/RadioGroup';
|
|
18
19
|
export * from './components/uis/StatusbarBrightness/StatusBarBrightness';
|
package/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AACA,cAAc,6CAA6C,CAAC;AAC5D,cAAc,uCAAuC,CAAC;AAGtD,cAAc,iDAAiD,CAAC;AAGhE,cAAc,aAAa,CAAC;AAG5B,cAAc,sCAAsC,CAAC;AACrD,cAAc,gCAAgC,CAAC;AAC/C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,oCAAoC,CAAC;AACnD,cAAc,kDAAkD,CAAC;AACjE,cAAc,oCAAoC,CAAC;AACnD,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,oDAAoD,CAAC;AACnE,cAAc,gCAAgC,CAAC;AAC/C,cAAc,wCAAwC,CAAC;AACvD,cAAc,0DAA0D,CAAC;AACzE,cAAc,4CAA4C,CAAC;AAC3D,cAAc,wCAAwC,CAAC;AAGvD,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AACA,cAAc,6CAA6C,CAAC;AAC5D,cAAc,uCAAuC,CAAC;AAGtD,cAAc,iDAAiD,CAAC;AAGhE,cAAc,aAAa,CAAC;AAG5B,cAAc,sCAAsC,CAAC;AACrD,cAAc,gCAAgC,CAAC;AAC/C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,oCAAoC,CAAC;AACnD,cAAc,kDAAkD,CAAC;AACjE,cAAc,oCAAoC,CAAC;AACnD,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,oDAAoD,CAAC;AACnE,cAAc,sCAAsC,CAAC;AACrD,cAAc,gCAAgC,CAAC;AAC/C,cAAc,wCAAwC,CAAC;AACvD,cAAc,0DAA0D,CAAC;AACzE,cAAc,4CAA4C,CAAC;AAC3D,cAAc,wCAAwC,CAAC;AAGvD,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC"}
|
package/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export * from './components/uis/Hr/Hr';
|
|
|
17
17
|
export * from './components/uis/Icon/Icon';
|
|
18
18
|
export * from './components/uis/IconButton/IconButton';
|
|
19
19
|
export * from './components/uis/LoadingIndicator/LoadingIndicator';
|
|
20
|
+
export * from './components/uis/PinchZoom/PinchZoom';
|
|
20
21
|
export * from './components/uis/Rating/Rating';
|
|
21
22
|
export * from './components/uis/RadioGroup/RadioGroup';
|
|
22
23
|
export * from './components/uis/StatusbarBrightness/StatusBarBrightness';
|
package/package.json
CHANGED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import {action} from '@storybook/addon-actions';
|
|
2
|
+
import type {Meta, StoryObj} from '@storybook/react';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import {Image, Text, View} from 'react-native';
|
|
5
|
+
import {css} from 'kstyled';
|
|
6
|
+
|
|
7
|
+
import {withThemeProvider} from '../../../../.storybook/decorators';
|
|
8
|
+
import {PinchZoom} from './PinchZoom';
|
|
9
|
+
|
|
10
|
+
const meta = {
|
|
11
|
+
title: 'PinchZoom',
|
|
12
|
+
component: PinchZoom,
|
|
13
|
+
parameters: {
|
|
14
|
+
docs: {
|
|
15
|
+
description: {
|
|
16
|
+
component: `
|
|
17
|
+
A powerful PinchZoom component that enables pinch-to-zoom and pan gestures for images and content.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
- **Pinch to Zoom**: Two-finger pinch gesture for zooming in/out
|
|
21
|
+
- **Pan Gesture**: Single-finger drag to move zoomed content
|
|
22
|
+
- **Momentum Scrolling**: Smooth deceleration after pan gesture release
|
|
23
|
+
- **Boundary Detection**: Prevents excessive panning beyond content bounds
|
|
24
|
+
- **Customizable Constraints**: Control overflow behavior on X and Y axes
|
|
25
|
+
- **Programmatic Control**: Imperative API to set scale and translation values
|
|
26
|
+
|
|
27
|
+
## Props
|
|
28
|
+
- \`onScaleChanged\`: Callback fired when zoom scale changes
|
|
29
|
+
- \`onTranslateChanged\`: Callback fired when content position changes
|
|
30
|
+
- \`onRelease\`: Callback fired after gesture animation completes (decay or snap-back)
|
|
31
|
+
- \`allowEmpty\`: When \`true\`, allows unrestricted overflow on specific axes (x/y). When \`false\` or undefined, clamps translation to content bounds
|
|
32
|
+
- \`fixOverflowAfterRelease\`: Auto-snap to bounds after release (default: true). When \`false\`, \`onRelease\` fires immediately without snap animation
|
|
33
|
+
- \`style\`: Custom view style. **Warning**: Passing \`transform\` in style will disable pinch-zoom. Use a wrapper View for custom transforms
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
\`\`\`tsx
|
|
37
|
+
<PinchZoom
|
|
38
|
+
onScaleChanged={(scale) => console.log('Scale:', scale)}
|
|
39
|
+
onTranslateChanged={({x, y}) => console.log('Position:', x, y)}
|
|
40
|
+
onRelease={() => console.log('Released')}
|
|
41
|
+
>
|
|
42
|
+
<Image
|
|
43
|
+
source={{uri: 'https://example.com/image.jpg'}}
|
|
44
|
+
style={{width: 300, height: 300}}
|
|
45
|
+
/>
|
|
46
|
+
</PinchZoom>
|
|
47
|
+
\`\`\`
|
|
48
|
+
|
|
49
|
+
## Imperative API
|
|
50
|
+
\`\`\`tsx
|
|
51
|
+
const pinchZoomRef = useRef<PinchZoomRef>(null);
|
|
52
|
+
|
|
53
|
+
// Set zoom and position programmatically
|
|
54
|
+
pinchZoomRef.current?.setValues({
|
|
55
|
+
scale: 2,
|
|
56
|
+
translate: {x: 50, y: 50}
|
|
57
|
+
});
|
|
58
|
+
\`\`\`
|
|
59
|
+
`,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
argTypes: {
|
|
64
|
+
fixOverflowAfterRelease: {
|
|
65
|
+
control: 'boolean',
|
|
66
|
+
description: 'Automatically snap content back to bounds after gesture release',
|
|
67
|
+
defaultValue: true,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
decorators: [withThemeProvider],
|
|
71
|
+
} satisfies Meta<typeof PinchZoom>;
|
|
72
|
+
|
|
73
|
+
export default meta;
|
|
74
|
+
|
|
75
|
+
type Story = StoryObj<typeof meta>;
|
|
76
|
+
|
|
77
|
+
export const WithImage: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
onScaleChanged: action('onScaleChanged'),
|
|
80
|
+
onTranslateChanged: action('onTranslateChanged'),
|
|
81
|
+
onRelease: action('onRelease'),
|
|
82
|
+
fixOverflowAfterRelease: true,
|
|
83
|
+
},
|
|
84
|
+
render: (args) => (
|
|
85
|
+
<View
|
|
86
|
+
style={css`
|
|
87
|
+
flex: 1;
|
|
88
|
+
justify-content: center;
|
|
89
|
+
align-items: center;
|
|
90
|
+
background-color: #f5f5f5;
|
|
91
|
+
`}
|
|
92
|
+
>
|
|
93
|
+
<PinchZoom {...args}>
|
|
94
|
+
<Image
|
|
95
|
+
source={{
|
|
96
|
+
uri: 'https://picsum.photos/300/300',
|
|
97
|
+
}}
|
|
98
|
+
style={css`
|
|
99
|
+
width: 300px;
|
|
100
|
+
height: 300px;
|
|
101
|
+
border-radius: 8px;
|
|
102
|
+
`}
|
|
103
|
+
/>
|
|
104
|
+
</PinchZoom>
|
|
105
|
+
</View>
|
|
106
|
+
),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const WithColorBox: Story = {
|
|
110
|
+
args: {
|
|
111
|
+
onScaleChanged: action('onScaleChanged'),
|
|
112
|
+
onTranslateChanged: action('onTranslateChanged'),
|
|
113
|
+
onRelease: action('onRelease'),
|
|
114
|
+
fixOverflowAfterRelease: true,
|
|
115
|
+
},
|
|
116
|
+
render: (args) => (
|
|
117
|
+
<View
|
|
118
|
+
style={css`
|
|
119
|
+
flex: 1;
|
|
120
|
+
justify-content: center;
|
|
121
|
+
align-items: center;
|
|
122
|
+
background-color: #f5f5f5;
|
|
123
|
+
`}
|
|
124
|
+
>
|
|
125
|
+
<PinchZoom {...args}>
|
|
126
|
+
<View
|
|
127
|
+
style={css`
|
|
128
|
+
width: 250px;
|
|
129
|
+
height: 250px;
|
|
130
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
131
|
+
border-radius: 16px;
|
|
132
|
+
justify-content: center;
|
|
133
|
+
align-items: center;
|
|
134
|
+
shadow-color: #000;
|
|
135
|
+
shadow-offset: 0px 4px;
|
|
136
|
+
shadow-opacity: 0.3;
|
|
137
|
+
shadow-radius: 8px;
|
|
138
|
+
elevation: 5;
|
|
139
|
+
`}
|
|
140
|
+
>
|
|
141
|
+
<Text
|
|
142
|
+
style={css`
|
|
143
|
+
color: white;
|
|
144
|
+
font-size: 24px;
|
|
145
|
+
font-weight: bold;
|
|
146
|
+
`}
|
|
147
|
+
>
|
|
148
|
+
Pinch to Zoom
|
|
149
|
+
</Text>
|
|
150
|
+
<Text
|
|
151
|
+
style={css`
|
|
152
|
+
color: rgba(255, 255, 255, 0.9);
|
|
153
|
+
font-size: 14px;
|
|
154
|
+
margin-top: 8px;
|
|
155
|
+
`}
|
|
156
|
+
>
|
|
157
|
+
Try pinching and dragging!
|
|
158
|
+
</Text>
|
|
159
|
+
</View>
|
|
160
|
+
</PinchZoom>
|
|
161
|
+
</View>
|
|
162
|
+
),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export const WithoutBoundarySnap: Story = {
|
|
166
|
+
args: {
|
|
167
|
+
onScaleChanged: action('onScaleChanged'),
|
|
168
|
+
onTranslateChanged: action('onTranslateChanged'),
|
|
169
|
+
onRelease: action('onRelease'),
|
|
170
|
+
fixOverflowAfterRelease: false,
|
|
171
|
+
},
|
|
172
|
+
render: (args) => (
|
|
173
|
+
<View
|
|
174
|
+
style={css`
|
|
175
|
+
flex: 1;
|
|
176
|
+
justify-content: center;
|
|
177
|
+
align-items: center;
|
|
178
|
+
background-color: #f5f5f5;
|
|
179
|
+
`}
|
|
180
|
+
>
|
|
181
|
+
<PinchZoom {...args}>
|
|
182
|
+
<Image
|
|
183
|
+
source={{
|
|
184
|
+
uri: 'https://picsum.photos/300/400',
|
|
185
|
+
}}
|
|
186
|
+
style={css`
|
|
187
|
+
width: 300px;
|
|
188
|
+
height: 400px;
|
|
189
|
+
border-radius: 8px;
|
|
190
|
+
`}
|
|
191
|
+
/>
|
|
192
|
+
</PinchZoom>
|
|
193
|
+
<Text
|
|
194
|
+
style={css`
|
|
195
|
+
position: absolute;
|
|
196
|
+
bottom: 40px;
|
|
197
|
+
color: #666;
|
|
198
|
+
font-size: 12px;
|
|
199
|
+
`}
|
|
200
|
+
>
|
|
201
|
+
fixOverflowAfterRelease = false
|
|
202
|
+
</Text>
|
|
203
|
+
</View>
|
|
204
|
+
),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const WithCustomStyle: Story = {
|
|
208
|
+
args: {
|
|
209
|
+
onScaleChanged: action('onScaleChanged'),
|
|
210
|
+
onTranslateChanged: action('onTranslateChanged'),
|
|
211
|
+
onRelease: action('onRelease'),
|
|
212
|
+
fixOverflowAfterRelease: true,
|
|
213
|
+
},
|
|
214
|
+
render: (args) => (
|
|
215
|
+
<View
|
|
216
|
+
style={css`
|
|
217
|
+
flex: 1;
|
|
218
|
+
justify-content: center;
|
|
219
|
+
align-items: center;
|
|
220
|
+
background-color: #1a1a1a;
|
|
221
|
+
`}
|
|
222
|
+
>
|
|
223
|
+
<PinchZoom
|
|
224
|
+
{...args}
|
|
225
|
+
style={css`
|
|
226
|
+
border-width: 3px;
|
|
227
|
+
border-color: #667eea;
|
|
228
|
+
border-radius: 12px;
|
|
229
|
+
overflow: hidden;
|
|
230
|
+
`}
|
|
231
|
+
>
|
|
232
|
+
<View
|
|
233
|
+
style={css`
|
|
234
|
+
width: 280px;
|
|
235
|
+
height: 280px;
|
|
236
|
+
background-color: #2d2d2d;
|
|
237
|
+
justify-content: center;
|
|
238
|
+
align-items: center;
|
|
239
|
+
padding: 20px;
|
|
240
|
+
`}
|
|
241
|
+
>
|
|
242
|
+
<Text
|
|
243
|
+
style={css`
|
|
244
|
+
color: #667eea;
|
|
245
|
+
font-size: 20px;
|
|
246
|
+
font-weight: bold;
|
|
247
|
+
text-align: center;
|
|
248
|
+
`}
|
|
249
|
+
>
|
|
250
|
+
Custom Styled
|
|
251
|
+
</Text>
|
|
252
|
+
<Text
|
|
253
|
+
style={css`
|
|
254
|
+
color: #999;
|
|
255
|
+
font-size: 14px;
|
|
256
|
+
text-align: center;
|
|
257
|
+
margin-top: 12px;
|
|
258
|
+
`}
|
|
259
|
+
>
|
|
260
|
+
This PinchZoom has custom border and background styles
|
|
261
|
+
</Text>
|
|
262
|
+
</View>
|
|
263
|
+
</PinchZoom>
|
|
264
|
+
</View>
|
|
265
|
+
),
|
|
266
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import '@testing-library/jest-native/extend-expect';
|
|
2
|
+
|
|
3
|
+
import React, {type ReactElement} from 'react';
|
|
4
|
+
import {Text, View} from 'react-native';
|
|
5
|
+
import type {RenderAPI} from '@testing-library/react-native';
|
|
6
|
+
import {fireEvent, render} from '@testing-library/react-native';
|
|
7
|
+
|
|
8
|
+
import {createComponent} from '../../../../test/testUtils';
|
|
9
|
+
import {PinchZoom} from './PinchZoom';
|
|
10
|
+
import type {PinchZoomProps} from './PinchZoom';
|
|
11
|
+
|
|
12
|
+
let testingLib: RenderAPI;
|
|
13
|
+
|
|
14
|
+
const Component = ({props}: {props?: PinchZoomProps}): ReactElement =>
|
|
15
|
+
createComponent(
|
|
16
|
+
<PinchZoom {...props}>
|
|
17
|
+
<View testID="child-view">
|
|
18
|
+
<Text>Test Child</Text>
|
|
19
|
+
</View>
|
|
20
|
+
</PinchZoom>,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
describe('[PinchZoom]', () => {
|
|
24
|
+
it('should render without crashing', () => {
|
|
25
|
+
testingLib = render(Component({}));
|
|
26
|
+
|
|
27
|
+
const json = testingLib.toJSON();
|
|
28
|
+
expect(json).toBeTruthy();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should render children correctly', () => {
|
|
32
|
+
testingLib = render(Component({}));
|
|
33
|
+
|
|
34
|
+
expect(testingLib.getByTestId('child-view')).toBeTruthy();
|
|
35
|
+
expect(testingLib.getByText('Test Child')).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should render with custom testID', () => {
|
|
39
|
+
testingLib = render(Component({props: {testID: 'custom-pinch-zoom'}}));
|
|
40
|
+
|
|
41
|
+
expect(testingLib.getByTestId('custom-pinch-zoom')).toBeTruthy();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should call onScaleChanged callback', () => {
|
|
45
|
+
const onScaleChanged = jest.fn();
|
|
46
|
+
testingLib = render(Component({props: {onScaleChanged}}));
|
|
47
|
+
|
|
48
|
+
// Note: Actual scale changes would require gesture simulation
|
|
49
|
+
// which is complex in unit tests. This test verifies the prop is passed.
|
|
50
|
+
expect(onScaleChanged).toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should call onTranslateChanged callback', () => {
|
|
54
|
+
const onTranslateChanged = jest.fn();
|
|
55
|
+
testingLib = render(Component({props: {onTranslateChanged}}));
|
|
56
|
+
|
|
57
|
+
// Note: Actual translation changes would require gesture simulation
|
|
58
|
+
// which is complex in unit tests. This test verifies the prop is passed.
|
|
59
|
+
expect(onTranslateChanged).toBeDefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should call onRelease callback', () => {
|
|
63
|
+
const onRelease = jest.fn();
|
|
64
|
+
testingLib = render(Component({props: {onRelease}}));
|
|
65
|
+
|
|
66
|
+
expect(onRelease).toBeDefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should render with custom style', () => {
|
|
70
|
+
const customStyle = {backgroundColor: 'red', width: 300, height: 300};
|
|
71
|
+
testingLib = render(Component({props: {style: customStyle}}));
|
|
72
|
+
|
|
73
|
+
const container = testingLib.getByTestId('pinch-zoom-container');
|
|
74
|
+
expect(container).toHaveStyle(customStyle);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should accept fixOverflowAfterRelease prop', () => {
|
|
78
|
+
testingLib = render(Component({props: {fixOverflowAfterRelease: false}}));
|
|
79
|
+
|
|
80
|
+
expect(testingLib.getByTestId('pinch-zoom-container')).toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should accept allowEmpty prop', () => {
|
|
84
|
+
testingLib = render(
|
|
85
|
+
Component({props: {allowEmpty: {x: true, y: false}}}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(testingLib.getByTestId('pinch-zoom-container')).toBeTruthy();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should render with all props combined', () => {
|
|
92
|
+
const onScaleChanged = jest.fn();
|
|
93
|
+
const onTranslateChanged = jest.fn();
|
|
94
|
+
const onRelease = jest.fn();
|
|
95
|
+
|
|
96
|
+
testingLib = render(
|
|
97
|
+
Component({
|
|
98
|
+
props: {
|
|
99
|
+
testID: 'full-props-test',
|
|
100
|
+
style: {backgroundColor: 'blue'},
|
|
101
|
+
onScaleChanged,
|
|
102
|
+
onTranslateChanged,
|
|
103
|
+
onRelease,
|
|
104
|
+
fixOverflowAfterRelease: true,
|
|
105
|
+
allowEmpty: {x: false, y: true},
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(testingLib.getByTestId('full-props-test')).toBeTruthy();
|
|
111
|
+
expect(testingLib.getByTestId('child-view')).toBeTruthy();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import type {PropsWithChildren, Ref} from 'react';
|
|
2
|
+
import React, {
|
|
3
|
+
forwardRef,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import type {
|
|
10
|
+
NativeMethods,
|
|
11
|
+
NativeTouchEvent,
|
|
12
|
+
PanResponderInstance,
|
|
13
|
+
ViewStyle,
|
|
14
|
+
} from 'react-native';
|
|
15
|
+
import {Animated, PanResponder} from 'react-native';
|
|
16
|
+
|
|
17
|
+
export type PinchZoomProps = PropsWithChildren<{
|
|
18
|
+
/**
|
|
19
|
+
* Custom view style.
|
|
20
|
+
* @warning Passing `transform` in style will disable pinch-zoom functionality.
|
|
21
|
+
* Use a wrapper View for custom transforms instead.
|
|
22
|
+
*/
|
|
23
|
+
style?: ViewStyle;
|
|
24
|
+
children?: any;
|
|
25
|
+
/** Callback fired when zoom scale changes */
|
|
26
|
+
onScaleChanged?(value: number): void;
|
|
27
|
+
/** Callback fired when content position changes */
|
|
28
|
+
onTranslateChanged?(valueXY: {x: number; y: number}): void;
|
|
29
|
+
/** Callback fired after gesture animation completes (decay or snap-back) */
|
|
30
|
+
onRelease?(): void;
|
|
31
|
+
/**
|
|
32
|
+
* Allow unrestricted overflow on specific axes.
|
|
33
|
+
* When true, allows overflow. When false/undefined, clamps to bounds.
|
|
34
|
+
*/
|
|
35
|
+
allowEmpty?: {x?: boolean; y?: boolean};
|
|
36
|
+
/**
|
|
37
|
+
* Auto-snap to bounds after release.
|
|
38
|
+
* @default true
|
|
39
|
+
*/
|
|
40
|
+
fixOverflowAfterRelease?: boolean;
|
|
41
|
+
/** Test ID for component testing */
|
|
42
|
+
testID?: string;
|
|
43
|
+
}>;
|
|
44
|
+
|
|
45
|
+
export interface PinchZoomRef {
|
|
46
|
+
animatedValue: {scale: Animated.Value; translate: Animated.ValueXY};
|
|
47
|
+
setValues(_: {scale?: number; translate?: {x: number; y: number}}): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type TouchePosition = Pick<
|
|
51
|
+
NativeTouchEvent,
|
|
52
|
+
'locationX' | 'locationY' | 'pageX' | 'pageY'
|
|
53
|
+
>;
|
|
54
|
+
|
|
55
|
+
function getDistanceFromTouches(touches: NativeTouchEvent[]): number {
|
|
56
|
+
const [touch1, touch2] = touches;
|
|
57
|
+
|
|
58
|
+
return Math.sqrt(
|
|
59
|
+
(touch1.pageX - touch2.pageX) ** 2 + (touch1.pageY - touch2.pageY) ** 2,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getRelativeTouchesCenterPosition(
|
|
64
|
+
touches: NativeTouchEvent[],
|
|
65
|
+
layout: {width: number; height: number; pageX: number; pageY: number},
|
|
66
|
+
transformCache: {scale: number; translateX: number; translateY: number},
|
|
67
|
+
): TouchePosition {
|
|
68
|
+
const pageX =
|
|
69
|
+
(touches[0].pageX + touches[1].pageX) / 2 - layout.width / 2 - layout.pageX;
|
|
70
|
+
|
|
71
|
+
const pageY =
|
|
72
|
+
(touches[0].pageY + touches[1].pageY) / 2 -
|
|
73
|
+
layout.height / 2 -
|
|
74
|
+
layout.pageY;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
locationX: (pageX - transformCache.translateX) / transformCache.scale,
|
|
78
|
+
locationY: (pageY - transformCache.translateY) / transformCache.scale,
|
|
79
|
+
pageX,
|
|
80
|
+
pageY,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function PinchZoom(
|
|
85
|
+
{
|
|
86
|
+
style,
|
|
87
|
+
children,
|
|
88
|
+
onScaleChanged,
|
|
89
|
+
onRelease,
|
|
90
|
+
onTranslateChanged,
|
|
91
|
+
allowEmpty,
|
|
92
|
+
fixOverflowAfterRelease = true,
|
|
93
|
+
testID = 'pinch-zoom-container',
|
|
94
|
+
}: PinchZoomProps,
|
|
95
|
+
ref: Ref<PinchZoomRef>,
|
|
96
|
+
): JSX.Element {
|
|
97
|
+
const containerView = useRef<NativeMethods | null>(null);
|
|
98
|
+
const scale = useRef(new Animated.Value(1)).current;
|
|
99
|
+
const translate = useRef(new Animated.ValueXY({x: 0, y: 0})).current;
|
|
100
|
+
|
|
101
|
+
const transformCache = useRef({
|
|
102
|
+
scale: 1,
|
|
103
|
+
translateX: 0,
|
|
104
|
+
translateY: 0,
|
|
105
|
+
}).current;
|
|
106
|
+
|
|
107
|
+
const lastTransform = useRef({scale: 1, translateX: 0, translateY: 0});
|
|
108
|
+
const initialDistance = useRef<number | undefined>(undefined);
|
|
109
|
+
const initialTouchesCenter = useRef<TouchePosition | undefined>(undefined);
|
|
110
|
+
|
|
111
|
+
type Layout = {
|
|
112
|
+
width: number;
|
|
113
|
+
height: number;
|
|
114
|
+
pageX: number;
|
|
115
|
+
pageY: number;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const layout = useRef<Layout | undefined>(undefined);
|
|
119
|
+
|
|
120
|
+
const decayingTranslateAnimation = useRef<
|
|
121
|
+
Animated.CompositeAnimation | undefined
|
|
122
|
+
>(undefined);
|
|
123
|
+
const isResponderActive = useRef(false);
|
|
124
|
+
const movingVelocity = useRef<{x: number; y: number} | undefined>(undefined);
|
|
125
|
+
|
|
126
|
+
containerView.current?.measure((x, y, width, height, pageX, pageY) => {
|
|
127
|
+
layout.current = {width, height, pageX, pageY};
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
scale.addListener(({value}) => {
|
|
132
|
+
transformCache.scale = value;
|
|
133
|
+
onScaleChanged?.(value);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const id = translate.addListener(({x, y}) => {
|
|
137
|
+
if (
|
|
138
|
+
decayingTranslateAnimation.current &&
|
|
139
|
+
layout.current &&
|
|
140
|
+
!isResponderActive.current
|
|
141
|
+
) {
|
|
142
|
+
const overflowX =
|
|
143
|
+
!allowEmpty?.x &&
|
|
144
|
+
Math.abs(x) > ((transformCache.scale - 1) * layout.current.width) / 2;
|
|
145
|
+
|
|
146
|
+
const overflowY =
|
|
147
|
+
!allowEmpty?.y &&
|
|
148
|
+
Math.abs(y) >
|
|
149
|
+
((transformCache.scale - 1) * layout.current.height) / 2;
|
|
150
|
+
|
|
151
|
+
if (overflowX || overflowY) {
|
|
152
|
+
decayingTranslateAnimation.current?.stop();
|
|
153
|
+
decayingTranslateAnimation.current = undefined;
|
|
154
|
+
|
|
155
|
+
translate.setValue({
|
|
156
|
+
x: overflowX
|
|
157
|
+
? (Math.sign(x) *
|
|
158
|
+
(transformCache.scale - 1) *
|
|
159
|
+
layout.current.width) /
|
|
160
|
+
2
|
|
161
|
+
: x,
|
|
162
|
+
y: overflowY
|
|
163
|
+
? (Math.sign(y) *
|
|
164
|
+
(transformCache.scale - 1) *
|
|
165
|
+
layout.current.height) /
|
|
166
|
+
2
|
|
167
|
+
: y,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
transformCache.translateX = x;
|
|
173
|
+
transformCache.translateY = y;
|
|
174
|
+
onTranslateChanged?.({x, y});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return () => {
|
|
178
|
+
scale.removeAllListeners();
|
|
179
|
+
translate.removeListener(id);
|
|
180
|
+
};
|
|
181
|
+
}, [
|
|
182
|
+
onScaleChanged,
|
|
183
|
+
onTranslateChanged,
|
|
184
|
+
scale,
|
|
185
|
+
transformCache,
|
|
186
|
+
translate,
|
|
187
|
+
allowEmpty?.x,
|
|
188
|
+
allowEmpty?.y,
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
const [panResponder, setPanResponder] = useState<PanResponderInstance>();
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
setPanResponder(
|
|
195
|
+
PanResponder.create({
|
|
196
|
+
onMoveShouldSetPanResponder: () => true,
|
|
197
|
+
onPanResponderGrant: ({nativeEvent}) => {
|
|
198
|
+
isResponderActive.current = true;
|
|
199
|
+
movingVelocity.current = undefined;
|
|
200
|
+
|
|
201
|
+
if (decayingTranslateAnimation.current) {
|
|
202
|
+
decayingTranslateAnimation.current.stop();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const {touches} = nativeEvent;
|
|
206
|
+
|
|
207
|
+
lastTransform.current = {...transformCache};
|
|
208
|
+
|
|
209
|
+
initialDistance.current = undefined;
|
|
210
|
+
|
|
211
|
+
if (layout.current != null) {
|
|
212
|
+
if (touches.length === 2) {
|
|
213
|
+
initialDistance.current = getDistanceFromTouches(touches);
|
|
214
|
+
|
|
215
|
+
initialTouchesCenter.current = getRelativeTouchesCenterPosition(
|
|
216
|
+
touches,
|
|
217
|
+
layout.current,
|
|
218
|
+
transformCache,
|
|
219
|
+
);
|
|
220
|
+
} else {
|
|
221
|
+
initialDistance.current = undefined;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
onPanResponderMove: ({nativeEvent}, gestureState) => {
|
|
226
|
+
const {touches} = nativeEvent;
|
|
227
|
+
|
|
228
|
+
if (layout.current == null) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (movingVelocity.current) {
|
|
233
|
+
movingVelocity.current = {
|
|
234
|
+
x: (movingVelocity.current.x + gestureState.vx) / 2,
|
|
235
|
+
y: (movingVelocity.current.y + gestureState.vy) / 2,
|
|
236
|
+
};
|
|
237
|
+
} else {
|
|
238
|
+
movingVelocity.current = {x: gestureState.vx, y: gestureState.vy};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (touches.length === 2) {
|
|
242
|
+
if (
|
|
243
|
+
initialDistance.current &&
|
|
244
|
+
initialTouchesCenter.current &&
|
|
245
|
+
layout.current
|
|
246
|
+
) {
|
|
247
|
+
const newScale = Math.max(
|
|
248
|
+
1,
|
|
249
|
+
(getDistanceFromTouches(touches) / initialDistance.current) *
|
|
250
|
+
lastTransform.current.scale,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const {pageX, pageY} = getRelativeTouchesCenterPosition(
|
|
254
|
+
touches,
|
|
255
|
+
layout.current,
|
|
256
|
+
transformCache,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
scale.setValue(newScale);
|
|
260
|
+
|
|
261
|
+
const newTranslateX =
|
|
262
|
+
pageX - initialTouchesCenter.current.locationX * newScale;
|
|
263
|
+
|
|
264
|
+
const newTranslateY =
|
|
265
|
+
pageY - initialTouchesCenter.current.locationY * newScale;
|
|
266
|
+
|
|
267
|
+
translate.setValue({
|
|
268
|
+
x: allowEmpty?.x
|
|
269
|
+
? newTranslateX
|
|
270
|
+
: Math.min(
|
|
271
|
+
Math.abs(newTranslateX),
|
|
272
|
+
((newScale - 1) * layout.current.width) / 2,
|
|
273
|
+
) * Math.sign(newTranslateX),
|
|
274
|
+
y: allowEmpty?.y
|
|
275
|
+
? newTranslateY
|
|
276
|
+
: Math.min(
|
|
277
|
+
Math.abs(newTranslateY),
|
|
278
|
+
((newScale - 1) * layout.current.height) / 2,
|
|
279
|
+
) * Math.sign(newTranslateY),
|
|
280
|
+
});
|
|
281
|
+
} else {
|
|
282
|
+
initialDistance.current = getDistanceFromTouches(touches);
|
|
283
|
+
|
|
284
|
+
initialTouchesCenter.current = getRelativeTouchesCenterPosition(
|
|
285
|
+
touches,
|
|
286
|
+
layout.current,
|
|
287
|
+
transformCache,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
} else if (touches.length === 1) {
|
|
291
|
+
if (initialDistance.current) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const newTranslateX =
|
|
296
|
+
lastTransform.current.translateX + gestureState.dx;
|
|
297
|
+
|
|
298
|
+
const newTranslateY =
|
|
299
|
+
lastTransform.current.translateY + gestureState.dy;
|
|
300
|
+
|
|
301
|
+
translate.setValue({
|
|
302
|
+
x: newTranslateX,
|
|
303
|
+
y: newTranslateY,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
onPanResponderRelease: () => {
|
|
308
|
+
isResponderActive.current = false;
|
|
309
|
+
|
|
310
|
+
if (layout.current == null) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const overflowX =
|
|
315
|
+
!allowEmpty?.x &&
|
|
316
|
+
Math.abs(transformCache.translateX) >
|
|
317
|
+
((transformCache.scale - 1) * layout.current.width) / 2;
|
|
318
|
+
|
|
319
|
+
const overflowY =
|
|
320
|
+
!allowEmpty?.y &&
|
|
321
|
+
Math.abs(transformCache.translateY) >
|
|
322
|
+
((transformCache.scale - 1) * layout.current.height) / 2;
|
|
323
|
+
|
|
324
|
+
if (overflowX || overflowY) {
|
|
325
|
+
if (!fixOverflowAfterRelease) {
|
|
326
|
+
onRelease?.();
|
|
327
|
+
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
decayingTranslateAnimation.current?.stop();
|
|
332
|
+
decayingTranslateAnimation.current = undefined;
|
|
333
|
+
|
|
334
|
+
const toValue = {
|
|
335
|
+
x: overflowX
|
|
336
|
+
? (Math.sign(transformCache.translateX) *
|
|
337
|
+
(transformCache.scale - 1) *
|
|
338
|
+
layout.current.width) /
|
|
339
|
+
2
|
|
340
|
+
: transformCache.translateX,
|
|
341
|
+
y: overflowY
|
|
342
|
+
? (Math.sign(transformCache.translateY) *
|
|
343
|
+
(transformCache.scale - 1) *
|
|
344
|
+
layout.current.height) /
|
|
345
|
+
2
|
|
346
|
+
: transformCache.translateY,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
Animated.timing(translate, {
|
|
350
|
+
toValue,
|
|
351
|
+
duration: 100,
|
|
352
|
+
useNativeDriver: true,
|
|
353
|
+
}).start(() => {
|
|
354
|
+
onRelease?.();
|
|
355
|
+
});
|
|
356
|
+
} else {
|
|
357
|
+
decayingTranslateAnimation.current = Animated.decay(translate, {
|
|
358
|
+
velocity: movingVelocity.current ?? {x: 0, y: 0},
|
|
359
|
+
useNativeDriver: true,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
decayingTranslateAnimation.current.start(() => {
|
|
363
|
+
decayingTranslateAnimation.current = undefined;
|
|
364
|
+
onRelease?.();
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
}, [
|
|
371
|
+
allowEmpty?.x,
|
|
372
|
+
allowEmpty?.y,
|
|
373
|
+
fixOverflowAfterRelease,
|
|
374
|
+
onRelease,
|
|
375
|
+
scale,
|
|
376
|
+
transformCache,
|
|
377
|
+
translate,
|
|
378
|
+
]);
|
|
379
|
+
|
|
380
|
+
useImperativeHandle(ref, () => ({
|
|
381
|
+
animatedValue: {scale, translate},
|
|
382
|
+
setValues: (values) => {
|
|
383
|
+
values.scale != null && scale.setValue(values.scale);
|
|
384
|
+
values.translate != null && translate.setValue(values.translate);
|
|
385
|
+
},
|
|
386
|
+
}));
|
|
387
|
+
|
|
388
|
+
// Warn if style.transform is provided, as it will disable pinch-zoom
|
|
389
|
+
if (__DEV__ && style?.transform) {
|
|
390
|
+
console.warn(
|
|
391
|
+
'PinchZoom: passing transform in style prop will disable pinch-zoom functionality. ' +
|
|
392
|
+
'Use a wrapper View for custom transforms instead.',
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return (
|
|
397
|
+
<Animated.View
|
|
398
|
+
// @ts-ignore - ref type issue with NativeMethods
|
|
399
|
+
ref={(pinchViewRef: NativeMethods | null) => {
|
|
400
|
+
containerView.current = pinchViewRef;
|
|
401
|
+
}}
|
|
402
|
+
style={[
|
|
403
|
+
style,
|
|
404
|
+
style?.transform
|
|
405
|
+
? {}
|
|
406
|
+
: {
|
|
407
|
+
transform: [
|
|
408
|
+
{translateX: translate.x},
|
|
409
|
+
{translateY: translate.y},
|
|
410
|
+
{scale},
|
|
411
|
+
],
|
|
412
|
+
},
|
|
413
|
+
]}
|
|
414
|
+
testID={testID}
|
|
415
|
+
{...(panResponder?.panHandlers || {})}
|
|
416
|
+
>
|
|
417
|
+
{children}
|
|
418
|
+
</Animated.View>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const PinchZoomRoot = forwardRef<PinchZoomRef, PinchZoomProps>(PinchZoom);
|
|
423
|
+
|
|
424
|
+
export {PinchZoomRoot as PinchZoom};
|
package/src/index.tsx
CHANGED
|
@@ -20,6 +20,7 @@ export * from './components/uis/Hr/Hr';
|
|
|
20
20
|
export * from './components/uis/Icon/Icon';
|
|
21
21
|
export * from './components/uis/IconButton/IconButton';
|
|
22
22
|
export * from './components/uis/LoadingIndicator/LoadingIndicator';
|
|
23
|
+
export * from './components/uis/PinchZoom/PinchZoom';
|
|
23
24
|
export * from './components/uis/Rating/Rating';
|
|
24
25
|
export * from './components/uis/RadioGroup/RadioGroup';
|
|
25
26
|
export * from './components/uis/StatusbarBrightness/StatusBarBrightness';
|