Skip to content

Commit 01e4e97

Browse files
committed
feat: add Spring class
1 parent 70419da commit 01e4e97

File tree

3 files changed

+183
-3
lines changed

3 files changed

+183
-3
lines changed

packages/svelte/src/internal/shared/utils.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,25 @@ export function run_all(arr) {
2323
arr[i]();
2424
}
2525
}
26+
27+
/**
28+
* TODO replace with Promise.withResolvers once supported widely enough
29+
* @template T
30+
* @returns {PromiseWithResolvers<T>}
31+
*/
32+
export function deferred() {
33+
/** @type {(value: T) => void} */
34+
var resolve;
35+
36+
/** @type {(reason: any) => void} */
37+
var reject;
38+
39+
/** @type {Promise<T>} */
40+
var promise = new Promise((res, rej) => {
41+
resolve = res;
42+
reject = rej;
43+
});
44+
45+
// @ts-expect-error
46+
return { promise, resolve, reject };
47+
}

packages/svelte/src/motion/private.d.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { Spring } from './public.js';
2-
31
export interface TickContext<T> {
42
inv_mass: number;
53
dt: number;
6-
opts: Spring<T>;
4+
opts: {
5+
stiffness: number;
6+
damping: number;
7+
precision: number;
8+
};
79
settled: boolean;
810
}
911

packages/svelte/src/motion/spring.js

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { writable } from '../store/index.js';
22
import { loop } from '../internal/client/loop.js';
33
import { raf } from '../internal/client/timing.js';
44
import { is_date } from './utils.js';
5+
import { set, source } from '../internal/client/reactivity/sources.js';
6+
import { render_effect } from '../internal/client/reactivity/effects.js';
7+
import { get } from '../internal/client/runtime.js';
8+
import { deferred, noop } from '../internal/shared/utils.js';
59

610
/**
711
* @template T
@@ -136,3 +140,155 @@ export function spring(value, opts = {}) {
136140
};
137141
return spring;
138142
}
143+
144+
/**
145+
* @template T
146+
*/
147+
export class Spring {
148+
#stiffness = source(0.15);
149+
#damping = source(0.8);
150+
#precision = source(0.01);
151+
152+
#current = source(/** @type {T} */ (undefined));
153+
154+
#target_value = /** @type {T} */ (undefined);
155+
#last_value = /** @type {T} */ (undefined);
156+
#last_time = 0;
157+
158+
#inverse_mass = 1;
159+
#momentum = 0;
160+
161+
/** @type {import('../internal/client/types').Task | null} */
162+
#task = null;
163+
164+
/** @type {PromiseWithResolvers<any> | null} */
165+
#deferred = null;
166+
167+
/**
168+
* @param {T | (() => T)} value
169+
* @param {{ stiffness?: number, damping?: number, precision?: number }} [options]
170+
*/
171+
constructor(value, options = {}) {
172+
if (typeof value === 'function') {
173+
render_effect(() => {
174+
this.#update(/** @type {() => T} */ (value)());
175+
});
176+
} else {
177+
this.#current.v = this.#target_value = value;
178+
}
179+
180+
if (typeof options.stiffness === 'number') this.#stiffness.v = clamp(options.stiffness, 0, 1);
181+
if (typeof options.damping === 'number') this.#damping.v = clamp(options.damping, 0, 1);
182+
if (typeof options.precision === 'number') this.#precision.v = options.precision;
183+
}
184+
185+
/** @param {T} value */
186+
#update(value) {
187+
this.#target_value = value;
188+
189+
this.#current.v ??= value;
190+
this.#last_value ??= this.#current.v;
191+
192+
if (!this.#task) {
193+
this.#last_time = raf.now();
194+
195+
var inv_mass_recovery_rate = 1 / (this.#momentum * 60);
196+
197+
this.#task ??= loop((now) => {
198+
this.#inverse_mass = Math.min(this.#inverse_mass + inv_mass_recovery_rate, 1);
199+
200+
/** @type {import('./private').TickContext<T>} */
201+
const ctx = {
202+
inv_mass: this.#inverse_mass,
203+
opts: {
204+
stiffness: this.#stiffness.v,
205+
damping: this.#damping.v,
206+
precision: this.#precision.v
207+
},
208+
settled: true,
209+
dt: ((now - this.#last_time) * 60) / 1000
210+
};
211+
212+
var next = tick_spring(ctx, this.#last_value, this.#current.v, this.#target_value);
213+
this.#last_value = this.#current.v;
214+
this.#last_time = now;
215+
set(this.#current, next);
216+
217+
if (ctx.settled) {
218+
this.#task = null;
219+
}
220+
221+
return !ctx.settled;
222+
});
223+
}
224+
225+
return this.#task.promise;
226+
}
227+
228+
/**
229+
* @param {T} value
230+
* @param {{ instant?: boolean; preserveMomentum?: number }} [options]
231+
*/
232+
set(value, options) {
233+
this.#deferred?.reject(new Error('Aborted'));
234+
235+
if (options?.instant || this.#current.v === undefined) {
236+
this.#task?.abort();
237+
this.#task = null;
238+
set(this.#current, (this.#target_value = value));
239+
return Promise.resolve();
240+
}
241+
242+
if (options?.preserveMomentum) {
243+
this.#inverse_mass = 0;
244+
this.#momentum = options.preserveMomentum;
245+
}
246+
247+
var d = (this.#deferred = deferred());
248+
d.promise.catch(noop);
249+
250+
this.#update(value).then(() => {
251+
if (d !== this.#deferred) return;
252+
d.resolve(undefined);
253+
});
254+
255+
return d.promise;
256+
}
257+
258+
get current() {
259+
return get(this.#current);
260+
}
261+
262+
get damping() {
263+
return get(this.#damping);
264+
}
265+
266+
set damping(v) {
267+
set(this.#damping, clamp(v, 0, 1));
268+
}
269+
270+
get precision() {
271+
return get(this.#precision);
272+
}
273+
274+
set precision(v) {
275+
set(this.#precision, v);
276+
}
277+
278+
get stiffness() {
279+
return get(this.#stiffness);
280+
}
281+
282+
set stiffness(v) {
283+
set(this.#stiffness, clamp(v, 0, 1));
284+
}
285+
}
286+
287+
/**
288+
* @param {number} n
289+
* @param {number} min
290+
* @param {number} max
291+
*/
292+
function clamp(n, min, max) {
293+
return Math.max(min, Math.min(max, n));
294+
}

0 commit comments

Comments
 (0)