Thursday, September 27, 2007

Canvas Loading Indicator

If any of you have been writing Web 2.0 apps for the iPhone, you will have realized by now that it doesn't allow you to use animated gifs. This is a bit of a problem since you want to provide some sort of feedback to the user if you're waiting for some data to load. Normally you would show something like what you find at http://www.ajaxload.info/. The other thing you might know is that you can use the canvas with the iPhone. Clearly the solution to the problem is to use the canvas to draw your indicator. I looked and couldn't find anyone who had built one. So I wrote one. Turns out, if you keep things simple, its relatively easy to write your basic spinner.
function getLoading(context, bars, center, innerRadius, size, color) {
var animating = true,
    currentOffset = 0;

function makeRGBA(){
    return "rgba(" + [].slice.call(arguments, 0).join(",") + ")";
}
function drawBlock(ctx, barNo){
    ctx.fillStyle = makeRGBA(color.red, color.green, color.blue, (bars+1-barNo)/(bars+1));
    ctx.fillRect(-size.width/2, 0, size.width, size.height);
}
function calculateAngle(barNo){
    return 2 * barNo * Math.PI / bars;
}
function calculatePosition(barNo){
    angle = calculateAngle(barNo);
    return {
        y: (innerRadius * Math.cos(-angle)),
        x: (innerRadius * Math.sin(-angle)),
        angle: angle
    };
}
function draw(ctx, offset) {
    clearFrame(ctx);
    ctx.save();
    ctx.translate(center.x, center.y);
    for(var i = 0; i<bars; i++){
        var curbar = (offset+i) % bars,
            pos = calculatePosition(curbar);
        ctx.save();
        ctx.translate(pos.x, pos.y);
        ctx.rotate(pos.angle);
        drawBlock(context, i);
        ctx.restore();
    }
    ctx.restore();
}
function clearFrame(ctx) {
    ctx.clearRect(0, 0, ctx.canvas.clientWidth, ctx.canvas.clientHeight);
}
function nextAnimation(){
    if (!animating) {
        return;
    };
    currentOffset = (currentOffset + 1) % bars;
    draw(context, currentOffset);
    setTimeout(nextAnimation, 50);
}
nextAnimation(0);
return {
    stop: function (){
        animating = false;
        clearFrame(context);
    },
    start: function (){
        animating = true;
        nextAnimation(0);
    }
};
}
This is a fair chunk of code but its not entirely clear how you use it. Its actually quite simple. Assuming you have a canvas to work with, All you do is call
var controller = getLoading(canvas.getContext("2d"), 9, {x:100, y:100}, 10, {width: 2, height:10}, {red: 0, green: 17, blue: 58});
The getLoading function takes a 2d context, the number of bars that you want your indicator to use, the X and Y coordinates of the center of the indicator, the radius of the inner portion of the indicator the height and width of each of the bars of the indicator and, finally, the color you want to use for the indicator. We ask for the components of the color separately because each spoke of the indicator gets a progressively smaller alpha value. The function returns a simple object with two methods, start() and stop(). The indicator is created spinning. If you wanted a more fancy set of spokes, all you would really have to do is rewrite the drawBlock function. Blogger seems to strip out script tags so there's no real way to provide a test (expect maybe a honkin huge bookmarklet) so if you want to try it out, copy the script and he following:
function (){
  var canvas = document.createElement("canvas");
  canvas.width= 200;
  canvas.height = 200;
  canvas.style.cssText="position:absolute; top:100px; left:100px; background:#transparent; border: 3px solid red";
  document.body.appendChild(canvas);
  var controller = getLoading(canvas.getContext("2d"), 9, {x:100, y:100}, 10, {width: 2, height:10}, {red: 0, green: 17, blue: 58});

  var button1 = document.createElement("input");
  button1.value="stop";
  button1.type="button";
  button1.onclick = function (){
      controller.stop();
  };
  document.body.appendChild(button1);

  var button2 = document.createElement("input");
  button2.value="start";
  button2.type = "button";
  button2.onclick = function (){
      controller.start();
  };
  document.body.appendChild(button2);
})();
in firebug and it should be fine.

5 comments:

  1. Hey Adam, I threw the demo up here for you.

    http://test.jpsykes.com/iphone/canvas/loading.php

    ReplyDelete
  2. as cool as that is, wouldnt it be more practical to use an css image sprite, and just use some JS to offset the background position to show each frame??

    ReplyDelete
  3. Jon,

    Thanks! I was a little worried about how I was going to put up a demo on blogger.

    Anon,

    Yes, that would be definitely work but I'm not sure its necessarily better and the javascript isn't nearly as cool.

    ReplyDelete
  4. Animated GIFs work fine on my iPhone. I'm not sure I'm getting the purpose of this demo.

    ReplyDelete
  5. Ok, I'll admit that I read Apple's documentation (http://developer.apple.com/iphone/designingcontent.html#optimize_images) wrong; large animated gifs only show the first frame. Small animated gifs work fine.

    It has some advantages. First is that the file side is constant, regardless of the size of the indicator you wish to use (minimized and gzipped it compares to the smallest of the indicator gifs). Its also trivially brandable; change colours quickly and easily. If you use two different colours for what ever reason, you get progressively better space savings.

    And as I said, this code is much cooler than an animated gif.

    ReplyDelete