TimerLib offers a simple high resolution animation timer object and drop-in replacements for
the
WindowOrWorkerGlobalScope.setTimeout()
and
WindowOrWorkerGlobalScope.setInterval()
timers. If you look at the documentation
for those functions you will begin to realise quite how many problems and limitations they have.
In addition, CSS Animation
has limitations and quirks and the
Web Animation API
is hideously complicated for simple (or what should be
simple) animations and is, as yet, poorly supported across browsers.
While I was developing BoxNav I needed an easier, more flexible way of animating and I developed this object library to address that need. I found it so easy to understand and use that I have made it available as an open source module, and here it is.
This library uses the
window.performance
API and
window.getAnimationFrame()
to provide highly
accurate timers that are performative and kind to smartphone and laptop batteries. It makes web animation
simple.
It will be helpful to understand what
window.getAnimationFrame()
does, and really
it is very simple. getAnimationFrame()
calls a callback function whenever
the browser is about to do a screen update, passing it a
DOMHighResTimeStamp
. That is a little like saying to your wife (or your mum, if you
are a proper geek), "Give me a nudge
next time you're heading down the shops so I can..." So every time the browser is about to paint it
calls your animation function, allowing you to update positions or fades or colours etc and the TimerLib
timer object keeps track of how far through the animation we are by converting the timestamp
returned by getAnimationFrame()
into an elapsed time as a percentage of the animation
duration; expressed as a floating point number between 0.0 and 1.0
What does that mean? Well it means you can multiply any (numeric) property value by the timer's progress value to scale it, i.e. change it by the amount required for each point in the animation timeline. This is called tweening, calculating intermediate positions between a start and end value.
The timer object can also convert that progress value to one adjusted by an easing function. This allows you to apply easeIn, easeOut etc to any animated property.
TimerLib is implemented as both an object-oriented library or module. To use it as a library add <script src="timerlib.js"></script> in your html. To import the objects and functions as modules use eg. import {Timer} from timerlib.module.js in your javascript. Modules are great but, sadly, you can run into CORS difficulties when running your app from local files.
It offers the following objects:
The Timer object provides a progress percentage as a number between 0 and 1 that indicates the progress through your animation. Since it is a number between 0 and 1 it can be used as a multiplier to scale a distance, rotation, fade etc.
Call with const myTimer = new Timer(id[, duration]);
Generally you would use it in conjunction with requestAnimationFrame()
, which calls a callback,
passing it a high resolution timestamp. For example:
const myTimer = new Timer(requestAnimationFrame('animate'), 500);
That statement creates a timer object using the timestamp returned from requestAnimationFrame()
as its id. requestAnimationFrame()
calls the animate callback almost immediately (usually 60 times
every second, or whatever the refresh rate is for your monitor) and the new timer object stores the
animation duration. At this point the timer has not actually started timing.
Inside your animation callback you call myTimer.progress()
, which returns the percentage of the
animation duration that has elapsed. The first time myTimer.progress()
is called it starts the timer,
using the performance API to get a timestamp, in milliseconds accurate to the nearest
5 microseconds, and stores that as the start time. It then returns 0 (the beginning of the animation)
to your calling animation function.
The next time your animation calls myTimer.progress() it returns the elapsed time as a percentage of total animation duration.
Perhaps it is easiest to get to grips with how the timer is used by showing some example code:
import {Timer} from './timerlib.module.js'; // import timer from the module const myAnimation = () => { const tick = () => { // percentage of time elapsed as float between 0.0 and 1.0 // the first time this is called the timer starts timing const progress = myTimer.progress(); // use various easing functions for different transforms const ease1 = myTimer.ease('easeOut'); const ease2 = myTimer.ease('parametric'); const ease3 = myTimer.ease('parabolic'); // multiply distances by progress value or eased value const rot = ease1 * 90; // rotation from 0 to 90 degrees const newX = ease2 * 300; // value for x transform, from 0 to 300px const newZ = ease3 * 400; // value for z transform, from 0 to 400px const size = 1 - ease1; // value for scale transform, from 1 to 0 const opacity = 1 - progress; // opacity value, linear (no ease) from 1 to 0 // animate the element by applying the transforms and fade myThing.style.transform = `translate3d(${newX}px, 0, ${-newZ}px) rotateX(${rot}deg) scale(${size})`; myThing.style.opacity = `${opacity}`; // recursively call the tick() callback // until our timer tells us the duration has expired if (!myTimer.done) requestAnimationFrame(tick); else { // reset or do anything necessary when animation ends resetAnim(); // we can also emulate CSS Animation and the Web Animation API // by emitting an animationend event document.despatchEvent(new Event('animationend')): } } // reset the animation element once the animation ends const resetAnim = () => { myThing.style.top = '1rem'; myThing.style.left = '.5rem'; myThing.style.transform = ''; myThing.style.opacity = 1; } // prepare anything necessary before starting the animation const myThing = document.querySelector('#myThing'); // kick off the animation const myTimer = new Timer(requestAnimationFrame(tick), 5000); }
The above code animates an html element so that it rotates around the x-axis, slides to the right (along the x-axis), disappears into the distance (z-axis), shrinking and fading as it goes, all over a period of five seconds (5000ms). Click the 'run' button to see what it does.
The Schedule object is a drop-in replacement for
WindowOrWorkerGlobalScope.setTimeout()
, again employing the
Performance API
and
window.getAnimationFrame()
for performance, compatibility and battery saving.
schedule is also far more capable than setTimeout()
since it is possible
to pause and resume or reset the timeout as well as to cancel it before it fires.
setTimeout()
can only be canceled.
You use schedule as follows:
const mySchedule = new Schedule(delay, callback[, ...args]);
delay is given in milliseconds and specifies how long to wait before calling the callback.
callback is the name of the callback function to call.
...args is zero or one or more arguments to pass to the callback function.
This is very similar to the setTimeout()
signature except that setTimeout()
requires the callback as the first parameter and delay as the second. Personally I think it is
more logical to give the delay first so as not to separate the callback from its arguments.
You pause, reset, cancel or resume the timeout by calling schedule's methods:
mySchedule.pause();
mySchedule.play();
mySchedule.reset();
mySchedule.cancel();
pause() pauses the timeout at its current elapsed time until play(), reset() or cancel() are called.
play() continues the countdown from wherever the delay was paused, or from 0 if reset() was called while the schedule was paused.
reset() resets the delay back to its original value and you are free to call reset() while the delay is running or paused.
cancel() does what it says on the tin: your callback never gets called.
You are even free to swap callbacks or modify parameters mid-flight by assigning new values to the schedule's callback and args properties. eg:
mySchedule.callback = 'aDifferentFunction';
or
mySchedule.args[2] = 200;
You can also freely change the delay time while the countdown is running by assigning to the schedule's delay property, eg:
mySchedule.delay = 500;
If you just want a one-shot timer callback and don't need to pause, reset or cancel the callback then you can instead use the library's delayCall() function, which has the same calling signature but is far more succinct in its implimentation, saving resources by loading less module code. Use it like this:
delayCall(delay, callback[, ...args]);
The Schedules object is a drop-in replacement for
WindowOrWorkerGlobalScope.setInterval()
. Like schedule it employs the
Performance API
and
window.getAnimationFrame()
for performance, compatibility and battery saving.
Schedules is actually a subclass of the schedule class and differs in that it calls its callback repeatedly at a specified interval rather than just once after a set delay.
It also refers to the delay parameter as interval rather than delay. Other than that it has a similar call signature and functionality to schedule.
You call it as follows:
const mySchedules = new Schedules(interval, callback[, ...args]);
The methods and properties are the same as for the schedule object above except for the naming of the delay property.
You can change the shedules interval in a similar way, eg:
mySchedules.interval = 500;
So here's the library code. You can copy and paste it into a file or download it as a zip file:
// Timer library using the performance API and requestAnimationFrame() // by Prajna Pranab // version 0.1.1 // // durations, delays and intervals are given in milliseconds to +/- 5 microsecond // accuracy (though browsers that don't support HiDef timestamps may round to the // nearest millisecond) so you can use, eg, interval = new schedule(0.5, myCallback) // to call myCallback every 500 microseconds // // for more info see the // documentation // general purpose timer object ------------------------------------------------ // default duration = 200ms // either set the duration when instanting or use, eg, myTimer.duration = 2000; // call with const myTimer = new timer(requestAnimationFrame(callback)); // timer doesn't start until myTimer.progress() or myTimer.ease() is called class Timer { constructor(id, duration=200) { this.id = id; this.duration = duration; this.done = false; } // update the timer entry track() { const [entry] = performance.getEntriesByName(this.id); if (!entry) { // create a new entry if there isn't one yet performance.mark(this.id); return 0; } // return the elapsed time return performance.now() - entry.startTime; } // get elapsed time from the tracker, convert to 0.0 < progress < 1.0 progress() { const percent = Math.min(this.track() / this.duration, 1); // when it reaches 1 we're done if (percent == 1) { // remove the entry performance.clearMarks(this.id); this.done = true; } return percent; }; // easing functions convert progress to eased progress ease(fn) { const p = this.progress(); switch(fn) { case 'easeIn': return p * p; case 'easeOut': return p * (2 - p); case 'bezier': return p * p * (3 - 2 * p); case 'parametric': return (p * p) / (2 * (p * p - p) + 1); case 'parabola': return -p + (.5 * p * p); } } } // schedue a callback function to be called after a delay ---------------------- // replaces setTimeout() // call with mySchedule = new schedule(delay, callback[, ...args]); // then you can mySchedule.pause() or mySchedule.cancel() // use delayCall() instead if you don't need to cancel etc // timer starts when assigned class Schedule { constructor(delay, callback, ...args) { this._delay = delay; this.originalDelay = delay; this.callback = callback; this.args = args; this.canceled = false; this.paused = false; this.start = performance.now(); requestAnimationFrame(this.loop.bind(this)); } get delay() { return this._delay; } set delay(period) { this._delay = period; this.originalDelay = period; } loop() { if (this.canceled || this.paused) return; if (performance.now() - this.start < this._delay) requestAnimationFrame(this.loop.bind(this)); else this.callback.call(this, ...this.args); } cancel() { this.canceled = true; } pause() { this._delay = performance.now() - this.start; this.paused = true; } play() { this.start = performance.now(); this.paused = false; requestAnimationFrame(this.loop.bind(this)); } reset() { this._delay = this.originalDelay; this.canceled = false; this.start = performance.now(); } } // schedule a callback function to repeat -------------------------------------- // replaces setInterval() // call with mySchedule = new schedules(interval, callback[, ...args]); // timer starts when assigned // call mySchedule.cancel() to stop class Schedules extends schedule { constructor(period, callback, ...args) { super(period, callback, ...args); } get interval() { return super.delay; } set interval(period) { super.delay = period; } loop() { if (this.canceled || this.paused) return; if (performance.now() - this.start < this._delay) requestAnimationFrame(this.loop.bind(this)); else { this.callback.call(this, ...this.args); this.reset(); requestAnimationFrame(this.loop.bind(this)); } } } // minimal replacement for setTimeout() ---------------------------------------- // more accurate and kinder to batteries, automatically paused when minimised // call with delayCall(delay, callback[, ...args]) const delayCall = (delay, callback, ...args) => { const loop = () => { if (performance.now() - start < delay) requestAnimationFrame(loop); else callback.call(this, ...args); }; const start = performance.now(); requestAnimationFrame(loop); }