diff --git a/.changeset/tame-bottles-switch.md b/.changeset/tame-bottles-switch.md new file mode 100644 index 000000000000..c597f5ea997c --- /dev/null +++ b/.changeset/tame-bottles-switch.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: add `Spring` and `Tween` classes to `svelte/motion` diff --git a/packages/svelte/src/internal/shared/utils.js b/packages/svelte/src/internal/shared/utils.js index d843413e57ef..92d29d9e1d68 100644 --- a/packages/svelte/src/internal/shared/utils.js +++ b/packages/svelte/src/internal/shared/utils.js @@ -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} */ + var promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + // @ts-expect-error + return { promise, resolve, reject }; +} + /** * @template V * @param {V} value diff --git a/packages/svelte/src/motion/private.d.ts b/packages/svelte/src/motion/private.d.ts index 1a9afb0781b2..22b8cc4af39d 100644 --- a/packages/svelte/src/motion/private.d.ts +++ b/packages/svelte/src/motion/private.d.ts @@ -1,9 +1,11 @@ -import { Spring } from './public.js'; - -export interface TickContext { +export interface TickContext { inv_mass: number; dt: number; - opts: Spring; + 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 = (target_value: T, value: T) => T; diff --git a/packages/svelte/src/motion/public.d.ts b/packages/svelte/src/motion/public.d.ts index c0f55a4cca53..8fb9b9e66a1b 100644 --- a/packages/svelte/src/motion/public.d.ts +++ b/packages/svelte/src/motion/public.d.ts @@ -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 extends Readable { - set: (new_value: T, opts?: SpringUpdateOpts) => Promise; + set(new_value: T, opts?: SpringUpdateOpts): Promise; + /** + * @deprecated Only exists on the legacy `spring` store, not the `Spring` class + */ update: (fn: Updater, opts?: SpringUpdateOpts) => Promise; + /** + * @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 + * + * + * + * + * ``` + */ +export class Spring { + 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 + * + * ``` + */ + static of(fn: () => U, options?: SpringOpts): Spring; + + /** + * 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; + + 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 extends Readable { set(value: T, opts?: TweenedOptions): Promise; update(updater: Updater, opts?: TweenedOptions): Promise; } -export * from './index.js'; +export { spring, tweened, Tween } from './index.js'; diff --git a/packages/svelte/src/motion/spring.js b/packages/svelte/src/motion/spring.js index 992085b4b50d..270fabd4c774 100644 --- a/packages/svelte/src/motion/spring.js +++ b/packages/svelte/src/motion/spring.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} 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} + * @returns {SpringStore} */ 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} */ + /** @type {TickContext} */ const ctx = { inv_mass, opts: spring, @@ -127,7 +132,8 @@ export function spring(value, opts = {}) { }); }); } - /** @type {Spring} */ + /** @type {SpringStore} */ + // @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 + * + * + * + * + * ``` + * @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 | 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 + * + * ``` + * @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)); +} diff --git a/packages/svelte/src/motion/tweened.js b/packages/svelte/src/motion/tweened.js index 8f689878f736..d732dbc2831a 100644 --- a/packages/svelte/src/motion/tweened.js +++ b/packages/svelte/src/motion/tweened.js @@ -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} [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 + * + * + * + * + * ``` + * @template T + * @since 5.8.0 + */ +export class Tween { + #current = source(/** @type {T} */ (undefined)); + #target = source(/** @type {T} */ (undefined)); + + /** @type {TweenedOptions} */ + #defaults; + + /** @type {import('../internal/client/types').Task | null} */ + #task = null; + + /** + * @param {T} value + * @param {TweenedOptions} 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 + * + * ``` + * @template U + * @param {() => U} fn + * @param {TweenedOptions} [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} [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); + } +} diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index db6ac88c2c2a..f3f9580dc6dc 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -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 extends Readable { - set: (new_value: T, opts?: SpringUpdateOpts) => Promise; + set(new_value: T, opts?: SpringUpdateOpts): Promise; + /** + * @deprecated Only exists on the legacy `spring` store, not the `Spring` class + */ update: (fn: Updater, opts?: SpringUpdateOpts) => Promise; + /** + * @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 + * + * + * + * + * ``` + */ + export class Spring { + 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 + * + * ``` + */ + static of(fn: () => U, options?: SpringOpts): Spring; + + /** + * 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; + + 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 extends Readable { set(value: T, opts?: TweenedOptions): Promise; update(updater: Updater, opts?: TweenedOptions): Promise; @@ -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 = (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 - * - * - * - * - * {#if visible} - *

- * flies in, unless the user prefers reduced motion - *

- * {/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(value?: T | undefined, opts?: SpringOpts | undefined): Spring; /** * 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(value?: T | undefined, defaults?: TweenedOptions | undefined): Tweened; + /** + * 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 + * + * + * + * + * ``` + * @since 5.8.0 + */ + export class Tween { + /** + * 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 + * + * ``` + * + */ + static of(fn: () => U, options?: TweenedOptions | undefined): Tween; + + constructor(value: T, options?: TweenedOptions); + /** + * 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 | undefined): Promise; + get current(): T; + set target(v: T); + get target(): T; + #private; + } export {}; }