Monday, July 6, 2009

 

Step right up to view the fabulous ListingViewController!

Tickets $25 $5 $1 OK, fine, free.

I have had a highly productive day with the iPhone SDK and now I am going to inflict it on you too. Specifically, I am going to show you the complete annotated code for the ListingViewController, the view controller which, despite its less than thrilling name, is kind of the heart of my iPhone application.

Ready? Buckle your seat belts. It's gonna be a bumpy post.

Here goes nothing: here's the header file -


//
// ListingsViewController.h
// iTravelWrite
//
// Created by Jon Evans on 02/07/09.
//

#import
#import
#import "PageSearchResult.h"
#import "ViewEntry.h"

@interface ListingsViewController : UITableViewController {
NSManagedObjectContext *managedObjectContext;
CLLocationManager *locationManager;
PageSearchResult *page;
ViewEntry *rootEntry;
UIBarButtonItem *addNoteButton;
UIBarButtonItem *editListingButton;
UIBarButtonItem *iAmThereButton;
BOOL createdRoot;
}
-(void)addNote;
-(void)editListing;
-(void)iAmThere;

@property (nonatomic, retain) NSManagedObjectContext *managedObjectContext;
@property (nonatomic, retain) CLLocationManager *locationManager;
@property (nonatomic, retain) PageSearchResult *page;
@property (nonatomic, retain) ViewEntry *rootEntry;
@property (nonatomic, retain) UIBarButtonItem *addNoteButton;
@property (nonatomic, retain) UIBarButtonItem *editListingButton;
@property (nonatomic, retain) UIBarButtonItem *iAmThereButton;

@end


Lots of stuff there, most of it self-explanatory. A few bits you won't recognize:

"PageSearchResult" comes from the previous view in the hierarchy, in which the user searches WikiTravel (or selects from previously used pages), and contains the name and URL of the page that the user wishes to edit.

"ViewEntry" is a horrifically generic name and I should probably rename it to "WikiTravelEntry" or something like it. See, WikiTravel pages have intrinsic hierarchies. The "Toronto" page, for instance, has sections like "Districts" and "Eat", which in turn have subsections like "Islands" and "Farmer's Markets". Meanwhile, individual listings - like "Royal Ontario Museum" and "Yumi Japanese Restaurant" - can be attached to any section at all.

Too complicated? Let me give you an analogy. You've got your iPod, right? Well, under the "Music" heading, you can have a list of Artists; each Artist can have a list of Albums; and individual Songs can appear under Music, Artist, or Album.

Think of a WikiTravel listing as a Song, and a WikiTravel section as a folder. Well, a ViewEntry can represent either a Section or an individual Listing; and if it's a Section, then it can have children, and they too can be Sections or Listings.

Huh. So WikiTravel is basically laid out in the same way as an iPod's music. Hey, does that mean the iPhone SDK, with its seamless hierarchy-navigation UI tools, is totally perfect for a WikiTravel iPhone app?

Dude. Does it ever. Check it out:


//
// ListingsViewController.m
// iTravelWrite
//
// Created by Jon Evans on 02/07/09.
//

#import "ListingsViewController.h"
#import "NoteEditController.h"
#import "ListingEditController.h"
#import "HttpHelper.h"
#import "Util.h"
#import "Note.h"

@implementation ListingsViewController

@synthesize managedObjectContext, locationManager, page, rootEntry, addNoteButton, editListingButton, iAmThereButton;

