Skip to content

feat: add initial velocity to spring transitions - #53

Merged
janicduplessis merged 1 commit into
mainfrom
feat/spring-initial-velocity
Aug 3, 2026
Merged

feat: add initial velocity to spring transitions#53
janicduplessis merged 1 commit into
mainfrom
feat/spring-initial-velocity

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

SpringTransition gains a velocity field, matching Animated.spring's velocity: the spring starts with the property already in motion instead of at rest.

I hit this porting TouchableBounce to EaseView in an app. TouchableBounce uses Animated.spring({ velocity: 0.4, bounciness }), and there was no way to express that 0.4.

It can't be folded into the other constants. A spring released from rest has x'(0) = 0 by construction, so no choice of stiffness/damping/mass produces a curve that starts with slope. Fitting the TouchableBounce press-in (scale 1 → 0.93, bounciness: 0) with a zero-velocity spring, searching stiffness over [20, 2000] and damping over [1, 200], the best fit still misses by 5.2% of the travel and can't reproduce the shape: because velocity is signed in value space and the target is below the start, +0.4 pushes away from the target first, peaking at scale 1.0022 around 13ms before springing down. Against the same spring with velocity: 0 the curves differ by up to 11% of the travel.

To be clear about scale: on a ~170pt button that press-in overshoot is a fraction of a point, so for TouchableBounce specifically this is a fidelity detail, not a visible bug. The parameter earns its place on gestures — a fling handed off at 1200 DIP/s is dominated by the initial velocity, and there is currently no way to express it.

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

Units follow the JS side — DIPs/s for translateX/translateY, degrees/s for rotate, unitless for scale and opacity — and the value is signed in value space, so positive means the property is already increasing.

The two native platforms want this quantity in different terms, which is where the only real subtlety is:

  • iOSCASpringAnimation.initialVelocity is normalized against the from→to distance (1 means "the whole distance in one second"), not in value units, so the value is divided by the delta. That division also produces the correct sign when the animation is decreasing. settlingDuration accounts for the initial velocity, so duration is read after it's set.
  • AndroidSpringAnimation.setStartVelocity works in the property's own units, which for TRANSLATION_X/Y is pixels, so the DIP value is converted the same way EaseViewManager converts the target. Every other property already shares its unit with the JS side.
  • Web — not applied, and documented as native-only. See below; this is a deliberate stop, not an oversight.

One consequence of velocity living on the transition rather than the property: a transform transition covering both translate and scale can only carry one meaningful velocity. The refactor skill notes to split the categories when both need one.

Why web isn't approximated

Worth spelling out, since the machinery looks like it's already there. Web emits a per-CSS-property transition list (opacity 300ms linear(…), transform 300ms linear(…)), so a velocity-shaped curve per property is expressible in principle — linear() stops can fall outside [0, 1], so even the pull-back-past-the-start shape survives. Two things stop it being worth doing now:

  1. The normalized curve depends on velocity / delta, so it needs each property's from→to distance. That means tracking previous animate values and keying the easing cache on the ratio rather than just damping-stiffness-mass.
  2. transform is one CSS property carrying translateX/Y, scale and rotate together, so it gets exactly one timing function — but each of those has its own delta and therefore its own normalization. Native animates them separately and normalizes each correctly; web can't, short of splitting into per-axis elements.

So the properties web could support (opacity, border-radius) are the ones velocity is least useful for, and the one that motivates the parameter — transform, where gestures live — is the one it can't. I'd rather have it honestly documented as native-only than working on web in the cases nobody asks for. Happy to revisit if someone has a real opacity-with-velocity case.

Test Plan

  • yarn test — added cases covering that velocity reaches the native config, that negative values survive, that it defaults to 0, and that it stays 0 on a timing transition.
  • New Spring Velocity demo under Timing in the example app: three boxes on the same spring with velocity -600 / 0 / 600, so the negative case (pulls back before travelling) and the positive case (launches ahead) are visible side by side. I have not run the example app on a simulator or device yet — the native paths below are reasoned from the platform APIs, and that demo is the thing that should confirm them before this is marked ready.
  • The iOS change is running in an app via a yarn patch of 0.7.3 carrying the same EaseView.mm edit: the prop parses, the animation is created, and a TouchableBounce port driven by it behaves correctly through press/release. That exercises the plumbing end to end, but I did not isolate the visual effect of velocity itself there.
  • Android is unexercised so far.

format:clang could not run locally — the bundled clang-format binary is x86_64 and fails to spawn on this machine (EBADARCH). I formatted ios/EaseView.mm with a system clang-format against the repo config instead; CI's check is the real signal.

`SpringTransition` gains a `velocity` field, matching `Animated.spring`'s
`velocity`: the spring starts with the property already in motion instead of
at rest. This is what a spring that continues a gesture needs — without it,
porting `Animated.spring({ velocity })` or `withSpring(v, { velocity })` to
EaseView drops the initial impulse and the animation reads as sluggish.

Units follow the JS side — DIPs/s for translateX/translateY, degrees/s for
rotate, unitless for scale and opacity — and the value is signed in value
space, so positive means the property is already increasing.

Platform notes:

- iOS: `CASpringAnimation.initialVelocity` is normalized against the
  from->to distance rather than being in value units, so the value is divided
  by the delta. That division also produces the correct sign for decreasing
  animations. `settlingDuration` accounts for the initial velocity, so
  `duration` is read after it is set.
- Android: `SpringAnimation.setStartVelocity` works in the property's own
  units, which for TRANSLATION_X/Y is pixels — the DIP value is converted the
  same way `EaseViewManager` converts the target.
- Web: not applied. A spring compiles to one normalized `linear()` easing
  curve shared by every property in the category, and an initial velocity only
  has meaning relative to each property's own from->to distance.

Adds a Spring Velocity demo to the example app under Timing, plus README,
usage, api-reference and refactor-skill updates. The skill also gains a note
on `Animated.spring`'s bounciness/speed pair, which resolves through
`SpringConfig.fromBouncinessAndSpeed` rather than mapping directly.
@janicduplessis
janicduplessis marked this pull request as ready for review August 3, 2026 18:20
@janicduplessis
janicduplessis merged commit 00b1fc5 into main Aug 3, 2026
7 checks passed
@janicduplessis
janicduplessis deleted the feat/spring-initial-velocity branch August 3, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant