react-native-puff-pop 1.0.1 → 1.0.3

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/LICENSE CHANGED
@@ -20,3 +20,4 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
22
 
23
+
package/README.md CHANGED
@@ -133,6 +133,25 @@ function App() {
133
133
  }
134
134
  ```
135
135
 
136
+ ### Loop Animation
137
+
138
+ ```tsx
139
+ // Loop infinitely
140
+ <PuffPop effect="rotate" loop={true}>
141
+ <LoadingSpinner />
142
+ </PuffPop>
143
+
144
+ // Loop 3 times
145
+ <PuffPop effect="bounce" loop={3}>
146
+ <NotificationBadge />
147
+ </PuffPop>
148
+
149
+ // Loop with delay between iterations
150
+ <PuffPop effect="scale" loop={true} loopDelay={500}>
151
+ <PulsingDot />
152
+ </PuffPop>
153
+ ```
154
+
136
155
  ## Props
137
156
 
138
157
  | Prop | Type | Default | Description |
@@ -147,6 +166,8 @@ function App() {
147
166
  | `animateOnMount` | `boolean` | `true` | Animate when component mounts |
148
167
  | `onAnimationComplete` | `() => void` | - | Callback when animation completes |
149
168
  | `style` | `ViewStyle` | - | Custom container style |
169
+ | `loop` | `boolean \| number` | `false` | Loop animation (true=infinite, number=times) |
170
+ | `loopDelay` | `number` | `0` | Delay between loop iterations in ms |
150
171
 
151
172
  ### Animation Effects (`PuffPopEffect`)
152
173
 
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState, useCallback, } from 'react';
2
+ import { useEffect, useRef, useState, useCallback, useMemo, } from 'react';
3
3
  import { View, Animated, StyleSheet, Easing, } from 'react-native';
4
4
  /**
5
5
  * Get easing function based on type
@@ -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, }) {
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, }) {
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;
@@ -36,6 +36,26 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
36
36
  const [measuredHeight, setMeasuredHeight] = useState(null);
37
37
  const animatedHeight = useRef(new Animated.Value(0)).current;
38
38
  const hasAnimated = useRef(false);
39
+ const loopAnimationRef = useRef(null);
40
+ const loopTimeoutRef = useRef(null);
41
+ // Memoize effect type checks to avoid repeated includes() calls
42
+ const effectFlags = useMemo(() => ({
43
+ hasScale: ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect),
44
+ hasRotate: ['rotate', 'rotateScale'].includes(effect),
45
+ hasFlip: effect === 'flip',
46
+ hasTranslateX: ['slideLeft', 'slideRight'].includes(effect),
47
+ hasTranslateY: ['slideUp', 'slideDown', 'bounce'].includes(effect),
48
+ hasRotateEffect: ['rotate', 'rotateScale', 'flip'].includes(effect),
49
+ }), [effect]);
50
+ // Memoize interpolations to avoid recreating on every render
51
+ const rotateInterpolation = useMemo(() => rotate.interpolate({
52
+ inputRange: [-360, 0, 360],
53
+ outputRange: ['-360deg', '0deg', '360deg'],
54
+ }), [rotate]);
55
+ const flipInterpolation = useMemo(() => rotate.interpolate({
56
+ inputRange: [-180, 0],
57
+ outputRange: ['-180deg', '0deg'],
58
+ }), [rotate]);
39
59
  // Handle layout measurement for non-skeleton mode
40
60
  const onLayout = useCallback((event) => {
41
61
  if (!skeleton && measuredHeight === null) {
@@ -61,7 +81,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
61
81
  ...config,
62
82
  }));
63
83
  // Scale animation
64
- if (['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect)) {
84
+ if (effectFlags.hasScale) {
65
85
  const targetScale = toVisible ? 1 : getInitialScale(effect);
66
86
  animations.push(Animated.timing(scale, {
67
87
  toValue: targetScale,
@@ -70,7 +90,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
70
90
  }));
71
91
  }
72
92
  // Rotate animation
73
- if (['rotate', 'rotateScale', 'flip'].includes(effect)) {
93
+ if (effectFlags.hasRotate || effectFlags.hasFlip) {
74
94
  const targetRotate = toVisible ? 0 : getInitialRotate(effect);
75
95
  animations.push(Animated.timing(rotate, {
76
96
  toValue: targetRotate,
@@ -78,7 +98,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
78
98
  }));
79
99
  }
80
100
  // TranslateX animation
81
- if (['slideLeft', 'slideRight'].includes(effect)) {
101
+ if (effectFlags.hasTranslateX) {
82
102
  const targetX = toVisible ? 0 : getInitialTranslateX(effect);
83
103
  animations.push(Animated.timing(translateX, {
84
104
  toValue: targetX,
@@ -86,7 +106,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
86
106
  }));
87
107
  }
88
108
  // TranslateY animation
89
- if (['slideUp', 'slideDown', 'bounce'].includes(effect)) {
109
+ if (effectFlags.hasTranslateY) {
90
110
  const targetY = toVisible ? 0 : getInitialTranslateY(effect);
91
111
  animations.push(Animated.timing(translateY, {
92
112
  toValue: targetY,
@@ -105,18 +125,70 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
105
125
  }
106
126
  // Run animations with delay
107
127
  const parallelAnimation = Animated.parallel(animations);
128
+ // Reset values function for looping
129
+ const resetValues = () => {
130
+ opacity.setValue(0);
131
+ scale.setValue(getInitialScale(effect));
132
+ rotate.setValue(getInitialRotate(effect));
133
+ translateX.setValue(getInitialTranslateX(effect));
134
+ translateY.setValue(getInitialTranslateY(effect));
135
+ if (!skeleton && measuredHeight !== null) {
136
+ animatedHeight.setValue(0);
137
+ }
138
+ };
139
+ // Build the animation sequence
140
+ let animation;
108
141
  if (delay > 0) {
109
- Animated.sequence([
142
+ animation = Animated.sequence([
110
143
  Animated.delay(delay),
111
144
  parallelAnimation,
112
- ]).start(() => {
113
- if (toVisible && onAnimationComplete) {
114
- onAnimationComplete();
115
- }
116
- });
145
+ ]);
117
146
  }
118
147
  else {
119
- parallelAnimation.start(() => {
148
+ animation = parallelAnimation;
149
+ }
150
+ // Handle loop option
151
+ if (toVisible && loop) {
152
+ // Stop any existing loop animation
153
+ if (loopAnimationRef.current) {
154
+ loopAnimationRef.current.stop();
155
+ }
156
+ const loopCount = typeof loop === 'number' ? loop : -1;
157
+ let currentIteration = 0;
158
+ const runLoop = () => {
159
+ resetValues();
160
+ animation.start(({ finished }) => {
161
+ if (finished) {
162
+ currentIteration++;
163
+ if (loopCount === -1 || currentIteration < loopCount) {
164
+ // Add delay between loops if specified
165
+ if (loopDelay > 0) {
166
+ // Clear any existing timeout before setting a new one
167
+ if (loopTimeoutRef.current) {
168
+ clearTimeout(loopTimeoutRef.current);
169
+ }
170
+ loopTimeoutRef.current = setTimeout(runLoop, loopDelay);
171
+ }
172
+ else {
173
+ runLoop();
174
+ }
175
+ }
176
+ else if (onAnimationComplete) {
177
+ onAnimationComplete();
178
+ }
179
+ }
180
+ });
181
+ };
182
+ // Store reference and start
183
+ runLoop();
184
+ }
185
+ else {
186
+ // Stop any existing loop animation
187
+ if (loopAnimationRef.current) {
188
+ loopAnimationRef.current.stop();
189
+ loopAnimationRef.current = null;
190
+ }
191
+ animation.start(() => {
120
192
  if (toVisible && onAnimationComplete) {
121
193
  onAnimationComplete();
122
194
  }
@@ -127,6 +199,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
127
199
  duration,
128
200
  easing,
129
201
  effect,
202
+ effectFlags,
130
203
  measuredHeight,
131
204
  onAnimationComplete,
132
205
  opacity,
@@ -136,6 +209,8 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
136
209
  translateX,
137
210
  translateY,
138
211
  animatedHeight,
212
+ loop,
213
+ loopDelay,
139
214
  ]);
140
215
  // Handle initial mount animation
141
216
  useEffect(() => {
@@ -150,36 +225,30 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
150
225
  animate(visible);
151
226
  }
152
227
  }, [visible, animate]);
153
- // For non-skeleton mode, measure first
154
- if (!skeleton && measuredHeight === null) {
155
- return (_jsx(View, { style: styles.measureContainer, onLayout: onLayout, children: _jsx(View, { style: styles.hidden, children: children }) }));
156
- }
157
- // Build transform based on effect
158
- const getTransform = () => {
159
- const hasScale = ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect);
160
- const hasRotate = ['rotate', 'rotateScale'].includes(effect);
161
- const hasFlip = effect === 'flip';
162
- const hasTranslateX = ['slideLeft', 'slideRight'].includes(effect);
163
- const hasTranslateY = ['slideUp', 'slideDown', 'bounce'].includes(effect);
228
+ // Cleanup loop animation and timeout on unmount
229
+ useEffect(() => {
230
+ return () => {
231
+ if (loopAnimationRef.current) {
232
+ loopAnimationRef.current.stop();
233
+ }
234
+ if (loopTimeoutRef.current) {
235
+ clearTimeout(loopTimeoutRef.current);
236
+ }
237
+ };
238
+ }, []);
239
+ // Memoize transform array to avoid recreating on every render
240
+ // IMPORTANT: All hooks must be called before any conditional returns
241
+ const transform = useMemo(() => {
242
+ const { hasScale, hasRotate, hasFlip, hasTranslateX, hasTranslateY } = effectFlags;
164
243
  const transforms = [];
165
244
  if (hasScale) {
166
245
  transforms.push({ scale });
167
246
  }
168
247
  if (hasRotate) {
169
- transforms.push({
170
- rotate: rotate.interpolate({
171
- inputRange: [-360, 0, 360],
172
- outputRange: ['-360deg', '0deg', '360deg'],
173
- }),
174
- });
248
+ transforms.push({ rotate: rotateInterpolation });
175
249
  }
176
250
  if (hasFlip) {
177
- transforms.push({
178
- rotateY: rotate.interpolate({
179
- inputRange: [-180, 0],
180
- outputRange: ['-180deg', '0deg'],
181
- }),
182
- });
251
+ transforms.push({ rotateY: flipInterpolation });
183
252
  }
184
253
  if (hasTranslateX) {
185
254
  transforms.push({ translateX });
@@ -188,15 +257,26 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
188
257
  transforms.push({ translateY });
189
258
  }
190
259
  return transforms.length > 0 ? transforms : undefined;
191
- };
192
- const animatedStyle = {
260
+ }, [effectFlags, scale, rotateInterpolation, flipInterpolation, translateX, translateY]);
261
+ // Memoize animated style
262
+ const animatedStyle = useMemo(() => ({
193
263
  opacity,
194
- transform: getTransform(),
195
- };
196
- // Container style for non-skeleton mode
197
- const containerAnimatedStyle = !skeleton && measuredHeight !== null
198
- ? { height: animatedHeight, overflow: 'hidden' }
199
- : {};
264
+ transform,
265
+ }), [opacity, transform]);
266
+ // Memoize container style for non-skeleton mode
267
+ const containerAnimatedStyle = useMemo(() => {
268
+ if (!skeleton && measuredHeight !== null) {
269
+ return {
270
+ height: animatedHeight,
271
+ overflow: effectFlags.hasRotateEffect ? 'visible' : 'hidden'
272
+ };
273
+ }
274
+ return {};
275
+ }, [skeleton, measuredHeight, animatedHeight, effectFlags.hasRotateEffect]);
276
+ // For non-skeleton mode, measure first (after all hooks)
277
+ if (!skeleton && measuredHeight === null) {
278
+ return (_jsx(View, { style: styles.measureContainer, onLayout: onLayout, children: _jsx(View, { style: styles.hidden, children: children }) }));
279
+ }
200
280
  return (_jsx(Animated.View, { style: [styles.container, style, containerAnimatedStyle], children: _jsx(Animated.View, { style: animatedStyle, children: children }) }));
201
281
  }
202
282
  /**
@@ -57,9 +57,21 @@ export interface PuffPopProps {
57
57
  * @default true
58
58
  */
59
59
  animateOnMount?: boolean;
60
+ /**
61
+ * Loop the animation
62
+ * - true: loop infinitely
63
+ * - number: loop specific number of times
64
+ * @default false
65
+ */
66
+ loop?: boolean | number;
67
+ /**
68
+ * Delay between loop iterations in milliseconds
69
+ * @default 0
70
+ */
71
+ loopDelay?: number;
60
72
  }
61
73
  /**
62
74
  * PuffPop - Animate children with beautiful entrance effects
63
75
  */
64
- export declare function PuffPop({ children, effect, duration, delay, easing, skeleton, visible, onAnimationComplete, style, animateOnMount, }: PuffPopProps): ReactElement;
76
+ export declare function PuffPop({ children, effect, duration, delay, easing, skeleton, visible, onAnimationComplete, style, animateOnMount, loop, loopDelay, }: PuffPopProps): ReactElement;
65
77
  export default PuffPop;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-puff-pop",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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';
@@ -101,6 +102,20 @@ export interface PuffPopProps {
101
102
  * @default true
102
103
  */
103
104
  animateOnMount?: boolean;
105
+
106
+ /**
107
+ * Loop the animation
108
+ * - true: loop infinitely
109
+ * - number: loop specific number of times
110
+ * @default false
111
+ */
112
+ loop?: boolean | number;
113
+
114
+ /**
115
+ * Delay between loop iterations in milliseconds
116
+ * @default 0
117
+ */
118
+ loopDelay?: number;
104
119
  }
105
120
 
106
121
  /**
@@ -139,6 +154,8 @@ export function PuffPop({
139
154
  onAnimationComplete,
140
155
  style,
141
156
  animateOnMount = true,
157
+ loop = false,
158
+ loopDelay = 0,
142
159
  }: PuffPopProps): ReactElement {
143
160
  // Animation values
144
161
  const opacity = useRef(new Animated.Value(animateOnMount ? 0 : 1)).current;
@@ -151,6 +168,31 @@ export function PuffPop({
151
168
  const [measuredHeight, setMeasuredHeight] = useState<number | null>(null);
152
169
  const animatedHeight = useRef(new Animated.Value(0)).current;
153
170
  const hasAnimated = useRef(false);
171
+ const loopAnimationRef = useRef<Animated.CompositeAnimation | null>(null);
172
+ const loopTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
173
+
174
+ // Memoize effect type checks to avoid repeated includes() calls
175
+ const effectFlags = useMemo(() => ({
176
+ hasScale: ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect),
177
+ hasRotate: ['rotate', 'rotateScale'].includes(effect),
178
+ hasFlip: effect === 'flip',
179
+ hasTranslateX: ['slideLeft', 'slideRight'].includes(effect),
180
+ hasTranslateY: ['slideUp', 'slideDown', 'bounce'].includes(effect),
181
+ hasRotateEffect: ['rotate', 'rotateScale', 'flip'].includes(effect),
182
+ }), [effect]);
183
+
184
+ // Memoize interpolations to avoid recreating on every render
185
+ const rotateInterpolation = useMemo(() =>
186
+ rotate.interpolate({
187
+ inputRange: [-360, 0, 360],
188
+ outputRange: ['-360deg', '0deg', '360deg'],
189
+ }), [rotate]);
190
+
191
+ const flipInterpolation = useMemo(() =>
192
+ rotate.interpolate({
193
+ inputRange: [-180, 0],
194
+ outputRange: ['-180deg', '0deg'],
195
+ }), [rotate]);
154
196
 
155
197
  // Handle layout measurement for non-skeleton mode
156
198
  const onLayout = useCallback(
@@ -187,7 +229,7 @@ export function PuffPop({
187
229
  );
188
230
 
189
231
  // Scale animation
190
- if (['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect)) {
232
+ if (effectFlags.hasScale) {
191
233
  const targetScale = toVisible ? 1 : getInitialScale(effect);
192
234
  animations.push(
193
235
  Animated.timing(scale, {
@@ -199,7 +241,7 @@ export function PuffPop({
199
241
  }
200
242
 
201
243
  // Rotate animation
202
- if (['rotate', 'rotateScale', 'flip'].includes(effect)) {
244
+ if (effectFlags.hasRotate || effectFlags.hasFlip) {
203
245
  const targetRotate = toVisible ? 0 : getInitialRotate(effect);
204
246
  animations.push(
205
247
  Animated.timing(rotate, {
@@ -210,7 +252,7 @@ export function PuffPop({
210
252
  }
211
253
 
212
254
  // TranslateX animation
213
- if (['slideLeft', 'slideRight'].includes(effect)) {
255
+ if (effectFlags.hasTranslateX) {
214
256
  const targetX = toVisible ? 0 : getInitialTranslateX(effect);
215
257
  animations.push(
216
258
  Animated.timing(translateX, {
@@ -221,7 +263,7 @@ export function PuffPop({
221
263
  }
222
264
 
223
265
  // TranslateY animation
224
- if (['slideUp', 'slideDown', 'bounce'].includes(effect)) {
266
+ if (effectFlags.hasTranslateY) {
225
267
  const targetY = toVisible ? 0 : getInitialTranslateY(effect);
226
268
  animations.push(
227
269
  Animated.timing(translateY, {
@@ -246,18 +288,74 @@ export function PuffPop({
246
288
 
247
289
  // Run animations with delay
248
290
  const parallelAnimation = Animated.parallel(animations);
291
+
292
+ // Reset values function for looping
293
+ const resetValues = () => {
294
+ opacity.setValue(0);
295
+ scale.setValue(getInitialScale(effect));
296
+ rotate.setValue(getInitialRotate(effect));
297
+ translateX.setValue(getInitialTranslateX(effect));
298
+ translateY.setValue(getInitialTranslateY(effect));
299
+ if (!skeleton && measuredHeight !== null) {
300
+ animatedHeight.setValue(0);
301
+ }
302
+ };
303
+
304
+ // Build the animation sequence
305
+ let animation: Animated.CompositeAnimation;
249
306
 
250
307
  if (delay > 0) {
251
- Animated.sequence([
308
+ animation = Animated.sequence([
252
309
  Animated.delay(delay),
253
310
  parallelAnimation,
254
- ]).start(() => {
255
- if (toVisible && onAnimationComplete) {
256
- onAnimationComplete();
257
- }
258
- });
311
+ ]);
259
312
  } else {
260
- parallelAnimation.start(() => {
313
+ animation = parallelAnimation;
314
+ }
315
+
316
+ // Handle loop option
317
+ if (toVisible && loop) {
318
+ // Stop any existing loop animation
319
+ if (loopAnimationRef.current) {
320
+ loopAnimationRef.current.stop();
321
+ }
322
+
323
+ const loopCount = typeof loop === 'number' ? loop : -1;
324
+ let currentIteration = 0;
325
+
326
+ const runLoop = () => {
327
+ resetValues();
328
+ animation.start(({ finished }) => {
329
+ if (finished) {
330
+ currentIteration++;
331
+ if (loopCount === -1 || currentIteration < loopCount) {
332
+ // Add delay between loops if specified
333
+ if (loopDelay > 0) {
334
+ // Clear any existing timeout before setting a new one
335
+ if (loopTimeoutRef.current) {
336
+ clearTimeout(loopTimeoutRef.current);
337
+ }
338
+ loopTimeoutRef.current = setTimeout(runLoop, loopDelay);
339
+ } else {
340
+ runLoop();
341
+ }
342
+ } else if (onAnimationComplete) {
343
+ onAnimationComplete();
344
+ }
345
+ }
346
+ });
347
+ };
348
+
349
+ // Store reference and start
350
+ runLoop();
351
+ } else {
352
+ // Stop any existing loop animation
353
+ if (loopAnimationRef.current) {
354
+ loopAnimationRef.current.stop();
355
+ loopAnimationRef.current = null;
356
+ }
357
+
358
+ animation.start(() => {
261
359
  if (toVisible && onAnimationComplete) {
262
360
  onAnimationComplete();
263
361
  }
@@ -269,6 +367,7 @@ export function PuffPop({
269
367
  duration,
270
368
  easing,
271
369
  effect,
370
+ effectFlags,
272
371
  measuredHeight,
273
372
  onAnimationComplete,
274
373
  opacity,
@@ -278,6 +377,8 @@ export function PuffPop({
278
377
  translateX,
279
378
  translateY,
280
379
  animatedHeight,
380
+ loop,
381
+ loopDelay,
281
382
  ]
282
383
  );
283
384
 
@@ -296,23 +397,22 @@ export function PuffPop({
296
397
  }
297
398
  }, [visible, animate]);
298
399
 
299
- // For non-skeleton mode, measure first
300
- if (!skeleton && measuredHeight === null) {
301
- return (
302
- <View style={styles.measureContainer} onLayout={onLayout}>
303
- <View style={styles.hidden}>{children}</View>
304
- </View>
305
- );
306
- }
307
-
308
- // Build transform based on effect
309
- const getTransform = () => {
310
- const hasScale = ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect);
311
- const hasRotate = ['rotate', 'rotateScale'].includes(effect);
312
- const hasFlip = effect === 'flip';
313
- const hasTranslateX = ['slideLeft', 'slideRight'].includes(effect);
314
- const hasTranslateY = ['slideUp', 'slideDown', 'bounce'].includes(effect);
400
+ // Cleanup loop animation and timeout on unmount
401
+ useEffect(() => {
402
+ return () => {
403
+ if (loopAnimationRef.current) {
404
+ loopAnimationRef.current.stop();
405
+ }
406
+ if (loopTimeoutRef.current) {
407
+ clearTimeout(loopTimeoutRef.current);
408
+ }
409
+ };
410
+ }, []);
315
411
 
412
+ // Memoize transform array to avoid recreating on every render
413
+ // IMPORTANT: All hooks must be called before any conditional returns
414
+ const transform = useMemo(() => {
415
+ const { hasScale, hasRotate, hasFlip, hasTranslateX, hasTranslateY } = effectFlags;
316
416
  const transforms = [];
317
417
 
318
418
  if (hasScale) {
@@ -320,21 +420,11 @@ export function PuffPop({
320
420
  }
321
421
 
322
422
  if (hasRotate) {
323
- transforms.push({
324
- rotate: rotate.interpolate({
325
- inputRange: [-360, 0, 360],
326
- outputRange: ['-360deg', '0deg', '360deg'],
327
- }),
328
- });
423
+ transforms.push({ rotate: rotateInterpolation });
329
424
  }
330
425
 
331
426
  if (hasFlip) {
332
- transforms.push({
333
- rotateY: rotate.interpolate({
334
- inputRange: [-180, 0],
335
- outputRange: ['-180deg', '0deg'],
336
- }),
337
- });
427
+ transforms.push({ rotateY: flipInterpolation });
338
428
  }
339
429
 
340
430
  if (hasTranslateX) {
@@ -346,17 +436,33 @@ export function PuffPop({
346
436
  }
347
437
 
348
438
  return transforms.length > 0 ? transforms : undefined;
349
- };
439
+ }, [effectFlags, scale, rotateInterpolation, flipInterpolation, translateX, translateY]);
350
440
 
351
- const animatedStyle = {
441
+ // Memoize animated style
442
+ const animatedStyle = useMemo(() => ({
352
443
  opacity,
353
- transform: getTransform(),
354
- };
444
+ transform,
445
+ }), [opacity, transform]);
446
+
447
+ // Memoize container style for non-skeleton mode
448
+ const containerAnimatedStyle = useMemo(() => {
449
+ if (!skeleton && measuredHeight !== null) {
450
+ return {
451
+ height: animatedHeight,
452
+ overflow: effectFlags.hasRotateEffect ? 'visible' as const : 'hidden' as const
453
+ };
454
+ }
455
+ return {};
456
+ }, [skeleton, measuredHeight, animatedHeight, effectFlags.hasRotateEffect]);
355
457
 
356
- // Container style for non-skeleton mode
357
- const containerAnimatedStyle = !skeleton && measuredHeight !== null
358
- ? { height: animatedHeight, overflow: 'hidden' as const }
359
- : {};
458
+ // For non-skeleton mode, measure first (after all hooks)
459
+ if (!skeleton && measuredHeight === null) {
460
+ return (
461
+ <View style={styles.measureContainer} onLayout={onLayout}>
462
+ <View style={styles.hidden}>{children}</View>
463
+ </View>
464
+ );
465
+ }
360
466
 
361
467
  return (
362
468
  <Animated.View style={[styles.container, style, containerAnimatedStyle]}>