Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,25 @@ Spring animations use a physics-based model for natural-feeling motion. Great fo
| `damping` | `number` | `15` | Friction — higher values reduce oscillation |
| `stiffness` | `number` | `120` | Spring constant — higher values mean faster animation |
| `mass` | `number` | `1` | Mass of the object — higher values mean slower, more momentum |
| `velocity` | `number` | `0` | Initial velocity in property units per second (native only) |
| `delay` | `number` | `0` | Delay in milliseconds before the animation starts |

`velocity` gives the spring a head start, the way `Animated.spring`'s `velocity` does — useful
when the animation continues a gesture that was already moving. It is signed in value space, so a
positive value means the property is already increasing:

```tsx
<EaseView
animate={{ translateY: dismissed ? 400 : 0 }}
transition={{ type: 'spring', damping: 18, stiffness: 200, velocity: 1200 }}
/>
```

Units follow the JS side — DIPs per second for `translateX`/`translateY`, degrees per second for
`rotate`, and plain units per second for `scale` and `opacity`. It has no effect on web, where a
spring compiles to a single normalized CSS easing curve that cannot express a per-property
starting velocity.

Spring presets for common feels:

```tsx
Expand Down Expand Up @@ -575,6 +592,7 @@ Properties not specified in `animate` default to their identity values.
damping?: number; // default: 15
stiffness?: number; // default: 120
mass?: number; // default: 1
velocity?: number; // default: 0 (property units/s, native only)
delay?: number; // default: 0 (ms)
}
```
Expand Down
14 changes: 14 additions & 0 deletions android/src/main/java/com/ease/EaseView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
val damping: Float,
val stiffness: Float,
val mass: Float,
val velocity: Float,
val loop: String,
val delay: Long
)
Expand Down Expand Up @@ -86,6 +87,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
damping = configMap.getDouble("damping").toFloat(),
stiffness = configMap.getDouble("stiffness").toFloat(),
mass = configMap.getDouble("mass").toFloat(),
velocity = if (configMap.hasKey("velocity")) configMap.getDouble("velocity").toFloat() else 0f,
loop = configMap.getString("loop")!!,
delay = configMap.getInt("delay").toLong()
)
Expand Down Expand Up @@ -892,11 +894,23 @@ class EaseView(context: Context) : ReactViewGroup(context) {
val dampingRatio = (config.damping / (2.0f * sqrt(config.stiffness * config.mass)))
.coerceAtLeast(0.01f)

// translationX/Y animate in pixels, so a DIP/s velocity has to be converted
// the same way the target value is in EaseViewManager. Every other property
// (scale, rotation, alpha) already shares its unit with the JS side.
val startVelocity = when (viewProperty) {
DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y ->
PixelUtil.toPixelFromDIP(config.velocity)
else -> config.velocity
}

val spring = SpringAnimation(this, viewProperty).apply {
spring = SpringForce(toValue).apply {
this.dampingRatio = dampingRatio
this.stiffness = config.stiffness
}
if (startVelocity != 0f) {
setStartVelocity(startVelocity)
}
addUpdateListener { _, _, _ ->
// First update — enable hardware layer
if (activeAnimationCount == 0) {
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ A `View` that animates property changes using native platform APIs.
damping?: number;
stiffness?: number;
mass?: number;
/** Property units per second, signed in value space. Native only. */
velocity?: number;
delay?: number;
}
```
Expand Down
17 changes: 17 additions & 0 deletions docs/docs/usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,25 @@ Available easing curves:
| `damping` | `number` | `15` | Friction — higher values reduce oscillation |
| `stiffness` | `number` | `120` | Spring constant — higher values mean faster animation |
| `mass` | `number` | `1` | Mass of the object — higher values mean slower, more momentum |
| `velocity` | `number` | `0` | Initial velocity in property units per second (native only) |
| `delay` | `number` | `0` | Delay in milliseconds before the animation starts |

`velocity` gives the spring a head start, the way `Animated.spring`'s `velocity` does — useful when
the animation continues a gesture that was already moving. It is signed in value space, so a
positive value means the property is already increasing:

```tsx
<EaseView
animate={{ translateY: dismissed ? 400 : 0 }}
transition={{ type: 'spring', damping: 18, stiffness: 200, velocity: 1200 }}
/>
```

Units follow the JS side — DIPs per second for `translateX`/`translateY`, degrees per second for
`rotate`, and plain units per second for `scale` and `opacity`. It has no effect on web, where a
spring compiles to a single normalized CSS easing curve that cannot express a per-property starting
velocity.

## Disabling animations

```tsx
Expand Down
68 changes: 68 additions & 0 deletions example/src/demos/SpringVelocityDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { EaseView } from 'react-native-ease';

import { Section } from '../components/Section';
import { Button } from '../components/Button';

const SPRING = { damping: 14, stiffness: 120, mass: 1 } as const;

const ROWS = [
{ label: 'velocity: -600', velocity: -600 },
{ label: 'velocity: 0', velocity: 0 },
{ label: 'velocity: 600', velocity: 600 },
];

export function SpringVelocityDemo() {
const [moved, setMoved] = useState(false);
return (
<Section title="Spring Velocity">
<Text style={styles.caption}>
Same spring, different initial velocity. Positive launches the box in
the direction it is already travelling, negative pulls it back first.
</Text>
{ROWS.map((row) => (
<View key={row.label} style={styles.row}>
<Text style={styles.label}>{row.label}</Text>
<View style={styles.track}>
<EaseView
animate={{ translateX: moved ? 180 : 0 }}
transition={{ type: 'spring', ...SPRING, velocity: row.velocity }}
style={styles.box}
/>
</View>
</View>
))}
<Button
label={moved ? 'Back' : 'Go'}
onPress={() => setMoved((v) => !v)}
/>
</Section>
);
}

const styles = StyleSheet.create({
caption: {
color: '#8a939e',
fontSize: 13,
marginBottom: 16,
},
row: {
marginBottom: 12,
},
label: {
color: '#8a939e',
fontSize: 12,
marginBottom: 4,
},
track: {
height: 40,
justifyContent: 'center',
},
box: {
width: 40,
height: 40,
backgroundColor: '#4a90d9',
borderRadius: 8,
},
});
6 changes: 6 additions & 0 deletions example/src/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { TransformOriginDemo } from './TransformOriginDemo';
import { PerPropertyDemo } from './PerPropertyDemo';
import { ShadowDemo } from './ShadowDemo';
import { SpinDemo } from './SpinDemo';
import { SpringVelocityDemo } from './SpringVelocityDemo';
import { UniwindDemo } from './uniwind/UniwindDemo';

interface DemoEntry {
Expand Down Expand Up @@ -66,6 +67,11 @@ export const demos: Record<string, DemoEntry> = {
section: 'Timing',
},
'delay': { component: DelayDemo, title: 'Delay', section: 'Timing' },
'spring-velocity': {
component: SpringVelocityDemo,
title: 'Spring Velocity',
section: 'Timing',
},
'combined': { component: CombinedDemo, title: 'Combined', section: 'Timing' },
'styled-card': {
component: StyledCardDemo,
Expand Down
15 changes: 15 additions & 0 deletions ios/EaseView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ static CATransform3D composeTransform(CGFloat scaleX, CGFloat scaleY,
float damping;
float stiffness;
float mass;
float velocity;
std::string loop;
int delay;
};
Expand All @@ -113,6 +114,7 @@ static EaseTransitionConfig transitionConfigFromStruct(const T &src) {
config.damping = src.damping;
config.stiffness = src.stiffness;
config.mass = src.mass;
config.velocity = src.velocity;
config.loop = src.loop;
config.delay = src.delay;
return config;
Expand Down Expand Up @@ -294,7 +296,20 @@ - (CAAnimation *)createAnimationForKeyPath:(NSString *)keyPath
spring.damping = config.damping;
spring.stiffness = config.stiffness;
spring.mass = config.mass;
// config.velocity is in value units per second; CASpringAnimation
// normalizes initialVelocity against the from->to distance (1 means "the
// whole distance in one second"), which also flips the sign for
// decreasing animations.
spring.initialVelocity = 0;
if (config.velocity != 0 && [fromValue isKindOfClass:[NSNumber class]] &&
[toValue isKindOfClass:[NSNumber class]]) {
double delta = [(NSNumber *)toValue doubleValue] -
[(NSNumber *)fromValue doubleValue];
if (fabs(delta) > 1e-9) {
spring.initialVelocity = config.velocity / delta;
}
}
// settlingDuration accounts for initialVelocity, so read it after.
spring.duration = spring.settlingDuration;
return spring;
} else {
Expand Down
12 changes: 11 additions & 1 deletion skills/react-native-ease-refactor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Use this table to convert Reanimated/Animated patterns to EaseView:
| Reanimated / Animated Pattern | EaseView Equivalent |
| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `useSharedValue` + `useAnimatedStyle` + `withTiming` for opacity, translate, scale, rotate, borderRadius, backgroundColor | `animate={{ prop: value }}` + `transition={{ type: 'timing', duration, easing }}` |
| `withSpring` | `transition={{ type: 'spring', damping, stiffness, mass }}` |
| `withSpring` | `transition={{ type: 'spring', damping, stiffness, mass, velocity }}` |
| `entering={FadeIn}` / `FadeIn.duration(N)` | `initialAnimate={{ opacity: 0 }}` + `animate={{ opacity: 1 }}` + timing transition |
| `entering={FadeInDown}` / `FadeInUp` | `initialAnimate={{ opacity: 0, translateY: ±value }}` + `animate={{ opacity: 1, translateY: 0 }}` |
| `entering={SlideInLeft}` / `SlideInRight` | `initialAnimate={{ translateX: ±value }}` + `animate={{ translateX: 0 }}` |
Expand Down Expand Up @@ -116,6 +116,7 @@ Use this table to convert Reanimated/Animated patterns to EaseView:
| `damping` | `10` | `15` | **Must set `damping: 10`** |
| `stiffness` | `100` | `120` | **Must set `stiffness: 100`** |
| `mass` | `1` | `1` | Same — omit |
| `velocity` | `0` | `0` | Same — carry over only when set |

**Reanimated v4 defaults:**

Expand All @@ -124,6 +125,7 @@ Use this table to convert Reanimated/Animated patterns to EaseView:
| `damping` | `120` | `15` | **Must set `damping: 120`** |
| `stiffness` | `900` | `120` | **Must set `stiffness: 900`** |
| `mass` | `4` | `1` | **Must set `mass: 4`** |
| `velocity` | `0` | `0` | Same — carry over only when set |

Reanimated v4 changed to a critically damped, snappy spring (no bounce) as the default. The rationale was that the old physics-based defaults were too sensitive to start/end conditions. v4 recommends using `duration` + `dampingRatio` instead of raw physics params.

Expand Down Expand Up @@ -179,12 +181,19 @@ RN Animated uses `friction`/`tension` by default: `friction: 7, tension: 40`. Th
| stiffness (tension) | `40` | `120` | **Must set `stiffness: 40`** |
| damping (friction) | `7` | `15` | **Must set `damping: 7`** |
| mass | `1` | `1` | Same — omit |
| velocity | `0` | `0` | Same — carry over only when set |

`Animated.spring` also accepts `bounciness`/`speed` instead of `friction`/`tension`. Those go
through `SpringConfig.fromBouncinessAndSpeed`, so convert with that function rather than mapping
the numbers directly — the default `bounciness: 0, speed: 12` resolves to
`{ stiffness: 342.1, damping: 36.93 }`.

### Unit Conversions

- **Rotation:** Reanimated uses `'45deg'` strings in transforms → EaseView uses `45` (number, degrees). Strip the `'deg'` suffix and parse to number.
- **Translation:** Both use DIPs (density-independent pixels). No conversion needed.
- **Scale:** Both use unitless multipliers. No conversion needed.
- **Spring velocity:** Same units as the property being animated, per second — DIPs for translate, degrees for rotate, unitless for scale and opacity. No conversion needed, but note it is per-transition, so a `transform` transition shared by translate and scale can only carry one meaningful velocity. Split the categories if both need one.

---

Expand Down Expand Up @@ -416,6 +425,7 @@ transition={{
damping: 15, // default 15
stiffness: 120, // default 120
mass: 1, // default 1
velocity: 0, // property units/s, default 0, native only
delay: 0, // ms, default 0
}}
```
Expand Down
3 changes: 3 additions & 0 deletions src/EaseView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const DEFAULT_CONFIG: NativeTransitionConfig = {
damping: 15,
stiffness: 120,
mass: 1,
velocity: 0,
loop: 'none',
delay: 0,
};
Expand Down Expand Up @@ -133,6 +134,7 @@ function resolveSingleConfig(config: SingleTransition): NativeTransitionConfig {
const damping = config.type === 'spring' ? config.damping ?? 15 : 15;
const stiffness = config.type === 'spring' ? config.stiffness ?? 120 : 120;
const mass = config.type === 'spring' ? config.mass ?? 1 : 1;
const velocity = config.type === 'spring' ? config.velocity ?? 0 : 0;
const loop: string =
config.type === 'timing' ? config.loop ?? 'none' : 'none';
const delay =
Expand All @@ -146,6 +148,7 @@ function resolveSingleConfig(config: SingleTransition): NativeTransitionConfig {
damping,
stiffness,
mass,
velocity,
loop,
delay,
};
Expand Down
3 changes: 3 additions & 0 deletions src/EaseView.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,9 @@ function resolveEasing(transition: SingleTransition | undefined): string {
const d = transition.damping ?? 15;
const s = transition.stiffness ?? 120;
const m = transition.mass ?? 1;
// `velocity` is not applied here: the easing is a single normalized 0->1
// curve shared by every property in the category, while an initial velocity
// only has meaning relative to each property's own from->to distance.
if (supportsLinearEasing()) {
return getSpringEasing(d, s, m).easing;
}
Expand Down
1 change: 1 addition & 0 deletions src/EaseViewNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type NativeTransitionConfig = Readonly<{
damping: Float;
stiffness: Float;
mass: Float;
velocity: Float;
loop: string;
delay: Int32;
}>;
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/EaseView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,37 @@ describe('EaseView', () => {
expect(t.defaultConfig.damping).toBe(15);
expect(t.defaultConfig.stiffness).toBe(120);
expect(t.defaultConfig.mass).toBe(1);
expect(t.defaultConfig.velocity).toBe(0);
});

it('passes spring velocity through to the native config', () => {
const { rerender } = render(
<EaseView
testID="ease"
transition={{ type: 'spring', velocity: 2.5 }}
/>,
);
expect(getNativeProps().transitions.defaultConfig.velocity).toBe(2.5);

// Negative velocity means the value is already moving down.
rerender(
<EaseView
testID="ease"
transition={{ type: 'spring', velocity: -2.5 }}
/>,
);
expect(getNativeProps().transitions.defaultConfig.velocity).toBe(-2.5);
});

it('ignores velocity on timing transitions', () => {
render(
<EaseView
testID="ease"
// @ts-expect-error velocity is spring-only
transition={{ type: 'timing', duration: 200, velocity: 5 }}
/>,
);
expect(getNativeProps().transitions.defaultConfig.velocity).toBe(0);
});

it('passes none transition type to defaultConfig', () => {
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ export type SpringTransition = {
stiffness?: number;
/** Mass of the object — higher values mean slower, more momentum. @default 1 */
mass?: number;
/**
* Initial velocity, in animated-property units per second, signed in value
* space — positive means the value is already moving up. Units follow the JS
* side: DIPs for translateX/translateY, degrees for rotate, unitless for
* scale and opacity. Ignored on web. @default 0
*/
velocity?: number;
/** Delay in milliseconds before the animation starts. @default 0 */
delay?: number;
};
Expand Down
Loading