Thursday, July 02, 2009

Rethinking The Problem

I believe this is officially the first of what will hopefully be many technical posts about my iPhone program SlickShopper. I'm just about to release a semi-major update, and a couple of revelations I had along the way are, I feel, deserving of some attention.

The Reason



SlickShopper doesn't break any new ground in the area of user interface. It is a simple, table-driven interface that uses built-in widgets almost everywhere. But the actual table cell - the row of information that is displayed - is a key area where I needed to provide something a little beyond what Apple includes out of the box. Here is a sample that is typical of my three main list views:



It's not super fancy, but for sake of this discussion, the important thing to notice is the number of pieces of information. There is the 1) description, 2) quantity, 3) cost, and 4) checkbox. So far, so good.

I am a big believer of options in software. As a user, I generally want options, and as a developer, if two reasonable people can see two reasonable ways of doing something, then if possible I'd like to support both people. And I carried this philosophy into this program. Except for the description, each other piece of information can be disabled. If you don't care about costs or quantities, you can turn them off. I personally can't stand having the checkboxes there (they are great looking; don't get me wrong... but I don't like having the second touch zone that they provide), so I turn those off, too.

The more math-oriented readers might already see the factorial that is forming here. I have 3 items with different display possibilities, so right now I have 3! = 3x2x1 = 6 variations. But I'm not done yet. I do have one display mode where I need to show the checkboxes, even if the user has turned them off. I need them for visual distinction between selected and not-selected, but I just want them for display; I don't want the second touch zone. So add one more option, and now I'm at 4! = 4x3x2x1 = 24 possibilities. It actually isn't this bad; due to some overlap of the options, it turns out that I really only have 12 scenarios to handle. For example:
checkboxes ON, quantity OFF, price ON; or
checkboxes ON, quantity ON, price OFF
...and so on. The logic for determining which scenario I'm in is going to be convoluted pretty much no matter what, but what about displaying of the actual cells?

The First Attempt



This would have been much simpler without the checkboxes. I've had enough trouble getting my head wrapped around the use of Interface Builder for iPhone development as is, but I couldn't find any examples that included a button in the cell. The only examples I could find built the cells in code, so that's what I did. I'm not going to bother laying out the code for them, but I'll list a few of the file names just to give an idea of what I was doing:

CompleteListCell.h
CompleteNoCheckListCell.h
CompleteImageOnlyListCell.h
MinimalListCell.h
MinimalNoCheckListCell.h
MinimalImageOnlyListCell.h

So first I decide how much information is being shown, based on the preference settings. If costs, prices, and checkboxes are all turned on, then I use the CompleteListCell. If the user turns off the checkboxes, then I fall back to CompleteNoCheckListCell. BUT, if the user then chooses a display mode where I need to show the checkboxes, I swap in the CompleteImageOnlyListCell, which replaces the button with an image.

This approach certainly does work, and is what shipped with my 1.0 and 1.1 versions. When I originally planned this out, I came up with 12 variations, so I have 12 of these files. Making them was easy enough; I built the most complicated one first, then pretty much just copy-pasted to make the others, removing code that no longer applied. And as long as I don't ever need to change anything (HA!), these files will be just fine. But if I decide to, say, change the font used for the description, I have to make that change in 12 places. And if I decide to add more information to my cell, I'm going to need a crapload of new files. So, this approach is workable, but ultimately doomed.

Duh



The first revelation concerns the checkboxes. What normally happens when a user taps on a row is that tableView:didSelectRowAtIndexPath: is called, and in the method you decide what you want to do in response to that tap. In this case, I want to slide in a detail view:



Easy enough. However, the whole point of having the checkbox is to provide an alternate action. If the user hits the checkbox, I want the row to animate out, like this:



I have the checkbox pointed to a buttonPressed: method, where I do the necessary steps to animate out the row.

