Thursday, July 9, 2009

In Search of a Better Way: Editable Detail Views

Some of the ugliest iPhone code I've written to date - perhaps most of the ugly code I've written to date - has been in table view controllers acting as detail editing panes. Normally, when you use a table view you use it to present a list of data to the user from an array or fetch request, and for doing that, the table view architecture is beautiful. But, when you want to use it to present - and allow the user to edit - properties of a single object, things get a bit gnarlier.

Sure, you can use Interface Builder to build your editing panes. But, most people take their cues from Apple about how to do things, and detail editing views are usually implemented as tables. Take a look at the Contacts app, for example:



It uses table views. So does the Settings application. Let's face it, most of us are going to want to use the same approach.

But, it's hard to write good clean code to handle these types of detail editing views. Because the editing of any particularly property could be handled by a different controller class, it's very hard to write elegant code to implement these. You end up with a lot of similar yet not-easy-to-refactor code.

The most obvious way to write these controllers is to use enums to define your table view's sections, and the rows within each section, sort of like this:

typedef enum  
{
ProjectTableSectionNameSection,
ProjectTableSectionTasksAndExpensesSection,
ProjectTableSectionPrimaryContactSection,
ProjectTableSectionDateSection,
ProjectTableSectionBudgetSection,
ProjectTableSectionLocationSection,
ProjectTableSectionReportSection,
ProjectTableSectionCompleteButtonSection,
ProjectTableSectionDeleteButtonSection,

ProjectTableSectionNumberOfSections
}
ProjectTableSection;

typedef enum
{
ExpenseEditSectionName,
ExpenseEditSectionGeneral,
ExpenseEditSectionDate,
ExpenseEditSectionDeleteButton,

ExpenseEditSectionCount
}
ExpenseEditSection;
...

Then, in the implementation of your class, you write switch statements to handle the logic for each section and row combination. The advantage of this approach is that you can rearrange rows and sections just by changing the enumerations. Because each value in the enum is one higher than the one before, you just change the order of the constants, and the rows or sections change order in the actual table.

But…

If you have an object with many properties, divided up into multiple sections, you end up with nasty, hard-to-maintain code doing that. Sure, you don't have to rearrange or change large chunks of code to rearrange the visual appearance, but it's still hard to find the code you're looking for when you go to make changes or fix bugs and it's still mingling the view and controller parts of MVC together in an uncomfortable living situation. You're violating MVC, and the design of the table view controller is actually kind of encouraging you to do so.

To give you an example, here is an excerpt from one of the first (and utterly horrible) complex table-based editing views I wrote using this technique:

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
switch ([indexPath section])
{
case ProjectTableSectionNameSection:
{
TextFieldEditingViewController *controller = [[TextFieldEditingViewController alloc] initWithStyle:UITableViewStyleGrouped];
controller.fieldNames = [NSArray arrayWithObject:NSLocalizedString(@"Project Name", @"Project Name")];
controller.fieldKeys = [NSArray arrayWithObject:@"name"];
controller.fieldValues = [NSArray arrayWithObject:project.name];
controller.shouldClearOnEditing = [project.name isEqualToString:NSLocalizedString(@"Untitled Project", @"Untitled Project - name given to new projects")];

controller.delegate = self;
[self.navigationController pushViewController:controller animated:YES];
[controller release];
break;
}

case ProjectTableSectionPrimaryContactSection:
{
if ([indexPath row] == [project.contacts count])
{
ABPeoplePickerNavigationController *peoplePickerNavigationController = [[ABPeoplePickerNavigationController alloc] init];
peoplePickerNavigationController.peoplePickerDelegate = self;

[self presentModalViewController:peoplePickerNavigationController animated:YES];
[peoplePickerNavigationController release];
}

else
{
ABPersonViewController *controller = [[ABPersonViewController alloc] init];
controller.personViewDelegate = self;
controller.allowsEditing = YES;
controller.addressBook = project.addressBook;
ABRecordRef theRef = [project recordRefForContactAtIndex:[indexPath row]];
controller.displayedPerson = theRef;
[self.navigationController pushViewController:controller animated:YES];
[controller release];

}

break;
}

case ProjectTableSectionReportSection:
{
ReportViewController *controller = [[ReportViewController alloc] initWithNibName:@"ReportView" bundle:nil];
controller.reportHTML = [project projectReportAsHTML];
controller.title = NSLocalizedString(@"Project Report", @"Project Report");
[self.navigationController pushViewController:controller animated:YES];
[controller release];
break;
}

case ProjectTableSectionDateSection:
{
dateBeingEdited = ([indexPath row] == 0) ? project.startDate : project.expectedCompletionDate;
DateViewController *controller = [[DateViewController alloc] init];
controller.delegate = self;
controller.date = dateBeingEdited;
controller.title = ([indexPath row] == 0) ? NSLocalizedString(@"Start Date", @"Start Date") : NSLocalizedString(@"Exp. Completion", @"Expected Completion Date (abbreviated to fit in title bar)");
[self.navigationController pushViewController:controller animated:YES];
[controller release];
break;
}

case ProjectTableSectionBudgetSection:
{
TextFieldEditingViewController *controller = [[TextFieldEditingViewController alloc] initWithStyle:UITableViewStyleGrouped];
controller.fieldNames = [NSArray arrayWithObject:NSLocalizedString(@"Budget", @"Project Budget")];
controller.fieldKeys = [NSArray arrayWithObject:@"budget"];
controller.fieldValues = [NSArray arrayWithObject:[project.budget stringValue]];
controller.shouldClearOnEditing = ([project.budget doubleValue] == 0.0);
[controller setKeyboardType:UIKeyboardTypeNumberPad forIndex:0];

controller.delegate = self;
[self.navigationController pushViewController:controller animated:YES];
[controller release];
break;
}

case ProjectTableSectionTasksAndExpensesSection:
{
switch ([indexPath row])
{
case 0: // Tasks
{
TaskCategoryViewController *controller = [[TaskCategoryViewController alloc] initWithNibName:@"TaskCategoryView" bundle:nil];
controller.project = project;
[self.navigationController pushViewController:controller animated:YES];
[controller release];
break;
}

case 1: // Expenses
{
ExpenseListViewController *controller = [[ExpenseListViewController alloc] initWithStyle:UITableViewStylePlain];
controller.project = project;
[self.navigationController pushViewController:controller animated:YES];
[controller release];
break;
}

case 2: // Change Requests
{
ChangeRequestCategoryViewController *controller = [[ChangeRequestCategoryViewController alloc] initWithNibName:@"ChangeRequestCategoryView" bundle:nil];
controller.project = project;
[self.navigationController pushViewController:controller animated:YES];
[controller release];
break;
}

case 3: // Budget Metrics
{
BudgetMetricsViewController *controller = [[BudgetMetricsViewController alloc] initWithStyle:UITableViewStyleGrouped];
controller.project = project;
[self.navigationController pushViewController:controller animated:YES];
[controller release];
break;
}

default:
break;
}

break;
}

case ProjectTableSectionLocationSection:
{
LocationViewController *controller = [[LocationViewController alloc] initWithStyle:UITableViewStyleGrouped];
controller.project = project;
[self.navigationController pushViewController:controller animated:YES];
[controller release];
break;
}

case ProjectTableSectionDeleteButtonSection:
break;
default:
break;
}

