react-native-puff-pop 1.0.2 → 1.0.4
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/README.md +19 -0
- package/lib/module/index.js +87 -44
- package/lib/typescript/src/index.d.ts +15 -1
- package/package.json +1 -1
- package/src/index.tsx +129 -47
package/README.md
CHANGED
|
@@ -164,10 +164,13 @@ function App() {
|
|
|
164
164
|
| `skeleton` | `boolean` | `true` | Reserve space before animation |
|
|
165
165
|
| `visible` | `boolean` | `true` | Control visibility |
|
|
166
166
|
| `animateOnMount` | `boolean` | `true` | Animate when component mounts |
|
|
167
|
+
| `onAnimationStart` | `() => void` | - | Callback when animation starts |
|
|
167
168
|
| `onAnimationComplete` | `() => void` | - | Callback when animation completes |
|
|
168
169
|
| `style` | `ViewStyle` | - | Custom container style |
|
|
169
170
|
| `loop` | `boolean \| number` | `false` | Loop animation (true=infinite, number=times) |
|
|
170
171
|
| `loopDelay` | `number` | `0` | Delay between loop iterations in ms |
|
|
172
|
+
| `respectReduceMotion` | `boolean` | `true` | Respect system reduce motion setting |
|
|
173
|
+
| `testID` | `string` | - | Test ID for testing purposes |
|
|
171
174
|
|
|
172
175
|
### Animation Effects (`PuffPopEffect`)
|
|
173
176
|
|
|
@@ -204,6 +207,22 @@ The component reserves its full space immediately, and only the visual appearanc
|
|
|
204
207
|
### `skeleton={false}`
|
|
205
208
|
The component's height starts at 0 and expands during animation, pushing other content below it. This creates a more dynamic entrance effect.
|
|
206
209
|
|
|
210
|
+
## Accessibility
|
|
211
|
+
|
|
212
|
+
PuffPop respects the system's "Reduce Motion" accessibility setting by default. When users have enabled reduce motion in their device settings, animations will be instant (0 duration) to avoid discomfort.
|
|
213
|
+
|
|
214
|
+
```tsx
|
|
215
|
+
// Respect reduce motion setting (default)
|
|
216
|
+
<PuffPop respectReduceMotion={true}>
|
|
217
|
+
<YourComponent />
|
|
218
|
+
</PuffPop>
|
|
219
|
+
|
|
220
|
+
// Ignore reduce motion setting (always animate)
|
|
221
|
+
<PuffPop respectReduceMotion={false}>
|
|
222
|
+
<YourComponent />
|
|
223
|
+
</PuffPop>
|
|
224
|
+
```
|
|
225
|
+
|
|
207
226
|
## License
|
|
208
227
|
|
|
209
228
|
MIT
|
package/lib/module/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useRef, useState, useCallback, } from 'react';
|
|
3
|
-
import { View, Animated, StyleSheet, Easing, } from 'react-native';
|
|
2
|
+
import { useEffect, useRef, useState, useCallback, useMemo, } from 'react';
|
|
3
|
+
import { View, Animated, StyleSheet, Easing, AccessibilityInfo, } from 'react-native';
|
|
4
4
|
/**
|
|
5
5
|
* Get easing function based on type
|
|
6
6
|
*/
|
|
@@ -25,7 +25,7 @@ function getEasing(type) {
|
|
|
25
25
|
/**
|
|
26
26
|
* PuffPop - Animate children with beautiful entrance effects
|
|
27
27
|
*/
|
|
28
|
-
export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0, easing = 'easeOut', skeleton = true, visible = true, onAnimationComplete, style, animateOnMount = true, loop = false, loopDelay = 0, }) {
|
|
28
|
+
export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0, easing = 'easeOut', skeleton = true, visible = true, onAnimationComplete, onAnimationStart, style, animateOnMount = true, loop = false, loopDelay = 0, respectReduceMotion = true, testID, }) {
|
|
29
29
|
// Animation values
|
|
30
30
|
const opacity = useRef(new Animated.Value(animateOnMount ? 0 : 1)).current;
|
|
31
31
|
const scale = useRef(new Animated.Value(animateOnMount ? getInitialScale(effect) : 1)).current;
|
|
@@ -37,6 +37,42 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
37
37
|
const animatedHeight = useRef(new Animated.Value(0)).current;
|
|
38
38
|
const hasAnimated = useRef(false);
|
|
39
39
|
const loopAnimationRef = useRef(null);
|
|
40
|
+
const loopTimeoutRef = useRef(null);
|
|
41
|
+
// Reduce motion accessibility support
|
|
42
|
+
const [isReduceMotionEnabled, setIsReduceMotionEnabled] = useState(false);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!respectReduceMotion)
|
|
45
|
+
return;
|
|
46
|
+
const checkReduceMotion = async () => {
|
|
47
|
+
const reduceMotion = await AccessibilityInfo.isReduceMotionEnabled();
|
|
48
|
+
setIsReduceMotionEnabled(reduceMotion);
|
|
49
|
+
};
|
|
50
|
+
checkReduceMotion();
|
|
51
|
+
const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setIsReduceMotionEnabled);
|
|
52
|
+
return () => {
|
|
53
|
+
subscription.remove();
|
|
54
|
+
};
|
|
55
|
+
}, [respectReduceMotion]);
|
|
56
|
+
// Effective duration (0 if reduce motion is enabled)
|
|
57
|
+
const effectiveDuration = respectReduceMotion && isReduceMotionEnabled ? 0 : duration;
|
|
58
|
+
// Memoize effect type checks to avoid repeated includes() calls
|
|
59
|
+
const effectFlags = useMemo(() => ({
|
|
60
|
+
hasScale: ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect),
|
|
61
|
+
hasRotate: ['rotate', 'rotateScale'].includes(effect),
|
|
62
|
+
hasFlip: effect === 'flip',
|
|
63
|
+
hasTranslateX: ['slideLeft', 'slideRight'].includes(effect),
|
|
64
|
+
hasTranslateY: ['slideUp', 'slideDown', 'bounce'].includes(effect),
|
|
65
|
+
hasRotateEffect: ['rotate', 'rotateScale', 'flip'].includes(effect),
|
|
66
|
+
}), [effect]);
|
|
67
|
+
// Memoize interpolations to avoid recreating on every render
|
|
68
|
+
const rotateInterpolation = useMemo(() => rotate.interpolate({
|
|
69
|
+
inputRange: [-360, 0, 360],
|
|
70
|
+
outputRange: ['-360deg', '0deg', '360deg'],
|
|
71
|
+
}), [rotate]);
|
|
72
|
+
const flipInterpolation = useMemo(() => rotate.interpolate({
|
|
73
|
+
inputRange: [-180, 0],
|
|
74
|
+
outputRange: ['-180deg', '0deg'],
|
|
75
|
+
}), [rotate]);
|
|
40
76
|
// Handle layout measurement for non-skeleton mode
|
|
41
77
|
const onLayout = useCallback((event) => {
|
|
42
78
|
if (!skeleton && measuredHeight === null) {
|
|
@@ -46,12 +82,16 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
46
82
|
}, [skeleton, measuredHeight]);
|
|
47
83
|
// Animate function
|
|
48
84
|
const animate = useCallback((toVisible) => {
|
|
85
|
+
// Call onAnimationStart callback
|
|
86
|
+
if (toVisible && onAnimationStart) {
|
|
87
|
+
onAnimationStart();
|
|
88
|
+
}
|
|
49
89
|
const easingFn = getEasing(easing);
|
|
50
90
|
// When skeleton is false, we animate height which doesn't support native driver
|
|
51
91
|
// So we must use JS driver for all animations in that case
|
|
52
92
|
const useNative = skeleton;
|
|
53
93
|
const config = {
|
|
54
|
-
duration,
|
|
94
|
+
duration: effectiveDuration,
|
|
55
95
|
easing: easingFn,
|
|
56
96
|
useNativeDriver: useNative,
|
|
57
97
|
};
|
|
@@ -62,7 +102,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
62
102
|
...config,
|
|
63
103
|
}));
|
|
64
104
|
// Scale animation
|
|
65
|
-
if (
|
|
105
|
+
if (effectFlags.hasScale) {
|
|
66
106
|
const targetScale = toVisible ? 1 : getInitialScale(effect);
|
|
67
107
|
animations.push(Animated.timing(scale, {
|
|
68
108
|
toValue: targetScale,
|
|
@@ -71,7 +111,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
71
111
|
}));
|
|
72
112
|
}
|
|
73
113
|
// Rotate animation
|
|
74
|
-
if (
|
|
114
|
+
if (effectFlags.hasRotate || effectFlags.hasFlip) {
|
|
75
115
|
const targetRotate = toVisible ? 0 : getInitialRotate(effect);
|
|
76
116
|
animations.push(Animated.timing(rotate, {
|
|
77
117
|
toValue: targetRotate,
|
|
@@ -79,7 +119,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
79
119
|
}));
|
|
80
120
|
}
|
|
81
121
|
// TranslateX animation
|
|
82
|
-
if (
|
|
122
|
+
if (effectFlags.hasTranslateX) {
|
|
83
123
|
const targetX = toVisible ? 0 : getInitialTranslateX(effect);
|
|
84
124
|
animations.push(Animated.timing(translateX, {
|
|
85
125
|
toValue: targetX,
|
|
@@ -87,7 +127,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
87
127
|
}));
|
|
88
128
|
}
|
|
89
129
|
// TranslateY animation
|
|
90
|
-
if (
|
|
130
|
+
if (effectFlags.hasTranslateY) {
|
|
91
131
|
const targetY = toVisible ? 0 : getInitialTranslateY(effect);
|
|
92
132
|
animations.push(Animated.timing(translateY, {
|
|
93
133
|
toValue: targetY,
|
|
@@ -99,7 +139,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
99
139
|
const targetHeight = toVisible ? measuredHeight : 0;
|
|
100
140
|
animations.push(Animated.timing(animatedHeight, {
|
|
101
141
|
toValue: targetHeight,
|
|
102
|
-
duration,
|
|
142
|
+
duration: effectiveDuration,
|
|
103
143
|
easing: easingFn,
|
|
104
144
|
useNativeDriver: false,
|
|
105
145
|
}));
|
|
@@ -144,7 +184,11 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
144
184
|
if (loopCount === -1 || currentIteration < loopCount) {
|
|
145
185
|
// Add delay between loops if specified
|
|
146
186
|
if (loopDelay > 0) {
|
|
147
|
-
|
|
187
|
+
// Clear any existing timeout before setting a new one
|
|
188
|
+
if (loopTimeoutRef.current) {
|
|
189
|
+
clearTimeout(loopTimeoutRef.current);
|
|
190
|
+
}
|
|
191
|
+
loopTimeoutRef.current = setTimeout(runLoop, loopDelay);
|
|
148
192
|
}
|
|
149
193
|
else {
|
|
150
194
|
runLoop();
|
|
@@ -173,11 +217,13 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
173
217
|
}
|
|
174
218
|
}, [
|
|
175
219
|
delay,
|
|
176
|
-
|
|
220
|
+
effectiveDuration,
|
|
177
221
|
easing,
|
|
178
222
|
effect,
|
|
223
|
+
effectFlags,
|
|
179
224
|
measuredHeight,
|
|
180
225
|
onAnimationComplete,
|
|
226
|
+
onAnimationStart,
|
|
181
227
|
opacity,
|
|
182
228
|
rotate,
|
|
183
229
|
scale,
|
|
@@ -201,44 +247,30 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
201
247
|
animate(visible);
|
|
202
248
|
}
|
|
203
249
|
}, [visible, animate]);
|
|
204
|
-
// Cleanup loop animation on unmount
|
|
250
|
+
// Cleanup loop animation and timeout on unmount
|
|
205
251
|
useEffect(() => {
|
|
206
252
|
return () => {
|
|
207
253
|
if (loopAnimationRef.current) {
|
|
208
254
|
loopAnimationRef.current.stop();
|
|
209
255
|
}
|
|
256
|
+
if (loopTimeoutRef.current) {
|
|
257
|
+
clearTimeout(loopTimeoutRef.current);
|
|
258
|
+
}
|
|
210
259
|
};
|
|
211
260
|
}, []);
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
// Build transform based on effect
|
|
217
|
-
const getTransform = () => {
|
|
218
|
-
const hasScale = ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect);
|
|
219
|
-
const hasRotate = ['rotate', 'rotateScale'].includes(effect);
|
|
220
|
-
const hasFlip = effect === 'flip';
|
|
221
|
-
const hasTranslateX = ['slideLeft', 'slideRight'].includes(effect);
|
|
222
|
-
const hasTranslateY = ['slideUp', 'slideDown', 'bounce'].includes(effect);
|
|
261
|
+
// Memoize transform array to avoid recreating on every render
|
|
262
|
+
// IMPORTANT: All hooks must be called before any conditional returns
|
|
263
|
+
const transform = useMemo(() => {
|
|
264
|
+
const { hasScale, hasRotate, hasFlip, hasTranslateX, hasTranslateY } = effectFlags;
|
|
223
265
|
const transforms = [];
|
|
224
266
|
if (hasScale) {
|
|
225
267
|
transforms.push({ scale });
|
|
226
268
|
}
|
|
227
269
|
if (hasRotate) {
|
|
228
|
-
transforms.push({
|
|
229
|
-
rotate: rotate.interpolate({
|
|
230
|
-
inputRange: [-360, 0, 360],
|
|
231
|
-
outputRange: ['-360deg', '0deg', '360deg'],
|
|
232
|
-
}),
|
|
233
|
-
});
|
|
270
|
+
transforms.push({ rotate: rotateInterpolation });
|
|
234
271
|
}
|
|
235
272
|
if (hasFlip) {
|
|
236
|
-
transforms.push({
|
|
237
|
-
rotateY: rotate.interpolate({
|
|
238
|
-
inputRange: [-180, 0],
|
|
239
|
-
outputRange: ['-180deg', '0deg'],
|
|
240
|
-
}),
|
|
241
|
-
});
|
|
273
|
+
transforms.push({ rotateY: flipInterpolation });
|
|
242
274
|
}
|
|
243
275
|
if (hasTranslateX) {
|
|
244
276
|
transforms.push({ translateX });
|
|
@@ -247,16 +279,27 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
247
279
|
transforms.push({ translateY });
|
|
248
280
|
}
|
|
249
281
|
return transforms.length > 0 ? transforms : undefined;
|
|
250
|
-
};
|
|
251
|
-
|
|
282
|
+
}, [effectFlags, scale, rotateInterpolation, flipInterpolation, translateX, translateY]);
|
|
283
|
+
// Memoize animated style
|
|
284
|
+
const animatedStyle = useMemo(() => ({
|
|
252
285
|
opacity,
|
|
253
|
-
transform
|
|
254
|
-
};
|
|
255
|
-
//
|
|
256
|
-
const containerAnimatedStyle =
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
286
|
+
transform,
|
|
287
|
+
}), [opacity, transform]);
|
|
288
|
+
// Memoize container style for non-skeleton mode
|
|
289
|
+
const containerAnimatedStyle = useMemo(() => {
|
|
290
|
+
if (!skeleton && measuredHeight !== null) {
|
|
291
|
+
return {
|
|
292
|
+
height: animatedHeight,
|
|
293
|
+
overflow: effectFlags.hasRotateEffect ? 'visible' : 'hidden'
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return {};
|
|
297
|
+
}, [skeleton, measuredHeight, animatedHeight, effectFlags.hasRotateEffect]);
|
|
298
|
+
// For non-skeleton mode, measure first (after all hooks)
|
|
299
|
+
if (!skeleton && measuredHeight === null) {
|
|
300
|
+
return (_jsx(View, { style: styles.measureContainer, onLayout: onLayout, children: _jsx(View, { style: styles.hidden, children: children }) }));
|
|
301
|
+
}
|
|
302
|
+
return (_jsx(Animated.View, { style: [styles.container, style, containerAnimatedStyle], testID: testID, children: _jsx(Animated.View, { style: animatedStyle, children: children }) }));
|
|
260
303
|
}
|
|
261
304
|
/**
|
|
262
305
|
* Get initial scale value based on effect
|
|
@@ -69,9 +69,23 @@ export interface PuffPopProps {
|
|
|
69
69
|
* @default 0
|
|
70
70
|
*/
|
|
71
71
|
loopDelay?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Callback when animation starts
|
|
74
|
+
*/
|
|
75
|
+
onAnimationStart?: () => void;
|
|
76
|
+
/**
|
|
77
|
+
* Respect system reduce motion accessibility setting
|
|
78
|
+
* When true and reduce motion is enabled, animations will be instant
|
|
79
|
+
* @default true
|
|
80
|
+
*/
|
|
81
|
+
respectReduceMotion?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Test ID for testing purposes
|
|
84
|
+
*/
|
|
85
|
+
testID?: string;
|
|
72
86
|
}
|
|
73
87
|
/**
|
|
74
88
|
* PuffPop - Animate children with beautiful entrance effects
|
|
75
89
|
*/
|
|
76
|
-
export declare function PuffPop({ children, effect, duration, delay, easing, skeleton, visible, onAnimationComplete, style, animateOnMount, loop, loopDelay, }: PuffPopProps): ReactElement;
|
|
90
|
+
export declare function PuffPop({ children, effect, duration, delay, easing, skeleton, visible, onAnimationComplete, onAnimationStart, style, animateOnMount, loop, loopDelay, respectReduceMotion, testID, }: PuffPopProps): ReactElement;
|
|
77
91
|
export default PuffPop;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-puff-pop",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "A React Native animation library for revealing children components with beautiful puff and pop effects",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"types": "./lib/typescript/src/index.d.ts",
|
package/src/index.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
useRef,
|
|
4
4
|
useState,
|
|
5
5
|
useCallback,
|
|
6
|
+
useMemo,
|
|
6
7
|
type ReactNode,
|
|
7
8
|
type ReactElement,
|
|
8
9
|
} from 'react';
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
Animated,
|
|
12
13
|
StyleSheet,
|
|
13
14
|
Easing,
|
|
15
|
+
AccessibilityInfo,
|
|
14
16
|
type LayoutChangeEvent,
|
|
15
17
|
type StyleProp,
|
|
16
18
|
type ViewStyle,
|
|
@@ -115,6 +117,23 @@ export interface PuffPopProps {
|
|
|
115
117
|
* @default 0
|
|
116
118
|
*/
|
|
117
119
|
loopDelay?: number;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Callback when animation starts
|
|
123
|
+
*/
|
|
124
|
+
onAnimationStart?: () => void;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Respect system reduce motion accessibility setting
|
|
128
|
+
* When true and reduce motion is enabled, animations will be instant
|
|
129
|
+
* @default true
|
|
130
|
+
*/
|
|
131
|
+
respectReduceMotion?: boolean;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Test ID for testing purposes
|
|
135
|
+
*/
|
|
136
|
+
testID?: string;
|
|
118
137
|
}
|
|
119
138
|
|
|
120
139
|
/**
|
|
@@ -151,10 +170,13 @@ export function PuffPop({
|
|
|
151
170
|
skeleton = true,
|
|
152
171
|
visible = true,
|
|
153
172
|
onAnimationComplete,
|
|
173
|
+
onAnimationStart,
|
|
154
174
|
style,
|
|
155
175
|
animateOnMount = true,
|
|
156
176
|
loop = false,
|
|
157
177
|
loopDelay = 0,
|
|
178
|
+
respectReduceMotion = true,
|
|
179
|
+
testID,
|
|
158
180
|
}: PuffPopProps): ReactElement {
|
|
159
181
|
// Animation values
|
|
160
182
|
const opacity = useRef(new Animated.Value(animateOnMount ? 0 : 1)).current;
|
|
@@ -168,6 +190,56 @@ export function PuffPop({
|
|
|
168
190
|
const animatedHeight = useRef(new Animated.Value(0)).current;
|
|
169
191
|
const hasAnimated = useRef(false);
|
|
170
192
|
const loopAnimationRef = useRef<Animated.CompositeAnimation | null>(null);
|
|
193
|
+
const loopTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
194
|
+
|
|
195
|
+
// Reduce motion accessibility support
|
|
196
|
+
const [isReduceMotionEnabled, setIsReduceMotionEnabled] = useState(false);
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (!respectReduceMotion) return;
|
|
200
|
+
|
|
201
|
+
const checkReduceMotion = async () => {
|
|
202
|
+
const reduceMotion = await AccessibilityInfo.isReduceMotionEnabled();
|
|
203
|
+
setIsReduceMotionEnabled(reduceMotion);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
checkReduceMotion();
|
|
207
|
+
|
|
208
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
209
|
+
'reduceMotionChanged',
|
|
210
|
+
setIsReduceMotionEnabled
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return () => {
|
|
214
|
+
subscription.remove();
|
|
215
|
+
};
|
|
216
|
+
}, [respectReduceMotion]);
|
|
217
|
+
|
|
218
|
+
// Effective duration (0 if reduce motion is enabled)
|
|
219
|
+
const effectiveDuration = respectReduceMotion && isReduceMotionEnabled ? 0 : duration;
|
|
220
|
+
|
|
221
|
+
// Memoize effect type checks to avoid repeated includes() calls
|
|
222
|
+
const effectFlags = useMemo(() => ({
|
|
223
|
+
hasScale: ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect),
|
|
224
|
+
hasRotate: ['rotate', 'rotateScale'].includes(effect),
|
|
225
|
+
hasFlip: effect === 'flip',
|
|
226
|
+
hasTranslateX: ['slideLeft', 'slideRight'].includes(effect),
|
|
227
|
+
hasTranslateY: ['slideUp', 'slideDown', 'bounce'].includes(effect),
|
|
228
|
+
hasRotateEffect: ['rotate', 'rotateScale', 'flip'].includes(effect),
|
|
229
|
+
}), [effect]);
|
|
230
|
+
|
|
231
|
+
// Memoize interpolations to avoid recreating on every render
|
|
232
|
+
const rotateInterpolation = useMemo(() =>
|
|
233
|
+
rotate.interpolate({
|
|
234
|
+
inputRange: [-360, 0, 360],
|
|
235
|
+
outputRange: ['-360deg', '0deg', '360deg'],
|
|
236
|
+
}), [rotate]);
|
|
237
|
+
|
|
238
|
+
const flipInterpolation = useMemo(() =>
|
|
239
|
+
rotate.interpolate({
|
|
240
|
+
inputRange: [-180, 0],
|
|
241
|
+
outputRange: ['-180deg', '0deg'],
|
|
242
|
+
}), [rotate]);
|
|
171
243
|
|
|
172
244
|
// Handle layout measurement for non-skeleton mode
|
|
173
245
|
const onLayout = useCallback(
|
|
@@ -183,12 +255,17 @@ export function PuffPop({
|
|
|
183
255
|
// Animate function
|
|
184
256
|
const animate = useCallback(
|
|
185
257
|
(toVisible: boolean) => {
|
|
258
|
+
// Call onAnimationStart callback
|
|
259
|
+
if (toVisible && onAnimationStart) {
|
|
260
|
+
onAnimationStart();
|
|
261
|
+
}
|
|
262
|
+
|
|
186
263
|
const easingFn = getEasing(easing);
|
|
187
264
|
// When skeleton is false, we animate height which doesn't support native driver
|
|
188
265
|
// So we must use JS driver for all animations in that case
|
|
189
266
|
const useNative = skeleton;
|
|
190
267
|
const config = {
|
|
191
|
-
duration,
|
|
268
|
+
duration: effectiveDuration,
|
|
192
269
|
easing: easingFn,
|
|
193
270
|
useNativeDriver: useNative,
|
|
194
271
|
};
|
|
@@ -204,7 +281,7 @@ export function PuffPop({
|
|
|
204
281
|
);
|
|
205
282
|
|
|
206
283
|
// Scale animation
|
|
207
|
-
if (
|
|
284
|
+
if (effectFlags.hasScale) {
|
|
208
285
|
const targetScale = toVisible ? 1 : getInitialScale(effect);
|
|
209
286
|
animations.push(
|
|
210
287
|
Animated.timing(scale, {
|
|
@@ -216,7 +293,7 @@ export function PuffPop({
|
|
|
216
293
|
}
|
|
217
294
|
|
|
218
295
|
// Rotate animation
|
|
219
|
-
if (
|
|
296
|
+
if (effectFlags.hasRotate || effectFlags.hasFlip) {
|
|
220
297
|
const targetRotate = toVisible ? 0 : getInitialRotate(effect);
|
|
221
298
|
animations.push(
|
|
222
299
|
Animated.timing(rotate, {
|
|
@@ -227,7 +304,7 @@ export function PuffPop({
|
|
|
227
304
|
}
|
|
228
305
|
|
|
229
306
|
// TranslateX animation
|
|
230
|
-
if (
|
|
307
|
+
if (effectFlags.hasTranslateX) {
|
|
231
308
|
const targetX = toVisible ? 0 : getInitialTranslateX(effect);
|
|
232
309
|
animations.push(
|
|
233
310
|
Animated.timing(translateX, {
|
|
@@ -238,7 +315,7 @@ export function PuffPop({
|
|
|
238
315
|
}
|
|
239
316
|
|
|
240
317
|
// TranslateY animation
|
|
241
|
-
if (
|
|
318
|
+
if (effectFlags.hasTranslateY) {
|
|
242
319
|
const targetY = toVisible ? 0 : getInitialTranslateY(effect);
|
|
243
320
|
animations.push(
|
|
244
321
|
Animated.timing(translateY, {
|
|
@@ -254,7 +331,7 @@ export function PuffPop({
|
|
|
254
331
|
animations.push(
|
|
255
332
|
Animated.timing(animatedHeight, {
|
|
256
333
|
toValue: targetHeight,
|
|
257
|
-
duration,
|
|
334
|
+
duration: effectiveDuration,
|
|
258
335
|
easing: easingFn,
|
|
259
336
|
useNativeDriver: false,
|
|
260
337
|
})
|
|
@@ -306,7 +383,11 @@ export function PuffPop({
|
|
|
306
383
|
if (loopCount === -1 || currentIteration < loopCount) {
|
|
307
384
|
// Add delay between loops if specified
|
|
308
385
|
if (loopDelay > 0) {
|
|
309
|
-
|
|
386
|
+
// Clear any existing timeout before setting a new one
|
|
387
|
+
if (loopTimeoutRef.current) {
|
|
388
|
+
clearTimeout(loopTimeoutRef.current);
|
|
389
|
+
}
|
|
390
|
+
loopTimeoutRef.current = setTimeout(runLoop, loopDelay);
|
|
310
391
|
} else {
|
|
311
392
|
runLoop();
|
|
312
393
|
}
|
|
@@ -335,11 +416,13 @@ export function PuffPop({
|
|
|
335
416
|
},
|
|
336
417
|
[
|
|
337
418
|
delay,
|
|
338
|
-
|
|
419
|
+
effectiveDuration,
|
|
339
420
|
easing,
|
|
340
421
|
effect,
|
|
422
|
+
effectFlags,
|
|
341
423
|
measuredHeight,
|
|
342
424
|
onAnimationComplete,
|
|
425
|
+
onAnimationStart,
|
|
343
426
|
opacity,
|
|
344
427
|
rotate,
|
|
345
428
|
scale,
|
|
@@ -367,32 +450,22 @@ export function PuffPop({
|
|
|
367
450
|
}
|
|
368
451
|
}, [visible, animate]);
|
|
369
452
|
|
|
370
|
-
// Cleanup loop animation on unmount
|
|
453
|
+
// Cleanup loop animation and timeout on unmount
|
|
371
454
|
useEffect(() => {
|
|
372
455
|
return () => {
|
|
373
456
|
if (loopAnimationRef.current) {
|
|
374
457
|
loopAnimationRef.current.stop();
|
|
375
458
|
}
|
|
459
|
+
if (loopTimeoutRef.current) {
|
|
460
|
+
clearTimeout(loopTimeoutRef.current);
|
|
461
|
+
}
|
|
376
462
|
};
|
|
377
463
|
}, []);
|
|
378
464
|
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
<View style={styles.hidden}>{children}</View>
|
|
384
|
-
</View>
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Build transform based on effect
|
|
389
|
-
const getTransform = () => {
|
|
390
|
-
const hasScale = ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect);
|
|
391
|
-
const hasRotate = ['rotate', 'rotateScale'].includes(effect);
|
|
392
|
-
const hasFlip = effect === 'flip';
|
|
393
|
-
const hasTranslateX = ['slideLeft', 'slideRight'].includes(effect);
|
|
394
|
-
const hasTranslateY = ['slideUp', 'slideDown', 'bounce'].includes(effect);
|
|
395
|
-
|
|
465
|
+
// Memoize transform array to avoid recreating on every render
|
|
466
|
+
// IMPORTANT: All hooks must be called before any conditional returns
|
|
467
|
+
const transform = useMemo(() => {
|
|
468
|
+
const { hasScale, hasRotate, hasFlip, hasTranslateX, hasTranslateY } = effectFlags;
|
|
396
469
|
const transforms = [];
|
|
397
470
|
|
|
398
471
|
if (hasScale) {
|
|
@@ -400,21 +473,11 @@ export function PuffPop({
|
|
|
400
473
|
}
|
|
401
474
|
|
|
402
475
|
if (hasRotate) {
|
|
403
|
-
transforms.push({
|
|
404
|
-
rotate: rotate.interpolate({
|
|
405
|
-
inputRange: [-360, 0, 360],
|
|
406
|
-
outputRange: ['-360deg', '0deg', '360deg'],
|
|
407
|
-
}),
|
|
408
|
-
});
|
|
476
|
+
transforms.push({ rotate: rotateInterpolation });
|
|
409
477
|
}
|
|
410
478
|
|
|
411
479
|
if (hasFlip) {
|
|
412
|
-
transforms.push({
|
|
413
|
-
rotateY: rotate.interpolate({
|
|
414
|
-
inputRange: [-180, 0],
|
|
415
|
-
outputRange: ['-180deg', '0deg'],
|
|
416
|
-
}),
|
|
417
|
-
});
|
|
480
|
+
transforms.push({ rotateY: flipInterpolation });
|
|
418
481
|
}
|
|
419
482
|
|
|
420
483
|
if (hasTranslateX) {
|
|
@@ -426,20 +489,39 @@ export function PuffPop({
|
|
|
426
489
|
}
|
|
427
490
|
|
|
428
491
|
return transforms.length > 0 ? transforms : undefined;
|
|
429
|
-
};
|
|
492
|
+
}, [effectFlags, scale, rotateInterpolation, flipInterpolation, translateX, translateY]);
|
|
430
493
|
|
|
431
|
-
|
|
494
|
+
// Memoize animated style
|
|
495
|
+
const animatedStyle = useMemo(() => ({
|
|
432
496
|
opacity,
|
|
433
|
-
transform
|
|
434
|
-
};
|
|
497
|
+
transform,
|
|
498
|
+
}), [opacity, transform]);
|
|
499
|
+
|
|
500
|
+
// Memoize container style for non-skeleton mode
|
|
501
|
+
const containerAnimatedStyle = useMemo(() => {
|
|
502
|
+
if (!skeleton && measuredHeight !== null) {
|
|
503
|
+
return {
|
|
504
|
+
height: animatedHeight,
|
|
505
|
+
overflow: effectFlags.hasRotateEffect ? 'visible' as const : 'hidden' as const
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
return {};
|
|
509
|
+
}, [skeleton, measuredHeight, animatedHeight, effectFlags.hasRotateEffect]);
|
|
435
510
|
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
511
|
+
// For non-skeleton mode, measure first (after all hooks)
|
|
512
|
+
if (!skeleton && measuredHeight === null) {
|
|
513
|
+
return (
|
|
514
|
+
<View style={styles.measureContainer} onLayout={onLayout}>
|
|
515
|
+
<View style={styles.hidden}>{children}</View>
|
|
516
|
+
</View>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
440
519
|
|
|
441
520
|
return (
|
|
442
|
-
<Animated.View
|
|
521
|
+
<Animated.View
|
|
522
|
+
style={[styles.container, style, containerAnimatedStyle]}
|
|
523
|
+
testID={testID}
|
|
524
|
+
>
|
|
443
525
|
<Animated.View style={animatedStyle}>{children}</Animated.View>
|
|
444
526
|
</Animated.View>
|
|
445
527
|
);
|