Skip to content

Commit

Permalink
Change method used for tweening (#573)
Browse files Browse the repository at this point in the history
  • Loading branch information
thetarnav authored Mar 23, 2024
2 parents e982f4e + f4d87f4 commit 27958e7
Show file tree
Hide file tree
Showing 3 changed files with 183 additions and 23 deletions.
5 changes: 5 additions & 0 deletions .changeset/tall-buttons-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/tween": minor
---

Change method used for tweening (#573)
65 changes: 45 additions & 20 deletions packages/tween/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { createSignal, createEffect, onCleanup, on } from "solid-js";
import { isServer } from "solid-js/web";

export type TweenProps = {
duration?: number,
ease?: (t: number) => number,
}

/**
* Creates a simple tween method.
*
Expand All @@ -10,32 +15,52 @@ import { isServer } from "solid-js/web";
*
* @example
* ```ts
* const tweenedValue = createTween(myNumber, { duration: 500 });
* const [value, setValue] = createSignal(100);
* const tweenedValue = createTween(value, {
* duration: 500,
* ease: (t) => 0.5 - Math.cos(Math.PI * t) / 2
* });
* ```
* ```jsx
* <button onClick={() => setValue(value() === 0 ? 100 : 0)}>
* {Math.round(tweenedValue())}
* </button>
* ```
*/
export default function createTween<T extends number>(
target: () => T,
{ ease = (t: T) => t, duration = 100 },
): () => T {
export default function createTween(
target: () => number,
{ ease = (t: number) => t, duration = 100 }: TweenProps,
): () => number {
if (isServer) {
return target;
}

const [start, setStart] = createSignal(performance.now());
const [current, setCurrent] = createSignal<T>(target());
createEffect(on(target, () => setStart(performance.now()), { defer: true }));
createEffect(
on([start, current], () => {
const cancelId = requestAnimationFrame(t => {
const elapsed = t - (start() || 0) + 1;
// @ts-ignore
setCurrent(c =>
elapsed < duration ? (target() - c) * ease((elapsed / duration) as T) + c : target(),
);
});
onCleanup(() => cancelAnimationFrame(cancelId));
}),
);
const [current, setCurrent] = createSignal(target());
let start: number;
let startValue: number;
let delta: number;
let cancelId: number;

function tick(t: number) {
const elapsed = t - start;

if (elapsed < duration) {
setCurrent(startValue + ease(elapsed / duration) * delta);
cancelId = requestAnimationFrame(tick);
}
else {
setCurrent(target());
}
}

createEffect(on(target, () => {
start = performance.now();
startValue = current();
delta = target() - startValue;
cancelId = requestAnimationFrame(tick);
onCleanup(() => cancelAnimationFrame(cancelId));
}, { defer: true }));

return current;
}

Expand Down
136 changes: 133 additions & 3 deletions packages/tween/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,135 @@
import { describe, it } from "vitest";
import { createRoot, createSignal } from "solid-js";
import { describe, expect, it, vi, afterEach } from "vitest";
import createTween from "../src/index.js";

describe("tween", () => {
it.todo("needs to be tested");
let _last_id = 0
let _raf_callbacks_old = new Map<number, FrameRequestCallback>()
let _raf_callbacks_new = new Map<number, FrameRequestCallback>()

function _flush_raf(time: number) {
_raf_callbacks_old = _raf_callbacks_new
_raf_callbacks_new = new Map()
for (const callback of _raf_callbacks_old.values()) {
callback(time)
}
_raf_callbacks_old.clear()
}

function _mocked_requestAnimationFrame(callback: FrameRequestCallback): number {
const id = _last_id++;
_raf_callbacks_new.set(id, callback)
return id;
}
function _mocked_cancelAnimationFrame(id: number): void {
_raf_callbacks_new.delete(id)
}

vi.stubGlobal("requestAnimationFrame", _mocked_requestAnimationFrame)
vi.stubGlobal("cancelAnimationFrame", _mocked_cancelAnimationFrame)

afterEach(() => {
_raf_callbacks_old.clear()
_raf_callbacks_new.clear()
_last_id = 0
})

describe("animation", () => {
it("updates when its target changes", () => {
const [source, setSource] = createSignal(0);
let dispose!: () => void
let tweened!: () => number
createRoot(d => {
dispose = d;
tweened = createTween(source, {});
})

const start = performance.now()
expect(tweened()).toBe(0);

setSource(100);
expect(tweened()).toBe(0);

_flush_raf(start + 200);
expect(tweened()).toBe(100);

dispose()
});

it("uses a linear animation by default", () => {
const [value, setValue] = createSignal(0);
let dispose!: () => void
let tweened!: () => number
createRoot(d => {
dispose = d;
tweened = createTween(value, { duration: 100 });
})

const start = performance.now()
setValue(100);
expect(tweened()).toBe(0);

_flush_raf(start + 25)
expect(tweened()).toBeCloseTo(25, 0);

_flush_raf(start + 50)
expect(tweened()).toBeCloseTo(50, 0);

_flush_raf(start + 75)
expect(tweened()).toBeCloseTo(75, 0);

_flush_raf(start + 100)
expect(tweened()).toBeCloseTo(100, 0);

dispose()
});

it("accepts custom easing functions", () => {
const [value, setValue] = createSignal(0);
let dispose!: () => void
let tweened!: () => number
createRoot(d => {
dispose = d;
tweened = createTween(value, { duration: 100, ease: t => t * t });
})

const start = performance.now()
setValue(100);
expect(tweened()).toBe(0);

_flush_raf(start + 25)
expect(tweened()).toBeCloseTo(6.25, 0);

_flush_raf(start + 50)
expect(tweened()).toBeCloseTo(25, 0);

_flush_raf(start + 75)
expect(tweened()).toBeCloseTo(56.25, 0);

_flush_raf(start + 100)
expect(tweened()).toBeCloseTo(100, 0);

dispose()
});

it("can be interrupted part-way through an animation", () => {
const [value, setValue] = createSignal(0);
let dispose!: () => void
let tweened!: () => number
createRoot(d => {
dispose = d;
tweened = createTween(value, { duration: 1000 });
})
const start = performance.now();

setValue(100);
_flush_raf(start + 600);
expect(tweened()).toBeCloseTo(60, 0);

setValue(0);

_flush_raf(start + 500);
expect(tweened()).toBeCloseTo(30, 0);

dispose()
});
});

0 comments on commit 27958e7

Please sign in to comment.