The stumbling block was when I needed to show the checkboxes, but I didn't want them to function as buttons. So instead of imbedding the button into my table cell, I chose to make extra cells that contained the checkmark image. And then based on the appropriate preference setting, I chose which cell to display. I was thinking about this far too literally.

It suddenly occurred to me that I don't need to care about what kind of physical item - button or image - is being displayed, I only care about what happens when it is touched. If it is an image, normal row selection should happen, if it is a button, something extra should happen. But how do I tell the difference if I don't use a physically different item?

The answer is so blindingly obvious (in hindsight, of course) and simple that I'm really mad I didn't figure it out sooner. And that answer is the buttonPressed: method. I already have a method for declaring what should happen when the button is tapped, so what I need to do is make a small change to it to accommodate both of my needs. Here is the code:


if (![userDefaults boolForKey:kPrefsCheckboxesKey])
{
[self tableView:[self tableView] didSelectRowAtIndexPath:indexPath];
return;
}


First, I figure out what the preference setting is. If the checkboxes are turned off, I redirect to the exact same method that would normally be called when a row is tapped, and then bail out of the rest of the method. The end result appears to be only a single touch zone for the user, even though technically it is still two. I get my desired behavior, and this lets me remove the CompleteImageOnlyListCell.h cell and equivalent variations. So, this realization alone allowed me to go from 12 to 8 cells.

Don't overthink the problem, kids.

A Better Example



I didn't get super motivated to evaluate my cell structure until I saw this post on the Cocoa-dev mailing list by mmalc Crawford. I had glossed over it originally, not realizing it was actually an iPhone question. But sure enough, down under the 'Replicated content' section he provides instructions for using Interface Builder to graphically build cells that contain buttons. I believe that mmalc is one of Apple's tech writers, and have since found almost the exact same explanation in the "A Closer Look at Table-View Cells" section of the "Table View Programming Guide for iPhone OS". There is also a relatively new example called TaggedLocations that demonstrates the technique.

So, just like the first go-around, I laid out the most complicated version first, just to see if I could make the technique work. Here is what that looks like, with color added so that you can more easily see the different UILabels involved:



There really isn't too much code going along with this cell. This is my header file, and the implementation doesn't contain a whole lot more. It pretty much just synthesizes the accessors, inits, and deallocs.


@interface CompletePortraitListCell : UITableViewCell
{
UIButton *checkButton;
UILabel *primaryLabel;
UILabel *leftSubLabel;
UILabel *rightSubLabel;
}

@property (nonatomic, retain) IBOutlet UIButton *checkButton;
@property (nonatomic, retain) IBOutlet UILabel *primaryLabel;
@property (nonatomic, retain) IBOutlet UILabel *leftSubLabel;
@property (nonatomic, retain) IBOutlet UILabel *rightSubLabel;

@end


So that's good, but doesn't really help me a whole lot yet. Don't I need 7 more of these? As it turns out, no, this is the only file I need, and it can handle all 8 of the remaining variations.

I should probably take a moment to say that everything I'm about to achieve probably could have been used on my older code-only cell, too. But I do feel confident in saying that it probably would have been a bit more complicated to do so. And I can make size and position changes in IB so much more quickly than I can in code.

We begin with the complete cell as shown before, and let's take a quick look at it running on the phone to make sure it works:



The first option I want to handle is when the user has elected to display only one piece of additional information - quantity OR cost, but not both. I made the decision early on that in this case, the piece of information would be displayed at the left (green box), regardless of which piece of information it was. I can redirect the text into either box, so that's not hard. And it turns out that choosing not to display a box isn't hard, either. UILabel inherits from UIView, which in turn has a hidden property. So, after initializing my cell, all I have to do is:

[[cell rightSubLabel] setHidden:YES];


This results in:



Or, if only cost is being displayed:



So if hiding can make things go away, maybe I can do that with the button, too. And I can:

[[cell checkButton] setHidden:YES];


...which results in:



