Skip to content

Commit

Permalink
Add and refurbish an old tweening function
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Alhadis committed Nov 16, 2017
1 parent 9168bb3 commit 09b8eaf
Show file tree
Hide file tree
Showing 2 changed files with 183 additions and 0 deletions.
77 changes: 77 additions & 0 deletions lib/objects.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,80 @@ function parseKeywords(keywords){
k.split(/\s+/g).filter(i => i).forEach(k => output[k] = true);
return output;
}


/**
* Perform basic animation of an object's property.
*
* @uses {@link deCasteljau}
* @example
* // Animated scrolling
* tween(document.documentElement, "scrollTop", scrollY + 600);
* tween(document.documentElement, "scrollTop", scrollY - 100, {duration: 6000});
*
* // Faux progress meter
* tween(element, "textContent", 100, {
* duration: 8000,
* curve: tween.LINEAR,
* filter: num => `Loading: ${num}%`
* });
*
* @param {Object} subject - Target object whose property is being animated
* @param {String} propertyName - Animated property's name
* @param {Number} endValue - Animated property's value after tween has completed
* @param {Object} [options={}] - Optional tweening settings
* @param {Point[]} [options.curve=tween.EASE] - Easing function expressed as a Bézier curve
* @param {Function} [options.callback=null] - Callback fired after each interpolated frame
* @param {Function} [options.filter=null] - Override value before assigning to property
* @param {Number} [options.duration=300] - Animation length in milliseconds
* @param {Number} [options.fps=60] - Animation frame rate
* @return {Promise} Resolves once playback finishes or is cancelled
* by calling the `stop` method defined by the returned Promise object.
*/
function tween(subject, propertyName, endValue, options = {}){
let stopped = false;
return Object.assign(new Promise(resolve => {
const {
curve = tween.EASE,
callback = null,
filter = null,
duration = 300,
fps = 60,
} = options;
const delay = 1 / fps * 1000;
const from = +subject[propertyName] || 0;
const to = endValue;
const step = (progress, iterations) => {
if(stopped)
return resolve();
const midpoint = deCasteljau(curve, progress)[0][1];
if(midpoint >= 1){
const value = (null !== filter) ? filter(to, 1) : to;
subject[propertyName] = value;
if(null !== callback)
callback(value, 1);
return resolve();
}
if(progress){
let value = from + ((to - from) * midpoint);
if(null !== filter)
value = filter(value, progress);
subject[propertyName] = value;
if(null !== callback)
callback(value, progress);
}
setTimeout(() => {
step(delay * iterations / duration, ++iterations);
}, delay);
};
step(0, 0);
}), {stop: () => stopped = true});
}

Object.assign(tween, {
LINEAR: [[0, 0], [1, 1]],
EASE: [[0, 0], [.25, .1], [.25, 1], [1, 1]],
EASE_IN: [[0, 0], [.42, 0], [1, 1], [1, 1]],
EASE_IN_OUT: [[0, 0], [.42, 0], [.58, 1], [1, 1]],
EASE_OUT: [[0, 0], [0, 0], [.58, 1], [1, 1]],
});
106 changes: 106 additions & 0 deletions test/1-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,112 @@ describe("Utility functions", () => {
expect(isNumeric("0xAF")).to.be.false;
});
});

