react-native-puff-pop 1.0.1

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 ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 liveforownhappiness
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # react-native-puff-pop 🎉
2
+
3
+ A React Native animation library for revealing children components with beautiful puff and pop effects.
4
+
5
+ Works with both **React Native CLI** and **Expo** projects - no native dependencies required!
6
+
7
+ ![npm](https://img.shields.io/npm/v/react-native-puff-pop)
8
+ ![license](https://img.shields.io/npm/l/react-native-puff-pop)
9
+
10
+ ## Demo
11
+
12
+ <p align="center">
13
+ <img src="./assets/demo.gif" alt="React Native PuffPop Demo" width="320" />
14
+ </p>
15
+
16
+ ## Features
17
+
18
+ - 🎬 **11 Animation Effects**: scale, rotate, fade, slideUp, slideDown, slideLeft, slideRight, bounce, flip, zoom, rotateScale
19
+ - 🦴 **Skeleton Mode**: Reserve space before animation or expand from zero height
20
+ - ⚡ **Native Driver Support**: Smooth 60fps animations
21
+ - 🎯 **Easy to Use**: Just wrap your components with `<PuffPop>`
22
+ - 📱 **Cross Platform**: Works on iOS, Android, and Web
23
+ - 🔧 **TypeScript**: Full TypeScript support with type definitions
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ # Using npm
29
+ npm install react-native-puff-pop
30
+
31
+ # Using yarn
32
+ yarn add react-native-puff-pop
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ### Basic Usage
38
+
39
+ ```tsx
40
+ import { PuffPop } from 'react-native-puff-pop';
41
+
42
+ function App() {
43
+ return (
44
+ <PuffPop>
45
+ <View style={styles.card}>
46
+ <Text>Hello, PuffPop!</Text>
47
+ </View>
48
+ </PuffPop>
49
+ );
50
+ }
51
+ ```
52
+
53
+ ### With Different Effects
54
+
55
+ ```tsx
56
+ // Scale from center (default)
57
+ <PuffPop effect="scale">
58
+ <YourComponent />
59
+ </PuffPop>
60
+
61
+ // Rotate while appearing
62
+ <PuffPop effect="rotate">
63
+ <YourComponent />
64
+ </PuffPop>
65
+
66
+ // Rotate + Scale combined
67
+ <PuffPop effect="rotateScale" easing="spring">
68
+ <YourComponent />
69
+ </PuffPop>
70
+
71
+ // Bounce effect
72
+ <PuffPop effect="bounce" duration={600}>
73
+ <YourComponent />
74
+ </PuffPop>
75
+
76
+ // Slide from bottom
77
+ <PuffPop effect="slideUp">
78
+ <YourComponent />
79
+ </PuffPop>
80
+
81
+ // 3D Flip effect
82
+ <PuffPop effect="flip">
83
+ <YourComponent />
84
+ </PuffPop>
85
+ ```
86
+
87
+ ### Skeleton Mode
88
+
89
+ By default, `skeleton={true}` reserves space for the component before animation:
90
+
91
+ ```tsx
92
+ // Reserves space (default)
93
+ <PuffPop skeleton={true}>
94
+ <YourComponent />
95
+ </PuffPop>
96
+
97
+ // Expands from zero height, pushing content below
98
+ <PuffPop skeleton={false}>
99
+ <YourComponent />
100
+ </PuffPop>
101
+ ```
102
+
103
+ ### Staggered Animations
104
+
105
+ ```tsx
106
+ <View>
107
+ <PuffPop delay={0}>
108
+ <Card title="First" />
109
+ </PuffPop>
110
+ <PuffPop delay={100}>
111
+ <Card title="Second" />
112
+ </PuffPop>
113
+ <PuffPop delay={200}>
114
+ <Card title="Third" />
115
+ </PuffPop>
116
+ </View>
117
+ ```
118
+
119
+ ### Controlled Visibility
120
+
121
+ ```tsx
122
+ function App() {
123
+ const [visible, setVisible] = useState(false);
124
+
125
+ return (
126
+ <>
127
+ <Button title="Toggle" onPress={() => setVisible(!visible)} />
128
+ <PuffPop visible={visible} animateOnMount={false}>
129
+ <YourComponent />
130
+ </PuffPop>
131
+ </>
132
+ );
133
+ }
134
+ ```
135
+
136
+ ## Props
137
+
138
+ | Prop | Type | Default | Description |
139
+ |------|------|---------|-------------|
140
+ | `children` | `ReactNode` | - | Children to animate |
141
+ | `effect` | `PuffPopEffect` | `'scale'` | Animation effect type |
142
+ | `duration` | `number` | `400` | Animation duration in ms |
143
+ | `delay` | `number` | `0` | Delay before animation starts in ms |
144
+ | `easing` | `PuffPopEasing` | `'easeOut'` | Easing function |
145
+ | `skeleton` | `boolean` | `true` | Reserve space before animation |
146
+ | `visible` | `boolean` | `true` | Control visibility |
147
+ | `animateOnMount` | `boolean` | `true` | Animate when component mounts |
148
+ | `onAnimationComplete` | `() => void` | - | Callback when animation completes |
149
+ | `style` | `ViewStyle` | - | Custom container style |
150
+
151
+ ### Animation Effects (`PuffPopEffect`)
152
+
153
+ | Effect | Description |
154
+ |--------|-------------|
155
+ | `scale` | Scale from center point |
156
+ | `rotate` | Full rotation (360°) while appearing |
157
+ | `fade` | Simple fade in |
158
+ | `slideUp` | Slide from bottom |
159
+ | `slideDown` | Slide from top |
160
+ | `slideLeft` | Slide from right |
161
+ | `slideRight` | Slide from left |
162
+ | `bounce` | Bounce effect with overshoot |
163
+ | `flip` | 3D flip effect |
164
+ | `zoom` | Zoom with slight overshoot |
165
+ | `rotateScale` | Rotate + Scale combined |
166
+
167
+ ### Easing Types (`PuffPopEasing`)
168
+
169
+ | Easing | Description |
170
+ |--------|-------------|
171
+ | `linear` | Linear animation |
172
+ | `easeIn` | Slow start |
173
+ | `easeOut` | Slow end |
174
+ | `easeInOut` | Slow start and end |
175
+ | `spring` | Spring-like effect |
176
+ | `bounce` | Bouncing effect |
177
+
178
+ ## Skeleton Mode Explained
179
+
180
+ ### `skeleton={true}` (default)
181
+ The component reserves its full space immediately, and only the visual appearance animates. This is useful when you don't want layout shifts.
182
+
183
+ ### `skeleton={false}`
184
+ The component's height starts at 0 and expands during animation, pushing other content below it. This creates a more dynamic entrance effect.
185
+
186
+ ## License
187
+
188
+ MIT
189
+
190
+ ## Contributing
191
+
192
+ Contributions are welcome! Please feel free to submit a Pull Request.
@@ -0,0 +1,274 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState, useCallback, } from 'react';
3
+ import { View, Animated, StyleSheet, Easing, } from 'react-native';
4
+ /**
5
+ * Get easing function based on type
6
+ */
7
+ function getEasing(type) {
8
+ switch (type) {
9
+ case 'linear':
10
+ return Easing.linear;
11
+ case 'easeIn':
12
+ return Easing.in(Easing.ease);
13
+ case 'easeOut':
14
+ return Easing.out(Easing.ease);
15
+ case 'easeInOut':
16
+ return Easing.inOut(Easing.ease);
17
+ case 'spring':
18
+ return Easing.out(Easing.back(1.5));
19
+ case 'bounce':
20
+ return Easing.bounce;
21
+ default:
22
+ return Easing.out(Easing.ease);
23
+ }
24
+ }
25
+ /**
26
+ * PuffPop - Animate children with beautiful entrance effects
27
+ */
28
+ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0, easing = 'easeOut', skeleton = true, visible = true, onAnimationComplete, style, animateOnMount = true, }) {
29
+ // Animation values
30
+ const opacity = useRef(new Animated.Value(animateOnMount ? 0 : 1)).current;
31
+ const scale = useRef(new Animated.Value(animateOnMount ? getInitialScale(effect) : 1)).current;
32
+ const rotate = useRef(new Animated.Value(animateOnMount ? getInitialRotate(effect) : 0)).current;
33
+ const translateX = useRef(new Animated.Value(animateOnMount ? getInitialTranslateX(effect) : 0)).current;
34
+ const translateY = useRef(new Animated.Value(animateOnMount ? getInitialTranslateY(effect) : 0)).current;
35
+ // For non-skeleton mode
36
+ const [measuredHeight, setMeasuredHeight] = useState(null);
37
+ const animatedHeight = useRef(new Animated.Value(0)).current;
38
+ const hasAnimated = useRef(false);
39
+ // Handle layout measurement for non-skeleton mode
40
+ const onLayout = useCallback((event) => {
41
+ if (!skeleton && measuredHeight === null) {
42
+ const { height } = event.nativeEvent.layout;
43
+ setMeasuredHeight(height);
44
+ }
45
+ }, [skeleton, measuredHeight]);
46
+ // Animate function
47
+ const animate = useCallback((toVisible) => {
48
+ const easingFn = getEasing(easing);
49
+ // When skeleton is false, we animate height which doesn't support native driver
50
+ // So we must use JS driver for all animations in that case
51
+ const useNative = skeleton;
52
+ const config = {
53
+ duration,
54
+ easing: easingFn,
55
+ useNativeDriver: useNative,
56
+ };
57
+ const animations = [];
58
+ // Opacity animation
59
+ animations.push(Animated.timing(opacity, {
60
+ toValue: toVisible ? 1 : 0,
61
+ ...config,
62
+ }));
63
+ // Scale animation
64
+ if (['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect)) {
65
+ const targetScale = toVisible ? 1 : getInitialScale(effect);
66
+ animations.push(Animated.timing(scale, {
67
+ toValue: targetScale,
68
+ ...config,
69
+ easing: effect === 'bounce' ? Easing.bounce : easingFn,
70
+ }));
71
+ }
72
+ // Rotate animation
73
+ if (['rotate', 'rotateScale', 'flip'].includes(effect)) {
74
+ const targetRotate = toVisible ? 0 : getInitialRotate(effect);
75
+ animations.push(Animated.timing(rotate, {
76
+ toValue: targetRotate,
77
+ ...config,
78
+ }));
79
+ }
80
+ // TranslateX animation
81
+ if (['slideLeft', 'slideRight'].includes(effect)) {
82
+ const targetX = toVisible ? 0 : getInitialTranslateX(effect);
83
+ animations.push(Animated.timing(translateX, {
84
+ toValue: targetX,
85
+ ...config,
86
+ }));
87
+ }
88
+ // TranslateY animation
89
+ if (['slideUp', 'slideDown', 'bounce'].includes(effect)) {
90
+ const targetY = toVisible ? 0 : getInitialTranslateY(effect);
91
+ animations.push(Animated.timing(translateY, {
92
+ toValue: targetY,
93
+ ...config,
94
+ }));
95
+ }
96
+ // Height animation for non-skeleton mode
97
+ if (!skeleton && measuredHeight !== null) {
98
+ const targetHeight = toVisible ? measuredHeight : 0;
99
+ animations.push(Animated.timing(animatedHeight, {
100
+ toValue: targetHeight,
101
+ duration,
102
+ easing: easingFn,
103
+ useNativeDriver: false,
104
+ }));
105
+ }
106
+ // Run animations with delay
107
+ const parallelAnimation = Animated.parallel(animations);
108
+ if (delay > 0) {
109
+ Animated.sequence([
110
+ Animated.delay(delay),
111
+ parallelAnimation,
112
+ ]).start(() => {
113
+ if (toVisible && onAnimationComplete) {
114
+ onAnimationComplete();
115
+ }
116
+ });
117
+ }
118
+ else {
119
+ parallelAnimation.start(() => {
120
+ if (toVisible && onAnimationComplete) {
121
+ onAnimationComplete();
122
+ }
123
+ });
124
+ }
125
+ }, [
126
+ delay,
127
+ duration,
128
+ easing,
129
+ effect,
130
+ measuredHeight,
131
+ onAnimationComplete,
132
+ opacity,
133
+ rotate,
134
+ scale,
135
+ skeleton,
136
+ translateX,
137
+ translateY,
138
+ animatedHeight,
139
+ ]);
140
+ // Handle initial mount animation
141
+ useEffect(() => {
142
+ if (animateOnMount && !hasAnimated.current && visible) {
143
+ hasAnimated.current = true;
144
+ animate(true);
145
+ }
146
+ }, [animate, animateOnMount, visible]);
147
+ // Handle visibility changes after mount
148
+ useEffect(() => {
149
+ if (hasAnimated.current) {
150
+ animate(visible);
151
+ }
152
+ }, [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);
164
+ const transforms = [];
165
+ if (hasScale) {
166
+ transforms.push({ scale });
167
+ }
168
+ if (hasRotate) {
169
+ transforms.push({
170
+ rotate: rotate.interpolate({
171
+ inputRange: [-360, 0, 360],
172
+ outputRange: ['-360deg', '0deg', '360deg'],
173
+ }),
174
+ });
175
+ }
176
+ if (hasFlip) {
177
+ transforms.push({
178
+ rotateY: rotate.interpolate({
179
+ inputRange: [-180, 0],
180
+ outputRange: ['-180deg', '0deg'],
181
+ }),
182
+ });
183
+ }
184
+ if (hasTranslateX) {
185
+ transforms.push({ translateX });
186
+ }
187
+ if (hasTranslateY) {
188
+ transforms.push({ translateY });
189
+ }
190
+ return transforms.length > 0 ? transforms : undefined;
191
+ };
192
+ const animatedStyle = {
193
+ opacity,
194
+ transform: getTransform(),
195
+ };
196
+ // Container style for non-skeleton mode
197
+ const containerAnimatedStyle = !skeleton && measuredHeight !== null
198
+ ? { height: animatedHeight, overflow: 'hidden' }
199
+ : {};
200
+ return (_jsx(Animated.View, { style: [styles.container, style, containerAnimatedStyle], children: _jsx(Animated.View, { style: animatedStyle, children: children }) }));
201
+ }
202
+ /**
203
+ * Get initial scale value based on effect
204
+ */
205
+ function getInitialScale(effect) {
206
+ switch (effect) {
207
+ case 'scale':
208
+ case 'rotateScale':
209
+ return 0;
210
+ case 'bounce':
211
+ return 0.3;
212
+ case 'zoom':
213
+ return 0.5;
214
+ case 'flip':
215
+ return 0.8;
216
+ default:
217
+ return 1;
218
+ }
219
+ }
220
+ /**
221
+ * Get initial rotate value based on effect
222
+ */
223
+ function getInitialRotate(effect) {
224
+ switch (effect) {
225
+ case 'rotate':
226
+ return -360;
227
+ case 'rotateScale':
228
+ return -180;
229
+ case 'flip':
230
+ return -180;
231
+ default:
232
+ return 0;
233
+ }
234
+ }
235
+ /**
236
+ * Get initial translateX value based on effect
237
+ */
238
+ function getInitialTranslateX(effect) {
239
+ switch (effect) {
240
+ case 'slideLeft':
241
+ return 100;
242
+ case 'slideRight':
243
+ return -100;
244
+ default:
245
+ return 0;
246
+ }
247
+ }
248
+ /**
249
+ * Get initial translateY value based on effect
250
+ */
251
+ function getInitialTranslateY(effect) {
252
+ switch (effect) {
253
+ case 'slideUp':
254
+ return 50;
255
+ case 'slideDown':
256
+ return -50;
257
+ case 'bounce':
258
+ return 30;
259
+ default:
260
+ return 0;
261
+ }
262
+ }
263
+ const styles = StyleSheet.create({
264
+ container: {},
265
+ measureContainer: {
266
+ position: 'absolute',
267
+ opacity: 0,
268
+ pointerEvents: 'none',
269
+ },
270
+ hidden: {
271
+ opacity: 0,
272
+ },
273
+ });
274
+ export default PuffPop;
@@ -0,0 +1,65 @@
1
+ import { type ReactNode, type ReactElement } from 'react';
2
+ import { type StyleProp, type ViewStyle } from 'react-native';
3
+ /**
4
+ * Animation effect types for PuffPop
5
+ */
6
+ export type PuffPopEffect = 'scale' | 'rotate' | 'fade' | 'slideUp' | 'slideDown' | 'slideLeft' | 'slideRight' | 'bounce' | 'flip' | 'zoom' | 'rotateScale';
7
+ /**
8
+ * Easing function types
9
+ */
10
+ export type PuffPopEasing = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'spring' | 'bounce';
11
+ export interface PuffPopProps {
12
+ /**
13
+ * Children to animate
14
+ */
15
+ children: ReactNode;
16
+ /**
17
+ * Animation effect type
18
+ * @default 'scale'
19
+ */
20
+ effect?: PuffPopEffect;
21
+ /**
22
+ * Animation duration in milliseconds
23
+ * @default 400
24
+ */
25
+ duration?: number;
26
+ /**
27
+ * Delay before animation starts in milliseconds
28
+ * @default 0
29
+ */
30
+ delay?: number;
31
+ /**
32
+ * Easing function for animation
33
+ * @default 'easeOut'
34
+ */
35
+ easing?: PuffPopEasing;
36
+ /**
37
+ * If true, reserves space for children before animation (skeleton mode)
38
+ * If false, children height starts at 0 and expands, pushing content below
39
+ * @default true
40
+ */
41
+ skeleton?: boolean;
42
+ /**
43
+ * Whether to trigger animation (set to true to animate)
44
+ * @default true
45
+ */
46
+ visible?: boolean;
47
+ /**
48
+ * Callback when animation completes
49
+ */
50
+ onAnimationComplete?: () => void;
51
+ /**
52
+ * Custom style for the container
53
+ */
54
+ style?: StyleProp<ViewStyle>;
55
+ /**
56
+ * Whether to animate on mount
57
+ * @default true
58
+ */
59
+ animateOnMount?: boolean;
60
+ }
61
+ /**
62
+ * PuffPop - Animate children with beautiful entrance effects
63
+ */
64
+ export declare function PuffPop({ children, effect, duration, delay, easing, skeleton, visible, onAnimationComplete, style, animateOnMount, }: PuffPopProps): ReactElement;
65
+ export default PuffPop;
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "react-native-puff-pop",
3
+ "version": "1.0.1",
4
+ "description": "A React Native animation library for revealing children components with beautiful puff and pop effects",
5
+ "main": "./lib/module/index.js",
6
+ "types": "./lib/typescript/src/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "source": "./src/index.tsx",
10
+ "types": "./lib/typescript/src/index.d.ts",
11
+ "default": "./lib/module/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "lib",
18
+ "!**/__tests__",
19
+ "!**/__fixtures__",
20
+ "!**/__mocks__",
21
+ "!**/.*"
22
+ ],
23
+ "scripts": {
24
+ "example": "yarn workspace react-native-puff-pop-example",
25
+ "ios": "yarn workspace react-native-puff-pop-example ios",
26
+ "android": "yarn workspace react-native-puff-pop-example android",
27
+ "web": "yarn workspace react-native-puff-pop-example web",
28
+ "clean": "del-cli lib",
29
+ "prepare": "yarn clean && tsc -p tsconfig.build.json",
30
+ "typecheck": "tsc",
31
+ "lint": "eslint \"**/*.{js,ts,tsx}\"",
32
+ "test": "jest"
33
+ },
34
+ "keywords": [
35
+ "react-native",
36
+ "ios",
37
+ "android",
38
+ "expo",
39
+ "animation",
40
+ "puff",
41
+ "pop",
42
+ "reveal",
43
+ "transition",
44
+ "entrance",
45
+ "effects",
46
+ "skeleton",
47
+ "react-native-puff-pop"
48
+ ],
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/liveforownhappiness/react-native-puff-pop.git"
52
+ },
53
+ "author": "liveforownhappiness <liveforownhappiness@gmail.com> (https://github.com/liveforownhappiness)",
54
+ "license": "MIT",
55
+ "bugs": {
56
+ "url": "https://github.com/liveforownhappiness/react-native-puff-pop/issues"
57
+ },
58
+ "homepage": "https://github.com/liveforownhappiness/react-native-puff-pop#readme",
59
+ "publishConfig": {
60
+ "registry": "https://registry.npmjs.org/"
61
+ },
62
+ "devDependencies": {
63
+ "@types/react": "^19.1.12",
64
+ "del-cli": "^6.0.0",
65
+ "react": "19.1.0",
66
+ "react-native": "0.81.5",
67
+ "typescript": "^5.9.2"
68
+ },
69
+ "peerDependencies": {
70
+ "react": "*",
71
+ "react-native": "*"
72
+ },
73
+ "workspaces": [
74
+ "example"
75
+ ],
76
+ "packageManager": "yarn@4.11.0",
77
+ "prettier": {
78
+ "quoteProps": "consistent",
79
+ "singleQuote": true,
80
+ "tabWidth": 2,
81
+ "trailingComma": "es5",
82
+ "useTabs": false
83
+ },
84
+ "create-react-native-library": {
85
+ "type": "library",
86
+ "languages": "js",
87
+ "version": "0.56.0"
88
+ }
89
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,446 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ useCallback,
6
+ type ReactNode,
7
+ type ReactElement,
8
+ } from 'react';
9
+ import {
10
+ View,
11
+ Animated,
12
+ StyleSheet,
13
+ Easing,
14
+ type LayoutChangeEvent,
15
+ type StyleProp,
16
+ type ViewStyle,
17
+ } from 'react-native';
18
+
19
+ /**
20
+ * Animation effect types for PuffPop
21
+ */
22
+ export type PuffPopEffect =
23
+ | 'scale' // Scale from center (점에서 커지면서 나타남)
24
+ | 'rotate' // Rotate while appearing (회전하면서 나타남)
25
+ | 'fade' // Simple fade in
26
+ | 'slideUp' // Slide from bottom
27
+ | 'slideDown' // Slide from top
28
+ | 'slideLeft' // Slide from right
29
+ | 'slideRight' // Slide from left
30
+ | 'bounce' // Bounce effect with overshoot
31
+ | 'flip' // 3D flip effect
32
+ | 'zoom' // Zoom with slight overshoot
33
+ | 'rotateScale'; // Rotate + Scale combined
34
+
35
+ /**
36
+ * Easing function types
37
+ */
38
+ export type PuffPopEasing =
39
+ | 'linear'
40
+ | 'easeIn'
41
+ | 'easeOut'
42
+ | 'easeInOut'
43
+ | 'spring'
44
+ | 'bounce';
45
+
46
+ export interface PuffPopProps {
47
+ /**
48
+ * Children to animate
49
+ */
50
+ children: ReactNode;
51
+
52
+ /**
53
+ * Animation effect type
54
+ * @default 'scale'
55
+ */
56
+ effect?: PuffPopEffect;
57
+
58
+ /**
59
+ * Animation duration in milliseconds
60
+ * @default 400
61
+ */
62
+ duration?: number;
63
+
64
+ /**
65
+ * Delay before animation starts in milliseconds
66
+ * @default 0
67
+ */
68
+ delay?: number;
69
+
70
+ /**
71
+ * Easing function for animation
72
+ * @default 'easeOut'
73
+ */
74
+ easing?: PuffPopEasing;
75
+
76
+ /**
77
+ * If true, reserves space for children before animation (skeleton mode)
78
+ * If false, children height starts at 0 and expands, pushing content below
79
+ * @default true
80
+ */
81
+ skeleton?: boolean;
82
+
83
+ /**
84
+ * Whether to trigger animation (set to true to animate)
85
+ * @default true
86
+ */
87
+ visible?: boolean;
88
+
89
+ /**
90
+ * Callback when animation completes
91
+ */
92
+ onAnimationComplete?: () => void;
93
+
94
+ /**
95
+ * Custom style for the container
96
+ */
97
+ style?: StyleProp<ViewStyle>;
98
+
99
+ /**
100
+ * Whether to animate on mount
101
+ * @default true
102
+ */
103
+ animateOnMount?: boolean;
104
+ }
105
+
106
+ /**
107
+ * Get easing function based on type
108
+ */
109
+ function getEasing(type: PuffPopEasing): (value: number) => number {
110
+ switch (type) {
111
+ case 'linear':
112
+ return Easing.linear;
113
+ case 'easeIn':
114
+ return Easing.in(Easing.ease);
115
+ case 'easeOut':
116
+ return Easing.out(Easing.ease);
117
+ case 'easeInOut':
118
+ return Easing.inOut(Easing.ease);
119
+ case 'spring':
120
+ return Easing.out(Easing.back(1.5));
121
+ case 'bounce':
122
+ return Easing.bounce;
123
+ default:
124
+ return Easing.out(Easing.ease);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * PuffPop - Animate children with beautiful entrance effects
130
+ */
131
+ export function PuffPop({
132
+ children,
133
+ effect = 'scale',
134
+ duration = 400,
135
+ delay = 0,
136
+ easing = 'easeOut',
137
+ skeleton = true,
138
+ visible = true,
139
+ onAnimationComplete,
140
+ style,
141
+ animateOnMount = true,
142
+ }: PuffPopProps): ReactElement {
143
+ // Animation values
144
+ const opacity = useRef(new Animated.Value(animateOnMount ? 0 : 1)).current;
145
+ const scale = useRef(new Animated.Value(animateOnMount ? getInitialScale(effect) : 1)).current;
146
+ const rotate = useRef(new Animated.Value(animateOnMount ? getInitialRotate(effect) : 0)).current;
147
+ const translateX = useRef(new Animated.Value(animateOnMount ? getInitialTranslateX(effect) : 0)).current;
148
+ const translateY = useRef(new Animated.Value(animateOnMount ? getInitialTranslateY(effect) : 0)).current;
149
+
150
+ // For non-skeleton mode
151
+ const [measuredHeight, setMeasuredHeight] = useState<number | null>(null);
152
+ const animatedHeight = useRef(new Animated.Value(0)).current;
153
+ const hasAnimated = useRef(false);
154
+
155
+ // Handle layout measurement for non-skeleton mode
156
+ const onLayout = useCallback(
157
+ (event: LayoutChangeEvent) => {
158
+ if (!skeleton && measuredHeight === null) {
159
+ const { height } = event.nativeEvent.layout;
160
+ setMeasuredHeight(height);
161
+ }
162
+ },
163
+ [skeleton, measuredHeight]
164
+ );
165
+
166
+ // Animate function
167
+ const animate = useCallback(
168
+ (toVisible: boolean) => {
169
+ const easingFn = getEasing(easing);
170
+ // When skeleton is false, we animate height which doesn't support native driver
171
+ // So we must use JS driver for all animations in that case
172
+ const useNative = skeleton;
173
+ const config = {
174
+ duration,
175
+ easing: easingFn,
176
+ useNativeDriver: useNative,
177
+ };
178
+
179
+ const animations: Animated.CompositeAnimation[] = [];
180
+
181
+ // Opacity animation
182
+ animations.push(
183
+ Animated.timing(opacity, {
184
+ toValue: toVisible ? 1 : 0,
185
+ ...config,
186
+ })
187
+ );
188
+
189
+ // Scale animation
190
+ if (['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect)) {
191
+ const targetScale = toVisible ? 1 : getInitialScale(effect);
192
+ animations.push(
193
+ Animated.timing(scale, {
194
+ toValue: targetScale,
195
+ ...config,
196
+ easing: effect === 'bounce' ? Easing.bounce : easingFn,
197
+ })
198
+ );
199
+ }
200
+
201
+ // Rotate animation
202
+ if (['rotate', 'rotateScale', 'flip'].includes(effect)) {
203
+ const targetRotate = toVisible ? 0 : getInitialRotate(effect);
204
+ animations.push(
205
+ Animated.timing(rotate, {
206
+ toValue: targetRotate,
207
+ ...config,
208
+ })
209
+ );
210
+ }
211
+
212
+ // TranslateX animation
213
+ if (['slideLeft', 'slideRight'].includes(effect)) {
214
+ const targetX = toVisible ? 0 : getInitialTranslateX(effect);
215
+ animations.push(
216
+ Animated.timing(translateX, {
217
+ toValue: targetX,
218
+ ...config,
219
+ })
220
+ );
221
+ }
222
+
223
+ // TranslateY animation
224
+ if (['slideUp', 'slideDown', 'bounce'].includes(effect)) {
225
+ const targetY = toVisible ? 0 : getInitialTranslateY(effect);
226
+ animations.push(
227
+ Animated.timing(translateY, {
228
+ toValue: targetY,
229
+ ...config,
230
+ })
231
+ );
232
+ }
233
+
234
+ // Height animation for non-skeleton mode
235
+ if (!skeleton && measuredHeight !== null) {
236
+ const targetHeight = toVisible ? measuredHeight : 0;
237
+ animations.push(
238
+ Animated.timing(animatedHeight, {
239
+ toValue: targetHeight,
240
+ duration,
241
+ easing: easingFn,
242
+ useNativeDriver: false,
243
+ })
244
+ );
245
+ }
246
+
247
+ // Run animations with delay
248
+ const parallelAnimation = Animated.parallel(animations);
249
+
250
+ if (delay > 0) {
251
+ Animated.sequence([
252
+ Animated.delay(delay),
253
+ parallelAnimation,
254
+ ]).start(() => {
255
+ if (toVisible && onAnimationComplete) {
256
+ onAnimationComplete();
257
+ }
258
+ });
259
+ } else {
260
+ parallelAnimation.start(() => {
261
+ if (toVisible && onAnimationComplete) {
262
+ onAnimationComplete();
263
+ }
264
+ });
265
+ }
266
+ },
267
+ [
268
+ delay,
269
+ duration,
270
+ easing,
271
+ effect,
272
+ measuredHeight,
273
+ onAnimationComplete,
274
+ opacity,
275
+ rotate,
276
+ scale,
277
+ skeleton,
278
+ translateX,
279
+ translateY,
280
+ animatedHeight,
281
+ ]
282
+ );
283
+
284
+ // Handle initial mount animation
285
+ useEffect(() => {
286
+ if (animateOnMount && !hasAnimated.current && visible) {
287
+ hasAnimated.current = true;
288
+ animate(true);
289
+ }
290
+ }, [animate, animateOnMount, visible]);
291
+
292
+ // Handle visibility changes after mount
293
+ useEffect(() => {
294
+ if (hasAnimated.current) {
295
+ animate(visible);
296
+ }
297
+ }, [visible, animate]);
298
+
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);
315
+
316
+ const transforms = [];
317
+
318
+ if (hasScale) {
319
+ transforms.push({ scale });
320
+ }
321
+
322
+ if (hasRotate) {
323
+ transforms.push({
324
+ rotate: rotate.interpolate({
325
+ inputRange: [-360, 0, 360],
326
+ outputRange: ['-360deg', '0deg', '360deg'],
327
+ }),
328
+ });
329
+ }
330
+
331
+ if (hasFlip) {
332
+ transforms.push({
333
+ rotateY: rotate.interpolate({
334
+ inputRange: [-180, 0],
335
+ outputRange: ['-180deg', '0deg'],
336
+ }),
337
+ });
338
+ }
339
+
340
+ if (hasTranslateX) {
341
+ transforms.push({ translateX });
342
+ }
343
+
344
+ if (hasTranslateY) {
345
+ transforms.push({ translateY });
346
+ }
347
+
348
+ return transforms.length > 0 ? transforms : undefined;
349
+ };
350
+
351
+ const animatedStyle = {
352
+ opacity,
353
+ transform: getTransform(),
354
+ };
355
+
356
+ // Container style for non-skeleton mode
357
+ const containerAnimatedStyle = !skeleton && measuredHeight !== null
358
+ ? { height: animatedHeight, overflow: 'hidden' as const }
359
+ : {};
360
+
361
+ return (
362
+ <Animated.View style={[styles.container, style, containerAnimatedStyle]}>
363
+ <Animated.View style={animatedStyle}>{children}</Animated.View>
364
+ </Animated.View>
365
+ );
366
+ }
367
+
368
+ /**
369
+ * Get initial scale value based on effect
370
+ */
371
+ function getInitialScale(effect: PuffPopEffect): number {
372
+ switch (effect) {
373
+ case 'scale':
374
+ case 'rotateScale':
375
+ return 0;
376
+ case 'bounce':
377
+ return 0.3;
378
+ case 'zoom':
379
+ return 0.5;
380
+ case 'flip':
381
+ return 0.8;
382
+ default:
383
+ return 1;
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Get initial rotate value based on effect
389
+ */
390
+ function getInitialRotate(effect: PuffPopEffect): number {
391
+ switch (effect) {
392
+ case 'rotate':
393
+ return -360;
394
+ case 'rotateScale':
395
+ return -180;
396
+ case 'flip':
397
+ return -180;
398
+ default:
399
+ return 0;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Get initial translateX value based on effect
405
+ */
406
+ function getInitialTranslateX(effect: PuffPopEffect): number {
407
+ switch (effect) {
408
+ case 'slideLeft':
409
+ return 100;
410
+ case 'slideRight':
411
+ return -100;
412
+ default:
413
+ return 0;
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Get initial translateY value based on effect
419
+ */
420
+ function getInitialTranslateY(effect: PuffPopEffect): number {
421
+ switch (effect) {
422
+ case 'slideUp':
423
+ return 50;
424
+ case 'slideDown':
425
+ return -50;
426
+ case 'bounce':
427
+ return 30;
428
+ default:
429
+ return 0;
430
+ }
431
+ }
432
+
433
+ const styles = StyleSheet.create({
434
+ container: {},
435
+ measureContainer: {
436
+ position: 'absolute',
437
+ opacity: 0,
438
+ pointerEvents: 'none',
439
+ },
440
+ hidden: {
441
+ opacity: 0,
442
+ },
443
+ });
444
+
445
+ export default PuffPop;
446
+