Designing Unread for iPad – Part 3
This post is part of my ongoing series of posts1 in which I’m documenting the design of Unread for iPad.
In my previous post in this series, I drew some rough paper sketches of possible layouts, ranging from the typical to the atypical. In thinking through which ones I liked more, I arrived again at a conclusion I’d reached when following the same kind of process for Unread for iPhone: I like apps that are physically comfortable, especially when frequently-used controls can be reached without having to reposition the device in my hand.
Most iPad apps put common controls at the top and bottom of the screen in hard-to-reach navigation bars or tab bars. This sacrifices comfort for the sake of familiarity. I’d rather Unread make the opposite sacrifice. The most comfortable areas to reach with one’s thumbs on an iPad are the outer edges of the screen, vertically-centered. These areas are right next to your hands if you’re holding your iPad in a typical grip. This is true of both one-handed and two-handed operation.
So this is my first design constraint: wherever possible, controls and navigation should be accessible within those comfort areas. Constraints are a necessity if I hope to finish my design in a reasonable amount of time. Like performing a gram stain on an unidentified bacterial culture, a hard design constraint decisively eliminates whole swaths of possibilities, whittling down the project to a more manageable scope.
Every screen in Unread needs a way to present a menu of options. Some screens have few options, others have many. I can only think of a few solutions to this problem within my primary constraint:
Persistent Vertical Toolbar
I could place a persistent vertical toolbar along the righthand edge of the screen. This would consist of a single column of icons without text labels.
Hovering Options Button
I could place simple hovering button on the right edge of the screen. This could be persistent or transient when scrolling, but would be superimposed over all the other content in the app. Tapping the button would show a temporary options menu overlay (setting aside the options menu layout itself for another day).
iPhone-Style Options Gesture
I could reuse the sideways pull-to-refresh gesture that I used in the iPhone app, with the transient options menu mentioned above.
iPhone-Style, unobscured content.
The persistent vertical toolbar won’t work for two reasons. First, it’s distracting, which undermines my overall goal of a comfortable reading experience. Second, icons alone aren’t enough to explain what certain options do. An icon button with a text label is more informative than the sum of its parts.
The hovering option button would be better than the persistent toolbar, but its still a distracting smudge on the interface.
So my best choice is to stick with the good solution I’ve already used on the iPhone. I could have intuited this from the beginning and saved a day of work, but it helps to think it through anyway. It’s not enough to have a good design. You have to be able to convince other people why your design is good.
-
Though I began this series as a video podcast, I think it makes more sense to continue it in whatever form makes sense for each stage. ↩
Cell Height Caching Dilemmas in Unread
The biggest performance challenge in Unread is calculating attributed strings and cell heights for the article summary lists. Those cells are practically just a UIView with some text drawn into it using the NSAttributedString UIKit Additions.
Once an attributed string or cell height is calculated, it is cached (in memory only) and re-used. This vastly improves scrolling performance, for obvious reasons. The challenge isn’t in caching per se, but in the fact that since Unread is an RSS reader, it is not uncommon for a single table view to contain 5,000 or more items.
In the current beta of version 1.2, when an article list is fetched from the local database, I have been pre-calculating all strings and heights on a background queue before popping back up to the main queue (in batches).
In versions 1.0 and 1.1, I didn’t pre-calculate anything. Instead, I used UITableView
’s new estimatedHeightForRowAtIndexPath:
optional delegate method. It isn’t ideal, but it worked reasonably well on slower devices without significantly increasing complexity.
There is a big drawback to this approach. As soon as a table view’s delegate implements that method, frustrating bugs crop up: tapping the status bar to scroll-to-top is wildly inaccurate, you can’t scroll to a target indexPath with reliable accuracy, etc. This is true even when I try returning an exact pre-calculated height from a call for an estimated height. This means I can’t preserve the semantic content offset when reloading a table view (i.e., when new stuff has been inserted above the current offset). The table view appears to jump around as new content is loaded, which is an irritating experience for the user.
So back to version 1.2. Pre-calculating works great—on newer devices. But older devices (iPods touch and iPhones 4S and earlier) have a hard time calculating 5000+ cell heights when viewing the “All Articles” screen. Even though they’re batched in, the entire sweep can still take 5 to 10 seconds or more. Not ideal. When calculations take that long, the window of exposure to potential race conditions is really wide. What if the user switches themes or font sizes during that interval?
Some Possible Solutions
There are several approaches that have come to mind to get acceptable results on all devices. Here are some solutions I’ve considered:
1) Write Heights to Disk
I could save the pre-calculated heights to disk, so that they’re only every calculated once. So the very first time a set of articles is ever loaded would be slow, but subsequent loads would only need to process the newest articles. But there’s a problem: cell heights change depending on the relative date stamp. Articles published today use the time (11:38 AM), articles published in the last seven days use the day and day number (THU 18), and so forth. So the caching strategy would require keeping track of the date the height was calculated, the current date, and the date the article was published. If I ever allow users to rename feeds, then every cached height for a given feed would need to be invalidated, too. What if that happens while the app is pre-calculating thousands of new articles on a background queue? Thinking of how I’d implement all this makes me queasy.
2) Redesign the Cells
I could write the heights to disk, but eliminate the need for date-based cache invalidation by redesigning the date stamps to never alter the flow of the other text. I really don’t want to do this. I’ve already spent an embarrassing amount of time experimenting with article cells. I’m happy with the current look. Besides, this wouldn’t solve the case where a user can rename a feed.
3) Revert to Estimated Heights
I could revert to the estimated height approach I used in versions 1.0 and 1.1, and just throw out the improvements like semantic content offsets when reloading for new data.
4) Scrolling-Based Batching
I could batch new articles into the table view as you scroll near the bottom of the existing content, thus only ever processing the articles you’re actually going to see. On the surface this sounds like the easiest approach, but note that this adds a new axis of interdependence between the model layer (objects that fetch and sort articles from the database) and the view controller layer. It also means having to think about what should and shouldn’t trigger a load-more. What if the app is restoring the previous interface state on the next launch, and the last-visible article was the 4,000th item in a long list of articles?
What I Think I’ll Do For Now
Since the problem with exact pre-calculation only affects older devices, I’m going to try Option 5:
5) Device-Specific Caching
Newer Devices • Newer devices will not use the estimated row height delegate method, but will instead pre-calculate all strings and heights on a background queue before popping back to the main thread to update the table view. Since I’m not using estimates, I’ll be able to preserve the user’s perceived content offset when reloading for newer offscreen data. Status bar taps will also be reliably accurate.
Older Devices • Older devices will continue to use theestimatedHeightForRowAtIndexPath:
method from versions 1.0 and 1.1. Heights will not be pre-calculated. This an acceptable trade-off between complexity and performance, since it’s limited to a minority pool of devices.
But how can I do that? As I stated above, merely implementing the estimated row height method introduces scrolling inconsistencies to a table view. I accomplish this via Dynamic Method Resolution. I provide an implementation like this in my table view’s delegate:
+ (BOOL)resolveInstanceMethod:(SEL)aSEL { if ([UIDevice unr_supportsPreCalculatedArticleCellHeights] == NO) { if (aSEL == @selector(tableView:estimatedHeightForRowAtIndexPath:)) { class_addMethod([self class], aSEL, (IMP) unr_tableViewEstimatedHeightForRowAtIndexPath, "f@:@@"); return YES; } else if (aSEL == @selector(tableView:estimatedHeightForHeaderInSection:)) { class_addMethod([self class], aSEL, (IMP) unr_tableViewEstimatedHeightForHeaderInSection, "f@:@i"); return YES; } else if (aSEL == @selector(tableView:estimatedHeightForFooterInSection:)) { class_addMethod([self class], aSEL, (IMP) unr_tableViewEstimatedHeightForFooterInSection, "f@:@i"); return YES; } } return [super resolveInstanceMethod:aSEL]; }
The delegate’s .m
file never actually implements the actual protocol methods. Instead, if the current device doesn’t support pre-calculated heights, I resolve those methods by adding custom methods at runtime using class_addMethod
. This works because the estimated height methods are optional. The super implementation of resolveInstanceMethod:
fails gracefully on newer devices. On older devices, my custom functions (prefixed with unr_
in the code above) will be called instead.
This approach requires the least possible amount of increase in complexity while still improving the app for some users. It also buys me some time while I continue to explore my options.
Feature Idea for UIKit: UIDeviceCharacteristics
Though I will probably always regard it as reeking of code smell, I have to admit that there are times when one has to write device-specific logic based on the current device’s hardware identifier. I’m referring to the identifier obtained using sysctl()
. For example, the iPhone 4S has the identifier iPhone4,1
.
A good example of a valid case for device-specific code is any app that has custom AirDrop features. For example, OvershareKit has to know whether or not the current device supports AirDrop when populating the list of available services for a share sheet. Since Apple provides no APIs for checking the availability of AirDrop, our only recourse is to maintain an exhaustive list of all hardware identifiers for devices that support AirDrop.
But AirDrop is only one use case among many. Lots of apps run into situations where code really needs adapt to the capabilities of the current device. Shoehorning the same code into a ever-widening array of devices results in compromised user experiences for either the oldest or the newest devices, or both. Last year’s iPod touch and the iPhone 5S are two devices that both run iOS 7.1, but with vastly different performance characteristics.
The deeper problem is that basing device-specific logic on hardware identifiers is an indirect solution. What developers really want to know about are the characteristics of the current device, not what kind of device it is. This will be even more true if (as?) Apple’s iOS lineup branches into new device categories (cars, TVs, and watches, to name a few).
A better solution would be for Apple to provide a hypothetical UIDeviceCharacteristics
framework, which would abstract out specific characteristics like graphics performance, brute computing power, hardware features, etc. This way developers could write code that targets stable and meaningful device characteristics, instead of brittle and incomplete lists of device identifiers.
I’ve submitted this as a Bug Radar, too.
Rdio iOS SDK
From the Holy Moly department: Rdio, my favorite music streaming service, has an iOS SDK. It looks fantastic. The Rdio.framework
is pretty much a plug-and-play solution. It handles all the low-level audio and network streaming stuff, and has convenient wrappers for RESTful API calls. In short, you could build a clone of the official Rdio app, and the only thing you’d have to worry about is design and caching API responses for performance.
Check out the SDK in action in Rdio’s sample iOS app on Github.
Rdio is my favorite service because it’s organized around albums. Younger folks who came of age after the age of Napster and iTunes seem to like singles or playlists. I still like to listen to whole albums, often on repeat. Rdio makes it easy to thumb through my album collection, and to snoop through the collections of people I follow.
Two Alarm Fire – One More Old Post From 2008
Here’s one more post from my old blog. I have no memory of writing this, though I remember the event just as clearly as the day it happened.
A FIVE ALARM fire is one that is responded to by five fire halls. I never knew this until a two-alarm fire was despatched to my apartment—or rather, what was left of my rapidly disintegrating apartment. Until then I had not suspected that I was living such a combustible life.
MY BROTHER AND I reached the basement door and emerged panting. We ran around to the front of the building so quickly that I have no memory of the lengthy jog it took to get us there. A crowd of spectators had already begun to gather, neighbors and passersby milling about in the middle of the street, arms crossed and heads cocked at thoughtful-looking angles, as if our building wasn’t burning but sobbing sloppy confessional tears to them, its confidants, who nodded softly and offered bracing mm-hmms. No one spoke to any of us survivors escaping from the building, for which I was very grateful. Silent distance can be a very decent thing, especially between strangers. I consider it a duty for one of disaster’s fifth-wheels to keep their comment-holes shut. Grief is like aerobic compost; it needs air to decompose without producing a necrotized stench.
THE TELEVISION CREWS, which arrived with or perhaps slightly before the fire department, were not as respectful. Our apartment fire was a three-alarm headline. The local ABC, NBC, and CBS affiliates were there, represented by office trollops with shimmering silk blouses and synthetic nails. FOX was conspicuously absent, or at least I couldn’t see them, and for that I owe to them my undying gratitude. If you’re reading this, thanks for not televising my unraveling life at the very moment of its unraveling.
TWO ALARM FIRES are a sight to behold. A fire hall does not send a basketball or a soccer team’s worth of men. It sends a full NFL franchise worth of first draft backdraft fighters, muscle-bound and thick-necked, stomping around your charred property in dusty yellow hides and air-tight facemasks. A man for every job. Clockwork. One spins the truck into position. Two open the hydrant and connect the hose. Two unload pressurized oxygen tanks still blackened and shopworn from the last fire. Uncounted dozens of front-line infantry suit up and mask up and stampede up your hellish stairs, axes flying. Raw, inchoate, manly shouts directed them inwards and upwards. Under the circumstances, the urgent barking was tantamount to hollering strategies at a rodeo cowboy on a bull in full-tilt. Isn’t the objective simple? DON’T DIE.
EVENTUALLY THEY REACHED my apartment and squelched the nine-foot flames that had been clawing their way out of the living room windows. The heat was popping out the panes. Glass was everywhere. The gutters hung in shriveled kinks like palsied limbs. Scorch marks blemished the bricks above the windows in a feathery pattern. The firemen waved the the high-pressure hose around my battered apartment like Hercules power-washing the augean stables. Whatver glass panes remained were blasted out. Clothes were soaked. The floors and walls were saturated and briny. Axes ripped gashes in the drywall, tore out ceiling and insulation, hacked my furniture into shards. No Chair Left Behind. A computer was thrown seventeen feet, from the dining room to the living room. Desks were flipped upside down, drawers tossed and emptied midair. More gashes were made in the drywall, some of them needlessly. It seemed the fire deparment’s policy was to ensure that anything usable or valuable that had survived the fire would not survive the fire department. Paintings and drawings were destroyed. Curtains torn, closets ransacked, bathroom cabinets pillaged. The Vandals had returned to once again dismantle the western way of life. Possessions were no more. Objects ceased to exist. Things fell apart. When some unspoken criteria of dystopic ruin had been met, the firemen switched off their hoses and dropped their axes to the floor. A job well done. Every measure had been taken to ensure that no half-assed crisis had interrupted my day. No, sir. I was a genuine refugee.