feat: add Spring and Tween classes (#11519)

* feat: add Spring class

* add some docs, Spring.of static method

* add Tween class

* lint

* preserveMomentum in milliseconds

* deprecate tweened

* changeset

* wrestle with types

* more consolidation

* flesh out the distinction a bit more, deprecate `subscribe`

---------

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
pull/14583/head
Rich Harris 9 months ago committed by GitHub
parent 60c0dc7a2d
commit 80ffcc3fe1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: add `Spring` and `Tween` classes to `svelte/motion`

@ -44,6 +44,27 @@ export function run_all(arr) {
}
}
/**
* TODO replace with Promise.withResolvers once supported widely enough
* @template T
*/
export function deferred() {
/** @type {(value: T) => void} */
var resolve;
/** @type {(reason: any) => void} */
var reject;
/** @type {Promise<T>} */
var promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// @ts-expect-error
return { promise, resolve, reject };
}
/**
* @template V
* @param {V} value

@ -1,9 +1,11 @@
import { Spring } from './public.js';
export interface TickContext<T> {
export interface TickContext {
inv_mass: number;
dt: number;
opts: Spring<T>;
opts: {
stiffness: number;
damping: number;
precision: number;
};
settled: boolean;
}
@ -14,8 +16,22 @@ export interface SpringOpts {
}
export interface SpringUpdateOpts {
/**
* @deprecated Only use this for the spring store; does nothing when set on the Spring class
*/
hard?: any;
/**
* @deprecated Only use this for the spring store; does nothing when set on the Spring class
*/
soft?: string | number | boolean;
/**
* Only use this for the Spring class; does nothing when set on the spring store
*/
instant?: boolean;
/**
* Only use this for the Spring class; does nothing when set on the spring store
*/
preserveMomentum?: number;
}
export type Updater<T> = (target_value: T, value: T) => T;

@ -1,17 +1,87 @@
import { Readable } from '../store/public.js';
import { SpringUpdateOpts, TweenedOptions, Updater } from './private.js';
import { Readable, type Unsubscriber } from '../store/public.js';
import { SpringUpdateOpts, TweenedOptions, Updater, SpringOpts } from './private.js';
// TODO we do declaration merging here in order to not have a breaking change (renaming the Spring interface)
// this means both the Spring class and the Spring interface are merged into one with some things only
// existing on one side. In Svelte 6, remove the type definition and move the jsdoc onto the class in spring.js
export interface Spring<T> extends Readable<T> {
set: (new_value: T, opts?: SpringUpdateOpts) => Promise<void>;
set(new_value: T, opts?: SpringUpdateOpts): Promise<void>;
/**
* @deprecated Only exists on the legacy `spring` store, not the `Spring` class
*/
update: (fn: Updater<T>, opts?: SpringUpdateOpts) => Promise<void>;
/**
* @deprecated Only exists on the legacy `spring` store, not the `Spring` class
*/
subscribe(fn: (value: T) => void): Unsubscriber;
precision: number;
damping: number;
stiffness: number;
}
/**
* A wrapper for a value that behaves in a spring-like fashion. Changes to `spring.target` will cause `spring.current` to
* move towards it over time, taking account of the `spring.stiffness` and `spring.damping` parameters.
*
* ```svelte
* <script>
* import { Spring } from 'svelte/motion';
*
* const spring = new Spring(0);
* </script>
*
* <input type="range" bind:value={spring.target} />
* <input type="range" bind:value={spring.current} disabled />
* ```
*/
export class Spring<T> {
constructor(value: T, options?: SpringOpts);
/**
* Create a spring whose value is bound to the return value of `fn`. This must be called
* inside an effect root (for example, during component initialisation).
*
* ```svelte
* <script>
* import { Spring } from 'svelte/motion';
*
* let { number } = $props();
*
* const spring = Spring.of(() => number);
* </script>
* ```
*/
static of<U>(fn: () => U, options?: SpringOpts): Spring<U>;
/**
* Sets `spring.target` to `value` and returns a `Promise` that resolves if and when `spring.current` catches up to it.
*
* If `options.instant` is `true`, `spring.current` immediately matches `spring.target`.
*
* If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for
* the specified number of milliseconds. This is useful for things like 'fling' gestures.
*/
set(value: T, options?: SpringUpdateOpts): Promise<void>;
damping: number;
precision: number;
stiffness: number;
/**
* The end value of the spring.
* This property only exists on the `Spring` class, not the legacy `spring` store.
*/
target: T;
/**
* The current value of the spring.
* This property only exists on the `Spring` class, not the legacy `spring` store.
*/
get current(): T;
}
export interface Tweened<T> extends Readable<T> {
set(value: T, opts?: TweenedOptions<T>): Promise<void>;
update(updater: Updater<T>, opts?: TweenedOptions<T>): Promise<void>;
}
export * from './index.js';
export { spring, tweened, Tween } from './index.js';