- (void)viewDidLoad {
[super viewDidLoad];

//nav bar
//TODO: localize this string, and those below
self.navigationItem.title=@"Loading...";

//location manager
[[self locationManager] startUpdatingLocation];

//toolbar
self.iAmThereButton = [[UIBarButtonItem alloc] initWithTitle:@"I'm There!" style:UIBarButtonItemStyleBordered
target:self action:@selector(iAmThere)];
iAmThereButton.enabled=NO;
self.addNoteButton = [[UIBarButtonItem alloc] initWithTitle:@"Note To Self" style:UIBarButtonItemStyleBordered
target:self action:@selector(addNote)];
addNoteButton.enabled=NO;
self.editListingButton = [[UIBarButtonItem alloc] initWithTitle:@"Edit Listing" style:UIBarButtonItemStyleBordered
target:self action:@selector(editListing)];
editListingButton.enabled=NO;

self.toolbarItems = [NSArray arrayWithObjects:iAmThereButton, addNoteButton, editListingButton, nil];
[self.navigationController setToolbarHidden:NO];

//activity button
UIActivityIndicatorView *loading = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhite];
loading.frame=CGRectMake(0.0, 0.0, 25.0, 25.0);
[loading sizeToFit];
loading.autoresizingMask = (UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight);
UIBarButtonItem *activityButton = [[UIBarButtonItem alloc] initWithCustomView:loading];
self.navigationItem.rightBarButtonItem = activityButton;
[loading release];
[activityButton release];
}


This is pretty basic stuff: assign text and buttons to the navigation bar and toolbar you get for free by subclassing UITableViewController.

Perhaps most interesting is the bit at the end, where I assign a UIActivityIndicatorView - that little swirly "something is happening" animated wheel - to a custom UIBarButton. Such a beast is invisible until you tell it "startAnimating". Which we do next, which is when things get interesting -


//Fill the table
- (void)viewDidAppear:(BOOL)animated
{
UIActivityIndicatorView *loading = (UIActivityIndicatorView*) self.navigationItem.rightBarButtonItem.customView;
if (!rootEntry) { //only load for the top of the hierarchy
[loading startAnimating];
NSThread* loadThread = [[NSThread alloc] initWithTarget:self selector:@selector(loadViewEntries) object:nil];
[loadThread start];
[loadThread release];
createdRoot = YES;
}
else
{
createdRoot = NO;
self.navigationItem.title=rootEntry.sectionName;
[self.tableView reloadData];
}
}


So. First, you may be wondering, "why is this in viewDidAppear rather than viewDidLoad?" The main reason is that it can take a while to load the data, and I don't want the app to freeze up completely for a few seconds.

Now, if "rootEntry" is nil, that means we have to go out to the Internet and get our data. But wait! If we were to go load this view's contents from the Internet in the same thread, the UI would block. If we want to feign responsiveness, and reassure the user that something is indeed happening with the UIActivityIndicatorView swirly, then we have to launch a new background thread. Which we do.

The whole "createdRoot" thing is really kind of ugly, but necessary to avoid deallocation disaster. If it's any kind of consolation, it's only ever used in dealloc.

So. Our new thread goes and calls the "loadViewEntries" selector. Note that we didn't even define "loadViewEntries" in the header file, but selectors work at run-time, so it doesn't matter. It's almost like the Smalltalk "perform:" notation. Which is kinda awesome. Oh, Objective-C, I love you, when I don't hate you.

So our thread reaches out and calls:


-(void)loadViewEntries
{
//autorelease pool
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];

//load listings
NSString *urlRoot = [[Util getListingsURL] stringByAppendingString:@"?page="];
NSString *urlString = [urlRoot stringByAppendingString:[page.urlSuffix stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]];
NSURL *url = [NSURL URLWithString:urlString];
NSURLRequest *request = [NSURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:10.0];
NSData *returnData = [NSURLConnection sendSynchronousRequest: request returningResponse: nil error: nil ];
NSString * responseString = [[NSString alloc] initWithData: returnData encoding: NSASCIIStringEncoding];
// [Util doAlert:@"Response" withMessage:responseString];

//parse listings
self.rootEntry = [[ViewEntry alloc] initWithValueString:responseString levelsFromTop:0];
rootEntry.sectionName=page.pageName;
[self performSelectorOnMainThread: @selector(loadFinished) withObject:nil waitUntilDone:NO];
[pool release];
}


