Skip to content

Commit 09b8eaf

Browse files
committed
Add and refurbish an old tweening function
Source: Alhadis/De-Casteljau@10df4ca. The exact same code became used in the development of DON Smallgoods' website, where the Tween class powers smooth autoscroll effects on its homepage. As of this writing, the class is *still* being used on isdonisgood.com.au, two years later. The "refurbished" version bears little resemblance to the old 2015 code. There's no class-based implementation, as I felt instances were overkill for something which was designed to play only once. Rewinding or pausing playback is done by cancelling the old tween and starting a new one from where the last interpolated value was at.
1 parent 9168bb3 commit 09b8eaf

File tree

2 files changed

+183
-0
lines changed

2 files changed

+183
-0
lines changed

lib/objects.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,80 @@ function parseKeywords(keywords){
212212
k.split(/\s+/g).filter(i => i).forEach(k => output[k] = true);
213213
return output;
214214
}
215+
216+
217+
/**
218+
* Perform basic animation of an object's property.
219+
*
220+
* @uses {@link deCasteljau}
221+
* @example
222+
* // Animated scrolling
223+
* tween(document.documentElement, "scrollTop", scrollY + 600);
224+
* tween(document.documentElement, "scrollTop", scrollY - 100, {duration: 6000});
225+
*
226+
* // Faux progress meter
227+
* tween(element, "textContent", 100, {
228+
* duration: 8000,
229+
* curve: tween.LINEAR,
230+
* filter: num => `Loading: ${num}%`
231+
* });
232+
*
233+
* @param {Object} subject - Target object whose property is being animated
234+
* @param {String} propertyName - Animated property's name
235+
* @param {Number} endValue - Animated property's value after tween has completed
236+
* @param {Object} [options={}] - Optional tweening settings
237+
* @param {Point[]} [options.curve=tween.EASE] - Easing function expressed as a Bézier curve
238+
* @param {Function} [options.callback=null] - Callback fired after each interpolated frame
239+
* @param {Function} [options.filter=null] - Override value before assigning to property
240+
* @param {Number} [options.duration=300] - Animation length in milliseconds
241+
* @param {Number} [options.fps=60] - Animation frame rate
242+
* @return {Promise} Resolves once playback finishes or is cancelled
243+
* by calling the `stop` method defined by the returned Promise object.
244+
*/
245+
function tween(subject, propertyName, endValue, options = {}){
246+
let stopped = false;
247+
return Object.assign(new Promise(resolve => {
248+
const {
249+
curve = tween.EASE,
250+
callback = null,
251+
filter = null,
252+
duration = 300,
253+
fps = 60,
254+
} = options;
255+
const delay = 1 / fps * 1000;
256+
const from = +subject[propertyName] || 0;
257+
const to = endValue;
258+
const step = (progress, iterations) => {
259+
if(stopped)
260+
return resolve();
261+
const midpoint = deCasteljau(curve, progress)[0][1];
262+
if(midpoint >= 1){
263+
const value = (null !== filter) ? filter(to, 1) : to;
264+
subject[propertyName] = value;
265+
if(null !== callback)
266+
callback(value, 1);
267+
return resolve();
268+
}
269+
if(progress){
270+
let value = from + ((to - from) * midpoint);
271+
if(null !== filter)
272+
value = filter(value, progress);
273+
subject[propertyName] = value;
274+
if(null !== callback)
275+
callback(value, progress);
276+
}
277+
setTimeout(() => {
278+
step(delay * iterations / duration, ++iterations);
279+
}, delay);
280+
};
281+
step(0, 0);
282+
}), {stop: () => stopped = true});
283+
}
284+
285+
Object.assign(tween, {
286+
LINEAR: [[0, 0], [1, 1]],
287+
EASE: [[0, 0], [.25, .1], [.25, 1], [1, 1]],
288+
EASE_IN: [[0, 0], [.42, 0], [1, 1], [1, 1]],
289+
EASE_IN_OUT: [[0, 0], [.42, 0], [.58, 1], [1, 1]],
290+
EASE_OUT: [[0, 0], [0, 0], [.58, 1], [1, 1]],
291+
});

