-
Notifications
You must be signed in to change notification settings - Fork 124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add createSpring primitive. #629
Changes from 12 commits
d94ae1e
ea6ea39
41d6679
45d16ec
24f4702
9fead8a
0ada2ae
ec7ac75
ca45603
98fed63
31bf2ce
eeff964
53b08a5
fd41228
0222ae1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
MIT License | ||
|
||
Copyright (c) 2021 Solid Primitives Working Group | ||
|
||
Permission is hereby granted, free of charge, to any person obtaining a copy | ||
of this software and associated documentation files (the "Software"), to deal | ||
in the Software without restriction, including without limitation the rights | ||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
copies of the Software, and to permit persons to whom the Software is | ||
furnished to do so, subject to the following conditions: | ||
|
||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
|
||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
SOFTWARE. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
<p> | ||
<img width="100%" src="https://assets.solidjs.com/banner?type=Primitives&background=tiles&project=spring" alt="Solid Primitives spring"> | ||
</p> | ||
|
||
# @solid-primitives/spring | ||
|
||
[![turborepo](https://img.shields.io/badge/built%20with-turborepo-cc00ff.svg?style=for-the-badge&logo=turborepo)](https://turborepo.org/) | ||
[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/spring?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/spring) | ||
[![version](https://img.shields.io/npm/v/@solid-primitives/spring?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/spring) | ||
[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) | ||
|
||
A small SolidJS hook to interpolate signal changes with spring physics. Inspired by & directly forked from [`svelte-motion/spring`](https://svelte.dev/docs/svelte-motion#spring) as such, has a very familiar API design. | ||
|
||
With this primitive, you can easily animate values that can be interpolated like `number`, `date`, and collections (arrays or nested objects) of those datatypes. | ||
|
||
- `createSpring` - Provides a getter and setter for the spring primitive. | ||
- `createDerivedSpring` - Provides only a getter for the spring primitive deriving from an accessor parameter. Similar to the [@solid-primitives/tween](https://github.com/solidjs-community/solid-primitives/tree/main/packages/tween) API. | ||
|
||
The following physics options are available: | ||
|
||
- `stiffness` (number, default `0.15`) — a value between 0 and 1 where higher means a 'tighter' spring | ||
- `damping` (number, default `0.8`) — a value between 0 and 1 where lower means a 'springier' spring | ||
- `precision` (number, default `0.01`) — determines the threshold at which the spring is considered to have 'settled', where lower means more precise | ||
|
||
## Installation | ||
|
||
```bash | ||
npm install @solid-primitives/spring | ||
# or | ||
yarn add @solid-primitives/spring | ||
# or | ||
pnpm add @solid-primitives/spring | ||
``` | ||
|
||
## How to use it | ||
|
||
```ts | ||
// Basic Example | ||
const [progress, setProgress] = createSpring(0); | ||
|
||
// Example with options (less sudden movement) | ||
const [radialProgress, setRadialProgress] = createSpring(0, { stiffness: 0.05 }); | ||
|
||
// Example with collections (e.g. Object or Array). | ||
const [xy, setXY] = createSpring( | ||
{ x: 50, y: 50 }, | ||
{ stiffness: 0.08, damping: 0.2, precision: 0.01 }, | ||
); | ||
|
||
// Example deriving from an existing signal. | ||
const [myNumber, myNumber] = createSignal(20); | ||
const springedValue = createDerivedSpring(myNumber, { stiffness: 0.03 }); | ||
``` | ||
|
||
## Demo | ||
|
||
- [CodeSandbox - Basic Example](https://codesandbox.io/p/devbox/ecstatic-borg-k2wqfr) | ||
|
||
## Changelog | ||
|
||
See [CHANGELOG.md](./CHANGELOG.md) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
{ | ||
"name": "@solid-primitives/spring", | ||
"version": "0.0.100", | ||
"description": "Primitive that creates spring physics functions.", | ||
"author": "Carlo Taleon <[email protected]>", | ||
"contributors": ["Damian Tarnawski <[email protected]>"], | ||
"license": "MIT", | ||
"homepage": "https://primitives.solidjs.community/package/spring", | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/solidjs-community/solid-primitives.git" | ||
}, | ||
"bugs": { | ||
"url": "https://github.com/solidjs-community/solid-primitives/issues" | ||
}, | ||
"primitive": { | ||
"name": "spring", | ||
"stage": 0, | ||
"list": [ | ||
"createSpring", | ||
"createDerivedSpring" | ||
], | ||
"category": "Animation" | ||
}, | ||
"keywords": [ | ||
"animate", | ||
"tween", | ||
"spring", | ||
"solid", | ||
"primitives" | ||
], | ||
"private": false, | ||
"sideEffects": false, | ||
"files": [ | ||
"dist" | ||
], | ||
"type": "module", | ||
"main": "./dist/index.cjs", | ||
"module": "./dist/index.js", | ||
"types": "./dist/index.d.ts", | ||
"browser": {}, | ||
"exports": { | ||
"import": { | ||
"types": "./dist/index.d.ts", | ||
"default": "./dist/index.js" | ||
}, | ||
"require": { | ||
"types": "./dist/index.d.cts", | ||
"default": "./dist/index.cjs" | ||
} | ||
}, | ||
"typesVersions": {}, | ||
"scripts": { | ||
"dev": "tsx ../../scripts/dev.ts", | ||
"build": "tsx ../../scripts/build.ts", | ||
"vitest": "vitest -c ../../configs/vitest.config.ts", | ||
"test": "pnpm run vitest", | ||
"test:ssr": "pnpm run vitest --mode ssr" | ||
}, | ||
"peerDependencies": { | ||
"solid-js": "^1.6.12" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,215 @@ | ||||||||||||||||||
import { Accessor, createEffect, createSignal, onCleanup } from "solid-js"; | ||||||||||||||||||
import { isServer } from "solid-js/web"; | ||||||||||||||||||
|
||||||||||||||||||
// https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/motion/utils.js | ||||||||||||||||||
|
||||||||||||||||||
function is_date(obj: any): obj is Date { | ||||||||||||||||||
return Object.prototype.toString.call(obj) === "[object Date]"; | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// =========================================================================== | ||||||||||||||||||
// createSpring hook | ||||||||||||||||||
// =========================================================================== | ||||||||||||||||||
|
||||||||||||||||||
export type SpringOptions = { | ||||||||||||||||||
/** | ||||||||||||||||||
* Stiffness of the spring. Higher values will create more sudden movement. | ||||||||||||||||||
* @default 0.15 | ||||||||||||||||||
*/ | ||||||||||||||||||
stiffness?: number; | ||||||||||||||||||
/** | ||||||||||||||||||
* Strength of opposing force. If set to 0, spring will oscillate indefinitely. | ||||||||||||||||||
* @default 0.8 | ||||||||||||||||||
*/ | ||||||||||||||||||
damping?: number; | ||||||||||||||||||
/** | ||||||||||||||||||
* Precision is the threshold relative to the target value at which the | ||||||||||||||||||
* animation will stop based on the current value. | ||||||||||||||||||
* | ||||||||||||||||||
* From 0, if the target value is 500, and the precision is 500, it will stop | ||||||||||||||||||
* the animation instantly (no animation, similar to `hard: true`). | ||||||||||||||||||
* | ||||||||||||||||||
* From 0, if the target value is 500, and the precision is 0.01, it will stop the | ||||||||||||||||||
* animation when the current value reaches 499.99 or 500.01 (longer animation). | ||||||||||||||||||
* | ||||||||||||||||||
* @default 0.01 | ||||||||||||||||||
*/ | ||||||||||||||||||
precision?: number; | ||||||||||||||||||
}; | ||||||||||||||||||
|
||||||||||||||||||
export type SpringTarget = | ||||||||||||||||||
| number | ||||||||||||||||||
| Date | ||||||||||||||||||
| { [key: string]: number | Date | SpringTarget } | ||||||||||||||||||
| readonly (number | Date)[] | ||||||||||||||||||
| readonly SpringTarget[]; | ||||||||||||||||||
|
||||||||||||||||||
/** | ||||||||||||||||||
* "Widen" Utility Type so that number types are not converted to | ||||||||||||||||||
* literal types when passed to `createSpring`. | ||||||||||||||||||
* | ||||||||||||||||||
* e.g. createSpring(0) returns `0`, not `number`. | ||||||||||||||||||
*/ | ||||||||||||||||||
export type WidenSpringTarget<T> = T extends number ? number : T; | ||||||||||||||||||
|
||||||||||||||||||
export type SpringSetterOptions = { hard?: boolean; soft?: boolean | number } | ||||||||||||||||||
export type SpringSetter<T> = ( | ||||||||||||||||||
newValue: T | ((prev: T) => T), | ||||||||||||||||||
opts?: SpringSetterOptions, | ||||||||||||||||||
) => Promise<void>; | ||||||||||||||||||
|
||||||||||||||||||
/** | ||||||||||||||||||
* Creates a signal and a setter that uses spring physics when interpolating from | ||||||||||||||||||
* one value to another. This means when the value changes, instead of | ||||||||||||||||||
* transitioning at a steady rate, it "bounces" like a spring would, | ||||||||||||||||||
* depending on the physics paramters provided. This adds a level of realism to | ||||||||||||||||||
* the transitions and can enhance the user experience. | ||||||||||||||||||
* | ||||||||||||||||||
* `T` - The type of the signal. It works for the basic data types that can be | ||||||||||||||||||
* interpolated: `number`, a `Date`, `Array<T>` or a nested object of T. | ||||||||||||||||||
* | ||||||||||||||||||
* @param initialValue The initial value of the signal. | ||||||||||||||||||
* @param options Options to configure the physics of the spring. | ||||||||||||||||||
* @returns Returns the spring value and a setter. | ||||||||||||||||||
* | ||||||||||||||||||
* @example | ||||||||||||||||||
* const [progress, setProgress] = createSpring(0, { stiffness: 0.15, damping: 0.8 }); | ||||||||||||||||||
*/ | ||||||||||||||||||
export function createSpring<T extends SpringTarget>( | ||||||||||||||||||
initialValue: T, | ||||||||||||||||||
options: SpringOptions = {}, | ||||||||||||||||||
): [Accessor<WidenSpringTarget<T>>, SpringSetter<WidenSpringTarget<T>>] { | ||||||||||||||||||
const [signal, setSignal] = createSignal(initialValue); | ||||||||||||||||||
const { stiffness = 0.15, damping = 0.8, precision = 0.01 } = options; | ||||||||||||||||||
|
||||||||||||||||||
if (isServer) { | ||||||||||||||||||
return [signal as any, ((param: any, opts: SpringSetterOptions = {}) => { | ||||||||||||||||||
if (opts.hard || signal() == null || (stiffness >= 1 && damping >= 1)) { | ||||||||||||||||||
setSignal(param); | ||||||||||||||||||
return Promise.resolve(); | ||||||||||||||||||
} | ||||||||||||||||||
return new Promise(() => {}); | ||||||||||||||||||
}) as any] | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
let value_current = initialValue; | ||||||||||||||||||
let value_last = initialValue; | ||||||||||||||||||
let value_target = initialValue; | ||||||||||||||||||
let inv_mass = 1; | ||||||||||||||||||
let inv_mass_recovery_rate = 0; | ||||||||||||||||||
let raf_id = 0 | ||||||||||||||||||
let settled = true | ||||||||||||||||||
let time_last = 0; | ||||||||||||||||||
let time_delta = 0 | ||||||||||||||||||
let resolve = () => {} | ||||||||||||||||||
|
||||||||||||||||||
const cleanup = onCleanup(() => { | ||||||||||||||||||
cancelAnimationFrame(raf_id) | ||||||||||||||||||
raf_id = 0 | ||||||||||||||||||
resolve() | ||||||||||||||||||
}) | ||||||||||||||||||
|
||||||||||||||||||
const frame: FrameRequestCallback = time => { | ||||||||||||||||||
time_delta = (time - time_last) * 60 / 1000 | ||||||||||||||||||
time_last = time | ||||||||||||||||||
|
||||||||||||||||||
inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1) | ||||||||||||||||||
settled = true | ||||||||||||||||||
|
||||||||||||||||||
let new_value = tick(value_last, value_current, value_target) | ||||||||||||||||||
value_last = value_current | ||||||||||||||||||
setSignal(value_current = new_value) | ||||||||||||||||||
|
||||||||||||||||||
if (settled) { | ||||||||||||||||||
cleanup() | ||||||||||||||||||
} else { | ||||||||||||||||||
raf_id = requestAnimationFrame(frame) | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
const set: SpringSetter<T> = (param, opts = {}) => { | ||||||||||||||||||
value_target = typeof param === "function" ? param(value_current) : param; | ||||||||||||||||||
|
||||||||||||||||||
if (opts.hard || (stiffness >= 1 && damping >= 1)) { | ||||||||||||||||||
cleanup() | ||||||||||||||||||
setSignal(_ => value_current = value_last = value_target); | ||||||||||||||||||
return Promise.resolve(); | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
if (opts.soft) { | ||||||||||||||||||
inv_mass_recovery_rate = 1 / (typeof opts.soft === "number" ? opts.soft * 60 : 30); | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This hard-codes an assumed 60 fps into the inverse mass recovery feature. On different hardware, I'm not sure the best fix for fps-independence here, but in my own adaptation I have gone for tracking the fps as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Turns out tracking fps this way is very flawed. At best it gives an inaccurate value (consistently getting ~180 fps when I should really be getting 166) and at worst is can go to extreme values and be very inconsistent. I can look into frame-independent impls unless somebody else wants to jump in with a solution There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My intuition would be to keep the recovery rate as a constant, only depending on There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was able to fix the solid-primitives/packages/spring/src/index.ts Lines 152 to 159 in eeff964
The frame-dependent time_delta is baked into this implementation in tick . It doesn't quite cancel out even though d is multipled by time_delta in the end either, but I'm not totally sure what is supposed to be going on instead.
https://github.com/pqml/spring/blob/master/lib/Spring.js - this looks promising, it is based on picking a timestep, checking how many of those timesteps have passed since the last frame, and solving for the position that many times There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Makes sense. I'm using the same technique in my force-graph implementation—which has the same problem that it cannot be simply "multiplied by delta_time" to make the simulation frame-independent—and it works pretty well: https://github.com/thetarnav/force-graph/blob/main/index.mjs#L86-L98 |
||||||||||||||||||
inv_mass = 0; // Infinite mass, unaffected by spring forces. | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
if (raf_id === 0) { | ||||||||||||||||||
time_last = performance.now() | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it should be something like Otherwise, in cases where There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm youre right But with requestAnimationFrame(prev_time => {
function frame(time) {
let delta = time-prev_time
prev_time = time
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)
})
// or
let prev_time = Infinity
function frame(time) {
let delta = Math.max(0, time-prev_time)
prev_time = time
requestAnimationFrame(frame)
}
requestAnimationFrame(frame) Since the first iteration will be ran with |
||||||||||||||||||
raf_id = requestAnimationFrame(frame) | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
return new Promise<void>(r => resolve = r); | ||||||||||||||||||
}; | ||||||||||||||||||
|
||||||||||||||||||
const tick = (last: T, current: T, target: T): any => { | ||||||||||||||||||
if (typeof current === "number" || is_date(current)) { | ||||||||||||||||||
const delta = +target - +current; | ||||||||||||||||||
const velocity = (+current - +last) / (time_delta || 1 / 60); // guard div by 0 | ||||||||||||||||||
const spring = stiffness * delta; | ||||||||||||||||||
const damper = damping * velocity; | ||||||||||||||||||
const acceleration = (spring - damper) * inv_mass; | ||||||||||||||||||
const d = (velocity + acceleration) * time_delta; | ||||||||||||||||||
|
||||||||||||||||||
if (Math.abs(d) < precision && Math.abs(delta) < precision) { | ||||||||||||||||||
return target; // settled | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
settled = false; // signal loop to keep ticking | ||||||||||||||||||
return typeof current === "number" ? current + d : new Date(+current + d); | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
if (Array.isArray(current)) { | ||||||||||||||||||
// @ts-expect-error | ||||||||||||||||||
return current.map((_, i) => tick(last[i], current[i], target[i])); | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
if (typeof current === "object") { | ||||||||||||||||||
const next = {...current}; | ||||||||||||||||||
for (const k in current) { | ||||||||||||||||||
// @ts-expect-error | ||||||||||||||||||
next[k] = tick(last[k], current[k], target[k]); | ||||||||||||||||||
} | ||||||||||||||||||
return next; | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
throw new Error(`Cannot spring ${typeof current} values`); | ||||||||||||||||||
}; | ||||||||||||||||||
|
||||||||||||||||||
return [signal as any, set as any]; | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// =========================================================================== | ||||||||||||||||||
// createDerivedSpring hook | ||||||||||||||||||
// =========================================================================== | ||||||||||||||||||
|
||||||||||||||||||
/** | ||||||||||||||||||
* Creates a spring value that interpolates based on changes on a passed signal. | ||||||||||||||||||
* Works similar to the `@solid-primitives/tween` | ||||||||||||||||||
* | ||||||||||||||||||
* @param target Target to be modified. | ||||||||||||||||||
* @param options Options to configure the physics of the spring. | ||||||||||||||||||
* @returns Returns the spring value only. | ||||||||||||||||||
* | ||||||||||||||||||
* @example | ||||||||||||||||||
* const percent = createMemo(() => current() / total() * 100); | ||||||||||||||||||
* | ||||||||||||||||||
* const springedPercent = createDerivedSignal(percent, { stiffness: 0.15, damping: 0.8 }); | ||||||||||||||||||
*/ | ||||||||||||||||||
export function createDerivedSpring<T extends SpringTarget>( | ||||||||||||||||||
target: Accessor<T>, | ||||||||||||||||||
options?: SpringOptions, | ||||||||||||||||||
) { | ||||||||||||||||||
const [springValue, setSpringValue] = createSpring(target(), options); | ||||||||||||||||||
|
||||||||||||||||||
createEffect(() => setSpringValue(target() as WidenSpringTarget<T>)); | ||||||||||||||||||
|
||||||||||||||||||
return springValue; | ||||||||||||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently it allows you to pass a string as an input (or an array of strings, object with string values etc), but you could use a type like this to provide some validation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a good suggestion. Was also considering this. I just made it close to Svelte's implementation which was more loose with types.
But under the hood it does actually only implement those specific primitives: Date, Number, Array<Date | number>, and { [key: any]: Date | number> }.
Will definitely add it here. Thanks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While implementing this, I ran into a bit of a hiccup. Didn't know generics behaved this way lol. They seem to convert the data primitives into constants. (e.g. passing
0
will make the type0
,1
will be1
, etc.)Luckily the fix was easy:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a side note but the "validation" will not catch this:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And it seems to break with
readonly number[]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also interfaces
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also in source code there is a
current_value == null
check, which I think is impossible with the validation.