动力 · 17
弹簧
Spring · spring用刚度、阻尼和质量描述回弹,并从当前位置与速度接续。
弹簧的目标固定,但到达方式由速度和阻尼决定:拖动后松手能顺着已有动量自然归位。只逐帧加力的近似解会随刷新率改变;解析解在同样的时间点给出同样的位置。
公式
k = (2π / (1.2·visualDuration))² · mass ζ = 1 − bounce c = 2ζ√(k·mass) m·x″ + c·x′ + k(x − target) = 0
参数
| 本页默认 | 成熟实现 | |
|---|---|---|
| visualDuration | 0.4 s | Motion 0.3 s |
| bounce | 0.2 | Motion 0.3 |
| stiffness / damping | derived | react-spring default 170 / 26 |
怎样做好
- 先用视觉时长和弹性调感觉,再看换算出的刚度与阻尼;Motion 用
(2π / (1.2·vd))²换算刚度。motion packages/motion-dom/src/animation/generators/spring.ts:249–256 - 释放时传入当下速度,位置和速度才都连续。
- 对照 react-spring 默认 tension 170、friction 26;不同库的参数名与积分方式不同,不要只凭数字移植。react-spring packages/core/src/constants.ts
在我们的案例里
7 个案例明确用了弹簧。cardwall、emulsion 等页面有弹性回归;另一些“看着像弹簧”的逐帧拉力归在质量问题里,不能把它们算作明确的 spring。
49 / 69 个案例 · 有逐帧积分(未乘 dt)来自 Codex 逐案质量标记(按关键词归类,近似值)
出现在 7 个案例
参考实现 · basics.js
/**
* Damped spring with an exact (analytic) step, so any dt is safe.
* Configure by feel: visualDuration (s) + bounce (0 = no overshoot, 0.5 = lively), like Motion / SwiftUI.
* Or by physics: { stiffness, damping, mass }.
* Rest thresholds are in the value's own units: the defaults suit 0..1 values; for pixels use
* restDelta 0.5 / restSpeed 2 (Motion's defaults), or the tail adds a second of invisible motion.
*/
export class Spring {
constructor({ value = 0, target = value, velocity = 0, visualDuration = 0.4, bounce = 0.2,
stiffness, damping, mass = 1, restDelta = 0.001, restSpeed = 0.01 } = {}) {
Object.assign(this, { value, target, velocity, restDelta, restSpeed, mass });
if (stiffness == null) {
const zeta = bounce >= 0 ? Math.max(1 - bounce, 0.05) : 1 / Math.max(1 + bounce, 0.05);
stiffness = (2 * Math.PI / (1.2 * visualDuration)) ** 2 * mass;
damping = 2 * zeta * Math.sqrt(stiffness * mass);
}
this.stiffness = stiffness; this.damping = damping;
}
get done() { return this.value === this.target && this.velocity === 0; }
step(dt) {
const { stiffness: k, damping: c, mass: m } = this;
const w0 = Math.sqrt(k / m), zeta = c / (2 * Math.sqrt(k * m));
const x0 = this.value - this.target, v0 = this.velocity;
let x, v;
if (zeta < 1) { // underdamped: oscillates and decays
const wd = w0 * Math.sqrt(1 - zeta * zeta), e = Math.exp(-zeta * w0 * dt);
const cos = Math.cos(wd * dt), sin = Math.sin(wd * dt);
x = e * (x0 * cos + ((v0 + zeta * w0 * x0) / wd) * sin);
v = e * (v0 * cos - ((zeta * w0 * v0 + w0 * w0 * x0) / wd) * sin);
} else if (zeta === 1) { // critically damped: fastest without overshoot
const e = Math.exp(-w0 * dt), b = v0 + w0 * x0;
x = e * (x0 + b * dt);
v = e * (v0 - w0 * b * dt);
} else { // overdamped: two decaying exponentials (no cosh overflow)
const s = Math.sqrt(zeta * zeta - 1), r1 = -w0 * (zeta - s), r2 = -w0 * (zeta + s);
const c2 = (v0 - r1 * x0) / (r2 - r1), c1 = x0 - c2;
const e1 = Math.exp(r1 * dt), e2 = Math.exp(r2 * dt);
x = c1 * e1 + c2 * e2;
v = r1 * c1 * e1 + r2 * c2 * e2;
}
if (Math.abs(x) < this.restDelta && Math.abs(v) < this.restSpeed) { x = 0; v = 0; }
this.value = this.target + x; this.velocity = v;
return this.value;
}
}