Friday, April 22, 2011

Designing a Notes iPhone web app

"A pedagogical decision hides behind every design decision" -Dan Meyer

For the last few weekends, I've been coding in the early mornings while my wife sleeps beside me, working on a replacement for the broken built-in Notes app on her iPhone. My replacement is usable and stable at this point, and I'm just putting the final polish on the code to remove duplication, improve comments, delete orphan code, etc.

The tools I used to design this are the same as the diet tracker app I wrote for her to track her protein and calcium during her pregnancy: handwritten JavaScript, and Google's appspot.com webserver to properly serve the cache manifest's content type. This was initially going to be a quick hack to learn the API for Web SQL Databases, so I could retrofit database support in the diet tracker, but along the way I learned a lot about designing an HTML Notes application, which was more involved than I anticipated.

This post recounts some of the lessons I learned, and the decisions I made between competing solutions that each had pluses and minuses. Some screenshots of the current product are available below the fold.

Custom icon on iPhone home screen.

Editing: The "back" button on the menu bar is three rectangles and a circle drawn using the HTML5 canvas spec. More on that below.

List: Normally all those minus signs won't be there. The red minus sign at the very top is twisted to indicate being in delete mode. Then each note gets its own minus sign, and twisting them unlocks a delete button. Again, more on that below.

Impetus

The story starts a few years ago when the first iPhone came out. I bought one for Liberty, and it quickly became the source of many pictures of Scout, and of our adventures - pictures that we didn't want to lose. Because of the fear of losing these images, we rarely synced the phone with iTunes, always backing the images up first, and fearing the bad things that would happen when newer iOS versions were installed.

During syncing, we've lost music, saw the built-in applications get slower, had mysterious timeouts backing up data, but fortunately have never lost any of the images. Since each upgrade seemed to make the whole system slower, and assuming newer OS versions would need even more horsepower, syncing again would make the phone virtually unusable.

"Why not upgrade to an iPhone 4" you ask? I've pitched this idea to Liberty, which she soundly dismissed. Her reason: the newer iPhones look more boxy, whereas the original looks curvy and sleek. Well, there's no arguing with that logic... or at least, I don't really know where I'd start, so there's nothing for it but to help her use the phone she has.

The current state of her phone is iOS 3.1.3 with the version of Safari using WebKit engine 528.18. Theses specific versions have some anomalies, namely the Notes app tends to break during the upgrade (file permission problems), and Safari's HTML5 support had some minor issues that have since been ironed out (such as placeholders on textarea fields, which I'll get into later).

The Notes app breaking was the real impetus of this project. Our symptoms consist of the app opening fine, but clicking on most of the notes results in the app simply closing. Only one note opens, but trying to alter it also results in the app closing. There are some suggestions for fixes online, which mainly involve monkeying around with the Settings app and trying again, but those haven't worked for us.

Unix hacker that I am, I could jailbreak the phone, install an SSH daemon, then connect from my PC and do some commandline chmods on the note files, but Liberty didn't like the idea of me hacking her phone like that. One of the things that is helping the two of us have a successful marriage is my not fixating on things like this like I would have a decade ago. If she wants an older phone with an unaltered OS, then that's the environment I work in. If the puzzle is hard to solve under those conditions, so much the better: the puzzle is the fun part. Solving the puzzle is the main focus, not advocacy for being modern or edgy (whatever that means).

When I finished the diet tracker app enough to be usable, I looked for another app to write to learn web databases, and the broken Notes app was the obvious candidate. And so, I began.

My previous blog entry on the diet tracker web app shows (along with my radical views on childbirth) some basics of using offline application caches, and how to turn a website bookmarked to the iPhone home screen into something that behaves like a native application, so I won't dwell on those points here. Let's start instead with databases.

Web Databases

There are two basic flavors of JavaScript accessible local databases: the Web SQL Database, which is supported in WebKit browsers (Chrome and Safari), and the Indexed Database API. The latter was proposed by Oracle a couple years ago, and currently the W3C is declaring Web SQL a deprecated standard. The newer standard has been adopted by Firefox 4, so Wikipedia tells me, as well as Chrome 11 (meaning support for it should be rolled back into WebKit in the near future, propagating to Safari and Chromium). For now, though, Web SQL is what's available to me for iPhone web apps, and that is what I used.

In Web SQL, the method window.openDatabase() returns a handle to a database object. You pass that method a few variables - the local and friendly names of the database, its version number, and the number of bytes that should be allocated for it - and the browser uses a built-in instance of SQLite to attempt to find or create the database.

A SQL transaction function, (transaction handle).executeSql(), can be created that specifies the SQL to execute, any variables to substitute for question marks in the SQL, and asynchronous functions to call on the success or failure of the SQL statement.