@ -1,14 +1,18 @@
/** @import { Task } from '#client' */
/** @import { SpringOpts, SpringUpdateOpts, TickContext } from './private.js' */
/** @import { Spring } from './public.js' */
/** @import { Spring as SpringStore } from './public.js' */
import { writable } from '../store/shared/index.js';
import { loop } from '../internal/client/loop.js';
import { raf } from '../internal/client/timing.js';
import { is_date } from './utils.js';
import { set, source } from '../internal/client/reactivity/sources.js';
import { render_effect } from '../internal/client/reactivity/effects.js';
import { get } from '../internal/client/runtime.js';
import { deferred, noop } from '../internal/shared/utils.js';
/**
* @template T
* @param {TickContext<T>} ctx
* @param {TickContext} ctx
* @param {T} last_value
* @param {T} current_value
* @param {T} target_value
@ -53,10 +57,11 @@ function tick_spring(ctx, last_value, current_value, target_value) {
/**
* The spring function in Svelte creates a store whose value is animated, with a motion that simulates the behavior of a spring. This means when the value changes, instead of transitioning at a steady rate, it "bounces" like a spring would, depending on the physics parameters provided. This adds a level of realism to the transitions and can enhance the user experience.
*
* @deprecated Use [`Spring`](https://svelte.dev/docs/svelte/svelte-motion#Spring) instead
* @template [T=any]
* @param {T} [value]
* @param {SpringOpts} [opts]
* @returns {Spring<T>}
* @returns {SpringStore<T>}
*/
export function spring(value, opts = {}) {
const store = writable(value);
@ -103,7 +108,7 @@ export function spring(value, opts = {}) {
return false;
}
inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1);
/** @type {TickContext<T>} */
/** @type {TickContext} */
const ctx = {
inv_mass,
opts: spring,
@ -127,7 +132,8 @@ export function spring(value, opts = {}) {
});
});
}
/** @type {Spring<T>} */
/** @type {SpringStore<T>} */
// @ts-expect-error - class-only properties are missing
const spring = {
set,
update: (fn, opts) => set(fn(/** @type {T} */ (target_value), /** @type {T} */ (value)), opts),
@ -138,3 +144,205 @@ export function spring(value, opts = {}) {
};
return spring;
}
/**
* A wrapper for a value that behaves in a spring-like fashion. Changes to `spring.target` will cause `spring.current` to
* move towards it over time, taking account of the `spring.stiffness` and `spring.damping` parameters.
*
* ```svelte
* <script>
* import { Spring } from 'svelte/motion';
*
* const spring = new Spring(0);
* </script>
*
* <input type="range" bind:value={spring.target} />
* <input type="range" bind:value={spring.current} disabled />
* ```
* @template T
* @since 5.8.0
*/
export class Spring {
#stiffness = source(0.15);
#damping = source(0.8);
#precision = source(0.01);
#current = source(/** @type {T} */ (undefined));
#target = source(/** @type {T} */ (undefined));
#last_value = /** @type {T} */ (undefined);
#last_time = 0;
#inverse_mass = 1;
#momentum = 0;
/** @type {import('../internal/client/types').Task | null} */
#task = null;
/** @type {ReturnType<typeof deferred> | null} */
#deferred = null;
/**
* @param {T} value
* @param {SpringOpts} [options]
*/
constructor(value, options = {}) {
this.#current.v = this.#target.v = value;
if (typeof options.stiffness === 'number') this.#stiffness.v = clamp(options.stiffness, 0, 1);
if (typeof options.damping === 'number') this.#damping.v = clamp(options.damping, 0, 1);
if (typeof options.precision === 'number') this.#precision.v = options.precision;
}
/**
* Create a spring whose value is bound to the return value of `fn`. This must be called
* inside an effect root (for example, during component initialisation).
*
* ```svelte
* <script>
* import { Spring } from 'svelte/motion';
*
* let { number } = $props();
*
* const spring = Spring.of(() => number);
* </script>
* ```
* @template U
* @param {() => U} fn
* @param {SpringOpts} [options]
*/
static of(fn, options) {
const spring = new Spring(fn(), options);
render_effect(() => {
spring.set(fn());
});
return spring;
}
/** @param {T} value */
#update(value) {
set(this.#target, value);
this.#current.v ??= value;
this.#last_value ??= this.#current.v;
if (!this.#task) {
this.#last_time = raf.now();
var inv_mass_recovery_rate = 1000 / (this.#momentum * 60);
this.#task ??= loop((now) => {
this.#inverse_mass = Math.min(this.#inverse_mass + inv_mass_recovery_rate, 1);
/** @type {import('./private').TickContext} */
const ctx = {
inv_mass: this.#inverse_mass,
opts: {
stiffness: this.#stiffness.v,
damping: this.#damping.v,
precision: this.#precision.v
},
settled: true,
dt: ((now - this.#last_time) * 60) / 1000
};
var next = tick_spring(ctx, this.#last_value, this.#current.v, this.#target.v);
this.#last_value = this.#current.v;
this.#last_time = now;
set(this.#current, next);
if (ctx.settled) {
this.#task = null;
}
return !ctx.settled;
});
}
return this.#task.promise;
}
/**
* Sets `spring.target` to `value` and returns a `Promise` that resolves if and when `spring.current` catches up to it.
*
* If `options.instant` is `true`, `spring.current` immediately matches `spring.target`.
*
* If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for
* the specified number of milliseconds. This is useful for things like 'fling' gestures.
*
* @param {T} value
* @param {SpringUpdateOpts} [options]
*/
set(value, options) {
this.#deferred?.reject(new Error('Aborted'));
if (options?.instant || this.#current.v === undefined) {
this.#task?.abort();
this.#task = null;
set(this.#current, set(this.#target, value));
return Promise.resolve();
}
if (options?.preserveMomentum) {
this.#inverse_mass = 0;
this.#momentum = options.preserveMomentum;
}
var d = (this.#deferred = deferred());
d.promise.catch(noop);
this.#update(value).then(() => {
if (d !== this.#deferred) return;
d.resolve(undefined);
});
return d.promise;
}
get current() {
return get(this.#current);
}
get damping() {
return get(this.#damping);
}
set damping(v) {
set(this.#damping, clamp(v, 0, 1));
}
get precision() {
return get(this.#precision);
}
set precision(v) {
set(this.#precision, v);
}
get stiffness() {
return get(this.#stiffness);
}
set stiffness(v) {
set(this.#stiffness, clamp(v, 0, 1));
}
get target() {
return get(this.#target);
}
set target(v) {
this.set(v);
}
}
/**
* @param {number} n
* @param {number} min
* @param {number} max
*/
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}

@ -6,6 +6,8 @@ import { raf } from '../internal/client/timing.js';
import { loop } from '../internal/client/loop.js';
import { linear } from '../easing/index.js';
import { is_date } from './utils.js';
import { set, source } from '../internal/client/reactivity/sources.js';
import { get, render_effect } from 'svelte/internal/client';
/**
* @template T
@ -76,6 +78,7 @@ function get_interpolator(a, b) {
/**
* A tweened store in Svelte is a special type of store that provides smooth transitions between state values over time.
*
* @deprecated Use [`Tween`](https://svelte.dev/docs/svelte/svelte-motion#Tween) instead
* @template T
* @param {T} [value]
* @param {TweenedOptions<T>} [defaults]
@ -152,3 +155,137 @@ export function tweened(value, defaults = {}) {
subscribe: store.subscribe
};
}
/**
* A wrapper for a value that tweens smoothly to its target value. Changes to `tween.target` will cause `tween.current` to
* move towards it over time, taking account of the `delay`, `duration` and `easing` options.
*
* ```svelte
* <script>
* import { Tween } from 'svelte/motion';
*
* const tween = new Tween(0);
* </script>
*
* <input type="range" bind:value={tween.target} />
* <input type="range" bind:value={tween.current} disabled />
* ```
* @template T
* @since 5.8.0
*/
export class Tween {
#current = source(/** @type {T} */ (undefined));
#target = source(/** @type {T} */ (undefined));
/** @type {TweenedOptions<T>} */
#defaults;
/** @type {import('../internal/client/types').Task | null} */
#task = null;
/**
* @param {T} value
* @param {TweenedOptions<T>} options
*/
constructor(value, options = {}) {
this.#current.v = this.#target.v = value;
this.#defaults = options;
}
/**
* Create a tween whose value is bound to the return value of `fn`. This must be called
* inside an effect root (for example, during component initialisation).
*
* ```svelte
* <script>
* import { Tween } from 'svelte/motion';
*
* let { number } = $props();
*
* const tween = Tween.of(() => number);
* </script>
* ```
* @template U
* @param {() => U} fn
* @param {TweenedOptions<U>} [options]
*/
static of(fn, options) {
const tween = new Tween(fn(), options);
render_effect(() => {
tween.set(fn());
});
return tween;
}
/**
* Sets `tween.target` to `value` and returns a `Promise` that resolves if and when `tween.current` catches up to it.
*
* If `options` are provided, they will override the tween's defaults.
* @param {T} value
* @param {TweenedOptions<T>} [options]
* @returns
*/
set(value, options) {
set(this.#target, value);
let previous_value = this.#current.v;
let previous_task = this.#task;
let started = false;
let {
delay = 0,
duration = 400,
easing = linear,
interpolate = get_interpolator
} = { ...this.#defaults, ...options };
const start = raf.now() + delay;
/** @type {(t: number) => T} */
let fn;
this.#task = loop((now) => {
if (now < start) {
return true;
}
if (!started) {
started = true;
fn = interpolate(/** @type {any} */ (previous_value), value);
if (typeof duration === 'function') {
duration = duration(/** @type {any} */ (previous_value), value);
}
previous_task?.abort();
}
const elapsed = now - start;
if (elapsed > /** @type {number} */ (duration)) {
set(this.#current, value);
return false;
}
set(this.#current, fn(easing(elapsed / /** @type {number} */ (duration))));
return true;
});
return this.#task.promise;
}
get current() {
return get(this.#current);
}
get target() {
return get(this.#target);
}
set target(v) {
this.set(v);
}
}

@ -1637,15 +1637,84 @@ declare module 'svelte/legacy' {
}
declare module 'svelte/motion' {
import type { MediaQuery } from 'svelte/reactivity';
// TODO we do declaration merging here in order to not have a breaking change (renaming the Spring interface)
// this means both the Spring class and the Spring interface are merged into one with some things only
// existing on one side. In Svelte 6, remove the type definition and move the jsdoc onto the class in spring.js
export interface Spring<T> extends Readable<T> {
set: (new_value: T, opts?: SpringUpdateOpts) => Promise<void>;
set(new_value: T, opts?: SpringUpdateOpts): Promise<void>;
/**
* @deprecated Only exists on the legacy `spring` store, not the `Spring` class
*/
update: (fn: Updater<T>, opts?: SpringUpdateOpts) => Promise<void>;
/**
* @deprecated Only exists on the legacy `spring` store, not the `Spring` class
*/
subscribe(fn: (value: T) => void): Unsubscriber;
precision: number;
damping: number;
stiffness: number;
}
/**
* A wrapper for a value that behaves in a spring-like fashion. Changes to `spring.target` will cause `spring.current` to
* move towards it over time, taking account of the `spring.stiffness` and `spring.damping` parameters.
*
* ```svelte
* <script>
* import { Spring } from 'svelte/motion';
*
* const spring = new Spring(0);
* </script>
*
* <input type="range" bind:value={spring.target} />
* <input type="range" bind:value={spring.current} disabled />
* ```
*/
export class Spring<T> {
constructor(value: T, options?: SpringOpts);
/**
* Create a spring whose value is bound to the return value of `fn`. This must be called
* inside an effect root (for example, during component initialisation).
*
* ```svelte
* <script>
* import { Spring } from 'svelte/motion';
*
* let { number } = $props();
*
* const spring = Spring.of(() => number);
* </script>
* ```
*/
static of<U>(fn: () => U, options?: SpringOpts): Spring<U>;
/**
* Sets `spring.target` to `value` and returns a `Promise` that resolves if and when `spring.current` catches up to it.
*
* If `options.instant` is `true`, `spring.current` immediately matches `spring.target`.
*
* If `options.preserveMomentum` is provided, the spring will continue on its current trajectory for
* the specified number of milliseconds. This is useful for things like 'fling' gestures.
*/
set(value: T, options?: SpringUpdateOpts): Promise<void>;
damping: number;
precision: number;
stiffness: number;
/**
* The end value of the spring.
* This property only exists on the `Spring` class, not the legacy `spring` store.
*/
target: T;
/**
* The current value of the spring.
* This property only exists on the `Spring` class, not the legacy `spring` store.
*/
get current(): T;
}
export interface Tweened<T> extends Readable<T> {
set(value: T, opts?: TweenedOptions<T>): Promise<void>;
update(updater: Updater<T>, opts?: TweenedOptions<T>): Promise<void>;
@ -1672,8 +1741,22 @@ declare module 'svelte/motion' {
}
interface SpringUpdateOpts {
/**
* @deprecated Only use this for the spring store; does nothing when set on the Spring class
*/
hard?: any;
/**
* @deprecated Only use this for the spring store; does nothing when set on the Spring class
*/
soft?: string | number | boolean;
/**
* Only use this for the Spring class; does nothing when set on the spring store
*/
instant?: boolean;
/**
* Only use this for the Spring class; does nothing when set on the spring store
*/
preserveMomentum?: number;
}
type Updater<T> = (target_value: T, value: T) => T;
@ -1684,40 +1767,64 @@ declare module 'svelte/motion' {
easing?: (t: number) => number;
interpolate?: (a: T, b: T) => (t: number) => T;
}
/**
* A [media query](https://svelte.dev/docs/svelte/svelte-reactivity#MediaQuery) that matches if the user [prefers reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion).
*
* ```svelte
* <script>
* import { prefersReducedMotion } from 'svelte/motion';
* import { fly } from 'svelte/transition';
*
* let visible = $state(false);
* </script>
*
* <button onclick={() => visible = !visible}>
* toggle
* </button>
*
* {#if visible}
* <p transition:fly={{ y: prefersReducedMotion.current ? 0 : 200 }}>
* flies in, unless the user prefers reduced motion
* </p>
* {/if}
* ```
* @since 5.7.0
*/
export const prefersReducedMotion: MediaQuery;
/**
* The spring function in Svelte creates a store whose value is animated, with a motion that simulates the behavior of a spring. This means when the value changes, instead of transitioning at a steady rate, it "bounces" like a spring would, depending on the physics parameters provided. This adds a level of realism to the transitions and can enhance the user experience.
*
* @deprecated Use [`Spring`](https://svelte.dev/docs/svelte/svelte-motion#Spring) instead
* */
export function spring<T = any>(value?: T | undefined, opts?: SpringOpts | undefined): Spring<T>;
/**
* A tweened store in Svelte is a special type of store that provides smooth transitions between state values over time.
*
* @deprecated Use [`Tween`](https://svelte.dev/docs/svelte/svelte-motion#Tween) instead
* */
export function tweened<T>(value?: T | undefined, defaults?: TweenedOptions<T> | undefined): Tweened<T>;
/**
* A wrapper for a value that tweens smoothly to its target value. Changes to `tween.target` will cause `tween.current` to
* move towards it over time, taking account of the `delay`, `duration` and `easing` options.
*
* ```svelte
* <script>
* import { Tween } from 'svelte/motion';
*
* const tween = new Tween(0);
* </script>
*
* <input type="range" bind:value={tween.target} />
* <input type="range" bind:value={tween.current} disabled />
* ```
* @since 5.8.0
*/
export class Tween<T> {
/**
* Create a tween whose value is bound to the return value of `fn`. This must be called
* inside an effect root (for example, during component initialisation).
*
* ```svelte
* <script>
* import { Tween } from 'svelte/motion';
*
* let { number } = $props();
*
* const tween = Tween.of(() => number);
* </script>
* ```
*
*/
static of<U>(fn: () => U, options?: TweenedOptions<U> | undefined): Tween<U>;
constructor(value: T, options?: TweenedOptions<T>);
/**
* Sets `tween.target` to `value` and returns a `Promise` that resolves if and when `tween.current` catches up to it.
*
* If `options` are provided, they will override the tween's defaults.
* */
set(value: T, options?: TweenedOptions<T> | undefined): Promise<void>;
get current(): T;
set target(v: T);
get target(): T;
#private;
}
export {};
}

Loading…
Cancel
Save