react-native-puff-pop 1.0.3 → 1.0.5
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 +70 -0
- package/lib/module/index.js +118 -10
- package/lib/typescript/src/index.d.ts +116 -1
- package/package.json +1 -1
- package/src/index.tsx +305 -7
package/README.md
CHANGED
|
@@ -102,6 +102,35 @@ By default, `skeleton={true}` reserves space for the component before animation:
|
|
|
102
102
|
|
|
103
103
|
### Staggered Animations
|
|
104
104
|
|
|
105
|
+
Use `PuffPopGroup` for easy staggered animations:
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
import { PuffPopGroup } from 'react-native-puff-pop';
|
|
109
|
+
|
|
110
|
+
// Simple stagger
|
|
111
|
+
<PuffPopGroup staggerDelay={100} effect="scale">
|
|
112
|
+
<Card title="First" />
|
|
113
|
+
<Card title="Second" />
|
|
114
|
+
<Card title="Third" />
|
|
115
|
+
</PuffPopGroup>
|
|
116
|
+
|
|
117
|
+
// With different directions
|
|
118
|
+
<PuffPopGroup staggerDelay={80} staggerDirection="reverse">
|
|
119
|
+
<Item />
|
|
120
|
+
<Item />
|
|
121
|
+
<Item />
|
|
122
|
+
</PuffPopGroup>
|
|
123
|
+
|
|
124
|
+
// Horizontal layout with gap
|
|
125
|
+
<PuffPopGroup horizontal gap={12} effect="slideUp">
|
|
126
|
+
<Avatar />
|
|
127
|
+
<Avatar />
|
|
128
|
+
<Avatar />
|
|
129
|
+
</PuffPopGroup>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Or use manual delays with `PuffPop`:
|
|
133
|
+
|
|
105
134
|
```tsx
|
|
106
135
|
<View>
|
|
107
136
|
<PuffPop delay={0}>
|
|
@@ -164,10 +193,35 @@ function App() {
|
|
|
164
193
|
| `skeleton` | `boolean` | `true` | Reserve space before animation |
|
|
165
194
|
| `visible` | `boolean` | `true` | Control visibility |
|
|
166
195
|
| `animateOnMount` | `boolean` | `true` | Animate when component mounts |
|
|
196
|
+
| `onAnimationStart` | `() => void` | - | Callback when animation starts |
|
|
167
197
|
| `onAnimationComplete` | `() => void` | - | Callback when animation completes |
|
|
168
198
|
| `style` | `ViewStyle` | - | Custom container style |
|
|
169
199
|
| `loop` | `boolean \| number` | `false` | Loop animation (true=infinite, number=times) |
|
|
170
200
|
| `loopDelay` | `number` | `0` | Delay between loop iterations in ms |
|
|
201
|
+
| `respectReduceMotion` | `boolean` | `true` | Respect system reduce motion setting |
|
|
202
|
+
| `testID` | `string` | - | Test ID for testing purposes |
|
|
203
|
+
|
|
204
|
+
### PuffPopGroup Props
|
|
205
|
+
|
|
206
|
+
| Prop | Type | Default | Description |
|
|
207
|
+
|------|------|---------|-------------|
|
|
208
|
+
| `children` | `ReactNode` | - | Children to animate with stagger effect |
|
|
209
|
+
| `effect` | `PuffPopEffect` | `'scale'` | Animation effect for all children |
|
|
210
|
+
| `duration` | `number` | `400` | Animation duration for each child in ms |
|
|
211
|
+
| `staggerDelay` | `number` | `100` | Delay between each child's animation in ms |
|
|
212
|
+
| `initialDelay` | `number` | `0` | Delay before the first child animates in ms |
|
|
213
|
+
| `easing` | `PuffPopEasing` | `'easeOut'` | Easing function for all children |
|
|
214
|
+
| `skeleton` | `boolean` | `true` | Reserve space before animation |
|
|
215
|
+
| `visible` | `boolean` | `true` | Control visibility of all children |
|
|
216
|
+
| `animateOnMount` | `boolean` | `true` | Animate when component mounts |
|
|
217
|
+
| `onAnimationStart` | `() => void` | - | Callback when first child starts animating |
|
|
218
|
+
| `onAnimationComplete` | `() => void` | - | Callback when all children complete |
|
|
219
|
+
| `style` | `ViewStyle` | - | Custom container style |
|
|
220
|
+
| `staggerDirection` | `'forward' \| 'reverse' \| 'center' \| 'edges'` | `'forward'` | Direction of stagger animation |
|
|
221
|
+
| `horizontal` | `boolean` | `false` | Render children in horizontal layout |
|
|
222
|
+
| `gap` | `number` | - | Gap between children |
|
|
223
|
+
| `respectReduceMotion` | `boolean` | `true` | Respect system reduce motion setting |
|
|
224
|
+
| `testID` | `string` | - | Test ID for testing purposes |
|
|
171
225
|
|
|
172
226
|
### Animation Effects (`PuffPopEffect`)
|
|
173
227
|
|
|
@@ -204,6 +258,22 @@ The component reserves its full space immediately, and only the visual appearanc
|
|
|
204
258
|
### `skeleton={false}`
|
|
205
259
|
The component's height starts at 0 and expands during animation, pushing other content below it. This creates a more dynamic entrance effect.
|
|
206
260
|
|
|
261
|
+
## Accessibility
|
|
262
|
+
|
|
263
|
+
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.
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
// Respect reduce motion setting (default)
|
|
267
|
+
<PuffPop respectReduceMotion={true}>
|
|
268
|
+
<YourComponent />
|
|
269
|
+
</PuffPop>
|
|
270
|
+
|
|
271
|
+
// Ignore reduce motion setting (always animate)
|
|
272
|
+
<PuffPop respectReduceMotion={false}>
|
|
273
|
+
<YourComponent />
|
|
274
|
+
</PuffPop>
|
|
275
|
+
```
|
|
276
|
+
|
|
207
277
|
## License
|
|
208
278
|
|
|
209
279
|
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, useMemo, } from 'react';
|
|
3
|
-
import { View, Animated, StyleSheet, Easing, } from 'react-native';
|
|
2
|
+
import { useEffect, useRef, useState, useCallback, useMemo, Children, } 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;
|
|
@@ -38,6 +38,23 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
38
38
|
const hasAnimated = useRef(false);
|
|
39
39
|
const loopAnimationRef = useRef(null);
|
|
40
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;
|
|
41
58
|
// Memoize effect type checks to avoid repeated includes() calls
|
|
42
59
|
const effectFlags = useMemo(() => ({
|
|
43
60
|
hasScale: ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect),
|
|
@@ -65,12 +82,16 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
65
82
|
}, [skeleton, measuredHeight]);
|
|
66
83
|
// Animate function
|
|
67
84
|
const animate = useCallback((toVisible) => {
|
|
85
|
+
// Call onAnimationStart callback
|
|
86
|
+
if (toVisible && onAnimationStart) {
|
|
87
|
+
onAnimationStart();
|
|
88
|
+
}
|
|
68
89
|
const easingFn = getEasing(easing);
|
|
69
90
|
// When skeleton is false, we animate height which doesn't support native driver
|
|
70
91
|
// So we must use JS driver for all animations in that case
|
|
71
92
|
const useNative = skeleton;
|
|
72
93
|
const config = {
|
|
73
|
-
duration,
|
|
94
|
+
duration: effectiveDuration,
|
|
74
95
|
easing: easingFn,
|
|
75
96
|
useNativeDriver: useNative,
|
|
76
97
|
};
|
|
@@ -118,7 +139,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
118
139
|
const targetHeight = toVisible ? measuredHeight : 0;
|
|
119
140
|
animations.push(Animated.timing(animatedHeight, {
|
|
120
141
|
toValue: targetHeight,
|
|
121
|
-
duration,
|
|
142
|
+
duration: effectiveDuration,
|
|
122
143
|
easing: easingFn,
|
|
123
144
|
useNativeDriver: false,
|
|
124
145
|
}));
|
|
@@ -152,11 +173,19 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
152
173
|
// Stop any existing loop animation
|
|
153
174
|
if (loopAnimationRef.current) {
|
|
154
175
|
loopAnimationRef.current.stop();
|
|
176
|
+
loopAnimationRef.current = null;
|
|
177
|
+
}
|
|
178
|
+
// Clear any existing timeout
|
|
179
|
+
if (loopTimeoutRef.current) {
|
|
180
|
+
clearTimeout(loopTimeoutRef.current);
|
|
181
|
+
loopTimeoutRef.current = null;
|
|
155
182
|
}
|
|
156
183
|
const loopCount = typeof loop === 'number' ? loop : -1;
|
|
157
184
|
let currentIteration = 0;
|
|
158
185
|
const runLoop = () => {
|
|
159
186
|
resetValues();
|
|
187
|
+
// Store the current animation reference so it can be stopped
|
|
188
|
+
loopAnimationRef.current = animation;
|
|
160
189
|
animation.start(({ finished }) => {
|
|
161
190
|
if (finished) {
|
|
162
191
|
currentIteration++;
|
|
@@ -173,13 +202,17 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
173
202
|
runLoop();
|
|
174
203
|
}
|
|
175
204
|
}
|
|
176
|
-
else
|
|
177
|
-
|
|
205
|
+
else {
|
|
206
|
+
// Loop finished, clear reference
|
|
207
|
+
loopAnimationRef.current = null;
|
|
208
|
+
if (onAnimationComplete) {
|
|
209
|
+
onAnimationComplete();
|
|
210
|
+
}
|
|
178
211
|
}
|
|
179
212
|
}
|
|
180
213
|
});
|
|
181
214
|
};
|
|
182
|
-
//
|
|
215
|
+
// Start the loop
|
|
183
216
|
runLoop();
|
|
184
217
|
}
|
|
185
218
|
else {
|
|
@@ -196,12 +229,13 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
196
229
|
}
|
|
197
230
|
}, [
|
|
198
231
|
delay,
|
|
199
|
-
|
|
232
|
+
effectiveDuration,
|
|
200
233
|
easing,
|
|
201
234
|
effect,
|
|
202
235
|
effectFlags,
|
|
203
236
|
measuredHeight,
|
|
204
237
|
onAnimationComplete,
|
|
238
|
+
onAnimationStart,
|
|
205
239
|
opacity,
|
|
206
240
|
rotate,
|
|
207
241
|
scale,
|
|
@@ -277,7 +311,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
|
|
|
277
311
|
if (!skeleton && measuredHeight === null) {
|
|
278
312
|
return (_jsx(View, { style: styles.measureContainer, onLayout: onLayout, children: _jsx(View, { style: styles.hidden, children: children }) }));
|
|
279
313
|
}
|
|
280
|
-
return (_jsx(Animated.View, { style: [styles.container, style, containerAnimatedStyle], children: _jsx(Animated.View, { style: animatedStyle, children: children }) }));
|
|
314
|
+
return (_jsx(Animated.View, { style: [styles.container, style, containerAnimatedStyle], testID: testID, children: _jsx(Animated.View, { style: animatedStyle, children: children }) }));
|
|
281
315
|
}
|
|
282
316
|
/**
|
|
283
317
|
* Get initial scale value based on effect
|
|
@@ -350,5 +384,79 @@ const styles = StyleSheet.create({
|
|
|
350
384
|
hidden: {
|
|
351
385
|
opacity: 0,
|
|
352
386
|
},
|
|
387
|
+
groupContainer: {},
|
|
353
388
|
});
|
|
389
|
+
/**
|
|
390
|
+
* PuffPopGroup - Animate multiple children with staggered entrance effects
|
|
391
|
+
*
|
|
392
|
+
* @example
|
|
393
|
+
* ```tsx
|
|
394
|
+
* <PuffPopGroup staggerDelay={100} effect="scale">
|
|
395
|
+
* <Card title="First" />
|
|
396
|
+
* <Card title="Second" />
|
|
397
|
+
* <Card title="Third" />
|
|
398
|
+
* </PuffPopGroup>
|
|
399
|
+
* ```
|
|
400
|
+
*/
|
|
401
|
+
export function PuffPopGroup({ children, effect = 'scale', duration = 400, staggerDelay = 100, initialDelay = 0, easing = 'easeOut', skeleton = true, visible = true, animateOnMount = true, onAnimationComplete, onAnimationStart, style, respectReduceMotion = true, testID, staggerDirection = 'forward', horizontal = false, gap, }) {
|
|
402
|
+
const childArray = Children.toArray(children);
|
|
403
|
+
const childCount = childArray.length;
|
|
404
|
+
const completedCount = useRef(0);
|
|
405
|
+
const hasCalledStart = useRef(false);
|
|
406
|
+
// Calculate delay for each child based on stagger direction
|
|
407
|
+
const getChildDelay = useCallback((index) => {
|
|
408
|
+
let delayIndex;
|
|
409
|
+
switch (staggerDirection) {
|
|
410
|
+
case 'reverse':
|
|
411
|
+
delayIndex = childCount - 1 - index;
|
|
412
|
+
break;
|
|
413
|
+
case 'center': {
|
|
414
|
+
const center = (childCount - 1) / 2;
|
|
415
|
+
delayIndex = Math.abs(index - center);
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
case 'edges': {
|
|
419
|
+
const center = (childCount - 1) / 2;
|
|
420
|
+
delayIndex = center - Math.abs(index - center);
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
case 'forward':
|
|
424
|
+
default:
|
|
425
|
+
delayIndex = index;
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
return initialDelay + delayIndex * staggerDelay;
|
|
429
|
+
}, [childCount, initialDelay, staggerDelay, staggerDirection]);
|
|
430
|
+
// Handle individual child animation complete
|
|
431
|
+
const handleChildComplete = useCallback(() => {
|
|
432
|
+
completedCount.current += 1;
|
|
433
|
+
if (completedCount.current >= childCount && onAnimationComplete) {
|
|
434
|
+
onAnimationComplete();
|
|
435
|
+
}
|
|
436
|
+
}, [childCount, onAnimationComplete]);
|
|
437
|
+
// Handle first child animation start
|
|
438
|
+
const handleChildStart = useCallback(() => {
|
|
439
|
+
if (!hasCalledStart.current && onAnimationStart) {
|
|
440
|
+
hasCalledStart.current = true;
|
|
441
|
+
onAnimationStart();
|
|
442
|
+
}
|
|
443
|
+
}, [onAnimationStart]);
|
|
444
|
+
// Reset counters when visibility changes
|
|
445
|
+
useEffect(() => {
|
|
446
|
+
if (visible) {
|
|
447
|
+
completedCount.current = 0;
|
|
448
|
+
hasCalledStart.current = false;
|
|
449
|
+
}
|
|
450
|
+
}, [visible]);
|
|
451
|
+
const containerStyle = useMemo(() => {
|
|
452
|
+
const baseStyle = {
|
|
453
|
+
flexDirection: horizontal ? 'row' : 'column',
|
|
454
|
+
};
|
|
455
|
+
if (gap !== undefined) {
|
|
456
|
+
baseStyle.gap = gap;
|
|
457
|
+
}
|
|
458
|
+
return baseStyle;
|
|
459
|
+
}, [horizontal, gap]);
|
|
460
|
+
return (_jsx(View, { style: [styles.groupContainer, containerStyle, style], testID: testID, children: childArray.map((child, index) => (_jsx(PuffPop, { effect: effect, duration: duration, delay: getChildDelay(index), easing: easing, skeleton: skeleton, visible: visible, animateOnMount: animateOnMount, onAnimationComplete: handleChildComplete, onAnimationStart: index === 0 ? handleChildStart : undefined, respectReduceMotion: respectReduceMotion, children: child }, index))) }));
|
|
461
|
+
}
|
|
354
462
|
export default PuffPop;
|
|
@@ -69,9 +69,124 @@ 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;
|
|
91
|
+
/**
|
|
92
|
+
* Props for PuffPopGroup component
|
|
93
|
+
*/
|
|
94
|
+
export interface PuffPopGroupProps {
|
|
95
|
+
/**
|
|
96
|
+
* Children to animate with stagger effect
|
|
97
|
+
*/
|
|
98
|
+
children: ReactNode;
|
|
99
|
+
/**
|
|
100
|
+
* Animation effect type applied to all children
|
|
101
|
+
* @default 'scale'
|
|
102
|
+
*/
|
|
103
|
+
effect?: PuffPopEffect;
|
|
104
|
+
/**
|
|
105
|
+
* Base animation duration in milliseconds for each child
|
|
106
|
+
* @default 400
|
|
107
|
+
*/
|
|
108
|
+
duration?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Delay between each child's animation start in milliseconds
|
|
111
|
+
* @default 100
|
|
112
|
+
*/
|
|
113
|
+
staggerDelay?: number;
|
|
114
|
+
/**
|
|
115
|
+
* Initial delay before the first child animates in milliseconds
|
|
116
|
+
* @default 0
|
|
117
|
+
*/
|
|
118
|
+
initialDelay?: number;
|
|
119
|
+
/**
|
|
120
|
+
* Easing function for all children
|
|
121
|
+
* @default 'easeOut'
|
|
122
|
+
*/
|
|
123
|
+
easing?: PuffPopEasing;
|
|
124
|
+
/**
|
|
125
|
+
* If true, reserves space for children before animation
|
|
126
|
+
* @default true
|
|
127
|
+
*/
|
|
128
|
+
skeleton?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Whether children are visible
|
|
131
|
+
* @default true
|
|
132
|
+
*/
|
|
133
|
+
visible?: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* Whether to animate on mount
|
|
136
|
+
* @default true
|
|
137
|
+
*/
|
|
138
|
+
animateOnMount?: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* Callback when all children animations complete
|
|
141
|
+
*/
|
|
142
|
+
onAnimationComplete?: () => void;
|
|
143
|
+
/**
|
|
144
|
+
* Callback when the first child animation starts
|
|
145
|
+
*/
|
|
146
|
+
onAnimationStart?: () => void;
|
|
147
|
+
/**
|
|
148
|
+
* Custom style for the group container
|
|
149
|
+
*/
|
|
150
|
+
style?: StyleProp<ViewStyle>;
|
|
151
|
+
/**
|
|
152
|
+
* Respect system reduce motion accessibility setting
|
|
153
|
+
* @default true
|
|
154
|
+
*/
|
|
155
|
+
respectReduceMotion?: boolean;
|
|
156
|
+
/**
|
|
157
|
+
* Test ID for testing purposes
|
|
158
|
+
*/
|
|
159
|
+
testID?: string;
|
|
160
|
+
/**
|
|
161
|
+
* Direction of stagger animation
|
|
162
|
+
* - 'forward': First to last child
|
|
163
|
+
* - 'reverse': Last to first child
|
|
164
|
+
* - 'center': From center outward
|
|
165
|
+
* - 'edges': From edges toward center
|
|
166
|
+
* @default 'forward'
|
|
167
|
+
*/
|
|
168
|
+
staggerDirection?: 'forward' | 'reverse' | 'center' | 'edges';
|
|
169
|
+
/**
|
|
170
|
+
* If true, children are rendered in a row (horizontal layout)
|
|
171
|
+
* @default false
|
|
172
|
+
*/
|
|
173
|
+
horizontal?: boolean;
|
|
174
|
+
/**
|
|
175
|
+
* Gap between children (uses flexbox gap)
|
|
176
|
+
*/
|
|
177
|
+
gap?: number;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* PuffPopGroup - Animate multiple children with staggered entrance effects
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```tsx
|
|
184
|
+
* <PuffPopGroup staggerDelay={100} effect="scale">
|
|
185
|
+
* <Card title="First" />
|
|
186
|
+
* <Card title="Second" />
|
|
187
|
+
* <Card title="Third" />
|
|
188
|
+
* </PuffPopGroup>
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
export declare function PuffPopGroup({ children, effect, duration, staggerDelay, initialDelay, easing, skeleton, visible, animateOnMount, onAnimationComplete, onAnimationStart, style, respectReduceMotion, testID, staggerDirection, horizontal, gap, }: PuffPopGroupProps): ReactElement;
|
|
77
192
|
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.5",
|
|
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
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
useState,
|
|
5
5
|
useCallback,
|
|
6
6
|
useMemo,
|
|
7
|
+
Children,
|
|
7
8
|
type ReactNode,
|
|
8
9
|
type ReactElement,
|
|
9
10
|
} from 'react';
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
Animated,
|
|
13
14
|
StyleSheet,
|
|
14
15
|
Easing,
|
|
16
|
+
AccessibilityInfo,
|
|
15
17
|
type LayoutChangeEvent,
|
|
16
18
|
type StyleProp,
|
|
17
19
|
type ViewStyle,
|
|
@@ -116,6 +118,23 @@ export interface PuffPopProps {
|
|
|
116
118
|
* @default 0
|
|
117
119
|
*/
|
|
118
120
|
loopDelay?: number;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Callback when animation starts
|
|
124
|
+
*/
|
|
125
|
+
onAnimationStart?: () => void;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Respect system reduce motion accessibility setting
|
|
129
|
+
* When true and reduce motion is enabled, animations will be instant
|
|
130
|
+
* @default true
|
|
131
|
+
*/
|
|
132
|
+
respectReduceMotion?: boolean;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Test ID for testing purposes
|
|
136
|
+
*/
|
|
137
|
+
testID?: string;
|
|
119
138
|
}
|
|
120
139
|
|
|
121
140
|
/**
|
|
@@ -152,10 +171,13 @@ export function PuffPop({
|
|
|
152
171
|
skeleton = true,
|
|
153
172
|
visible = true,
|
|
154
173
|
onAnimationComplete,
|
|
174
|
+
onAnimationStart,
|
|
155
175
|
style,
|
|
156
176
|
animateOnMount = true,
|
|
157
177
|
loop = false,
|
|
158
178
|
loopDelay = 0,
|
|
179
|
+
respectReduceMotion = true,
|
|
180
|
+
testID,
|
|
159
181
|
}: PuffPopProps): ReactElement {
|
|
160
182
|
// Animation values
|
|
161
183
|
const opacity = useRef(new Animated.Value(animateOnMount ? 0 : 1)).current;
|
|
@@ -171,6 +193,32 @@ export function PuffPop({
|
|
|
171
193
|
const loopAnimationRef = useRef<Animated.CompositeAnimation | null>(null);
|
|
172
194
|
const loopTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
173
195
|
|
|
196
|
+
// Reduce motion accessibility support
|
|
197
|
+
const [isReduceMotionEnabled, setIsReduceMotionEnabled] = useState(false);
|
|
198
|
+
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (!respectReduceMotion) return;
|
|
201
|
+
|
|
202
|
+
const checkReduceMotion = async () => {
|
|
203
|
+
const reduceMotion = await AccessibilityInfo.isReduceMotionEnabled();
|
|
204
|
+
setIsReduceMotionEnabled(reduceMotion);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
checkReduceMotion();
|
|
208
|
+
|
|
209
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
210
|
+
'reduceMotionChanged',
|
|
211
|
+
setIsReduceMotionEnabled
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
return () => {
|
|
215
|
+
subscription.remove();
|
|
216
|
+
};
|
|
217
|
+
}, [respectReduceMotion]);
|
|
218
|
+
|
|
219
|
+
// Effective duration (0 if reduce motion is enabled)
|
|
220
|
+
const effectiveDuration = respectReduceMotion && isReduceMotionEnabled ? 0 : duration;
|
|
221
|
+
|
|
174
222
|
// Memoize effect type checks to avoid repeated includes() calls
|
|
175
223
|
const effectFlags = useMemo(() => ({
|
|
176
224
|
hasScale: ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect),
|
|
@@ -208,12 +256,17 @@ export function PuffPop({
|
|
|
208
256
|
// Animate function
|
|
209
257
|
const animate = useCallback(
|
|
210
258
|
(toVisible: boolean) => {
|
|
259
|
+
// Call onAnimationStart callback
|
|
260
|
+
if (toVisible && onAnimationStart) {
|
|
261
|
+
onAnimationStart();
|
|
262
|
+
}
|
|
263
|
+
|
|
211
264
|
const easingFn = getEasing(easing);
|
|
212
265
|
// When skeleton is false, we animate height which doesn't support native driver
|
|
213
266
|
// So we must use JS driver for all animations in that case
|
|
214
267
|
const useNative = skeleton;
|
|
215
268
|
const config = {
|
|
216
|
-
duration,
|
|
269
|
+
duration: effectiveDuration,
|
|
217
270
|
easing: easingFn,
|
|
218
271
|
useNativeDriver: useNative,
|
|
219
272
|
};
|
|
@@ -279,7 +332,7 @@ export function PuffPop({
|
|
|
279
332
|
animations.push(
|
|
280
333
|
Animated.timing(animatedHeight, {
|
|
281
334
|
toValue: targetHeight,
|
|
282
|
-
duration,
|
|
335
|
+
duration: effectiveDuration,
|
|
283
336
|
easing: easingFn,
|
|
284
337
|
useNativeDriver: false,
|
|
285
338
|
})
|
|
@@ -318,6 +371,12 @@ export function PuffPop({
|
|
|
318
371
|
// Stop any existing loop animation
|
|
319
372
|
if (loopAnimationRef.current) {
|
|
320
373
|
loopAnimationRef.current.stop();
|
|
374
|
+
loopAnimationRef.current = null;
|
|
375
|
+
}
|
|
376
|
+
// Clear any existing timeout
|
|
377
|
+
if (loopTimeoutRef.current) {
|
|
378
|
+
clearTimeout(loopTimeoutRef.current);
|
|
379
|
+
loopTimeoutRef.current = null;
|
|
321
380
|
}
|
|
322
381
|
|
|
323
382
|
const loopCount = typeof loop === 'number' ? loop : -1;
|
|
@@ -325,6 +384,8 @@ export function PuffPop({
|
|
|
325
384
|
|
|
326
385
|
const runLoop = () => {
|
|
327
386
|
resetValues();
|
|
387
|
+
// Store the current animation reference so it can be stopped
|
|
388
|
+
loopAnimationRef.current = animation;
|
|
328
389
|
animation.start(({ finished }) => {
|
|
329
390
|
if (finished) {
|
|
330
391
|
currentIteration++;
|
|
@@ -339,14 +400,18 @@ export function PuffPop({
|
|
|
339
400
|
} else {
|
|
340
401
|
runLoop();
|
|
341
402
|
}
|
|
342
|
-
} else
|
|
343
|
-
|
|
403
|
+
} else {
|
|
404
|
+
// Loop finished, clear reference
|
|
405
|
+
loopAnimationRef.current = null;
|
|
406
|
+
if (onAnimationComplete) {
|
|
407
|
+
onAnimationComplete();
|
|
408
|
+
}
|
|
344
409
|
}
|
|
345
410
|
}
|
|
346
411
|
});
|
|
347
412
|
};
|
|
348
413
|
|
|
349
|
-
//
|
|
414
|
+
// Start the loop
|
|
350
415
|
runLoop();
|
|
351
416
|
} else {
|
|
352
417
|
// Stop any existing loop animation
|
|
@@ -364,12 +429,13 @@ export function PuffPop({
|
|
|
364
429
|
},
|
|
365
430
|
[
|
|
366
431
|
delay,
|
|
367
|
-
|
|
432
|
+
effectiveDuration,
|
|
368
433
|
easing,
|
|
369
434
|
effect,
|
|
370
435
|
effectFlags,
|
|
371
436
|
measuredHeight,
|
|
372
437
|
onAnimationComplete,
|
|
438
|
+
onAnimationStart,
|
|
373
439
|
opacity,
|
|
374
440
|
rotate,
|
|
375
441
|
scale,
|
|
@@ -465,7 +531,10 @@ export function PuffPop({
|
|
|
465
531
|
}
|
|
466
532
|
|
|
467
533
|
return (
|
|
468
|
-
<Animated.View
|
|
534
|
+
<Animated.View
|
|
535
|
+
style={[styles.container, style, containerAnimatedStyle]}
|
|
536
|
+
testID={testID}
|
|
537
|
+
>
|
|
469
538
|
<Animated.View style={animatedStyle}>{children}</Animated.View>
|
|
470
539
|
</Animated.View>
|
|
471
540
|
);
|
|
@@ -546,7 +615,236 @@ const styles = StyleSheet.create({
|
|
|
546
615
|
hidden: {
|
|
547
616
|
opacity: 0,
|
|
548
617
|
},
|
|
618
|
+
groupContainer: {},
|
|
549
619
|
});
|
|
550
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Props for PuffPopGroup component
|
|
623
|
+
*/
|
|
624
|
+
export interface PuffPopGroupProps {
|
|
625
|
+
/**
|
|
626
|
+
* Children to animate with stagger effect
|
|
627
|
+
*/
|
|
628
|
+
children: ReactNode;
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Animation effect type applied to all children
|
|
632
|
+
* @default 'scale'
|
|
633
|
+
*/
|
|
634
|
+
effect?: PuffPopEffect;
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Base animation duration in milliseconds for each child
|
|
638
|
+
* @default 400
|
|
639
|
+
*/
|
|
640
|
+
duration?: number;
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Delay between each child's animation start in milliseconds
|
|
644
|
+
* @default 100
|
|
645
|
+
*/
|
|
646
|
+
staggerDelay?: number;
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Initial delay before the first child animates in milliseconds
|
|
650
|
+
* @default 0
|
|
651
|
+
*/
|
|
652
|
+
initialDelay?: number;
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Easing function for all children
|
|
656
|
+
* @default 'easeOut'
|
|
657
|
+
*/
|
|
658
|
+
easing?: PuffPopEasing;
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* If true, reserves space for children before animation
|
|
662
|
+
* @default true
|
|
663
|
+
*/
|
|
664
|
+
skeleton?: boolean;
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Whether children are visible
|
|
668
|
+
* @default true
|
|
669
|
+
*/
|
|
670
|
+
visible?: boolean;
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Whether to animate on mount
|
|
674
|
+
* @default true
|
|
675
|
+
*/
|
|
676
|
+
animateOnMount?: boolean;
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Callback when all children animations complete
|
|
680
|
+
*/
|
|
681
|
+
onAnimationComplete?: () => void;
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Callback when the first child animation starts
|
|
685
|
+
*/
|
|
686
|
+
onAnimationStart?: () => void;
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Custom style for the group container
|
|
690
|
+
*/
|
|
691
|
+
style?: StyleProp<ViewStyle>;
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Respect system reduce motion accessibility setting
|
|
695
|
+
* @default true
|
|
696
|
+
*/
|
|
697
|
+
respectReduceMotion?: boolean;
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Test ID for testing purposes
|
|
701
|
+
*/
|
|
702
|
+
testID?: string;
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Direction of stagger animation
|
|
706
|
+
* - 'forward': First to last child
|
|
707
|
+
* - 'reverse': Last to first child
|
|
708
|
+
* - 'center': From center outward
|
|
709
|
+
* - 'edges': From edges toward center
|
|
710
|
+
* @default 'forward'
|
|
711
|
+
*/
|
|
712
|
+
staggerDirection?: 'forward' | 'reverse' | 'center' | 'edges';
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* If true, children are rendered in a row (horizontal layout)
|
|
716
|
+
* @default false
|
|
717
|
+
*/
|
|
718
|
+
horizontal?: boolean;
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Gap between children (uses flexbox gap)
|
|
722
|
+
*/
|
|
723
|
+
gap?: number;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* PuffPopGroup - Animate multiple children with staggered entrance effects
|
|
728
|
+
*
|
|
729
|
+
* @example
|
|
730
|
+
* ```tsx
|
|
731
|
+
* <PuffPopGroup staggerDelay={100} effect="scale">
|
|
732
|
+
* <Card title="First" />
|
|
733
|
+
* <Card title="Second" />
|
|
734
|
+
* <Card title="Third" />
|
|
735
|
+
* </PuffPopGroup>
|
|
736
|
+
* ```
|
|
737
|
+
*/
|
|
738
|
+
export function PuffPopGroup({
|
|
739
|
+
children,
|
|
740
|
+
effect = 'scale',
|
|
741
|
+
duration = 400,
|
|
742
|
+
staggerDelay = 100,
|
|
743
|
+
initialDelay = 0,
|
|
744
|
+
easing = 'easeOut',
|
|
745
|
+
skeleton = true,
|
|
746
|
+
visible = true,
|
|
747
|
+
animateOnMount = true,
|
|
748
|
+
onAnimationComplete,
|
|
749
|
+
onAnimationStart,
|
|
750
|
+
style,
|
|
751
|
+
respectReduceMotion = true,
|
|
752
|
+
testID,
|
|
753
|
+
staggerDirection = 'forward',
|
|
754
|
+
horizontal = false,
|
|
755
|
+
gap,
|
|
756
|
+
}: PuffPopGroupProps): ReactElement {
|
|
757
|
+
const childArray = Children.toArray(children);
|
|
758
|
+
const childCount = childArray.length;
|
|
759
|
+
const completedCount = useRef(0);
|
|
760
|
+
const hasCalledStart = useRef(false);
|
|
761
|
+
|
|
762
|
+
// Calculate delay for each child based on stagger direction
|
|
763
|
+
const getChildDelay = useCallback(
|
|
764
|
+
(index: number): number => {
|
|
765
|
+
let delayIndex: number;
|
|
766
|
+
|
|
767
|
+
switch (staggerDirection) {
|
|
768
|
+
case 'reverse':
|
|
769
|
+
delayIndex = childCount - 1 - index;
|
|
770
|
+
break;
|
|
771
|
+
case 'center': {
|
|
772
|
+
const center = (childCount - 1) / 2;
|
|
773
|
+
delayIndex = Math.abs(index - center);
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
case 'edges': {
|
|
777
|
+
const center = (childCount - 1) / 2;
|
|
778
|
+
delayIndex = center - Math.abs(index - center);
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
case 'forward':
|
|
782
|
+
default:
|
|
783
|
+
delayIndex = index;
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return initialDelay + delayIndex * staggerDelay;
|
|
788
|
+
},
|
|
789
|
+
[childCount, initialDelay, staggerDelay, staggerDirection]
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
// Handle individual child animation complete
|
|
793
|
+
const handleChildComplete = useCallback(() => {
|
|
794
|
+
completedCount.current += 1;
|
|
795
|
+
if (completedCount.current >= childCount && onAnimationComplete) {
|
|
796
|
+
onAnimationComplete();
|
|
797
|
+
}
|
|
798
|
+
}, [childCount, onAnimationComplete]);
|
|
799
|
+
|
|
800
|
+
// Handle first child animation start
|
|
801
|
+
const handleChildStart = useCallback(() => {
|
|
802
|
+
if (!hasCalledStart.current && onAnimationStart) {
|
|
803
|
+
hasCalledStart.current = true;
|
|
804
|
+
onAnimationStart();
|
|
805
|
+
}
|
|
806
|
+
}, [onAnimationStart]);
|
|
807
|
+
|
|
808
|
+
// Reset counters when visibility changes
|
|
809
|
+
useEffect(() => {
|
|
810
|
+
if (visible) {
|
|
811
|
+
completedCount.current = 0;
|
|
812
|
+
hasCalledStart.current = false;
|
|
813
|
+
}
|
|
814
|
+
}, [visible]);
|
|
815
|
+
|
|
816
|
+
const containerStyle = useMemo(() => {
|
|
817
|
+
const baseStyle: ViewStyle = {
|
|
818
|
+
flexDirection: horizontal ? 'row' : 'column',
|
|
819
|
+
};
|
|
820
|
+
if (gap !== undefined) {
|
|
821
|
+
baseStyle.gap = gap;
|
|
822
|
+
}
|
|
823
|
+
return baseStyle;
|
|
824
|
+
}, [horizontal, gap]);
|
|
825
|
+
|
|
826
|
+
return (
|
|
827
|
+
<View style={[styles.groupContainer, containerStyle, style]} testID={testID}>
|
|
828
|
+
{childArray.map((child, index) => (
|
|
829
|
+
<PuffPop
|
|
830
|
+
key={index}
|
|
831
|
+
effect={effect}
|
|
832
|
+
duration={duration}
|
|
833
|
+
delay={getChildDelay(index)}
|
|
834
|
+
easing={easing}
|
|
835
|
+
skeleton={skeleton}
|
|
836
|
+
visible={visible}
|
|
837
|
+
animateOnMount={animateOnMount}
|
|
838
|
+
onAnimationComplete={handleChildComplete}
|
|
839
|
+
onAnimationStart={index === 0 ? handleChildStart : undefined}
|
|
840
|
+
respectReduceMotion={respectReduceMotion}
|
|
841
|
+
>
|
|
842
|
+
{child}
|
|
843
|
+
</PuffPop>
|
|
844
|
+
))}
|
|
845
|
+
</View>
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
551
849
|
export default PuffPop;
|
|
552
850
|
|