react-native-puff-pop 1.0.6 → 1.0.8

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/src/index.tsx CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  useState,
5
5
  useCallback,
6
6
  useMemo,
7
+ memo,
7
8
  Children,
8
9
  type ReactNode,
9
10
  type ReactElement,
@@ -33,7 +34,12 @@ export type PuffPopEffect =
33
34
  | 'bounce' // Bounce effect with overshoot
34
35
  | 'flip' // 3D flip effect
35
36
  | 'zoom' // Zoom with slight overshoot
36
- | 'rotateScale'; // Rotate + Scale combined
37
+ | 'rotateScale' // Rotate + Scale combined
38
+ | 'shake' // Shake left-right (흔들림 효과)
39
+ | 'pulse' // Pulse scale effect (맥박처럼 커졌다 작아짐)
40
+ | 'swing' // Swing like pendulum (시계추처럼 흔들림)
41
+ | 'wobble' // Wobble with tilt (기울어지며 이동)
42
+ | 'elastic'; // Elastic stretch effect (탄성 효과)
37
43
 
38
44
  /**
39
45
  * Easing function types
@@ -46,6 +52,51 @@ export type PuffPopEasing =
46
52
  | 'spring'
47
53
  | 'bounce';
48
54
 
55
+ /**
56
+ * Anchor point for scale/rotate transformations
57
+ * Determines the origin point of the transformation
58
+ */
59
+ export type PuffPopAnchorPoint =
60
+ | 'center' // Default center point
61
+ | 'top' // Top center
62
+ | 'bottom' // Bottom center
63
+ | 'left' // Left center
64
+ | 'right' // Right center
65
+ | 'topLeft' // Top left corner
66
+ | 'topRight' // Top right corner
67
+ | 'bottomLeft' // Bottom left corner
68
+ | 'bottomRight'; // Bottom right corner
69
+
70
+ /**
71
+ * Spring animation configuration
72
+ * Used when useSpring is true for physics-based animations
73
+ */
74
+ export interface PuffPopSpringConfig {
75
+ /**
76
+ * Controls the spring stiffness. Higher values = faster, snappier animation
77
+ * @default 100
78
+ */
79
+ tension?: number;
80
+
81
+ /**
82
+ * Controls the spring damping. Higher values = less oscillation
83
+ * @default 10
84
+ */
85
+ friction?: number;
86
+
87
+ /**
88
+ * Animation speed multiplier
89
+ * @default 12
90
+ */
91
+ speed?: number;
92
+
93
+ /**
94
+ * Controls the bounciness. Higher values = more bouncy
95
+ * @default 8
96
+ */
97
+ bounciness?: number;
98
+ }
99
+
49
100
  export interface PuffPopProps {
50
101
  /**
51
102
  * Children to animate
@@ -135,6 +186,114 @@ export interface PuffPopProps {
135
186
  * Test ID for testing purposes
136
187
  */
137
188
  testID?: string;
189
+
190
+ // ============ Exit Animation Settings ============
191
+
192
+ /**
193
+ * Animation effect type when hiding (exit animation)
194
+ * If not specified, uses the reverse of the enter effect
195
+ */
196
+ exitEffect?: PuffPopEffect;
197
+
198
+ /**
199
+ * Animation duration for exit animation in milliseconds
200
+ * If not specified, uses the same duration as enter animation
201
+ */
202
+ exitDuration?: number;
203
+
204
+ /**
205
+ * Easing function for exit animation
206
+ * If not specified, uses the same easing as enter animation
207
+ */
208
+ exitEasing?: PuffPopEasing;
209
+
210
+ /**
211
+ * Delay before exit animation starts in milliseconds
212
+ * @default 0
213
+ */
214
+ exitDelay?: number;
215
+
216
+ // ============ Custom Initial Values ============
217
+
218
+ /**
219
+ * Custom initial opacity value (0-1)
220
+ * Overrides the default initial opacity for the effect
221
+ */
222
+ initialOpacity?: number;
223
+
224
+ /**
225
+ * Custom initial scale value
226
+ * Overrides the default initial scale for effects like 'scale', 'zoom', 'bounce'
227
+ */
228
+ initialScale?: number;
229
+
230
+ /**
231
+ * Custom initial rotation value in degrees
232
+ * Overrides the default initial rotation for effects like 'rotate', 'rotateScale'
233
+ */
234
+ initialRotate?: number;
235
+
236
+ /**
237
+ * Custom initial translateX value in pixels
238
+ * Overrides the default initial translateX for effects like 'slideLeft', 'slideRight'
239
+ */
240
+ initialTranslateX?: number;
241
+
242
+ /**
243
+ * Custom initial translateY value in pixels
244
+ * Overrides the default initial translateY for effects like 'slideUp', 'slideDown'
245
+ */
246
+ initialTranslateY?: number;
247
+
248
+ // ============ Reverse Mode ============
249
+
250
+ /**
251
+ * If true, reverses the animation direction
252
+ * - slideUp becomes slide from top
253
+ * - slideLeft becomes slide from left
254
+ * - rotate spins clockwise instead of counter-clockwise
255
+ * @default false
256
+ */
257
+ reverse?: boolean;
258
+
259
+ // ============ Animation Intensity ============
260
+
261
+ /**
262
+ * Animation intensity multiplier (0-1)
263
+ * Controls how far/much elements move during animation
264
+ * - 1 = full animation (default)
265
+ * - 0.5 = half the movement distance
266
+ * - 0 = no movement (instant appear)
267
+ * @default 1
268
+ */
269
+ intensity?: number;
270
+
271
+ // ============ Anchor Point ============
272
+
273
+ /**
274
+ * Anchor point for scale/rotate transformations
275
+ * Determines the origin point of the transformation
276
+ * - 'center' = default center point
277
+ * - 'top', 'bottom', 'left', 'right' = edge centers
278
+ * - 'topLeft', 'topRight', 'bottomLeft', 'bottomRight' = corners
279
+ * @default 'center'
280
+ */
281
+ anchorPoint?: PuffPopAnchorPoint;
282
+
283
+ // ============ Spring Animation ============
284
+
285
+ /**
286
+ * Use spring physics-based animation instead of timing
287
+ * Provides more natural, bouncy animations
288
+ * @default false
289
+ */
290
+ useSpring?: boolean;
291
+
292
+ /**
293
+ * Spring animation configuration
294
+ * Only used when useSpring is true
295
+ */
296
+ springConfig?: PuffPopSpringConfig;
138
297
  }
