How to Create a Lithium Theme

Last updated: 16 January 2015

Lithium themes are short JavaScript files. I will run through the various processes that your script needs to go through.

Before you begin, you should have some basic knowledge of JavaScript. It may also be helpful to look at some resources on the canvas element:
Canvas - Dive Into HTML5
Canvas Tutorial - MDN

Start with this function declaration:

function renderBattery(height, percentage, charging, low, color) {

The ordering of arguments cannot be changed.

A rundown of the arguments:

We're going to make a very basic rectangle that will shorten in length as the battery drains, but will have a transparent rectangle under it to show how much battery has been drained.

First we need to create our canvas element and our context.

var canvas = document.createElement("canvas"),
    context = canvas.getContext("2d");

Now we need to decide on a size for the rectangle. Let's make it half as tall as the status bar, and as wide as the status bar is tall (this will later change based on the percentage).

var rectWidth = height,
    rectHeight = height / 2;

We're going to make a variable called colorString which will be important later.

var colorString = color.join();

The Array.join() method puts the contents of an array into a string, separated by a comma (or a custom separator). For example:

[0, 0, 0].join();  // "0,0,0"

Now we can set the width and height of our canvas.

canvas.width = rectWidth;
canvas.height = rectHeight;

Most of our work will be on the context object from now on.

We need to set the fill color before we draw the background rectangle; we are going to use some conditional statements here.

context.fillStyle = "rgba(" + colorString + ",0.5)";
if(low) context.fillStyle = "rgba(255,0,0,0.5)";
if(charging) context.fillStyle = "rgba(0,255,0,0.5)";

This is what we're doing.

This way, if the device is low and charging, it will show green.

Notice how we use colorString to create the full rgba() string, and we can still use a custom transparency.

Now we need to fill in the background rectangle.

context.fillRect(0, 0, rectWidth, rectHeight);

The next step is to set the fill color for the main triangle. It's a similar process, but now we're using opaque colors.

context.fillStyle = "rgb(" + colorString + ")";
if(low) context.fillStyle = "#F00";
if(charging) context.fillStyle = "#0F0";

Notice that we're using rgb() strings and hex values instead of rgba() strings.

Now we can fill in the foreground rectangle. We're going to calculate the width of this rectangle at the same time as we draw it.

context.fillRect(0, 0, rectWidth * (percentage / 100), rectHeight);

We're basically done! Finish it off with this line.

return canvas.toDataURL("image/png");

This turns the data that we have on the canvas into a data URI, which we then turn back into an image on Lithium's end.

Don't forget the closing bracket } at the end of the function.

Here's how the whole thing looks:

function renderBattery(height, percentage, charging, low, color) {
    var canvas = document.createElement("canvas"),
        context = canvas.getContext("2d");
    var rectWidth = height,
        rectHeight = height / 2;
    var colorString = color.join();
    canvas.width = rectWidth;
    canvas.height = rectHeight;
    context.fillStyle = "rgba(" + colorString + ",0.5)";
    if(low) context.fillStyle = "rgba(255,0,0,0.5)";
    if(charging) context.fillStyle = "rgba(0,255,0,0.5)";
    context.fillRect(0, 0, rectWidth, rectHeight);
    context.fillStyle = "rgb(" + colorString + ")";
    if(low) context.fillStyle = "#F00";
    if(charging) context.fillStyle = "#0F0";
    context.fillRect(0, 0, rectWidth * (percentage / 100), rectHeight);
    return canvas.toDataURL("image/png");
}

The image that this function returns will actually be shorter than the status bar, but iOS will automatically center it vertically.

Run your code through the Google Closure Compiler (simple optimizations) in order to reduce file size and take advantage of basic JavaScript optimizations.

For example, the example above run through the Closure Compiler:

function renderBattery(b,h,f,g,c){var d=document.createElement("canvas"),a=d.getContext("2d"),e=b/2;c=c.join();d.width=b;d.height=e;a.fillStyle="rgba("+c+",0.5)";g&&(a.fillStyle="rgba(255,0,0,0.5)");f&&(a.fillStyle="rgba(0,255,0,0.5)");a.fillRect(0,0,b,e);a.fillStyle="rgb("+c+")";g&&(a.fillStyle="#F00");f&&(a.fillStyle="#0F0");a.fillRect(0,0,h/100*b,e);return d.toDataURL("image/png")};

Not very readable, but very compact. To work with Lithium, you have to make two small modifications to the script.

Here's our final script:

(b,h,f,g,c){var d=document.createElement("canvas"),a=d.getContext("2d"),e=b/2;c=c.join();d.width=b;d.height=e;a.fillStyle="rgba("+c+",0.5)";g&&(a.fillStyle="rgba(255,0,0,0.5)");f&&(a.fillStyle="rgba(0,255,0,0.5)");a.fillRect(0,0,b,e);a.fillStyle="rgb("+c+")";g&&(a.fillStyle="#F00");f&&(a.fillStyle="#0F0");a.fillRect(0,0,h/100*b,e);return d.toDataURL("image/png")}

Advanced Tips

When working with color strings, make sure you're being as efficient as possible; the Closure Compiler won't touch your strings, because it doesn't know that they're colors.

When working with proportions, make sure to round your coordinates or shape sizes in order to not have subpixel rendering, which increases rendering time.

If you need any additional help, up to and including me making a theme for you (I have plenty of free time!), feel free to e-mail me at carlos@aarzee.me. I don't bite, I promise.

Examples

Here are some examples of what you can do with Lithium, followed by the code necessary to get these results. Hopefully you'll feel inspired!

function renderBattery(height, percentage, charging, low, color) {
    var barHeightMinusTwo = 2 * Math.round(height * 2 / 5),
        barHeight = barHeightMinusTwo + 2,
        widthMinusTwo = 2 * Math.round(barHeightMinusTwo * 5 / 16),
        width = widthMinusTwo + 2,
        cutWidth = Math.floor(1 + width * 3 / 14),
        cutHeight = Math.floor(1 + barHeight / 11),
        canvas = document.createElement("canvas"),
        context = canvas.getContext("2d"),
        imageData;
    canvas.width = width;
    canvas.height = barHeight;
    context.fillStyle = "rgba(0,0,0,0.3)";
    context.fillRect(1, 1, widthMinusTwo, barHeightMinusTwo);
    context.fillStyle = "#000";
    context.fillRect(1, barHeightMinusTwo * ((100 - percentage) / 100), widthMinusTwo, barHeightMinusTwo);
    context.clearRect(0, 0, cutWidth, cutHeight);
    context.clearRect(width - cutWidth, 0, cutWidth, cutHeight);
    imageData = context.getImageData(0, 0, width, barHeight);
    for(var i = 0; i < imageData.data.length; i += 4) {
        imageData.data[i + 3] = 255 - imageData.data[i + 3];
    }
    context.putImageData(imageData, 0, 0);
    context.font = barHeightMinusTwo * ((percentage < 6) ? 0.625 : 0.375) + "pt HelveticaNeue-CondensedBold";
    context.textAlign = "center";
    context.textBaseline = "middle";
    context.fillText(charging ? '\u26a1' : (percentage < 6) ? '!' : percentage, width / 2, barHeight / 2, widthMinusTwo * 0.9);
    imageData = context.getImageData(0, 0, width, barHeight);
    for(var i = 0; i < imageData.data.length; i += 4) {
        imageData.data[i + 3] = 255 - imageData.data[i + 3];
        var currentTrans = imageData.data[i + 3] / 255;
        imageData.data[i] = color[0] * currentTrans;
        imageData.data[i + 1] = color[1] * currentTrans;
        imageData.data[i + 2] = color[2] * currentTrans;
    }
    context.putImageData(imageData, 0, 0);
    return canvas.toDataURL("image/png");
}
function renderBattery(height, percentage, charging, low, color) {
    var radius = Math.round(height * 4 / 5) / 2,
        lineWidth = Math.floor(height / 20),
        size = radius * 2 + lineWidth,
        canvas = document.createElement("canvas"),
        context = canvas.getContext("2d"),
        colorString = color.join(),
        halfSize = size/2,
        fontHeight = radius,
        pi = Math.PI;
    if(percentage == 100) fontHeight = Math.round(height * 3 / 5) / 2;
    canvas.width = size;
    canvas.height = size;
    context.lineWidth = lineWidth;
    context.strokeStyle = "rgba(" + colorString + ",0.3)";
    if(low) context.strokeStyle = "rgba(255,59,48,0.3)";
    if(charging) context.strokeStyle = "rgba(76,217,100,0.3)";
    context.arc(halfSize, halfSize, radius, 0, pi * 2);
    context.stroke();
    context.strokeStyle = "rgb(" + colorString + ")";
    if(low) context.strokeStyle = "#FF3B30";
    if(charging) context.strokeStyle = "#4CD964";
    context.fillStyle = context.strokeStyle;
    context.beginPath();
    context.arc(halfSize, halfSize, radius, pi * (((100 - percentage) / 50) - 0.5), pi * 1.5);
    context.stroke();
    context.font = fontHeight + "pt Helvetica Neue";
    context.textAlign = "center";
    context.textBaseline = "middle";
    context.fillText(percentage, halfSize, halfSize);
    return canvas.toDataURL("image/png");
}
function renderBattery(height, percentage, charging, low, color) {
    var emptyBarCanvas = document.createElement("canvas"),
        emptyBarContext = emptyBarCanvas.getContext("2d"),
        emptyBarData,
        fullBarCanvas = document.createElement("canvas"),
        fullBarContext = fullBarCanvas.getContext("2d"),
        fullBarData,
        finalCanvas = document.createElement("canvas"),
        finalContext = finalCanvas.getContext("2d"),
        barWidth = 2 + height / 4,
        barHeight = 2 + height / 2,
        radius = height / 8,
        horizontalPadding = Math.ceil(height / 16),
        totalWidth = barWidth * 5 + horizontalPadding * 4,
        colorString = "rgb(" + color.join() + ")",
        barData;
    finalCanvas.width = totalWidth;
    finalCanvas.height = barHeight;
    emptyBarCanvas.width = barWidth;
    emptyBarCanvas.height = barHeight;
    fullBarCanvas.width = barWidth;
    fullBarCanvas.height = barHeight;
    function drawPath(ctx) {
        ctx.beginPath();
        ctx.arc(barWidth / 2, barHeight - radius - 1, radius, 0, Math.PI);
        ctx.lineTo(1, radius + 1);
        ctx.arc(barWidth / 2, radius + 1, radius, Math.PI, 0);
        ctx.lineTo(barWidth - 1, barHeight - radius - 1);
        ctx.clip();
        ctx.lineWidth = Math.floor(height / 20);
        ctx.strokeStyle = colorString;
    }
    drawPath(emptyBarContext);
    emptyBarContext.stroke();
    emptyBarData = emptyBarContext.getImageData(0, 0, barWidth, barHeight);
    fullBarContext.fillStyle = colorString;
    if(low) fullBarContext.fillStyle = "#FF3B30";
    if(charging) fullBarContext.fillStyle = "#4CD964";
    drawPath(fullBarContext);
    fullBarContext.fill();
    fullBarContext.stroke();
    fullBarData = fullBarContext.getImageData(0, 0, barWidth, barHeight);
    for(var i = 0; i < 5; i++) {
        if(percentage <= 20 * i)
            barData = emptyBarData;
        else if(percentage >= 20 * (i+1) )
            barData = fullBarData;
        else {
            finalContext.putImageData(fullBarData, i * (barWidth + horizontalPadding), 0);
            barData = emptyBarContext.getImageData(0, 0, barWidth, (barHeight - 6) * (1 - (percentage - (20 * i)) / 20) + 3);
        }
        finalContext.putImageData(barData, i * (barWidth + horizontalPadding), 0);
    }
    return finalCanvas.toDataURL("image/png");
}
function renderBattery(height, percentage, charging, low, color) {
    var colorString = "rgb(" + color.join() + ")",
        scale = Math.floor(height / 20),
        halfHearts = Math.ceil(percentage / 5),
        fullHeartData,
        halfHeartData,
        emptyHeartData,
        finalCanvas = document.createElement("canvas"),
        finalContext = finalCanvas.getContext("2d");
    function initHeart(canvas, context) {
        canvas.width = scale * 8;
        canvas.height = scale * 7;
        context.fillStyle = colorString;
        context.fillRect(0, 0, scale * 8, scale * 7);
        context.clearRect(0, 0, scale, scale);
        context.clearRect(scale * 3, 0, scale, scale);
        context.clearRect(scale * 7, 0, scale, scale);
        context.clearRect(0, scale * 4, scale, scale);
        context.clearRect(scale * 7, scale * 4, scale, scale);
        context.clearRect(0, scale * 5, scale * 2, scale * 2);
        context.clearRect(scale * 6, scale * 5, scale * 2, scale * 2);
        context.clearRect(scale * 2, scale * 6, scale, scale);
        context.clearRect(scale * 5, scale * 6, scale, scale);
    }
    if(halfHearts > 1) {
        var fullHeartCanvas = document.createElement("canvas"),
            fullHeartContext = fullHeartCanvas.getContext("2d");
        initHeart(fullHeartCanvas, fullHeartContext);
        fullHeartData = fullHeartContext.getImageData(0, 0, scale * 8, scale * 7);
    }
    if(halfHearts % 2 == 1) {
        var halfHeartCanvas = document.createElement("canvas"),
            halfHeartContext = halfHeartCanvas.getContext("2d")
        initHeart(halfHeartCanvas, halfHeartContext);
        halfHeartContext.clearRect(scale * 4, scale, scale * 3, scale * 3);
        halfHeartContext.clearRect(scale * 4, scale * 4, scale * 2, scale);
        halfHeartContext.clearRect(scale * 4, scale * 5, scale, scale);
        halfHeartData = halfHeartContext.getImageData(0, 0, scale * 8, scale * 7);
    }
    if(halfHearts < 19) {
        var emptyHeartCanvas = document.createElement("canvas"),
            emptyHeartContext = emptyHeartCanvas.getContext("2d");
        initHeart(emptyHeartCanvas, emptyHeartContext);
        emptyHeartContext.clearRect(scale, scale, scale * 2, scale);
        emptyHeartContext.clearRect(scale * 4, scale, scale * 3, scale);
        emptyHeartContext.clearRect(scale, scale * 2, scale * 6, scale * 2);
        emptyHeartContext.clearRect(scale * 2, scale * 4, scale * 4, scale);
        emptyHeartContext.clearRect(scale * 3, scale * 5, scale * 2, scale);
        emptyHeartData = emptyHeartContext.getImageData(0, 0, scale * 8, scale * 7);
    }
    finalCanvas.width = scale * 44;
    finalCanvas.height = scale * 16;
    for(var i = 0; i < 10; i++) {
        finalContext.putImageData((halfHearts >= (i + 1) * 2) ? fullHeartData : (halfHearts - (i * 2) == 1) ? halfHeartData : emptyHeartData, (i % 5) * scale * 9, i < 5 ? scale * 9 : 0);
    }
    return finalCanvas.toDataURL("image/png");
}