[tableView deselectRowAtIndexPath:indexPath animated:YES];
}


Pretty horrid, huh? Yeah. Don't do that, kids. And what's worse is, all that code was just to handle the user interaction from maybe ten rows of data divided into a handful of sections. And that's just one method in the controller class. There are longer ones, actually. It's ugly, unclean code any way you look at it.

So, ever since I wrote that monstrosity above, I have been on the lookout for ways to make the process of using table views to display and edit properties from a single data object using a table more manageable. Using generic controller classes like the ones I wrote a while back helped some, but not enough and doesn't fix the breakdown of the wall between the C and V of MVC. Unfortunately, I got busy, and I put the quest for a better solution on a back burner.

However, one of the applications we wrote for More iPhone 3 Development (on Core Data) uses one of these detail editing panes, and I really wanted to find something more elegant before I committed the code to print for the world to see. So, I dove back into this problem recently and came up with something I'm actually happy with, though it still needs some refinement. It's a a proof-of-concept stage now, but it's a very promising proof of concept.

Instead of writing a custom controller class for every detail editing view I need, I now can just create an instance of a generic controller class designed for these types of detail editing views. I pass the location of a property list file to the init method of that controller, and that property list defines the structure and appearance of the table-based detail editing view. It supports sections and different types of editors. You can have the user edit an attribute in a text field, or you can present a drop down list without writing any code, and you can add additional editors just by subclassing an existing class. Just assemble a property list using Xcode's built-in property list editor and pass that property list into the generic class.

Want to rearrange rows or sections? Just drag the corresponding entries in the property list to their new location. Want to add a new section or a new row within a section? Just add a new entry into the property list. Want to delete a row or section? Just delete the entry from the property list.

Here's an example from my proof-of-concept application. I've defined a property list like this:


Click for larger version


If you look at the property list, you see that there are two sections defined, and a total of three rows. The resultant application looks like this:



You see? Two sections, three rows, no code. I just instantiate the generic controller with the path to the property list:

    NSString *layoutPath = [[NSBundle mainBundle] pathForResource:@"HeroLayout" ofType:@"plist"];
ManagedObjectDetailEditor *controller = [[ManagedObjectDetailEditor alloc] initWithLayoutFile:layoutPath];
controller.managedObject = newManagedObject;
[self.navigationController pushViewController:controller animated:YES];
[controller release];

You might have noticed that the last dictionary in the property list (representing the sex of the superhero) has an additional key value called arguments. This gives the flexibility to pass additional data to the controller class, so you can do things like present a list of values that the user can choose from. In this case, we let them choose the sex from a list that includes Male and Female rather than making them type in free-form text.



This code is still in its infancy with many datatype editors left to be developed, but I think there's a lot of potential here for saving developers time. I'm even thinking about developing a little tool to let you visually design the table based on the Core Data data model - but that would be quite a ways down the line. Even without that and just crafting property lists rather than custom classes, there's a huge time savings.

The source code will be available as part of the More iPhone 3 Development source code archive, and one section of the book will include a tutorial on how to create a property list to define a detail editing view layout.

No comments:

Post a Comment