motion · spring-ios · 灵感来源:iOS UIKit / SwiftUI 的 spring 动画(UISpringTimingParameters)
拖动参数,运动连续演化不跳变。
Spring Timing Explorer · 弹簧曲线 · 动效 / Motion
做一个 Spring Timing Explorer(弹簧曲线):
- 基准参数:response 0.5s、dampingFraction 0.825
- 无惯性的入场用 100% 阻尼,带惯性的滑动用 80%
- 积分步长与动画时长是两件事,别混
用这些 CSS 变量:--color-accent --color-bg --color-surface --color-text --color-text-muted --color-text-subtle --glass-border --glass-recess --shadow-md --text-2xs
灵感参考:iOS UIKit / SwiftUI 的 spring 动画(UISpringTimingParameters)--color-accent --color-bg --color-surface --color-text --color-text-muted --color-text-subtle --glass-border --glass-recess --shadow-md --text-2xs
(无必缺状态)
(尚未接进整页场景)
/r/motion--spring-ios.json
免外壳的 markdown 版:/llms/components/motion--spring-ios.mdm·x″ + c·x′ + k·(x−target) = 0(m=1)ζ = c / 2√k 决定性格:response/dampingFraction 本质是 k/c 的换皮prefers-reduced-motion:直接落位,不跑模拟import { useEffect, useRef, useState } from 'react';
interface Preset {
id: string;
label: string;
k: number;
c: number;
}
const PRESETS: Preset[] = [
{ id: 'ios', label: 'iOS 默认', k: 220, c: 22 },
{ id: 'bouncy', label: '软糖', k: 300, c: 9 },
{ id: 'critical', label: '无摆动', k: 220, c: 30 },
{ id: 'soft', label: '慢回弹', k: 90, c: 20 },
];
const WINDOW_MS = 3000; // 曲线视窗
const V_MIN = -0.2; // 值域(留过冲空间)
const V_MAX = 1.2;
export function SpringPlayground() {
const [k, setK] = useState(220);
const [c, setC] = useState(22);
const [, setTick] = useState(0);
const sim = useRef({ x: 0.15, v: 0, target: 0.7 });
const samples = useRef<{ t: number; x: number }[]>([]);
const params = useRef({ k, c });
params.current = { k, c };
const raf = useRef(0);
const sleeping = useRef(true);
const reduced = useRef(false);
const trackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
reduced.current = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced.current) {
sim.current.x = sim.current.target;
setTick((t) => t + 1);
return;
}
wake();
return () => cancelAnimationFrame(raf.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const wake = () => {
if (!sleeping.current) return;
sleeping.current = false;
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
const s = sim.current;
const { k: kk, c: cc } = params.current;
// 半隐式欧拉
s.v += (-kk * (s.x - s.target) - cc * s.v) * dt;
s.x += s.v * dt;
samples.current.push({ t: now, x: s.x });
const cutoff = now - WINDOW_MS;
while (samples.current.length && samples.current[0].t < cutoff) {
samples.current.shift();
}
setTick((t) => t + 1);
// 静止判定:误差 + 速度双阈值
if (Math.abs(s.x - s.target) < 0.0005 && Math.abs(s.v) < 0.0005) {
s.x = s.target;
s.v = 0;
sleeping.current = true;
setTick((t) => t + 1);
return;
}
raf.current = requestAnimationFrame(step);
};
raf.current = requestAnimationFrame(step);
};
const setTarget = (t: number) => {
sim.current.target = Math.max(0.02, Math.min(0.98, t));
if (reduced.current) {
sim.current.x = sim.current.target;
setTick((tick) => tick + 1);
return;
}
samples.current.push({ t: performance.now(), x: sim.current.x });
wake();
};
const onTrackClick = (e: React.MouseEvent) => {
const el = trackRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setTarget((e.clientX - r.left) / r.width);
};
const s = sim.current;
const zeta = c / (2 * Math.sqrt(k));
// 曲线点映射
const W = 240;
const H = 80;
const now = samples.current.length
? samples.current[samples.current.length - 1].t
: 0;
const toY = (v: number) => H - ((v - V_MIN) / (V_MAX - V_MIN)) * H;
const points = samples.current
.map((p) => {
const px = ((p.t - (now - WINDOW_MS)) / WINDOW_MS) * W;
return `${px.toFixed(1)},${toY(p.x).toFixed(1)}`;
})
.join(' ');
// 速度驱动的挤压拉伸
const stretch = Math.min(0.22, Math.abs(s.v) * 0.12);
return (
<div className="w-full max-w-xl space-y-4">
{/* 轨道:点击设定目标,球用弹簧追过去 */}
<div
ref={trackRef}
onClick={onTrackClick}
className="
relative h-24 rounded-2xl cursor-pointer overflow-hidden
bg-[var(--glass-recess)] border border-[var(--glass-border)]
"
>
{/* 目标环 */}
<div
className="absolute top-1/2 size-8 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-dashed border-[var(--color-text-subtle)]/60"
style={{ left: `${s.target * 100}%` }}
/>
{/* 球 */}
<div
className="absolute top-1/2 size-8 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--color-accent)] shadow-[var(--shadow-md)]"
style={{
left: `${s.x * 100}%`,
transform: `translate(-50%, -50%) scale(${1 + stretch}, ${1 - stretch})`,
}}
/>
<span className="absolute bottom-2 left-3 text-[length:var(--text-2xs)] text-[var(--color-text-subtle)] pointer-events-none">
点击轨道设定目标
</span>
</div>
{/* 实时曲线:位置随时间 */}
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full h-20 rounded-xl bg-[var(--color-surface)] border border-[var(--glass-border)]"
preserveAspectRatio="none"
aria-hidden="true"
>
<line x1="0" x2={W} y1={toY(0)} y2={toY(0)} stroke="var(--glass-border)" strokeWidth="1" />
<line x1="0" x2={W} y1={toY(1)} y2={toY(1)} stroke="var(--glass-border)" strokeWidth="1" />
<line
x1="0" x2={W} y1={toY(s.target)} y2={toY(s.target)}
stroke="var(--color-accent)" strokeWidth="1" strokeDasharray="3 3" opacity="0.5"
/>
<polyline
points={points}
fill="none"
stroke="var(--color-accent)"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
{/* 参数 */}
<div className="grid grid-cols-2 gap-4">
<label className="block">
<span className="flex justify-between text-xs text-[var(--color-text-muted)] mb-1.5">
<span>刚度 k</span>
<span className="font-mono">{k}</span>
</span>
<input
type="range" min={40} max={600} step={10} value={k}
onChange={(e) => setK(Number(e.target.value))}
className="w-full accent-[var(--color-accent)]"
aria-label="弹簧刚度"
/>
</label>
<label className="block">
<span className="flex justify-between text-xs text-[var(--color-text-muted)] mb-1.5">
<span>阻尼 c</span>
<span className="font-mono">{c}</span>
</span>
<input
type="range" min={2} max={60} step={1} value={c}
onChange={(e) => setC(Number(e.target.value))}
className="w-full accent-[var(--color-accent)]"
aria-label="弹簧阻尼"
/>
</label>
</div>
{/* 预设 + 阻尼比 */}
<div className="flex items-center gap-2 flex-wrap">
{PRESETS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => { setK(p.k); setC(p.c); }}
className={`
press-feedback px-3 py-1.5 rounded-full text-xs border
transition-colors cursor-pointer
${k === p.k && c === p.c
? 'bg-[var(--color-text)] text-[var(--color-bg)] border-transparent'
: 'bg-[var(--color-surface)] text-[var(--color-text-muted)] border-[var(--glass-border)] hover:text-[var(--color-text)]'}
`}
>
{p.label}
</button>
))}
<span className="ml-auto text-xs font-mono text-[var(--color-text-subtle)]">
ζ = {zeta.toFixed(2)}
<span className="opacity-60">
{zeta < 0.98 ? ' · 欠阻尼' : zeta <= 1.02 ? ' · 临界' : ' · 过阻尼'}
</span>
</span>
</div>
</div>
);
}
src/components/motion/spring-ios/