describe("tween()", function(){
const {tween, wait} = utils;
const duration = 600;
this.timeout(duration * 2);
this.slow(duration * 4);

it("interpolates property values over time", async () => {
const target = {prop: 0};
const tweenValue = tween(target, "prop", 100, {duration});
await wait(duration / 3).then(() => expect(target.prop).to.be.within(10, 50));
await wait(duration / 2).then(() => expect(target.prop).to.be.within(50, 100));
await tweenValue.then(() => expect(target.prop).to.equal(100));
});

it("begins tweening from the existing value", async () => {
const target = {prop: 90};
const tweenValue = tween(target, "prop", 100, {duration});
await wait(duration / 3).then(() => expect(target.prop).to.be.within(90, 100));
await tweenValue.then(() => expect(target.prop).to.equal(100));
});

it("invokes callback functions for each frame", async () => {
let callCount = 0;
const fps = 5;
const target = {prop: 0};
const previous = {value: -1, progress: -1};
const callback = (value, progress) => {
expect(value).to.be.above(previous.value);
expect(progress).to.be.above(previous.progress).and.within(0, 1);
previous.value = value;
previous.progress = progress;
++callCount;
};
await tween(target, "prop", 10, {duration, callback, fps});
expect(callCount).to.be.at.least(duration / 60 / fps);
expect(previous.progress).to.equal(1);
});

it("supports custom easing curves", async () => {
const target = {foo: 0, bar: 0};
const tweenA = tween(target, "foo", 100, {duration, curve: [[0,0],[1,0],[1,0],[1,1]]});
const tweenB = tween(target, "bar", 100, {duration, curve: [[0,0],[0,1],[0,1],[1,1]]});
await wait(duration / 4);
expect(target.foo).to.be.below(5);
expect(target.bar).to.be.above(35);
await wait(duration / 2);
expect(target.foo).to.be.below(50);
expect(target.bar).to.be.above(85);
await tweenA.then(() => expect(target.foo).to.equal(100));
await tweenB.then(() => expect(target.bar).to.equal(100));
});

it("supports early cancellation of playback", async () => {
const valuesWhenStopped = {A: 0, B: 0};
const target = {foo: 0, bar: 0};
const tweenA = tween(target, "foo", 10, {duration});
const tweenB = tween(target, "bar", 10, {duration});
await wait(duration / 4).then(() => expect(target.foo).to.be.above(0))
await wait(duration / 2).then(() => tweenA.stop());
valuesWhenStopped.A = target.foo;
valuesWhenStopped.B = target.bar;
expect(valuesWhenStopped.A).to.be.above(0).and.below(10);
expect(valuesWhenStopped.B).to.be.above(0).and.below(10);
await wait(duration / 1.5);
expect(target.foo).to.equal(valuesWhenStopped.A);
expect(target.bar).to.be.above(valuesWhenStopped.B).and.to.equal(10);
});

it("defines presets for common easing functions", () => {
expect(tween.LINEAR).to.be.an("array");
expect(tween.EASE).to.be.an("array");
expect(tween.EASE_IN).to.be.an("array");
expect(tween.EASE_IN_OUT).to.be.an("array");
expect(tween.EASE_OUT).to.be.an("array");
});

it("lets durations be specified", async () => {
const target = {foo: 0, bar: 0};
const result = [];
const tweenA = tween(target, "foo", 5, {duration: 500}).then(() => result.push("A"));
const tweenB = tween(target, "bar", 5, {duration: 250}).then(() => result.push("B"));
await Promise.all([tweenA, tweenB]);
expect(result).to.eql(["B", "A"]);
});

it("lets frame rates be specified", async () => {
const counts = {A: 0, B: 0};
const target = {foo: 0, bar: 0};
const tweenA = tween(target, "foo", 5, {duration, fps: 50, callback: () => ++counts.A});
const tweenB = tween(target, "bar", 5, {duration, fps: 25, callback: () => ++counts.B});
await Promise.all([tweenA, tweenB]);
expect(counts.A).to.be.above(counts.B);
expect(target.foo).to.equal(target.bar);
});

it("lets interpolated values be overridden by a filter", async () => {
const target = {prop: 0};
const filter = (value, progress) => {
expect(progress).to.be.within(0, 1);
return `Size: ${value}cm × ${value / 2}cm`;
};
await tween(target, "prop", 30, {duration, filter});
expect(target.prop).to.equal("Size: 30cm × 15cm");
});
});
});

describe("Regular expressions", () => {
Expand Down

0 comments on commit 09b8eaf

Please sign in to comment.