Two key things to note here. One is that we create an autorelease pool at the beginning of this method, and release it at the end. You have to do this whenever you create a new thread, or you face major memory leakage. What a hassle, and what a disaster if you forget. Oh, Objective-C, I hate you when I don't love you.

Next - and this is important - you cannot call UIKit, which in this context basically means anything to do with any view, from inside your new spawned thread. If you do, your app will die with "Tried to obtain the web lock with a thread other than the main thread or the web thread. This may be a result of calling to UIKit from a secondary thread." Oh, Objective-C, I hate you etc.

(Also: web thread? Where can I get me a piece of that?)

At least they give you a fairly easy way around it: the "performSelectorOnMainThread: withObject: waitUntilDone:" method on NSObject. Which works just fine, but means you have to create yet another method to do whatever you want done when the thread completes:


-(void)loadFinished
{
UIActivityIndicatorView *loading = (UIActivityIndicatorView*) self.navigationItem.rightBarButtonItem.customView;
self.navigationItem.title=rootEntry.sectionName;
[loading stopAnimating];
[self.tableView reloadData];
}


OK. So we've populated our hierarchy of data (which happens offscreen in ViewEntry's "init" method.) Now what are we going to do with it?


#pragma mark -
#pragma mark TableView

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return [[rootEntry getChildren] count];
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

static NSString *CellIdentifier = @"ViewEntryCell";

UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
if (cell == nil)
cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier] autorelease];

// Set up the cell...
ViewEntry *entry = [[rootEntry getChildren] objectAtIndex:indexPath.row];
cell.textLabel.text = [entry getName];
if ([entry hasChildren])
cell.accessoryType=UITableViewCellAccessoryDisclosureIndicator;
else
cell.accessoryType=UITableViewCellAccessoryNone;

return cell;
}


And that's all you need to do to show the data to the user. Pretty slick, eh? Did I mention that this entire view is created programmatically, with no nib file? Did you notice I never actually created or laid out a view? If your app fits into a hierarchy of TableViews, and fortunately mine fits perfectly, the iPhone SDK is really a dream to work with.

So we show the user the current section's children, which can be subsections or listings (think albums or songs). If the former, we show a little chevron which means you can drill down the hierarchy further. And when the user selects...


- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
ViewEntry *selectedEntry = [[rootEntry getChildren] objectAtIndex:indexPath.row];
if ([selectedEntry hasChildren]) {
//push this subsection onto the stack
ListingsViewController *lvController = [[ListingsViewController alloc] initWithNibName:nil bundle:nil];
lvController.managedObjectContext = self.managedObjectContext;
lvController.locationManager = self.locationManager;
lvController.page = self.page;
lvController.rootEntry = selectedEntry;
[self.navigationController pushViewController:lvController animated:YES];
[lvController release];
}
else {
addNoteButton.enabled=YES;
editListingButton.enabled=YES;
if ([locationManager location])
iAmThereButton.enabled=YES;
}
}


...we either create a new instance of this very same controller, pass the relevant data to it, and push it onto the view stack; or, if it's a listing (eg "Royal Ontario Museum"), we enable the three do-something-with-a-listing buttons in the toolbar.

Yes, that's right, that's all the code we need to navigate an arbitrarily sized view hierarchy by creating a tree of ListingViewControllers on the fly and stacking them atop one another. The SDK handles navigation and so forth for us. Cool, eh?

What do those buttons do? Well...

#pragma mark -
#pragma mark Business logic

