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.