diff --git a/README.md b/README.md index bf48d6e..0a3ead2 100644 --- a/README.md +++ b/README.md @@ -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 + +``` + +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 @@ -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) } ``` diff --git a/android/src/main/java/com/ease/EaseView.kt b/android/src/main/java/com/ease/EaseView.kt index 7815088..190c88f 100644 --- a/android/src/main/java/com/ease/EaseView.kt +++ b/android/src/main/java/com/ease/EaseView.kt @@ -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 ) @@ -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() ) @@ -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) { diff --git a/docs/docs/api-reference.mdx b/docs/docs/api-reference.mdx index 85e26bc..9711d6f 100644 --- a/docs/docs/api-reference.mdx +++ b/docs/docs/api-reference.mdx @@ -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; } ``` diff --git a/docs/docs/usage.mdx b/docs/docs/usage.mdx index 9b538d3..1d18fbe 100644 --- a/docs/docs/usage.mdx +++ b/docs/docs/usage.mdx @@ -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 + +``` + +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 diff --git a/example/src/demos/SpringVelocityDemo.tsx b/example/src/demos/SpringVelocityDemo.tsx new file mode 100644 index 0000000..ca59587 --- /dev/null +++ b/example/src/demos/SpringVelocityDemo.tsx @@ -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 ( +
+ + Same spring, different initial velocity. Positive launches the box in + the direction it is already travelling, negative pulls it back first. + + {ROWS.map((row) => ( + + {row.label} + + + + + ))} +
+ ); +} + +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, + }, +}); diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts index 755ff05..6ec1cf2 100644 --- a/example/src/demos/index.ts +++ b/example/src/demos/index.ts @@ -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 { @@ -66,6 +67,11 @@ export const demos: Record = { 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, diff --git a/ios/EaseView.mm b/ios/EaseView.mm index 55ef4db..2acab0b 100644 --- a/ios/EaseView.mm +++ b/ios/EaseView.mm @@ -87,6 +87,7 @@ static CATransform3D composeTransform(CGFloat scaleX, CGFloat scaleY, float damping; float stiffness; float mass; + float velocity; std::string loop; int delay; }; @@ -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; @@ -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 { diff --git a/skills/react-native-ease-refactor/SKILL.md b/skills/react-native-ease-refactor/SKILL.md index 51ff2e2..7fc3170 100644 --- a/skills/react-native-ease-refactor/SKILL.md +++ b/skills/react-native-ease-refactor/SKILL.md @@ -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 }}` | @@ -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:** @@ -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. @@ -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. --- @@ -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 }} ``` diff --git a/src/EaseView.tsx b/src/EaseView.tsx index f039261..aadc03f 100644 --- a/src/EaseView.tsx +++ b/src/EaseView.tsx @@ -96,6 +96,7 @@ const DEFAULT_CONFIG: NativeTransitionConfig = { damping: 15, stiffness: 120, mass: 1, + velocity: 0, loop: 'none', delay: 0, }; @@ -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 = @@ -146,6 +148,7 @@ function resolveSingleConfig(config: SingleTransition): NativeTransitionConfig { damping, stiffness, mass, + velocity, loop, delay, }; diff --git a/src/EaseView.web.tsx b/src/EaseView.web.tsx index f452d27..20963df 100644 --- a/src/EaseView.web.tsx +++ b/src/EaseView.web.tsx @@ -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; } diff --git a/src/EaseViewNativeComponent.ts b/src/EaseViewNativeComponent.ts index 4f22c1e..76e61fc 100644 --- a/src/EaseViewNativeComponent.ts +++ b/src/EaseViewNativeComponent.ts @@ -16,6 +16,7 @@ type NativeTransitionConfig = Readonly<{ damping: Float; stiffness: Float; mass: Float; + velocity: Float; loop: string; delay: Int32; }>; diff --git a/src/__tests__/EaseView.test.tsx b/src/__tests__/EaseView.test.tsx index a53af59..073ec68 100644 --- a/src/__tests__/EaseView.test.tsx +++ b/src/__tests__/EaseView.test.tsx @@ -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( + , + ); + expect(getNativeProps().transitions.defaultConfig.velocity).toBe(2.5); + + // Negative velocity means the value is already moving down. + rerender( + , + ); + expect(getNativeProps().transitions.defaultConfig.velocity).toBe(-2.5); + }); + + it('ignores velocity on timing transitions', () => { + render( + , + ); + expect(getNativeProps().transitions.defaultConfig.velocity).toBe(0); }); it('passes none transition type to defaultConfig', () => { diff --git a/src/types.ts b/src/types.ts index b6dc605..6d37ef6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; };