test/1-utils.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,112 @@ describe("Utility functions", () => {
6666
expect(isNumeric("0xAF")).to.be.false;
6767
});
6868
});
69+
70+
describe("tween()", function(){
71+
const {tween, wait} = utils;
72+
const duration = 600;
73+
this.timeout(duration * 2);
74+
this.slow(duration * 4);
75+
76+
it("interpolates property values over time", async () => {
77+
const target = {prop: 0};
78+
const tweenValue = tween(target, "prop", 100, {duration});
79+
await wait(duration / 3).then(() => expect(target.prop).to.be.within(10, 50));
80+
await wait(duration / 2).then(() => expect(target.prop).to.be.within(50, 100));
81+
await tweenValue.then(() => expect(target.prop).to.equal(100));
82+
});
83+
84+
it("begins tweening from the existing value", async () => {
85+
const target = {prop: 90};
86+
const tweenValue = tween(target, "prop", 100, {duration});
87+
await wait(duration / 3).then(() => expect(target.prop).to.be.within(90, 100));
88+
await tweenValue.then(() => expect(target.prop).to.equal(100));
89+
});
90+
91+
it("invokes callback functions for each frame", async () => {
92+
let callCount = 0;
93+
const fps = 5;
94+
const target = {prop: 0};
95+
const previous = {value: -1, progress: -1};
96+
const callback = (value, progress) => {
97+
expect(value).to.be.above(previous.value);
98+
expect(progress).to.be.above(previous.progress).and.within(0, 1);
99+
previous.value = value;
100+
previous.progress = progress;
101+
++callCount;
102+
};
103+
await tween(target, "prop", 10, {duration, callback, fps});
104+
expect(callCount).to.be.at.least(duration / 60 / fps);
105+
expect(previous.progress).to.equal(1);
106+
});
107+
108+
it("supports custom easing curves", async () => {
109+
const target = {foo: 0, bar: 0};
110+
const tweenA = tween(target, "foo", 100, {duration, curve: [[0,0],[1,0],[1,0],[1,1]]});
111+
const tweenB = tween(target, "bar", 100, {duration, curve: [[0,0],[0,1],[0,1],[1,1]]});
112+
await wait(duration / 4);
113+
expect(target.foo).to.be.below(5);
114+
expect(target.bar).to.be.above(35);
115+
await wait(duration / 2);
116+
expect(target.foo).to.be.below(50);
117+
expect(target.bar).to.be.above(85);
118+
await tweenA.then(() => expect(target.foo).to.equal(100));
119+
await tweenB.then(() => expect(target.bar).to.equal(100));
120+
});
121+
122+
it("supports early cancellation of playback", async () => {
123+
const valuesWhenStopped = {A: 0, B: 0};
124+
const target = {foo: 0, bar: 0};
125+
const tweenA = tween(target, "foo", 10, {duration});
126+
const tweenB = tween(target, "bar", 10, {duration});
127+
await wait(duration / 4).then(() => expect(target.foo).to.be.above(0))
128+
await wait(duration / 2).then(() => tweenA.stop());
129+
valuesWhenStopped.A = target.foo;
130+
valuesWhenStopped.B = target.bar;
131+
expect(valuesWhenStopped.A).to.be.above(0).and.below(10);
132+
expect(valuesWhenStopped.B).to.be.above(0).and.below(10);
133+
await wait(duration / 1.5);
134+
expect(target.foo).to.equal(valuesWhenStopped.A);
135+
expect(target.bar).to.be.above(valuesWhenStopped.B).and.to.equal(10);
136+
});
137+
138+
it("defines presets for common easing functions", () => {
139+
expect(tween.LINEAR).to.be.an("array");
140+
expect(tween.EASE).to.be.an("array");
141+
expect(tween.EASE_IN).to.be.an("array");
142+
expect(tween.EASE_IN_OUT).to.be.an("array");
143+
expect(tween.EASE_OUT).to.be.an("array");
144+
});
145+
146+
it("lets durations be specified", async () => {
147+
const target = {foo: 0, bar: 0};
148+
const result = [];
149+
const tweenA = tween(target, "foo", 5, {duration: 500}).then(() => result.push("A"));
150+
const tweenB = tween(target, "bar", 5, {duration: 250}).then(() => result.push("B"));
151+
await Promise.all([tweenA, tweenB]);
152+
expect(result).to.eql(["B", "A"]);
153+
});
154+
155+
it("lets frame rates be specified", async () => {
156+
const counts = {A: 0, B: 0};
157+
const target = {foo: 0, bar: 0};
158+
const tweenA = tween(target, "foo", 5, {duration, fps: 50, callback: () => ++counts.A});
159+
const tweenB = tween(target, "bar", 5, {duration, fps: 25, callback: () => ++counts.B});
160+
await Promise.all([tweenA, tweenB]);
161+
expect(counts.A).to.be.above(counts.B);
162+
expect(target.foo).to.equal(target.bar);
163+
});
164+
165+
it("lets interpolated values be overridden by a filter", async () => {
166+
const target = {prop: 0};
167+
const filter = (value, progress) => {
168+
expect(progress).to.be.within(0, 1);
169+
return `Size: ${value}cm × ${value / 2}cm`;
170+
};
171+
await tween(target, "prop", 30, {duration, filter});
172+
expect(target.prop).to.equal("Size: 30cm × 15cm");
173+
});
174+
});
69175
});
70176

71177
describe("Regular expressions", () => {

0 commit comments

Comments
 (0)