-(void) addNote {
NSIndexPath *indexPath = [self.tableView indexPathForSelectedRow];
ViewEntry *listing = [[rootEntry getChildren] objectAtIndex:indexPath.row];
Note *newNote = (Note *)[NSEntityDescription insertNewObjectForEntityForName:@"Note" inManagedObjectContext:managedObjectContext];

// Configure the new note
[newNote setIsUploaded:[NSNumber numberWithBool:FALSE]];
[newNote setCreationDate:[NSDate date]];
[newNote setUserEmail:[Util getEmail]];
[newNote setSectionName:[listing sectionName]];
[newNote setSectionNumber:[listing sectionNumber]];
[newNote setListingName:[listing listingName]];
[newNote setPageUri:[page urlSuffix]];
CLLocation *location = [locationManager location];
if (location) {
CLLocationCoordinate2D coordinate = [location coordinate];
[newNote setLatitude:[NSNumber numberWithDouble:coordinate.latitude]];
[newNote setLongitude:[NSNumber numberWithDouble:coordinate.longitude]];
}

NoteEditController *noteEditController = [NoteEditController alloc];
noteEditController.note = newNote;
[self.navigationController pushViewController:noteEditController animated:YES];
[noteEditController release];
}


Here you get to see a) part of the Core Data persistence layer in action, b) the LocationManager where-am-I code in action, and c) the creation of a new kind of detail-view controller.

a) We get the ManagedObjectContext from our parent ViewController, who in turn got it from the RootViewController, who in turn got it from the AppDelegate. There are times - such as when you want to save one object, but not another - when you might want more than one ManagedObjectContext, but I haven't bumped into them yet. I think I might be able to get it from the AppDelegate which serves as something of a global, but for now I'm passing them from ViewController to ViewController.

And yeah, "Note" is a horribly generic name. I oughta rename it to "TravelNote" or something. The idea is that, rather than deal with the detailed listing-edit screen, you can jot a few words where you are, and upload that Note to wetravelwrite.com. Then, later,you'll get an email reminder (if you've opted for them) complete with a link, so that when you're at a computer with a real keyboard and screen, you can go online and edit that lasting as per your note and location.

b) We construct the LocationManager in a method further below.

c) OK, so it's not that exciting.


-(void)editListing{
NSIndexPath *indexPath = [self.tableView indexPathForSelectedRow];
ViewEntry *listing = [[rootEntry getChildren] objectAtIndex:indexPath.row];

ListingEditController *listingEditController = [ListingEditController alloc];
listingEditController.listingData = [listing listingData];
NSArray *keys = [NSArray arrayWithObjects:@"oldName", @"sectionToEdit", @"sectionNumber", @"editPageUrl", nil];
NSArray *values = [NSArray arrayWithObjects:[listing listingName], [listing sectionName], [listing sectionNumber], [page urlSuffix], nil];
NSDictionary *listingMetaData = [NSDictionary dictionaryWithObjects:values forKeys: keys];
listingEditController.listingMetadata = listingMetaData;

[self.navigationController pushViewController:listingEditController animated:YES];
[listingEditController release];
}


Pretty much the same thing, only messier. That's a lot of crap passing between ViewControllers, and I'd be tempted to abstract it into an object if this happened anywhere else; fortunately, it doesn't. And the crap is necessary, alas. The detail controller includes seventeen fields. Yes, seventeen. Which is probably OK if you're just tweaking a few details of an existing listing, but is also the reason I added the note-to-future-self functionality. I am going to add an "Add New Listing" button, but I don't expect too many people to use it.


-(void)iAmThere{
NSIndexPath *indexPath = [self.tableView indexPathForSelectedRow];
ViewEntry *listing = [[rootEntry getChildren] objectAtIndex:indexPath.row];

//construct location string
CLLocation *location = [locationManager location];
CLLocationCoordinate2D coordinate = [location coordinate];
NSString *locationString = [[NSNumber numberWithDouble:coordinate.latitude] stringValue];
locationString = [locationString stringByAppendingString:@","];
locationString = [locationString stringByAppendingString:[[NSNumber numberWithDouble:coordinate.longitude] stringValue]];

//build http request
NSArray *postKeys = [NSArray arrayWithObjects:@"pageUri", @"sectionName", @"sectionNumber", @"listingName", @"location", nil];
NSArray *postValues = [NSArray arrayWithObjects:[page urlSuffix], listing.sectionName, listing.sectionNumber, listing.listingName, locationString, nil];
HttpHelper *http = [[HttpHelper alloc] initWithPostKeys:postKeys postValues:postValues urlString:[Util getLocationUpdateURL]];
NSURLRequest *request = [http request];

//create connection
NSURLConnection *theConnection = [NSURLConnection connectionWithRequest:request delegate: self];
if (!theConnection) {
// inform the user that the download could not be performed
// TODO: real error handling
[Util doAlert:@"Connection creation failure" withMessage:@"Connection failed"];
}
[http release];
}

