react-native-ease 0.2.0 → 0.4.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,462 @@
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
+ SingleTransition,
7
+ Transition,
8
+ TransitionEndEvent,
9
+ TransformOrigin,
10
+ } from './types';
11
+
12
+ /** Identity values used as defaults for animate/initialAnimate. */
13
+ const IDENTITY: Required<Omit<AnimateProps, 'scale' | 'backgroundColor'>> = {
14
+ opacity: 1,
15
+ translateX: 0,
16
+ translateY: 0,
17
+ scaleX: 1,
18
+ scaleY: 1,
19
+ rotate: 0,
20
+ rotateX: 0,
21
+ rotateY: 0,
22
+ borderRadius: 0,
23
+ };
24
+
25
+ /** Preset easing curves as cubic bezier control points. */
26
+ const EASING_PRESETS: Record<string, CubicBezier> = {
27
+ linear: [0, 0, 1, 1],
28
+ easeIn: [0.42, 0, 1, 1],
29
+ easeOut: [0, 0, 0.58, 1],
30
+ easeInOut: [0.42, 0, 0.58, 1],
31
+ };
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Spring simulation → CSS linear() easing
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Simulate a damped spring from 0 → 1 and return settling duration + sample points. */
38
+ function simulateSpring(
39
+ damping: number,
40
+ stiffness: number,
41
+ mass: number,
42
+ ): { durationMs: number; points: number[] } {
43
+ const dt = 1 / 120; // 120 Hz simulation
44
+ const maxTime = 10; // 10s safety cap
45
+ let x = 0;
46
+ let v = 0;
47
+ const samples: number[] = [0];
48
+ let step = 0;
49
+
50
+ while (step * dt < maxTime) {
51
+ const a = (-stiffness * (x - 1) - damping * v) / mass;
52
+ v += a * dt;
53
+ x += v * dt;
54
+ step++;
55
+ // Downsample to ~60 fps (every 2nd sample)
56
+ if (step % 2 === 0) {
57
+ samples.push(Math.round(x * 10000) / 10000);
58
+ }
59
+ // Settled?
60
+ if (Math.abs(x - 1) < 0.001 && Math.abs(v) < 0.001) break;
61
+ }
62
+
63
+ // Ensure last point is exactly 1
64
+ samples[samples.length - 1] = 1;
65
+
66
+ return {
67
+ durationMs: Math.round(step * dt * 1000),
68
+ points: samples,
69
+ };
70
+ }
71
+
72
+ /** Cache for computed spring easing strings (keyed by damping-stiffness-mass). */
73
+ const springCache = new Map<string, { duration: number; easing: string }>();
74
+
75
+ function getSpringEasing(
76
+ damping: number,
77
+ stiffness: number,
78
+ mass: number,
79
+ ): { duration: number; easing: string } {
80
+ const key = `${damping}-${stiffness}-${mass}`;
81
+ let cached = springCache.get(key);
82
+ if (cached) return cached;
83
+
84
+ const { durationMs, points } = simulateSpring(damping, stiffness, mass);
85
+ const easing = `linear(${points.join(', ')})`;
86
+ cached = { duration: durationMs, easing };
87
+ springCache.set(key, cached);
88
+ return cached;
89
+ }
90
+
91
+ /** Detect CSS linear() support (lazy, cached). */
92
+ let linearSupported: boolean | null = null;
93
+ function supportsLinearEasing(): boolean {
94
+ if (linearSupported != null) return linearSupported;
95
+ try {
96
+ const el = document.createElement('div');
97
+ el.style.transitionTimingFunction = 'linear(0, 1)';
98
+ linearSupported = el.style.transitionTimingFunction !== '';
99
+ } catch {
100
+ linearSupported = false;
101
+ }
102
+ return linearSupported;
103
+ }
104
+
105
+ const SPRING_FALLBACK_EASING = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)';
106
+
107
+ export type EaseViewProps = {
108
+ animate?: AnimateProps;
109
+ initialAnimate?: AnimateProps;
110
+ transition?: Transition;
111
+ onTransitionEnd?: (event: TransitionEndEvent) => void;
112
+ /** No-op on web. */
113
+ useHardwareLayer?: boolean;
114
+ transformOrigin?: TransformOrigin;
115
+ style?: StyleProp<ViewStyle>;
116
+ children?: React.ReactNode;
117
+ };
118
+
119
+ function resolveAnimateValues(props: AnimateProps | undefined): Required<
120
+ Omit<AnimateProps, 'scale' | 'backgroundColor'>
121
+ > & {
122
+ backgroundColor?: string;
123
+ } {
124
+ return {
125
+ ...IDENTITY,
126
+ ...props,
127
+ scaleX: props?.scaleX ?? props?.scale ?? IDENTITY.scaleX,
128
+ scaleY: props?.scaleY ?? props?.scale ?? IDENTITY.scaleY,
129
+ rotateX: props?.rotateX ?? IDENTITY.rotateX,
130
+ rotateY: props?.rotateY ?? IDENTITY.rotateY,
131
+ backgroundColor: props?.backgroundColor as string | undefined,
132
+ };
133
+ }
134
+
135
+ function buildTransform(vals: ReturnType<typeof resolveAnimateValues>): string {
136
+ const parts: string[] = [];
137
+ if (vals.translateX !== 0 || vals.translateY !== 0) {
138
+ parts.push(`translate(${vals.translateX}px, ${vals.translateY}px)`);
139
+ }
140
+ if (vals.scaleX !== 1 || vals.scaleY !== 1) {
141
+ parts.push(
142
+ vals.scaleX === vals.scaleY
143
+ ? `scale(${vals.scaleX})`
144
+ : `scale(${vals.scaleX}, ${vals.scaleY})`,
145
+ );
146
+ }
147
+ if (vals.rotate !== 0) {
148
+ parts.push(`rotate(${vals.rotate}deg)`);
149
+ }
150
+ if (vals.rotateX !== 0) {
151
+ parts.push(`rotateX(${vals.rotateX}deg)`);
152
+ }
153
+ if (vals.rotateY !== 0) {
154
+ parts.push(`rotateY(${vals.rotateY}deg)`);
155
+ }
156
+ return parts.length > 0 ? parts.join(' ') : 'none';
157
+ }
158
+
159
+ /** Returns true if the transition is a SingleTransition (has a `type` field). */
160
+ function isSingleTransition(t: Transition): t is SingleTransition {
161
+ return 'type' in t;
162
+ }
163
+
164
+ /** Resolve a single config into CSS-ready duration/easing. */
165
+ function resolveConfigForCss(config: SingleTransition | undefined): {
166
+ duration: number;
167
+ easing: string;
168
+ type: string;
169
+ } {
170
+ if (!config || config.type === 'none') {
171
+ return { duration: 0, easing: 'linear', type: config?.type ?? 'timing' };
172
+ }
173
+ return {
174
+ duration: resolveDuration(config),
175
+ easing: resolveEasing(config),
176
+ type: config.type,
177
+ };
178
+ }
179
+
180
+ /** CSS property names for each category. */
181
+ const CSS_PROP_MAP = {
182
+ opacity: 'opacity',
183
+ transform: 'transform',
184
+ borderRadius: 'border-radius',
185
+ backgroundColor: 'background-color',
186
+ } as const;
187
+
188
+ type CategoryKey = keyof typeof CSS_PROP_MAP;
189
+
190
+ /** Resolve transition prop into per-category CSS configs. */
191
+ function resolvePerCategoryConfigs(
192
+ transition: Transition | undefined,
193
+ ): Record<CategoryKey, { duration: number; easing: string; type: string }> {
194
+ if (!transition) {
195
+ const def = resolveConfigForCss(undefined);
196
+ return {
197
+ opacity: def,
198
+ transform: def,
199
+ borderRadius: def,
200
+ backgroundColor: def,
201
+ };
202
+ }
203
+ if (isSingleTransition(transition)) {
204
+ const def = resolveConfigForCss(transition);
205
+ return {
206
+ opacity: def,
207
+ transform: def,
208
+ borderRadius: def,
209
+ backgroundColor: def,
210
+ };
211
+ }
212
+ // TransitionMap
213
+ const defaultConfig = resolveConfigForCss(transition.default);
214
+ return {
215
+ opacity: transition.opacity
216
+ ? resolveConfigForCss(transition.opacity)
217
+ : defaultConfig,
218
+ transform: transition.transform
219
+ ? resolveConfigForCss(transition.transform)
220
+ : defaultConfig,
221
+ borderRadius: transition.borderRadius
222
+ ? resolveConfigForCss(transition.borderRadius)
223
+ : defaultConfig,
224
+ backgroundColor: transition.backgroundColor
225
+ ? resolveConfigForCss(transition.backgroundColor)
226
+ : defaultConfig,
227
+ };
228
+ }
229
+
230
+ function resolveEasing(transition: SingleTransition | undefined): string {
231
+ if (!transition || transition.type === 'none') {
232
+ return 'linear';
233
+ }
234
+ if (transition.type === 'spring') {
235
+ const d = transition.damping ?? 15;
236
+ const s = transition.stiffness ?? 120;
237
+ const m = transition.mass ?? 1;
238
+ if (supportsLinearEasing()) {
239
+ return getSpringEasing(d, s, m).easing;
240
+ }
241
+ return SPRING_FALLBACK_EASING;
242
+ }
243
+ // timing
244
+ const easing = transition.easing ?? 'easeInOut';
245
+ const bezier: CubicBezier = Array.isArray(easing)
246
+ ? easing
247
+ : EASING_PRESETS[easing]!;
248
+ return `cubic-bezier(${bezier[0]}, ${bezier[1]}, ${bezier[2]}, ${bezier[3]})`;
249
+ }
250
+
251
+ function resolveDuration(transition: SingleTransition | undefined): number {
252
+ if (!transition) return 300;
253
+ if (transition.type === 'timing') return transition.duration ?? 300;
254
+ if (transition.type === 'none') return 0;
255
+ // Spring: use simulation-derived duration (incorporates stiffness)
256
+ const d = transition.damping ?? 15;
257
+ const s = transition.stiffness ?? 120;
258
+ const m = transition.mass ?? 1;
259
+ return getSpringEasing(d, s, m).duration;
260
+ }
261
+
262
+ /** Counter for unique keyframe names. */
263
+ let keyframeCounter = 0;
264
+
265
+ export function EaseView({
266
+ animate,
267
+ initialAnimate,
268
+ transition,
269
+ onTransitionEnd,
270
+ useHardwareLayer: _useHardwareLayer,
271
+ transformOrigin,
272
+ style,
273
+ children,
274
+ }: EaseViewProps) {
275
+ const resolved = resolveAnimateValues(animate);
276
+ const hasInitial = initialAnimate != null;
277
+ const [mounted, setMounted] = useState(!hasInitial);
278
+ // On web, View ref gives us the underlying DOM element.
279
+ const viewRef = useRef<React.ComponentRef<typeof View>>(null);
280
+ const animationNameRef = useRef<string | null>(null);
281
+
282
+ const getElement = useCallback(
283
+ () => viewRef.current as unknown as HTMLElement | null,
284
+ [],
285
+ );
286
+
287
+ // For initialAnimate: render initial values first, then animate on mount.
288
+ useEffect(() => {
289
+ if (hasInitial) {
290
+ getElement()?.getBoundingClientRect();
291
+ setMounted(true);
292
+ }
293
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
294
+
295
+ const displayValues =
296
+ !mounted && hasInitial ? resolveAnimateValues(initialAnimate) : resolved;
297
+
298
+ const categoryConfigs = resolvePerCategoryConfigs(transition);
299
+
300
+ // For loop mode, use the default/single transition config
301
+ const singleTransition =
302
+ transition && isSingleTransition(transition)
303
+ ? transition
304
+ : transition && !isSingleTransition(transition)
305
+ ? transition.default
306
+ : undefined;
307
+ const loopMode =
308
+ singleTransition?.type === 'timing' ? singleTransition.loop : undefined;
309
+ const loopDuration = resolveDuration(singleTransition);
310
+ const loopEasing = resolveEasing(singleTransition);
311
+
312
+ const originX = ((transformOrigin?.x ?? 0.5) * 100).toFixed(1);
313
+ const originY = ((transformOrigin?.y ?? 0.5) * 100).toFixed(1);
314
+
315
+ const transitionCss =
316
+ !mounted && hasInitial
317
+ ? 'none'
318
+ : (Object.keys(CSS_PROP_MAP) as CategoryKey[])
319
+ .filter((key) => {
320
+ const cfg = categoryConfigs[key];
321
+ return cfg.type !== 'none' && cfg.duration > 0;
322
+ })
323
+ .map((key) => {
324
+ const cfg = categoryConfigs[key];
325
+ return `${CSS_PROP_MAP[key]} ${cfg.duration}ms ${cfg.easing}`;
326
+ })
327
+ .join(', ') || 'none';
328
+
329
+ // Apply CSS transition/animation properties imperatively (not in RN style spec).
330
+ useEffect(() => {
331
+ const el = getElement();
332
+ if (!el) return;
333
+
334
+ if (!loopMode) {
335
+ el.style.transition = transitionCss;
336
+ }
337
+ el.style.transformOrigin = `${originX}% ${originY}%`;
338
+ });
339
+
340
+ // Handle transitionend event via DOM listener.
341
+ useEffect(() => {
342
+ const el = getElement();
343
+ if (!el || !onTransitionEnd) return;
344
+
345
+ const handler = (e: TransitionEvent) => {
346
+ if (e.target !== e.currentTarget) return;
347
+ if (e.propertyName !== 'opacity' && e.propertyName !== 'transform')
348
+ return;
349
+ onTransitionEnd({ finished: true });
350
+ };
351
+
352
+ el.addEventListener('transitionend', handler);
353
+ return () => el.removeEventListener('transitionend', handler);
354
+ }, [onTransitionEnd, getElement]);
355
+
356
+ // Handle loop animations via CSS @keyframes.
357
+ useEffect(() => {
358
+ const el = getElement();
359
+ if (!loopMode || !el) {
360
+ if (animationNameRef.current) {
361
+ const cleanEl = getElement();
362
+ if (cleanEl) cleanEl.style.animation = '';
363
+ animationNameRef.current = null;
364
+ }
365
+ return;
366
+ }
367
+
368
+ const fromValues = initialAnimate
369
+ ? resolveAnimateValues(initialAnimate)
370
+ : resolveAnimateValues(undefined);
371
+ const toValues = resolveAnimateValues(animate);
372
+
373
+ const fromTransform = buildTransform(fromValues);
374
+ const toTransform = buildTransform(toValues);
375
+
376
+ const name = `ease-loop-${++keyframeCounter}`;
377
+ animationNameRef.current = name;
378
+
379
+ // Only include border-radius/background-color in keyframes when explicitly
380
+ // set by the user, to avoid overriding values from the style prop.
381
+ const hasBorderRadius =
382
+ initialAnimate?.borderRadius != null || animate?.borderRadius != null;
383
+ const hasBgColor =
384
+ initialAnimate?.backgroundColor != null ||
385
+ animate?.backgroundColor != null;
386
+
387
+ const fromBlock = [
388
+ `opacity: ${fromValues.opacity}`,
389
+ `transform: ${fromTransform}`,
390
+ hasBorderRadius ? `border-radius: ${fromValues.borderRadius}px` : '',
391
+ hasBgColor && fromValues.backgroundColor
392
+ ? `background-color: ${fromValues.backgroundColor}`
393
+ : '',
394
+ ]
395
+ .filter(Boolean)
396
+ .join('; ');
397
+
398
+ const toBlock = [
399
+ `opacity: ${toValues.opacity}`,
400
+ `transform: ${toTransform}`,
401
+ hasBorderRadius ? `border-radius: ${toValues.borderRadius}px` : '',
402
+ hasBgColor && toValues.backgroundColor
403
+ ? `background-color: ${toValues.backgroundColor}`
404
+ : '',
405
+ ]
406
+ .filter(Boolean)
407
+ .join('; ');
408
+
409
+ const keyframes = `@keyframes ${name} { from { ${fromBlock} } to { ${toBlock} } }`;
410
+
411
+ const styleEl = document.createElement('style');
412
+ styleEl.textContent = keyframes;
413
+ document.head.appendChild(styleEl);
414
+
415
+ const direction = loopMode === 'reverse' ? 'alternate' : 'normal';
416
+ el.style.transition = 'none';
417
+ el.style.animation = `${name} ${loopDuration}ms ${loopEasing} infinite ${direction}`;
418
+
419
+ return () => {
420
+ styleEl.remove();
421
+ el.style.animation = '';
422
+ animationNameRef.current = null;
423
+ };
424
+ }, [loopMode, animate, initialAnimate, loopDuration, loopEasing, getElement]);
425
+
426
+ // Build animated style using RN transform array format.
427
+ // react-native-web converts these to CSS transform strings.
428
+ const animatedStyle: ViewStyle = {
429
+ opacity: displayValues.opacity,
430
+ transform: [
431
+ ...(displayValues.translateX !== 0
432
+ ? [{ translateX: displayValues.translateX }]
433
+ : []),
434
+ ...(displayValues.translateY !== 0
435
+ ? [{ translateY: displayValues.translateY }]
436
+ : []),
437
+ ...(displayValues.scaleX !== 1 ? [{ scaleX: displayValues.scaleX }] : []),
438
+ ...(displayValues.scaleY !== 1 ? [{ scaleY: displayValues.scaleY }] : []),
439
+ ...(displayValues.rotate !== 0
440
+ ? [{ rotate: `${displayValues.rotate}deg` }]
441
+ : []),
442
+ ...(displayValues.rotateX !== 0
443
+ ? [{ rotateX: `${displayValues.rotateX}deg` }]
444
+ : []),
445
+ ...(displayValues.rotateY !== 0
446
+ ? [{ rotateY: `${displayValues.rotateY}deg` }]
447
+ : []),
448
+ ],
449
+ ...(displayValues.borderRadius > 0
450
+ ? { borderRadius: displayValues.borderRadius }
451
+ : {}),
452
+ ...(displayValues.backgroundColor
453
+ ? { backgroundColor: displayValues.backgroundColor }
454
+ : {}),
455
+ };
456
+
457
+ return (
458
+ <View ref={viewRef} style={[style, animatedStyle]}>
459
+ {children}
460
+ </View>
461
+ );
462
+ }
@@ -6,6 +6,28 @@ import {
6
6
  type ColorValue,
7
7
  } from 'react-native';
8
8
 
9
+ type Float = CodegenTypes.Float;
10
+ type Int32 = CodegenTypes.Int32;
11
+
12
+ type NativeTransitionConfig = Readonly<{
13
+ type: string;
14
+ duration: Int32;
15
+ easingBezier: ReadonlyArray<Float>;
16
+ damping: Float;
17
+ stiffness: Float;
18
+ mass: Float;
19
+ loop: string;
20
+ delay: Int32;
21
+ }>;
22
+
23
+ type NativeTransitions = Readonly<{
24
+ defaultConfig: NativeTransitionConfig;
25
+ transform?: NativeTransitionConfig;
26
+ opacity?: NativeTransitionConfig;
27
+ borderRadius?: NativeTransitionConfig;
28
+ backgroundColor?: NativeTransitionConfig;
29
+ }>;
30
+
9
31
  export interface NativeProps extends ViewProps {
10
32
  // Bitmask of which properties are animated (0 = none, let style handle all)
11
33
  animatedProperties?: CodegenTypes.WithDefault<CodegenTypes.Int32, 0>;
@@ -37,21 +59,8 @@ export interface NativeProps extends ViewProps {
37
59
  >;
38
60
  initialAnimateBackgroundColor?: ColorValue;
39
61
 
40
- // Transition config
41
- transitionType?: CodegenTypes.WithDefault<
42
- 'timing' | 'spring' | 'none',
43
- 'timing'
44
- >;
45
- transitionDuration?: CodegenTypes.WithDefault<CodegenTypes.Int32, 300>;
46
- // Easing cubic bezier control points [x1, y1, x2, y2] (default: easeInOut)
47
- transitionEasingBezier?: ReadonlyArray<CodegenTypes.Float>;
48
- transitionDamping?: CodegenTypes.WithDefault<CodegenTypes.Float, 15.0>;
49
- transitionStiffness?: CodegenTypes.WithDefault<CodegenTypes.Float, 120.0>;
50
- transitionMass?: CodegenTypes.WithDefault<CodegenTypes.Float, 1.0>;
51
- transitionLoop?: CodegenTypes.WithDefault<
52
- 'none' | 'repeat' | 'reverse',
53
- 'none'
54
- >;
62
+ // Unified transition config — one struct with per-property configs
63
+ transitions?: NativeTransitions;
55
64
 
56
65
  // Transform origin (0–1 fractions, default center)
57
66
  transformOriginX?: CodegenTypes.WithDefault<CodegenTypes.Float, 0.5>;
package/src/index.tsx CHANGED
@@ -4,6 +4,8 @@ export type {
4
4
  AnimateProps,
5
5
  CubicBezier,
6
6
  Transition,
7
+ SingleTransition,
8
+ TransitionMap,
7
9
  TimingTransition,
8
10
  SpringTransition,
9
11
  NoneTransition,
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. */
@@ -38,8 +42,28 @@ export type NoneTransition = {
38
42
  type: 'none';
39
43
  };
40
44
 
41
- /** Animation transition configuration. */
42
- export type Transition = TimingTransition | SpringTransition | NoneTransition;
45
+ /** A single animation transition configuration. */
46
+ export type SingleTransition =
47
+ | TimingTransition
48
+ | SpringTransition
49
+ | NoneTransition;
50
+
51
+ /** Per-property transition map with category-based keys. */
52
+ export type TransitionMap = {
53
+ /** Fallback config for properties not explicitly listed. */
54
+ default?: SingleTransition;
55
+ /** Config for all transform properties (translateX/Y, scaleX/Y, rotate, rotateX/Y). */
56
+ transform?: SingleTransition;
57
+ /** Config for opacity. */
58
+ opacity?: SingleTransition;
59
+ /** Config for borderRadius. */
60
+ borderRadius?: SingleTransition;
61
+ /** Config for backgroundColor. */
62
+ backgroundColor?: SingleTransition;
63
+ };
64
+
65
+ /** Animation transition configuration — either a single config or a per-property map. */
66
+ export type Transition = SingleTransition | TransitionMap;
43
67
 
44
68
  /** Event fired when the animation ends. */
45
69
  export type TransitionEndEvent = {