139
298
 
140
299
  /**
@@ -159,10 +318,133 @@ function getEasing(type: PuffPopEasing): (value: number) => number {
159
318
  }
160
319
  }
161
320
 
321
+ /**
322
+ * Get effect flags for any effect type
323
+ * Returns flags indicating which transforms are needed for the effect
324
+ */
325
+ function getEffectFlags(eff: PuffPopEffect) {
326
+ return {
327
+ hasScale: ['scale', 'bounce', 'zoom', 'rotateScale', 'flip', 'pulse', 'elastic'].includes(eff),
328
+ hasRotate: ['rotate', 'rotateScale', 'swing', 'wobble'].includes(eff),
329
+ hasFlip: eff === 'flip',
330
+ hasTranslateX: ['slideLeft', 'slideRight', 'shake', 'wobble'].includes(eff),
331
+ hasTranslateY: ['slideUp', 'slideDown', 'bounce'].includes(eff),
332
+ hasRotateEffect: ['rotate', 'rotateScale', 'flip', 'swing', 'wobble'].includes(eff),
333
+ // Special effects that need sequence animation
334
+ isShake: eff === 'shake',
335
+ isPulse: eff === 'pulse',
336
+ isSwing: eff === 'swing',
337
+ isWobble: eff === 'wobble',
338
+ isElastic: eff === 'elastic',
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Get anchor point offset multipliers
344
+ * Returns { x: -1 to 1, y: -1 to 1 } where 0 is center
345
+ */
346
+ function getAnchorPointOffset(anchorPoint: PuffPopAnchorPoint): { x: number; y: number } {
347
+ switch (anchorPoint) {
348
+ case 'top':
349
+ return { x: 0, y: -0.5 };
350
+ case 'bottom':
351
+ return { x: 0, y: 0.5 };
352
+ case 'left':
353
+ return { x: -0.5, y: 0 };
354
+ case 'right':
355
+ return { x: 0.5, y: 0 };
356
+ case 'topLeft':
357
+ return { x: -0.5, y: -0.5 };
358
+ case 'topRight':
359
+ return { x: 0.5, y: -0.5 };
360
+ case 'bottomLeft':
361
+ return { x: -0.5, y: 0.5 };
362
+ case 'bottomRight':
363
+ return { x: 0.5, y: 0.5 };
364
+ case 'center':
365
+ default:
366
+ return { x: 0, y: 0 };
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Props comparison function for PuffPop memoization
372
+ * Performs shallow comparison of props to prevent unnecessary re-renders
373
+ */
374
+ function arePuffPopPropsEqual(
375
+ prevProps: PuffPopProps,
376
+ nextProps: PuffPopProps
377
+ ): boolean {
378
+ // Compare primitive props
379
+ if (
380
+ prevProps.effect !== nextProps.effect ||
381
+ prevProps.duration !== nextProps.duration ||
382
+ prevProps.delay !== nextProps.delay ||
383
+ prevProps.easing !== nextProps.easing ||
384
+ prevProps.skeleton !== nextProps.skeleton ||
385
+ prevProps.visible !== nextProps.visible ||
386
+ prevProps.animateOnMount !== nextProps.animateOnMount ||
387
+ prevProps.loop !== nextProps.loop ||
388
+ prevProps.loopDelay !== nextProps.loopDelay ||
389
+ prevProps.respectReduceMotion !== nextProps.respectReduceMotion ||
390
+ prevProps.testID !== nextProps.testID ||
391
+ prevProps.reverse !== nextProps.reverse ||
392
+ prevProps.intensity !== nextProps.intensity ||
393
+ prevProps.anchorPoint !== nextProps.anchorPoint ||
394
+ prevProps.useSpring !== nextProps.useSpring ||
395
+ prevProps.exitEffect !== nextProps.exitEffect ||
396
+ prevProps.exitDuration !== nextProps.exitDuration ||
397
+ prevProps.exitEasing !== nextProps.exitEasing ||
398
+ prevProps.exitDelay !== nextProps.exitDelay ||
399
+ prevProps.initialOpacity !== nextProps.initialOpacity ||
400
+ prevProps.initialScale !== nextProps.initialScale ||
401
+ prevProps.initialRotate !== nextProps.initialRotate ||
402
+ prevProps.initialTranslateX !== nextProps.initialTranslateX ||
403
+ prevProps.initialTranslateY !== nextProps.initialTranslateY
404
+ ) {
405
+ return false;
406
+ }
407
+
408
+ // Compare springConfig object (shallow)
409
+ if (prevProps.springConfig !== nextProps.springConfig) {
410
+ if (
411
+ !prevProps.springConfig ||
412
+ !nextProps.springConfig ||
413
+ prevProps.springConfig.tension !== nextProps.springConfig.tension ||
414
+ prevProps.springConfig.friction !== nextProps.springConfig.friction ||
415
+ prevProps.springConfig.speed !== nextProps.springConfig.speed ||
416
+ prevProps.springConfig.bounciness !== nextProps.springConfig.bounciness
417
+ ) {
418
+ return false;
419
+ }
420
+ }
421
+
422
+ // Compare callbacks (reference equality - if changed, should re-render)
423
+ if (
424
+ prevProps.onAnimationStart !== nextProps.onAnimationStart ||
425
+ prevProps.onAnimationComplete !== nextProps.onAnimationComplete
426
+ ) {
427
+ return false;
428
+ }
429
+
430
+ // Style comparison - if style prop changes, re-render
431
+ // Note: Deep comparison of style is expensive, so we use reference equality
432
+ if (prevProps.style !== nextProps.style) {
433
+ return false;
434
+ }
435
+
436
+ // Children comparison - if children change, re-render
437
+ if (prevProps.children !== nextProps.children) {
438
+ return false;
439
+ }
440
+
441
+ return true;
442
+ }
443
+
162
444
  /**
163
445
  * PuffPop - Animate children with beautiful entrance effects
164
446
  */
165
- export function PuffPop({
447
+ function PuffPopComponent({
166
448
  children,
167
449
  effect = 'scale',
168
450
  duration = 400,
@@ -178,13 +460,57 @@ export function PuffPop({
178
460
  loopDelay = 0,
179
461
  respectReduceMotion = true,
180
462
  testID,
463
+ // Exit animation settings
464
+ exitEffect,
465
+ exitDuration,
466
+ exitEasing,
467
+ exitDelay = 0,
468
+ // Custom initial values
469
+ initialOpacity,
470
+ initialScale,
471
+ initialRotate,
472
+ initialTranslateX,
473
+ initialTranslateY,
474
+ // Reverse mode
475
+ reverse = false,
476
+ // Animation intensity
477
+ intensity = 1,
478
+ // Anchor point
479
+ anchorPoint = 'center',
480
+ // Spring animation
481
+ useSpring = false,
482
+ springConfig,
181
483
  }: PuffPopProps): ReactElement {
484
+ // Clamp intensity between 0 and 1
485
+ const clampedIntensity = Math.max(0, Math.min(1, intensity));
486
+
487
+ // Helper to get initial value with custom override, reverse, and intensity support
488
+ const getInitialOpacityValue = () => initialOpacity ?? 0;
489
+ const getInitialScaleValue = (eff: PuffPopEffect) => {
490
+ if (initialScale !== undefined) return initialScale;
491
+ const baseScale = getInitialScale(eff, reverse);
492
+ // Scale goes from baseScale to 1, so we interpolate: 1 - (1 - baseScale) * intensity
493
+ return 1 - (1 - baseScale) * clampedIntensity;
494
+ };
495
+ const getInitialRotateValue = (eff: PuffPopEffect) => {
496
+ if (initialRotate !== undefined) return initialRotate;
497
+ return getInitialRotate(eff, reverse) * clampedIntensity;
498
+ };
499
+ const getInitialTranslateXValue = (eff: PuffPopEffect) => {
500
+ if (initialTranslateX !== undefined) return initialTranslateX;
501
+ return getInitialTranslateX(eff, reverse) * clampedIntensity;
502
+ };
503
+ const getInitialTranslateYValue = (eff: PuffPopEffect) => {
504
+ if (initialTranslateY !== undefined) return initialTranslateY;
505
+ return getInitialTranslateY(eff, reverse) * clampedIntensity;
506
+ };
507
+
182
508
  // Animation values
183
- const opacity = useRef(new Animated.Value(animateOnMount ? 0 : 1)).current;
184
- const scale = useRef(new Animated.Value(animateOnMount ? getInitialScale(effect) : 1)).current;
185
- const rotate = useRef(new Animated.Value(animateOnMount ? getInitialRotate(effect) : 0)).current;
186
- const translateX = useRef(new Animated.Value(animateOnMount ? getInitialTranslateX(effect) : 0)).current;
187
- const translateY = useRef(new Animated.Value(animateOnMount ? getInitialTranslateY(effect) : 0)).current;
509
+ const opacity = useRef(new Animated.Value(animateOnMount ? getInitialOpacityValue() : 1)).current;
510
+ const scale = useRef(new Animated.Value(animateOnMount ? getInitialScaleValue(effect) : 1)).current;
511
+ const rotate = useRef(new Animated.Value(animateOnMount ? getInitialRotateValue(effect) : 0)).current;
512
+ const translateX = useRef(new Animated.Value(animateOnMount ? getInitialTranslateXValue(effect) : 0)).current;
513
+ const translateY = useRef(new Animated.Value(animateOnMount ? getInitialTranslateYValue(effect) : 0)).current;
188
514
 
189
515
  // For non-skeleton mode
190
516
  const [measuredHeight, setMeasuredHeight] = useState<number | null>(null);
@@ -218,16 +544,15 @@ export function PuffPop({
218
544
 
219
545
  // Effective duration (0 if reduce motion is enabled)
220
546
  const effectiveDuration = respectReduceMotion && isReduceMotionEnabled ? 0 : duration;
547
+ const effectiveExitDuration = respectReduceMotion && isReduceMotionEnabled ? 0 : (exitDuration ?? duration);
221
548
 
222
549
  // Memoize effect type checks to avoid repeated includes() calls
223
- const effectFlags = useMemo(() => ({
224
- hasScale: ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect),
225
- hasRotate: ['rotate', 'rotateScale'].includes(effect),
226
- hasFlip: effect === 'flip',
227
- hasTranslateX: ['slideLeft', 'slideRight'].includes(effect),
228
- hasTranslateY: ['slideUp', 'slideDown', 'bounce'].includes(effect),
229
- hasRotateEffect: ['rotate', 'rotateScale', 'flip'].includes(effect),
230
- }), [effect]);
550
+ const effectFlags = useMemo(() => getEffectFlags(effect), [effect]);
551
+
552
+ // Exit effect flags (use exitEffect if specified, otherwise same as enter effect)
553
+ const exitEffectFlags = useMemo(() =>
554
+ exitEffect ? getEffectFlags(exitEffect) : effectFlags
555
+ , [exitEffect, effectFlags]);
231
556
 
232
557
  // Memoize interpolations to avoid recreating on every render
233
558
  const rotateInterpolation = useMemo(() =>
@@ -261,78 +586,116 @@ export function PuffPop({
261
586
  onAnimationStart();
262
587
  }
263
588
 
264
- const easingFn = getEasing(easing);
589
+ // Determine which effect settings to use based on direction
590
+ const currentEffect = toVisible ? effect : (exitEffect ?? effect);
591
+ const currentDuration = toVisible ? effectiveDuration : effectiveExitDuration;
592
+ const currentEasing = toVisible ? easing : (exitEasing ?? easing);
593
+ const currentDelay = toVisible ? delay : exitDelay;
594
+ const currentFlags = toVisible ? effectFlags : exitEffectFlags;
595
+
596
+ const easingFn = getEasing(currentEasing);
265
597
  // When skeleton is false, we animate height which doesn't support native driver
266
598
  // So we must use JS driver for all animations in that case
267
599
  const useNative = skeleton;
268
- const config = {
269
- duration: effectiveDuration,
600
+
601
+ // Spring configuration
602
+ const springConf = {
603
+ tension: springConfig?.tension ?? 100,
604
+ friction: springConfig?.friction ?? 10,
605
+ speed: springConfig?.speed,
606
+ bounciness: springConfig?.bounciness,
607
+ useNativeDriver: useNative,
608
+ };
609
+
610
+ const timingConfig = {
611
+ duration: currentDuration,
270
612
  easing: easingFn,
271
613
  useNativeDriver: useNative,
272
614
  };
273
615
 
616
+ // Helper to create animation (spring or timing)
617
+ const createAnimation = (
618
+ value: Animated.Value,
619
+ toValue: number,
620
+ customEasing?: (t: number) => number
621
+ ): Animated.CompositeAnimation => {
622
+ if (useSpring) {
623
+ return Animated.spring(value, {
624
+ toValue,
625
+ ...springConf,
626
+ });
627
+ }
628
+ return Animated.timing(value, {
629
+ toValue,
630
+ ...timingConfig,
631
+ ...(customEasing ? { easing: customEasing } : {}),
632
+ });
633
+ };
634
+
274
635
  const animations: Animated.CompositeAnimation[] = [];
275
636
 
276
- // Opacity animation
637
+ // Opacity animation (always use timing for opacity for smoother fade)
277
638
  animations.push(
278
639
  Animated.timing(opacity, {
279
640
  toValue: toVisible ? 1 : 0,
280
- ...config,
641
+ ...timingConfig,
281
642
  })
282
643
  );
283
644
 
284
645
  // Scale animation
285
- if (effectFlags.hasScale) {
286
- const targetScale = toVisible ? 1 : getInitialScale(effect);
287
- animations.push(
288
- Animated.timing(scale, {
289
- toValue: targetScale,
290
- ...config,
291
- easing: effect === 'bounce' ? Easing.bounce : easingFn,
292
- })
293
- );
646
+ if (currentFlags.hasScale) {
647
+ const targetScale = toVisible ? 1 : getInitialScaleValue(currentEffect);
648
+ // Special easing for different effects
649
+ let scaleEasing = easingFn;
650
+ if (currentEffect === 'bounce') {
651
+ scaleEasing = Easing.bounce;
652
+ } else if (currentEffect === 'elastic') {
653
+ scaleEasing = Easing.elastic(1.5);
654
+ } else if (currentEffect === 'pulse') {
655
+ scaleEasing = Easing.out(Easing.back(3));
656
+ }
657
+ animations.push(createAnimation(scale, targetScale, scaleEasing));
294
658
  }
295
659
 
296
660
  // Rotate animation
297
- if (effectFlags.hasRotate || effectFlags.hasFlip) {
298
- const targetRotate = toVisible ? 0 : getInitialRotate(effect);
299
- animations.push(
300
- Animated.timing(rotate, {
301
- toValue: targetRotate,
302
- ...config,
303
- })
304
- );
661
+ if (currentFlags.hasRotate || currentFlags.hasFlip) {
662
+ const targetRotate = toVisible ? 0 : getInitialRotateValue(currentEffect);
663
+ // Special easing for swing and wobble
664
+ let rotateEasing = easingFn;
665
+ if (currentEffect === 'swing') {
666
+ rotateEasing = Easing.elastic(1.2);
667
+ } else if (currentEffect === 'wobble') {
668
+ rotateEasing = Easing.elastic(1.5);
669
+ }
670
+ animations.push(createAnimation(rotate, targetRotate, rotateEasing));
305
671
  }
306
672
 
307
673
  // TranslateX animation
308
- if (effectFlags.hasTranslateX) {
309
- const targetX = toVisible ? 0 : getInitialTranslateX(effect);
310
- animations.push(
311
- Animated.timing(translateX, {
312
- toValue: targetX,
313
- ...config,
314
- })
315
- );
674
+ if (currentFlags.hasTranslateX) {
675
+ const targetX = toVisible ? 0 : getInitialTranslateXValue(currentEffect);
676
+ // Special easing for shake and wobble
677
+ let translateXEasing = easingFn;
678
+ if (currentEffect === 'shake') {
679
+ translateXEasing = Easing.elastic(3);
680
+ } else if (currentEffect === 'wobble') {
681
+ translateXEasing = Easing.elastic(1.5);
682
+ }
683
+ animations.push(createAnimation(translateX, targetX, translateXEasing));
316
684
  }
317
685
 
318
686
  // TranslateY animation
319
- if (effectFlags.hasTranslateY) {
320
- const targetY = toVisible ? 0 : getInitialTranslateY(effect);
321
- animations.push(
322
- Animated.timing(translateY, {
323
- toValue: targetY,
324
- ...config,
325
- })
326
- );
687
+ if (currentFlags.hasTranslateY) {
688
+ const targetY = toVisible ? 0 : getInitialTranslateYValue(currentEffect);
689
+ animations.push(createAnimation(translateY, targetY));
327
690
  }
328
691
 
329
- // Height animation for non-skeleton mode
692
+ // Height animation for non-skeleton mode (always use timing)
330
693
  if (!skeleton && measuredHeight !== null) {
331
694
  const targetHeight = toVisible ? measuredHeight : 0;
332
695
  animations.push(
333
696
  Animated.timing(animatedHeight, {
334
697
  toValue: targetHeight,
335
- duration: effectiveDuration,
698
+ duration: currentDuration,
336
699
  easing: easingFn,
337
700
  useNativeDriver: false,
338
701
  })
@@ -344,11 +707,11 @@ export function PuffPop({
344
707
 
345
708
  // Reset values function for looping
346
709
  const resetValues = () => {
347
- opacity.setValue(0);
348
- scale.setValue(getInitialScale(effect));
349
- rotate.setValue(getInitialRotate(effect));
350
- translateX.setValue(getInitialTranslateX(effect));
351
- translateY.setValue(getInitialTranslateY(effect));
710
+ opacity.setValue(getInitialOpacityValue());
711
+ scale.setValue(getInitialScaleValue(effect));
712
+ rotate.setValue(getInitialRotateValue(effect));
713
+ translateX.setValue(getInitialTranslateXValue(effect));
714
+ translateY.setValue(getInitialTranslateYValue(effect));
352
715
  if (!skeleton && measuredHeight !== null) {
353
716
  animatedHeight.setValue(0);
354
717
  }
@@ -357,9 +720,9 @@ export function PuffPop({
357
720
  // Build the animation sequence
358
721
  let animation: Animated.CompositeAnimation;
359
722
 
360
- if (delay > 0) {
723
+ if (currentDelay > 0) {
361
724
  animation = Animated.sequence([
362
- Animated.delay(delay),
725
+ Animated.delay(currentDelay),
363
726
  parallelAnimation,
364
727
  ]);
365
728
  } else {
@@ -430,9 +793,14 @@ export function PuffPop({
430
793
  [
431
794
  delay,
432
795
  effectiveDuration,
796
+ effectiveExitDuration,
433
797
  easing,
434
798
  effect,
435
799
  effectFlags,
800
+ exitEffect,
801
+ exitEffectFlags,
802
+ exitEasing,
803
+ exitDelay,
436
804
  measuredHeight,
437
805
  onAnimationComplete,
438
806
  onAnimationStart,
@@ -445,6 +813,8 @@ export function PuffPop({
445
813
  animatedHeight,
446
814
  loop,
447
815
  loopDelay,
816
+ useSpring,
817
+ springConfig,
448
818
  ]
449
819
  );
450
820
 
@@ -475,12 +845,36 @@ export function PuffPop({
475
845
  };
476
846
  }, []);
477
847
 
848
+ // Calculate anchor point offset (using 100px as base size for skeleton mode)
849
+ const anchorOffset = useMemo(() => {
850
+ const offset = getAnchorPointOffset(anchorPoint);
851
+ // Use measured height if available, otherwise use 100px as base
852
+ const baseSize = measuredHeight ?? 100;
853
+ return {
854
+ x: offset.x * baseSize,
855
+ y: offset.y * baseSize,
856
+ };
857
+ }, [anchorPoint, measuredHeight]);
858
+
478
859
  // Memoize transform array to avoid recreating on every render
479
860
  // IMPORTANT: All hooks must be called before any conditional returns
480
861
  const transform = useMemo(() => {
481
862
  const { hasScale, hasRotate, hasFlip, hasTranslateX, hasTranslateY } = effectFlags;
482
- const transforms = [];
863
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
864
+ const transforms: any[] = [];
865
+ const needsAnchorOffset = anchorPoint !== 'center' && (hasScale || hasRotate || hasFlip);
866
+
867
+ // Step 1: Move to anchor point (negative offset)
868
+ if (needsAnchorOffset) {
869
+ if (anchorOffset.x !== 0) {
870
+ transforms.push({ translateX: -anchorOffset.x });
871
+ }
872
+ if (anchorOffset.y !== 0) {
873
+ transforms.push({ translateY: -anchorOffset.y });
874
+ }
875
+ }
483
876
 
877
+ // Step 2: Apply scale/rotate transforms
484
878
  if (hasScale) {
485
879
  transforms.push({ scale });
486
880
  }
@@ -493,6 +887,17 @@ export function PuffPop({
493
887
  transforms.push({ rotateY: flipInterpolation });
494
888
  }
495
889
 
890
+ // Step 3: Move back from anchor point (positive offset)
891
+ if (needsAnchorOffset) {
892
+ if (anchorOffset.x !== 0) {
893
+ transforms.push({ translateX: anchorOffset.x });
894
+ }
895
+ if (anchorOffset.y !== 0) {
896
+ transforms.push({ translateY: anchorOffset.y });
897
+ }
898
+ }
899
+
900
+ // Step 4: Apply other translate transforms
496
901
  if (hasTranslateX) {
497
902
  transforms.push({ translateX });
498
903
  }
@@ -502,7 +907,7 @@ export function PuffPop({
502
907
  }
503
908
 
504
909
  return transforms.length > 0 ? transforms : undefined;
505
- }, [effectFlags, scale, rotateInterpolation, flipInterpolation, translateX, translateY]);
910
+ }, [effectFlags, scale, rotateInterpolation, flipInterpolation, translateX, translateY, anchorPoint, anchorOffset]);
506
911
 
507
912
  // Memoize animated style
508
913
  const animatedStyle = useMemo(() => ({
@@ -540,13 +945,18 @@ export function PuffPop({
540
945
  );
541
946
  }
542
947
 
948
+ // Memoize PuffPop to prevent unnecessary re-renders
949
+ export const PuffPop = memo(PuffPopComponent, arePuffPopPropsEqual);
950
+
543
951
  /**
544
952
  * Get initial scale value based on effect
545
953
  */
546
- function getInitialScale(effect: PuffPopEffect): number {
954
+ function getInitialScale(effect: PuffPopEffect, _reverse = false): number {
955
+ // Scale doesn't change with reverse (parameter kept for consistent API)
547
956
  switch (effect) {
548
957
  case 'scale':
549
958
  case 'rotateScale':
959
+ case 'elastic':
550
960
  return 0;
551
961
  case 'bounce':
552
962
  return 0.3;
@@ -554,6 +964,8 @@ function getInitialScale(effect: PuffPopEffect): number {
554
964
  return 0.5;
555
965
  case 'flip':
556
966
  return 0.8;
967
+ case 'pulse':
968
+ return 0.6;
557
969
  default:
558
970
  return 1;
559
971
  }
@@ -562,14 +974,19 @@ function getInitialScale(effect: PuffPopEffect): number {
562
974
  /**
563
975
  * Get initial rotate value based on effect
564
976
  */
565
- function getInitialRotate(effect: PuffPopEffect): number {
977
+ function getInitialRotate(effect: PuffPopEffect, reverse = false): number {
978
+ const multiplier = reverse ? -1 : 1;
566
979
  switch (effect) {
567
980
  case 'rotate':
568
- return -360;
981
+ return -360 * multiplier;
569
982
  case 'rotateScale':
570
- return -180;
983
+ return -180 * multiplier;
571
984
  case 'flip':
572
- return -180;
985
+ return -180 * multiplier;
986
+ case 'swing':
987
+ return -15 * multiplier;
988
+ case 'wobble':
989
+ return -5 * multiplier;
573
990
  default:
574
991
  return 0;
575
992
  }
@@ -578,12 +995,17 @@ function getInitialRotate(effect: PuffPopEffect): number {
578
995
  /**
579
996
  * Get initial translateX value based on effect
580
997
  */
581
- function getInitialTranslateX(effect: PuffPopEffect): number {
998
+ function getInitialTranslateX(effect: PuffPopEffect, reverse = false): number {
999
+ const multiplier = reverse ? -1 : 1;
582
1000
  switch (effect) {
583
1001
  case 'slideLeft':
584
- return 100;
1002
+ return 100 * multiplier;
585
1003
  case 'slideRight':
586
- return -100;
1004
+ return -100 * multiplier;
1005
+ case 'shake':
1006
+ return -10 * multiplier;
1007
+ case 'wobble':
1008
+ return -25 * multiplier;
587
1009
  default:
588
1010
  return 0;
589
1011
  }
@@ -592,14 +1014,15 @@ function getInitialTranslateX(effect: PuffPopEffect): number {
592
1014
  /**
593
1015
  * Get initial translateY value based on effect
594
1016
  */
595
- function getInitialTranslateY(effect: PuffPopEffect): number {
1017
+ function getInitialTranslateY(effect: PuffPopEffect, reverse = false): number {
1018
+ const multiplier = reverse ? -1 : 1;
596
1019
  switch (effect) {
597
1020
  case 'slideUp':
598
- return 50;
1021
+ return 50 * multiplier;
599
1022
  case 'slideDown':
600
- return -50;
1023
+ return -50 * multiplier;
601
1024
  case 'bounce':
602
- return 30;
1025
+ return 30 * multiplier;
603
1026
  default:
604
1027
  return 0;
605
1028
  }
@@ -721,6 +1144,191 @@ export interface PuffPopGroupProps {
721
1144
  * Gap between children (uses flexbox gap)
722
1145
  */
723
1146
  gap?: number;
1147
+
1148
+ // ============ Custom Initial Values ============
1149
+
1150
+ /**
1151
+ * Custom initial opacity value (0-1) for all children
1152
+ */
1153
+ initialOpacity?: number;
1154
+
1155
+ /**
1156
+ * Custom initial scale value for all children
1157
+ */
1158
+ initialScale?: number;
1159
+
1160
+ /**
1161
+ * Custom initial rotation value in degrees for all children
1162
+ */
1163
+ initialRotate?: number;
1164
+
1165
+ /**
1166
+ * Custom initial translateX value in pixels for all children
1167
+ */
1168
+ initialTranslateX?: number;
1169
+
1170
+ /**
1171
+ * Custom initial translateY value in pixels for all children
1172
+ */
1173
+ initialTranslateY?: number;
1174
+
1175
+ // ============ Reverse Mode ============
1176
+
1177
+ /**
1178
+ * If true, reverses the animation direction for all children
1179
+ * @default false
1180
+ */
1181
+ reverse?: boolean;
1182
+
1183
+ // ============ Animation Intensity ============
1184
+
1185
+ /**
1186
+ * Animation intensity multiplier (0-1) for all children
1187
+ * Controls how far/much elements move during animation
1188
+ * @default 1
1189
+ */
1190
+ intensity?: number;
1191
+
1192
+ // ============ Anchor Point ============
1193
+
1194
+ /**
1195
+ * Anchor point for scale/rotate transformations for all children
1196
+ * @default 'center'
1197
+ */
1198
+ anchorPoint?: PuffPopAnchorPoint;
1199
+
1200
+ // ============ Spring Animation ============
1201
+
1202
+ /**
1203
+ * Use spring physics-based animation for all children
1204
+ * @default false
1205
+ */
1206
+ useSpring?: boolean;
1207
+
1208
+ /**
1209
+ * Spring animation configuration for all children
1210
+ */
1211
+ springConfig?: PuffPopSpringConfig;
1212
+
1213
+ // ============ Exit Animation Settings ============
1214
+
1215
+ /**
1216
+ * Animation effect type when hiding (exit animation) for all children
1217
+ * If not specified, uses the reverse of the enter effect
1218
+ */
1219
+ exitEffect?: PuffPopEffect;
1220
+
1221
+ /**
1222
+ * Animation duration for exit animation in milliseconds for all children
1223
+ * If not specified, uses the same duration as enter animation
1224
+ */
1225
+ exitDuration?: number;
1226
+
1227
+ /**
1228
+ * Easing function for exit animation for all children
1229
+ * If not specified, uses the same easing as enter animation
1230
+ */
1231
+ exitEasing?: PuffPopEasing;
1232
+
1233
+ /**
1234
+ * Delay before exit animation starts in milliseconds
1235
+ * @default 0
1236
+ */
1237
+ exitDelay?: number;
1238
+
1239
+ /**
1240
+ * Delay between each child's exit animation start in milliseconds
1241
+ * If not specified, all children exit simultaneously
1242
+ * @default 0
1243
+ */
1244
+ exitStaggerDelay?: number;
1245
+
1246
+ /**
1247
+ * Direction of exit stagger animation
1248
+ * - 'forward': First to last child
1249
+ * - 'reverse': Last to first child (most natural for exit)
1250
+ * - 'center': From center outward
1251
+ * - 'edges': From edges toward center
1252
+ * @default 'reverse'
1253
+ */
1254
+ exitStaggerDirection?: 'forward' | 'reverse' | 'center' | 'edges';
1255
+ }
1256
+
1257
+ /**
1258
+ * Props comparison function for PuffPopGroup memoization
1259
+ * Performs shallow comparison of props to prevent unnecessary re-renders
1260
+ */
1261
+ function arePuffPopGroupPropsEqual(
1262
+ prevProps: PuffPopGroupProps,
1263
+ nextProps: PuffPopGroupProps
1264
+ ): boolean {
1265
+ // Compare primitive props
1266
+ if (
1267
+ prevProps.effect !== nextProps.effect ||
1268
+ prevProps.duration !== nextProps.duration ||
1269
+ prevProps.staggerDelay !== nextProps.staggerDelay ||
1270
+ prevProps.initialDelay !== nextProps.initialDelay ||
1271
+ prevProps.easing !== nextProps.easing ||
1272
+ prevProps.skeleton !== nextProps.skeleton ||
1273
+ prevProps.visible !== nextProps.visible ||
1274
+ prevProps.animateOnMount !== nextProps.animateOnMount ||
1275
+ prevProps.respectReduceMotion !== nextProps.respectReduceMotion ||
1276
+ prevProps.testID !== nextProps.testID ||
1277
+ prevProps.staggerDirection !== nextProps.staggerDirection ||
1278
+ prevProps.horizontal !== nextProps.horizontal ||
1279
+ prevProps.gap !== nextProps.gap ||
1280
+ prevProps.reverse !== nextProps.reverse ||
1281
+ prevProps.intensity !== nextProps.intensity ||
1282
+ prevProps.anchorPoint !== nextProps.anchorPoint ||
1283
+ prevProps.useSpring !== nextProps.useSpring ||
1284
+ prevProps.exitEffect !== nextProps.exitEffect ||
1285
+ prevProps.exitDuration !== nextProps.exitDuration ||
1286
+ prevProps.exitEasing !== nextProps.exitEasing ||
1287
+ prevProps.exitDelay !== nextProps.exitDelay ||
1288
+ prevProps.exitStaggerDelay !== nextProps.exitStaggerDelay ||
1289
+ prevProps.exitStaggerDirection !== nextProps.exitStaggerDirection ||
1290
+ prevProps.initialOpacity !== nextProps.initialOpacity ||
1291
+ prevProps.initialScale !== nextProps.initialScale ||
1292
+ prevProps.initialRotate !== nextProps.initialRotate ||
1293
+ prevProps.initialTranslateX !== nextProps.initialTranslateX ||
1294
+ prevProps.initialTranslateY !== nextProps.initialTranslateY
1295
+ ) {
1296
+ return false;
1297
+ }
1298
+
1299
+ // Compare springConfig object (shallow)
1300
+ if (prevProps.springConfig !== nextProps.springConfig) {
1301
+ if (
1302
+ !prevProps.springConfig ||
1303
+ !nextProps.springConfig ||
1304
+ prevProps.springConfig.tension !== nextProps.springConfig.tension ||
1305
+ prevProps.springConfig.friction !== nextProps.springConfig.friction ||
1306
+ prevProps.springConfig.speed !== nextProps.springConfig.speed ||
1307
+ prevProps.springConfig.bounciness !== nextProps.springConfig.bounciness
1308
+ ) {
1309
+ return false;
1310
+ }
1311
+ }
1312
+
1313
+ // Compare callbacks
1314
+ if (
1315
+ prevProps.onAnimationStart !== nextProps.onAnimationStart ||
1316
+ prevProps.onAnimationComplete !== nextProps.onAnimationComplete
1317
+ ) {
1318
+ return false;
1319
+ }
1320
+
1321
+ // Style comparison
1322
+ if (prevProps.style !== nextProps.style) {
1323
+ return false;
1324
+ }
1325
+
1326
+ // Children comparison - if children change, re-render
1327
+ if (prevProps.children !== nextProps.children) {
1328
+ return false;
1329
+ }
1330
+
1331
+ return true;
724
1332
  }
725
1333
 
726
1334
  /**
@@ -735,7 +1343,7 @@ export interface PuffPopGroupProps {
735
1343
  * </PuffPopGroup>
736
1344
  * ```
737
1345
  */
738
- export function PuffPopGroup({
1346
+ function PuffPopGroupComponent({
739
1347
  children,
740
1348
  effect = 'scale',
741
1349
  duration = 400,
@@ -753,6 +1361,28 @@ export function PuffPopGroup({
753
1361
  staggerDirection = 'forward',
754
1362
  horizontal = false,
755
1363
  gap,
1364
+ // Custom initial values
1365
+ initialOpacity,
1366
+ initialScale,
1367
+ initialRotate,
1368
+ initialTranslateX,
1369
+ initialTranslateY,
1370
+ // Reverse mode
1371
+ reverse,
1372
+ // Animation intensity
1373
+ intensity,
1374
+ // Anchor point
1375
+ anchorPoint,
1376
+ // Spring animation
1377
+ useSpring,
1378
+ springConfig,
1379
+ // Exit animation settings
1380
+ exitEffect,
1381
+ exitDuration,
1382
+ exitEasing,
1383
+ exitDelay,
1384
+ exitStaggerDelay = 0,
1385
+ exitStaggerDirection = 'reverse',
756
1386
  }: PuffPopGroupProps): ReactElement {
757
1387
  const childArray = Children.toArray(children);
758
1388
  const childCount = childArray.length;
@@ -789,6 +1419,41 @@ export function PuffPopGroup({
789
1419
  [childCount, initialDelay, staggerDelay, staggerDirection]
790
1420
  );
791
1421
 
1422
+ // Calculate exit delay for each child based on exit stagger direction
1423
+ const getChildExitDelay = useCallback(
1424
+ (index: number): number => {
1425
+ if (exitStaggerDelay === 0) {
1426
+ return exitDelay ?? 0;
1427
+ }
1428
+
1429
+ let delayIndex: number;
1430
+
1431
+ switch (exitStaggerDirection) {
1432
+ case 'forward':
1433
+ delayIndex = index;
1434
+ break;
1435
+ case 'center': {
1436
+ const center = (childCount - 1) / 2;
1437
+ delayIndex = Math.abs(index - center);
1438
+ break;
1439
+ }
1440
+ case 'edges': {
1441
+ const center = (childCount - 1) / 2;
1442
+ delayIndex = center - Math.abs(index - center);
1443
+ break;
1444
+ }
1445
+ case 'reverse':
1446
+ default:
1447
+ // Reverse is default for exit (last in, first out)
1448
+ delayIndex = childCount - 1 - index;
1449
+ break;
1450
+ }
1451
+
1452
+ return (exitDelay ?? 0) + delayIndex * exitStaggerDelay;
1453
+ },
1454
+ [childCount, exitDelay, exitStaggerDelay, exitStaggerDirection]
1455
+ );
1456
+
792
1457
  // Handle individual child animation complete
793
1458
  const handleChildComplete = useCallback(() => {
794
1459
  completedCount.current += 1;
@@ -838,6 +1503,20 @@ export function PuffPopGroup({
838
1503
  onAnimationComplete={handleChildComplete}
839
1504
  onAnimationStart={index === 0 ? handleChildStart : undefined}
840
1505
  respectReduceMotion={respectReduceMotion}
1506
+ initialOpacity={initialOpacity}
1507
+ initialScale={initialScale}
1508
+ initialRotate={initialRotate}
1509
+ initialTranslateX={initialTranslateX}
1510
+ initialTranslateY={initialTranslateY}
1511
+ reverse={reverse}
1512
+ intensity={intensity}
1513
+ anchorPoint={anchorPoint}
1514
+ useSpring={useSpring}
1515
+ springConfig={springConfig}
1516
+ exitEffect={exitEffect}
1517
+ exitDuration={exitDuration}
1518
+ exitEasing={exitEasing}
1519
+ exitDelay={getChildExitDelay(index)}
841
1520
  >
842
1521
  {child}
843
1522
  </PuffPop>
@@ -846,5 +1525,8 @@ export function PuffPopGroup({
846
1525
  );
847
1526
  }
848
1527
 
1528
+ // Memoize PuffPopGroup to prevent unnecessary re-renders
1529
+ export const PuffPopGroup = memo(PuffPopGroupComponent, arePuffPopGroupPropsEqual);
1530
+
849
1531
  export default PuffPop;
850
1532