So the checkbox is gone, but now the text looks funny dangling out in space. I need to shift the text over to occupy the space vacated by the checkbox. This is accomplished by messing with the UILabel's frame. First, I'll grab the existing frame for the description label:

CGRect primaryFrame = [[cell primaryLabel] frame];


Then I'll feed that frame right back into the label, but make an adjustment to the X coordinate:


[[cell primaryLabel] setFrame:CGRectMake(primaryFrame.origin.x - 30,
primaryFrame.origin.y,
primaryFrame.size.width,
primaryFrame.size.height)];


Let's see what that does for us:



Well, we did succeed in pulling the label to the left, but the entire label moved. With short descriptions, the user might never notice the difference, but once they get some longer titles, they'll see their text getting truncated at an odd location. So at the same time we're moving the box to the left, we need to make the box wider to fill up the space. So, with a minor correction to the previous code, we now do this:


CGFloat adjustment = 30;
[[cell primaryLabel] setFrame:CGRectMake(primaryFrame.origin.x - adjustment,
primaryFrame.origin.y,
primaryFrame.size.width + adjustment,
primaryFrame.size.height)];


...and check our results:



Bingo. We can do the exact same thing with the green box. The blue one doesn't need to move in this case.

Speaking of moving a text box, that can probably handle the case where prices AND quantities are turned off. All that needs to happen there is for the description label to move down. Since we don't want the box to get taller as it moves, all we need to do is increase the Y coordinate:


CGFloat adjustment = 8;
[[cell primaryLabel] setFrame:CGRectMake(primaryFrame.origin.x,
primaryFrame.origin.y + adjustment,
primaryFrame.size.width,
primaryFrame.size.height)];


And of course hide the two detail boxes:

[[cell rightSubLabel] setHidden:YES];
[[cell leftSubLabel] setHidden: YES];


...resulting in:



At this point, the only differences between selected and not-selected are the checkbox state, and the color of the text. We'll stay with the black for selected items, so for unselected items we'll change the text color:

[[cell primaryLabel] setTextColor:[UIColor grayColor]];


The checkbox is similarly easy. In Interface Builder, select the button, and then in the Inspector set an appropriate image for "Default State Configuration", and the checked image for "Selected State configuration". After that it is simply a matter of feeding a boolean in for the state, which in this case is based on a property of my data model:

[[cell checkButton] setSelected:[currentItem isSelected]];


So after all of this, we are left with two cases: 1) not-selected item, no checkbox, 2) selected, but all options (checkbox, prices, quantities) turned off. Technically, our same cell could handle this. Set the checkbox to hidden, make the appropriate transformation to the frame coordinates, and we're there. But, this may perhaps be overkill. After all, we're talking about a cell that contains only a single line of text. That's a basic UITableViewCell. No reason to reinvent the wheel for that. Set the font size and color, done.

And as an added bonus - and my real reason for messing with my cells in the first place - this all works in landscape, too:



There's a little sneak peak at SlickShopper 1.5 for loyal readers.

Yeah, So What?



What did I actually accomplish? The key difference is that I went from 12 custom cell classes to only 1 (plus 1 built-in cell). That's less code to manage. And the new custom class is designed in Interface Builder, which means even less code. Less code is always good (less to break). And by having all of the important cell design in one place, that means a one-time change can have a wide-ranging impact.

I'm sure at some point I'll learn about a reason not to have done this, but for now it's working just fine. The only real weakness I can see at the moment is when I move the labels in code. Right now the transitions are all relative, so if I move the red box in IB, then the red box will also move in the simplified display. I'll have to make corrections to the code at that point. I could hard-code the destination coordinates, but then I'm really putting a wall between the nice stuff I now have in IB and what is happening in my code.

But for now, I'm happy, proud of myself, and grateful to mmalc.

1 comment:

Anonymous said...

You don't need to set the label frame manually when you hide parts of your cell. Just make sure the label's autoresize masks are set correctly in IB.