Wednesday, September 17, 2014

Simple Animation

In my web comics, I wanted to be able to take full advantage of the web as a medium. Not like the size between small and large, medium like the singular of media. Like the stuff we use to communicate.

I just wanted a simple and extensible way to make some parts of the comic cells move or change color or things of that nature. I use jQuery which has some animation features, but they are mostly geared towards the changing of CSS properties. I may want to do that sometimes, but not exclusively.

Here's what I came up with.

When I set up a type of animation on an img object, I set a few attributes on it. First, I set animated = 'true' to flag the animated images in a way that's easy to grab them using jQuery. Then I set another attribute that specifies the animation function to use. Something along the lines of animationType = 'flicker' for example. Then I set other attributes specific to that type of animation.

I might end up with some HTML like this:

<div>
    <img src="something.png" />
    <img src="frog.png" animated='true' animationType='flicker' flickerSpeed='1.5' />
    <img src="whatnot.png" />
</div>

I then have a setupAnimation function which performs the initial setup for my animated images. Here is a simplified version of what I use.

function setupAnimation() {

    window.animated = [];

    $("img[animated='true']").each(function(idx, elem) {

        var aType = $(elem).attr('animationType');

        if (aType === 'flicker') {

            // assign a function here. function must
            // be defined somewhere. we can also perform
            // any necessary initialization here.

            elem.aniFunction = flicker;

        } else if (aType === 'something else') {

            // other types can go here

        } else {

            // unknown animation type value
            elem.aniFunction = function() {
                // no op
            };

        }

        window.animated.push(elem);

    });

}

In this function, I am basically searching for all the animated elements, setting their animation functions, and performing any initialization that may need to happen. Then I save them in an array which I can later use to find them without having to use the jQuery selector again. I call this setupAnimation function once when the page is done loading.

As you see above, when the animation type is "flicker" I set the element's animation function to be "flicker." This implies that I have a function named flicker defined somewhere. I like this simple method of assigning a method to perform a certain type of animation because it allows me to easily add new types or modify the internal workings of existing types independent of the HTML. If tomorrow I want the "flicker" animation type to use a new "flicker2" function that I make, I can do that. If I want to totally change how the "flicker" function works, I can do that too.

As a future enhancement, I could have animation types self-initialize and put themselves in a map or something so I could get rid of the if...else if...else block here. Writing code that does not contain if...else statements should be the ultimate goal of every programmer, but I prefer to keep things simple at first and then refactor to better patterns later. I know it might sound like procrastination, but it works. It's a thing I do.

Anyway, once I have the animation set up, I have to kick off some kind of animation loop.

$(function () { 

    var lastTime = null;

    function run(timestamp) {

        window.requestAnimationFrame(function(e) { run(e); });

        var elapsed;
        if (lastTime === null) {
            lastTime = timestamp;
        }
        elapsed = timestamp - lastTime;
        lastTime = timestamp;

        animated.forEach(function (elem, idx) {
            elem.aniFunction(elapsed);
        });

    }

    window.requestAnimationFrame(function(e) { run(e); });

});

I leverage the window.requestAnimationFrame function to ensure smooth animation. The general contract of requestAnimationFrame is that it calls the given callback function and passes a really really accurate timestamp in milliseconds as the parameter. And if you want it to run again, you have to call it again from within the function that it calls. That's why I define a run function that takes the timestamp as a parameter and then call it from an anonymous function declared as the parameter for requestAnimationFrame.

In my run function, I use the timestamp argument to calculate the elapsed time since the last call, and pass that to the aniFunction of the elements in the animated array. The general contract of one of my animation handler functions is that it will receive a single argument indicating the elapsed time in milliseconds since the last call. This elapsed time value can be used to calculate how far an element should move, how much it should change color, if it should toggle its visibility, if it should change to a different image, or whatever else imaginable.

Here is my flicker animation function, so you can get an idea of what I am talking about.

function flicker(e) {

    if (typeof this.timespan === 'undefined') {
        this.timespan = -1;
    }

    if (typeof this.counter === 'undefined') {
        this.counter = 0;
    }

    if (this.timespan === -1) {
        // pick a new timespan value, based on speed
        this.timespan = Number($(this).attr('flickerSpeed')) * 1000 * Math.random();
    }

    this.counter += e;
    if (this.counter > this.timespan) {
        this.timespan = -1;
        this.counter = 0;
        $(this).toggle();
    }

}

It is a fairly simple animation that produces a flickering effect like an old fluorescent light bulb. After making sure that the timespan and counter properties are initialized, I check to see if I need to calculate a new wait time before toggling. It will be a random number between 0 and flickerSpeed seconds. In all cases, I add the current elapsed time to the total counter. If the counter is greater than the last calculated timespan, the element's visibility is toggled and timespan is set to -1 in order that a new timespan will be calculated the next time through.

It all works great, with one small catch. There are still web browsers out there that don't support the window.requestAnimationFrame function. I've found that at the very least, iOS Safari prior to version 6 does not. Since iPhones still running iOS 5 are not that uncommon, I needed to do something to provide them with animation.

The answer was to include a polyfill. A polyfill? Like pillow stuffing? Not quite. Polyfill in this context means "a piece of code that provides the technology that you, the developer, expect the browser to provide natively". That definition is from Remy Sharp, who explains the origin of the term on his blog. I basically had to check to see if the browser already had a requestAnimationFrame function defined, and if not, define it myself. The backwards-compatible solution is to use window.setTimeout and call the given callback function with a single argument representing the current timestamp in milliseconds. Here is what I use:

if ( !window.requestAnimationFrame ) {
 
    window.requestAnimationFrame = ( function() {

        return window.webkitRequestAnimationFrame ||
               window.mozRequestAnimationFrame ||
               window.oRequestAnimationFrame ||
               window.msRequestAnimationFrame ||
               function( callback, element ) {
                   window.setTimeout( function() { callback((new Date()).getTime()); }, 1000 / 60 );
               };

    } )();

}

I originally found that in a Gist written by Paul Irish, but it didn't pass the current timestamp to the callback function. So I added that part and we're all good now. Try it on your first-generation iPad. It will work. I have one. I tried it.

And that, my friends, is how I do simple animation in my web comics. It doesn't actually seem that simple now that I've written it all up, and I even simplified it for this post. Maybe I should refactor?

Amphibian.com comic for September 17, 2014

No comments:

Post a Comment