The database handle and the transaction function are married together with an odd construct, resulting in code that can look like this:

var db = openDatabase('etchnotes', '1.0', 'etchnotes', 200000);
db.transaction(function(t){
  t.executeSql('SELECT rowid, name FROM notes', [], function(tx, result) {
    var firstName = result.rows.item(0).name;
    // ...do other stuff with results;
  });
});

First, that's too much abstraction and punctuation for my taste; without a lot of familiarity with constructs like the above, it's hard to parse, and harder still find syntax errors in it. But such is the world of asynchronous callbacks.

A slightly better construct is this:

var db = openDatabase('etchnotes', '1.0', 'etchnotes', 200000);
function onSuccess(tx, result) { ...stuff... }
function onFailure(tx, result) { ...stuff... }

db.transaction(function(t){
  t.executeSql('SELECT rowid, name FROM notes', [], onSuccess, onFailure);
});

That reads a lot more clean to me, but I still don't like that the db object can't take SQL directly. To address that, I created a closure function that has a runSql method, opens and initializes the database, and manages its own transactions and error callbacks. This is a simplified version of the closure:

function getNotesDB() {
  var db = openDatabase('etchnotes', '1.0', 'etchnotes', 200000);
  var errorHandler = function(tx, e) { alert("Database error\n" + e.message); }

  this.runSql = function(sql, callback) {
    db.transaction(function(t){
      t.executeSql(sql, [], callback, errorHandler);
    });
  }

  return this;
}

And calling it is a breeze:

var dbClosure = getNotesDB();
dbClosure.runSql("select rowid, name from notes", onSuccess);

After a little exposure, I was able to get my head around async database work, and splitting functions into two parts: the click handler that submits a SQL query, and the callback function that parses the results. I won't dwell on that any more here, but the source code is available below.

Rotating an HTML5 canvas

A standard design pattern on the iPhone is unlocking a delete button. Rather than each removable item having a delete button always present, and an "are you sure?" popup to deal with, some iPhone apps have an edit mode that provides each item with a minus sign on the left. Touching that makes it rotate counter-clockwise, and exposes a hidden delete button for that item. Click the delete button, and the app assumes you knew what you were doing and deletes the item with no verification. Or click the minus button again, and it rotate back to where it was, hiding the delete button. Intuitive and neat, typical of Apple's UI design, and hard to find fault with.

Implementing a similar minus button rotation with JavaScript took a little research, but ultimately wasn't very difficult. The two basic methods I chose between were CSS transforms using something like:

minusButton.style['-webkit-transform'] = "rotate(30deg)";

...and HTML5 canvas context rotations, for example

var ctx = canvas.getContext('2d');
ctx.rotate(angle);
ctx.fillRect(x, y, w, h);

I chose the latter, choosing also to not use image files, but instead to draw the button objects on canvases using paths (think GeneralPath from Java2D). Since all I needed was a circle and a line for a minus button, this was pretty trivial. While rotating the canvas was easy after I figured out to use ctx.translate() to move the origin point to the center of the image, animating the rotation was a little more involved.

I may have been better off using a CSS transition, but having already headed down the path of canvases, I stuck with it, ending up with some arcane looking infrastructure around canvases and their states. Behold the Spinner Factory:

function SpinnerFactory(size) {
  var cr = Math.PI*2;        // Circle
  var deg = Math.PI/180;     // One degree
  var scale = size/100;

  function getScaledContext(obj) {
   // Get canvas context
    var ctx = obj.getContext('2d');

    if (!obj.scaled) {
      ctx.scale(scale, scale);
      ctx.translate(50, 50);
      obj.scaled = true;
    }

    ctx.clearRect(-50, -50, 100, 100);
    return ctx;
  }

  function drawMinus(obj) {
    // Get scaled canvas context, and clear previous image
    var ctx = getScaledContext(obj);
  
    // Red circle
    ctx.fillStyle = 'red';
    ctx.beginPath();
    ctx.arc(0, 0, 50, 0, cr, true);
    ctx.fill();
  
    // White minus sign, rotated to obj.angle degrees
    ctx.save();
    ctx.rotate(obj.angle * deg);
    ctx.fillStyle = 'white';
    ctx.fillRect(-29, -6, 58, 12);
    ctx.restore();
  }

  function spinLeft(obj) { spin(obj, -15, -90); }
  function spinRight(obj) { spin(obj, 15, 0); }
  
  function spin(obj, step, goal) {
    // While spinning, disable object's click handler to prevent competing spins
    if (obj.onclick) {
      obj.deferredOnclick = obj.onclick;
      obj.onclick = null;
    }
    if (step == 0) return;
    if ((step < 0 && obj.angle <= goal) || (step > 0 && obj.angle >= goal)) {
      obj.angle = goal;
      if (obj.nextLeft) {
        obj.nextLeft = false;
        if (obj.unlockCbk != null) obj.unlockCbk();
        obj.unlockObj.style.display = 'block';
      } else {
        obj.nextLeft = true;
        if (obj.lockCbk != null) obj.lockCbk();
        obj.unlockObj.style.display = 'none';
      }
      if (obj.deferredOnclick) {
        obj.onclick = obj.deferredOnclick;
        obj.deferredOnclick = null;
      }
      return;
    }
    obj.angle += step;
    drawMinus(obj);
    setTimeout(function() {spin(obj, step, goal)}, 30);
  }

  this.makeSpinner = function(className, unlockObj, lockCbk) {
    unlockObj.style.display = 'none';
    var cnv = document.createElement('canvas');
    cnv.width = size;
    cnv.height = size;
    cnv.className = className;
    cnv.angle = 0;
    cnv.nextLeft = true;
    cnv.unlockObj = unlockObj;
    cnv.spinRight = function() { spinRight(cnv); }
    cnv.onclick = function(e) {
      cnv.nextLeft ? spinLeft(cnv) : spinRight(cnv);
      e.stopPropagation();
    }
    if (lockCbk != null) cnv.lockCbk = lockCbk;
    drawMinus(cnv);
    return cnv;
  }

}

