From 09b8eafd72d090832cb40b57514eae99667bf97b Mon Sep 17 00:00:00 2001 From: Alhadis Date: Fri, 17 Nov 2017 02:22:58 +1100 Subject: [PATCH] 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. --- lib/objects.js | 77 +++++++++++++++++++++++++++++++++++ test/1-utils.js | 106 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/lib/objects.js b/lib/objects.js index f6c5e8b..13e16fb 100644 --- a/lib/objects.js +++ b/lib/objects.js @@ -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]], +}); diff --git a/test/1-utils.js b/test/1-utils.js index 5ab6496..b734a0c 100644 --- a/test/1-utils.js +++ b/test/1-utils.js @@ -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", () => {