- (void)connection:(NSURLConnection *)connection
didFailWithError:(NSError *)error
{
// release the connection, and inform the user
[Util handleError:@"Connection Failure" withError: error];
}

- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
//TODO: add checkbox indicating listing located
[Util doAlert:@"Success" withMessage:@"Connection finished"];
}


Here we have an HTTP connection. The idea being that people can geotag WikiTravel listings with one button; you're passing by the Royal Ontario Museum, you check your iPhone app, you notice that it does not yet have a geotag (I haven't yet, but will, added checkboxes to indicate this), and you push the "I'm there!" button to update its WikiTravel listing with your current location, no typing necessary.

Now, you can perform an HTTP request in a single line, with NSURLConnection's sendSynchronousRequest: method, but this one uses delegation, so we implement the "connectionDidFail..." and "connectionDidFinish" methods.

HttpHelper is my own class, used to give me a central point of control over all HttpRequests, partly so I didn't have to keep copy-pasting code, partly so I can handle caching in one place. (Although I think I may have to add a new "keep or clear cache" to its init.) Util is my own class that holds basic fixed values and utility methods.


#pragma mark -
#pragma mark Location manager
/**
Return a location manager -- create one if necessary.
*/
- (CLLocationManager *)locationManager {

if (locationManager != nil) {
return locationManager;
}

self.locationManager = [[CLLocationManager alloc] init];
[locationManager setDesiredAccuracy:kCLLocationAccuracyNearestTenMeters];
[locationManager setDelegate:self];

return locationManager;
}


/**
Conditionally enable the Add button:
If the location manager is generating updates, then enable the button;
If the location manager is failing, then disable the button.
*/
- (void)locationManager:(CLLocationManager *)manager
didUpdateToLocation:(CLLocation *)newLocation
fromLocation:(CLLocation *)oldLocation {
if ([[self tableView] indexPathForSelectedRow]) {
iAmThereButton.enabled = YES;
}
}

- (void)locationManager:(CLLocationManager *)manager
didFailWithError:(NSError *)error {
iAmThereButton.enabled = NO;
}


And that's how you use the location manager; like much of the iPhone SDK, it's a delegate pattern. You create it and set its delegate; it goes out and tries to work out where you are; when it succeeds or fails, it lets you know. Simple, really.


#pragma mark -
#pragma mark Cleanup

- (void)dealloc {
[addNoteButton release];
[editListingButton release];
[iAmThereButton release];
if (createdRoot) {
[rootEntry release];
[locationManager release];
}
[super dealloc];
}

@end


Note that we only release the rootEntry and locationManager if we're the king ViewController at the top of the hierarchy. Otherwise we're asking for serious EXC_BAD_ACCESS disaster.


Aaaaaand there you go. The whole ListingViewController, in all its glory.

Note, he mentioned with some pride, that I started it writing it only four days ago - and that it's only maybe a quarter of the code I've written and tested since then.

One last caveat. You'll notice there's no copyright claim up above, and you're welcome to use the code as is, but - while it's pretty much running now, there are still tweaks I want to make. Error handling and localization, in particular. And I have hardly any unit tests yet. So if your need isn't dire and urgent, I advise you to wait until I officially release the code at code.google.com, under an open-source license, and post to this blog to that effect.

Labels: , , , , , , , , , , ,


Comments:
Hi Jon! Your article helped me in resolving an issue with the dreaded web thread - great stuff in here. Thanks!
 

Post a Comment

Subscribe to Post Comments [Atom]





<< Home

This page is powered by Blogger. Isn't yours?

Subscribe to Posts [Atom]