The Spinner Factory is a JavaScript closure that takes a single variable, size, which determines the diameter of the spinner buttons it creates. Individual spinners are created with the makeSpinner() method, which takes variables for the CSS class name for the spinner, the object to hide/unhide at the end of the spin, and an optional function reference to call after an object is re-locked. Typical usage would look like this:

sf = new SpinnerFactory(30);
item.appendChild(sf.makeSpinner('minusBtn', deleteBtn));

...where "item" is a div tag you want to add a spinner to, and "deleteBtn" is the object that gets unhidden.

After getting all that working, I created some additional methods for the Spinner Factory to use the same scale to draw plus signs and arrows, which don't spin.

Sizing a textarea to fit its content

This was a fun one! After taking great pains to tweak CSS settings to make the note text line up with the lines on the background image, I noticed during testing that if I entered too much text, I couldn't scroll to see it. To complicate matters, mobile Safari doesn't appear to use scrollbars, so there wasn't even an indication that there was more text to see.

After some online searching, I found that you can scroll textareas on the iPhone by using two-fingered swipes. I tested this, and although it worked, it was awkward, and transitions between edit mode (when the keyboard display pops up) and normal viewing mode left the display in unusual positions. I could struggle through it, but it wasn't ideal, and certainly not good enough for my wife.

There are DOM properties for textareas that can be monitored after each character is entered. The one that seemed most important was scrollHeight. It remained constant until any text overflowed off of the screen, then the value changed to the height needed by all the text. This was all independent of font size or the width of the textarea.

I quickly hacked out an event listener that monitored for scrollHeight changes, and set the textarea height to match. Since no scrollbars ever appeared, the user would have no indication that anything interesting was happening. The main problem with this is that the box wouldn't shrink back down if you deleted a bunch of text. The scrollHeight attribute doesn't go below the current height of the textarea. Although not necessarily a showstopper, I didn't like the idea that a user might think there was a lot of whitespace offscreen at the end of the note. I wanted a box that could grow and shrink with the text.

I switched instead to a method that uses a second textarea, hidden offscreen, to make measurements. First, all textareas in this app have some very specific CSS settings for positioning:

textarea {
  border: 0;
  background-image:url('images/paper.png');
  margin:0;
  padding:0;
  padding-left: 13px;
  width: 307px;
  font-size: 17px;
  outline: none;
  font-family: Georgia;
}

Next, some simple CSS tells the "tester" area to hide offscreen (and to turn off scrollbars for testing with desktop Safari).

.tester {
  position: absolute;
  top: -1000px;
  overflow: hidden;
}

And finally the meat:

function textAreaSizer(obj) {
  hiddenTextArea = document.createElement('textarea');
  hiddenTextArea.rows = 20;
  hiddenTextArea.className = 'tester';
  obj.parentElement.appendChild(hiddenTextArea);
  var last = '';

  this.size = function(e) {
    if (last != obj.value) {
      last = obj.value;
      hiddenTextArea.value = obj.value + "\n";
      obj.style.height = hiddenTextArea.scrollHeight + 'px';
    }
  }

  this.size();
}

This closure accepts a textarea as input, and creates a sibling textarea using the "tester" class name. Note that I'm setting the hidden textarea's row size to 20, which means its scrollHeight will never fall below what's needed for 20 rows of text.

The web app in its current state is available here. The source code doesn't try to obfuscate anything, but I still have some commenting and cleanup to do.

Enjoy!

No comments:

Post a Comment