react-native-ease 0.2.0 → 0.3.0

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.
@@ -0,0 +1,295 @@
1
+ import React, { useEffect, useRef, useState, useCallback } from 'react';
2
+ import { View, type ViewStyle, type StyleProp } from 'react-native';
3
+ import type {
4
+ AnimateProps,
5
+ CubicBezier,
6
+ Transition,
7
+ TransitionEndEvent,
8
+ TransformOrigin,
9
+ } from './types';
10
+
11
+ /** Identity values used as defaults for animate/initialAnimate. */
12
+ const IDENTITY: Required<Omit<AnimateProps, 'scale' | 'backgroundColor'>> = {
13
+ opacity: 1,
14
+ translateX: 0,
15
+ translateY: 0,
16
+ scaleX: 1,
17
+ scaleY: 1,
18
+ rotate: 0,
19
+ rotateX: 0,
20
+ rotateY: 0,
21
+ borderRadius: 0,
22
+ };
23
+
24
+ /** Preset easing curves as cubic bezier control points. */
25
+ const EASING_PRESETS: Record<string, CubicBezier> = {
26
+ linear: [0, 0, 1, 1],
27
+ easeIn: [0.42, 0, 1, 1],
28
+ easeOut: [0, 0, 0.58, 1],
29
+ easeInOut: [0.42, 0, 0.58, 1],
30
+ };
31
+
32
+ export type EaseViewProps = {
33
+ animate?: AnimateProps;
34
+ initialAnimate?: AnimateProps;
35
+ transition?: Transition;
36
+ onTransitionEnd?: (event: TransitionEndEvent) => void;
37
+ /** No-op on web. */
38
+ useHardwareLayer?: boolean;
39
+ transformOrigin?: TransformOrigin;
40
+ style?: StyleProp<ViewStyle>;
41
+ children?: React.ReactNode;
42
+ };
43
+
44
+ function resolveAnimateValues(props: AnimateProps | undefined): Required<
45
+ Omit<AnimateProps, 'scale' | 'backgroundColor'>
46
+ > & {
47
+ backgroundColor?: string;
48
+ } {
49
+ return {
50
+ ...IDENTITY,
51
+ ...props,
52
+ scaleX: props?.scaleX ?? props?.scale ?? IDENTITY.scaleX,
53
+ scaleY: props?.scaleY ?? props?.scale ?? IDENTITY.scaleY,
54
+ rotateX: props?.rotateX ?? IDENTITY.rotateX,
55
+ rotateY: props?.rotateY ?? IDENTITY.rotateY,
56
+ backgroundColor: props?.backgroundColor as string | undefined,
57
+ };
58
+ }
59
+
60
+ function buildTransform(vals: ReturnType<typeof resolveAnimateValues>): string {
61
+ const parts: string[] = [];
62
+ if (vals.translateX !== 0 || vals.translateY !== 0) {
63
+ parts.push(`translate(${vals.translateX}px, ${vals.translateY}px)`);
64
+ }
65
+ if (vals.scaleX !== 1 || vals.scaleY !== 1) {
66
+ parts.push(
67
+ vals.scaleX === vals.scaleY
68
+ ? `scale(${vals.scaleX})`
69
+ : `scale(${vals.scaleX}, ${vals.scaleY})`,
70
+ );
71
+ }
72
+ if (vals.rotate !== 0) {
73
+ parts.push(`rotate(${vals.rotate}deg)`);
74
+ }
75
+ if (vals.rotateX !== 0) {
76
+ parts.push(`rotateX(${vals.rotateX}deg)`);
77
+ }
78
+ if (vals.rotateY !== 0) {
79
+ parts.push(`rotateY(${vals.rotateY}deg)`);
80
+ }
81
+ return parts.length > 0 ? parts.join(' ') : 'none';
82
+ }
83
+
84
+ function resolveEasing(transition: Transition | undefined): string {
85
+ if (!transition || transition.type !== 'timing') {
86
+ return 'cubic-bezier(0.42, 0, 0.58, 1)';
87
+ }
88
+ const easing = transition.easing ?? 'easeInOut';
89
+ const bezier: CubicBezier = Array.isArray(easing)
90
+ ? easing
91
+ : EASING_PRESETS[easing]!;
92
+ return `cubic-bezier(${bezier[0]}, ${bezier[1]}, ${bezier[2]}, ${bezier[3]})`;
93
+ }
94
+
95
+ function resolveDuration(transition: Transition | undefined): number {
96
+ if (!transition) return 300;
97
+ if (transition.type === 'timing') return transition.duration ?? 300;
98
+ if (transition.type === 'none') return 0;
99
+ const damping = transition.damping ?? 15;
100
+ const mass = transition.mass ?? 1;
101
+ const tau = (2 * mass) / damping;
102
+ return Math.round(tau * 4 * 1000);
103
+ }
104
+
105
+ /** CSS transition properties that we animate. */
106
+ const TRANSITION_PROPS = [
107
+ 'opacity',
108
+ 'transform',
109
+ 'border-radius',
110
+ 'background-color',
111
+ ];
112
+
113
+ /** Counter for unique keyframe names. */
114
+ let keyframeCounter = 0;
115
+
116
+ export function EaseView({
117
+ animate,
118
+ initialAnimate,
119
+ transition,
120
+ onTransitionEnd,
121
+ useHardwareLayer: _useHardwareLayer,
122
+ transformOrigin,
123
+ style,
124
+ children,
125
+ }: EaseViewProps) {
126
+ const resolved = resolveAnimateValues(animate);
127
+ const hasInitial = initialAnimate != null;
128
+ const [mounted, setMounted] = useState(!hasInitial);
129
+ // On web, View ref gives us the underlying DOM element.
130
+ const viewRef = useRef<React.ComponentRef<typeof View>>(null);
131
+ const animationNameRef = useRef<string | null>(null);
132
+
133
+ const getElement = useCallback(
134
+ () => viewRef.current as unknown as HTMLElement | null,
135
+ [],
136
+ );
137
+
138
+ // For initialAnimate: render initial values first, then animate on mount.
139
+ useEffect(() => {
140
+ if (hasInitial) {
141
+ getElement()?.getBoundingClientRect();
142
+ setMounted(true);
143
+ }
144
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
145
+
146
+ const displayValues =
147
+ !mounted && hasInitial ? resolveAnimateValues(initialAnimate) : resolved;
148
+
149
+ const duration = resolveDuration(transition);
150
+ const easing = resolveEasing(transition);
151
+
152
+ const originX = ((transformOrigin?.x ?? 0.5) * 100).toFixed(1);
153
+ const originY = ((transformOrigin?.y ?? 0.5) * 100).toFixed(1);
154
+
155
+ const transitionType = transition?.type ?? 'timing';
156
+ const loopMode = transition?.type === 'timing' ? transition.loop : undefined;
157
+
158
+ const transitionCss =
159
+ transitionType === 'none' || (!mounted && hasInitial)
160
+ ? 'none'
161
+ : TRANSITION_PROPS.map((prop) => `${prop} ${duration}ms ${easing}`).join(
162
+ ', ',
163
+ );
164
+
165
+ // Apply CSS transition/animation properties imperatively (not in RN style spec).
166
+ useEffect(() => {
167
+ const el = getElement();
168
+ if (!el) return;
169
+
170
+ if (!loopMode) {
171
+ const springTransition =
172
+ transitionType === 'spring'
173
+ ? TRANSITION_PROPS.map(
174
+ (prop) =>
175
+ `${prop} ${duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)`,
176
+ ).join(', ')
177
+ : null;
178
+ el.style.transition = springTransition ?? transitionCss;
179
+ }
180
+ el.style.transformOrigin = `${originX}% ${originY}%`;
181
+ });
182
+
183
+ // Handle transitionend event via DOM listener.
184
+ useEffect(() => {
185
+ const el = getElement();
186
+ if (!el || !onTransitionEnd) return;
187
+
188
+ const handler = (e: TransitionEvent) => {
189
+ if (e.target !== e.currentTarget) return;
190
+ if (e.propertyName !== 'opacity' && e.propertyName !== 'transform')
191
+ return;
192
+ onTransitionEnd({ finished: true });
193
+ };
194
+
195
+ el.addEventListener('transitionend', handler);
196
+ return () => el.removeEventListener('transitionend', handler);
197
+ }, [onTransitionEnd, getElement]);
198
+
199
+ // Handle loop animations via CSS @keyframes.
200
+ useEffect(() => {
201
+ const el = getElement();
202
+ if (!loopMode || !el) {
203
+ if (animationNameRef.current) {
204
+ const cleanEl = getElement();
205
+ if (cleanEl) cleanEl.style.animation = '';
206
+ animationNameRef.current = null;
207
+ }
208
+ return;
209
+ }
210
+
211
+ const fromValues = initialAnimate
212
+ ? resolveAnimateValues(initialAnimate)
213
+ : resolveAnimateValues(undefined);
214
+ const toValues = resolveAnimateValues(animate);
215
+
216
+ const fromTransform = buildTransform(fromValues);
217
+ const toTransform = buildTransform(toValues);
218
+
219
+ const name = `ease-loop-${++keyframeCounter}`;
220
+ animationNameRef.current = name;
221
+
222
+ // Only include border-radius/background-color in keyframes when explicitly
223
+ // set by the user, to avoid overriding values from the style prop.
224
+ const hasBorderRadius =
225
+ initialAnimate?.borderRadius != null || animate?.borderRadius != null;
226
+ const hasBgColor =
227
+ initialAnimate?.backgroundColor != null ||
228
+ animate?.backgroundColor != null;
229
+
230
+ const fromBlock = [
231
+ `opacity: ${fromValues.opacity}`,
232
+ `transform: ${fromTransform}`,
233
+ hasBorderRadius ? `border-radius: ${fromValues.borderRadius}px` : '',
234
+ hasBgColor && fromValues.backgroundColor
235
+ ? `background-color: ${fromValues.backgroundColor}`
236
+ : '',
237
+ ]
238
+ .filter(Boolean)
239
+ .join('; ');
240
+
241
+ const toBlock = [
242
+ `opacity: ${toValues.opacity}`,
243
+ `transform: ${toTransform}`,
244
+ hasBorderRadius ? `border-radius: ${toValues.borderRadius}px` : '',
245
+ hasBgColor && toValues.backgroundColor
246
+ ? `background-color: ${toValues.backgroundColor}`
247
+ : '',
248
+ ]
249
+ .filter(Boolean)
250
+ .join('; ');
251
+
252
+ const keyframes = `@keyframes ${name} { from { ${fromBlock} } to { ${toBlock} } }`;
253
+
254
+ const styleEl = document.createElement('style');
255
+ styleEl.textContent = keyframes;
256
+ document.head.appendChild(styleEl);
257
+
258
+ const direction = loopMode === 'reverse' ? 'alternate' : 'normal';
259
+ el.style.transition = 'none';
260
+ el.style.animation = `${name} ${duration}ms ${easing} infinite ${direction}`;
261
+
262
+ return () => {
263
+ styleEl.remove();
264
+ el.style.animation = '';
265
+ animationNameRef.current = null;
266
+ };
267
+ }, [loopMode, animate, initialAnimate, duration, easing, getElement]);
268
+
269
+ // Build animated style using RN transform array format.
270
+ // react-native-web converts these to CSS transform strings.
271
+ const animatedStyle: ViewStyle = {
272
+ opacity: displayValues.opacity,
273
+ transform: [
274
+ { translateX: displayValues.translateX },
275
+ { translateY: displayValues.translateY },
276
+ { scaleX: displayValues.scaleX },
277
+ { scaleY: displayValues.scaleY },
278
+ { rotate: `${displayValues.rotate}deg` },
279
+ { rotateX: `${displayValues.rotateX}deg` },
280
+ { rotateY: `${displayValues.rotateY}deg` },
281
+ ],
282
+ ...(displayValues.borderRadius > 0
283
+ ? { borderRadius: displayValues.borderRadius }
284
+ : {}),
285
+ ...(displayValues.backgroundColor
286
+ ? { backgroundColor: displayValues.backgroundColor }
287
+ : {}),
288
+ };
289
+
290
+ return (
291
+ <View ref={viewRef} style={[style, animatedStyle]}>
292
+ {children}
293
+ </View>
294
+ );
295
+ }
@@ -52,6 +52,7 @@ export interface NativeProps extends ViewProps {
52
52
  'none' | 'repeat' | 'reverse',
53
53
  'none'
54
54
  >;
55
+ transitionDelay?: CodegenTypes.WithDefault<CodegenTypes.Int32, 0>;
55
56
 
56
57
  // Transform origin (0–1 fractions, default center)
57
58
  transformOriginX?: CodegenTypes.WithDefault<CodegenTypes.Float, 0.5>;
package/src/types.ts CHANGED
@@ -20,6 +20,8 @@ export type TimingTransition = {
20
20
  easing?: EasingType;
21
21
  /** Loop mode — 'repeat' restarts from the beginning, 'reverse' alternates direction. */
22
22
  loop?: 'repeat' | 'reverse';
23
+ /** Delay in milliseconds before the animation starts. @default 0 */
24
+ delay?: number;
23
25
  };
24
26
 
25
27
  /** Physics-based spring transition. */
@@ -31,6 +33,8 @@ export type SpringTransition = {
31
33
  stiffness?: number;
32
34
  /** Mass of the object — higher values mean slower, more momentum. @default 1 */
33
35
  mass?: number;
36
+ /** Delay in milliseconds before the animation starts. @default 0 */
37
+ delay?: number;
34
38
  };
35
39
 
36
40
  /** No transition — values are applied immediately without animation. */