I've recently been involved in two iPhone projects that required a user interface control similar to what NSSavePanel gives us on the Mac. I thought long and hard about implementing something using a UINavigationController and a UITableViewController, but for a number of reasons that solution just didn't seem appropriate.
For one, these projects are MobileSubstrate extensions to the MobileSafari and MobileMail system applications, both of which have highly specialized rotation handling unlike anything we've seen in the land of iPhone SDK-friendliness (more on this in a future article). This also meant that making this control portable would have required a significant investment in time and effort, and the end result would have been rather ugly.
YFFileBrowser inside MobileMail with the AttachmentSaver extension
YFFileBrowser inside MobileMail with the AttachmentSaver extension
Needless to say I decided against that route and began looking into other possibilities. The inspiration finally came while I was browsing the web on desktop Safari and found myself using the "Download Linked File As.." dialog. I had become so accustomed to this dialog that it was almost invisible to me. That's when it struck me - open and save dialogs make more sense as modal popups than they do as full-screen windows! So lets break down our problem into smaller, more manageable pieces:
1. Modal popup that supports rotation
2. No dependence on navigation/view controllers
3. Allow the user to navigate the filesystem
4. Allow the creation of folders
5. Pass the selected path back to a handler
The closest thing we have to these dialogs on the iPhone is the infamous UIAlertView, which supports rotation natively, and does not depend on the presence of a UINavigationController/UIViewController in the application. I say infamous not because it is somehow intrinsically bad or ugly; I say that because it is always a real chore to implement if you want to do anything other than just displaying a message to the user, and even then it really seems like it involves more code than it should:
After finding myself typing out the same code over and over again, I decided to do something about it, so I created a nifty little utility class called QuickAlert to handle simple notifications, reducing that entire block into:UIAlertView* alert = nil; alert = [[UIAlertView alloc] initWithTitle:@"Alert" message:@"You just bricked your phone!" delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil]; [alert show]; [alert release];
Sometimes you just don't need to set a delegate or add extra buttons, simply because you're not expecting any input from the user. Now QuickAlert provides a solution for simple notifications, but what about cases where you'd like to get more functionality out of the alert view? The way you usually handle this is by setting the alertView's delegate property to a particular object, and then handling the appropriate delegate method in that object, like so:[QuickAlert showAlert:@"Alert" message:@"You just bricked your phone!"];
So how does this relate to our problem? Well, UIAlertView does not allow you to store a context (in our case this would be the path) that it could then pass on in its delegate method; and in a path selection dialog we care more about what path the user has chosen than which button was pressed. So we are obviously going to have to subclass UIAlertView again to add this functionality. UIAlertView like most controls on the iPhone, inherits from UIView, and thus allows easy access to its view hierarchy. This means we can add our own subviews with relative ease. In this case, we need to add a UITableView and two buttons.- (void)alertView:(UIAlertView *)alertView
clickedButtonAtIndex:(NSInteger)buttonIndex { if (buttonIndex != [alertView cancelButtonIndex]) { // do something } }
- (void)showAlert { UIAlertView* alert = nil; alert = [[UIAlertView alloc] initWithTitle:@"Confirm" message:@"Should I brick your phone?" delegate:self cancelButtonTitle:@"No" otherButtonTitles:@"Yes",nil]; [alert show]; [alert release]; }
Creating the Alert
We need a custom initializer for our subclass - one that we can conveniently call from anywhere that will do all the setup and drawing for us:
- (id)initWithPath:(NSString*)path
context:(id)ctx
delegate:(id)del
{
if (self = [super initWithTitle:nil // set later by setCurrentPath:
message:@"\n\n\n"
delegate:self
cancelButtonTitle:@"Cancel"
otherButtonTitles:@"Save", nil]) {
self.browserDelegate = del;
self.context = ctx;
self.currentPath = path;
[self draw]; // draw subviews
}
return self;
}
context:(id)ctx
delegate:(id)del
{
if (self = [super initWithTitle:nil // set later by setCurrentPath:
message:@"\n\n\n"
delegate:self
cancelButtonTitle:@"Cancel"
otherButtonTitles:@"Save", nil]) {
self.browserDelegate = del;
self.context = ctx;
self.currentPath = path;
[self draw]; // draw subviews
}
return self;
}
Buttons
The usual course of action here is to use a UIButton and set images for the normal and highlighted states. The problem with this approach is that custom images rarely match up with the appearance of the default buttons we are used to seeing in navigation bars, and will never match if the user is running a theme that modifies the appearance of these buttons. Instead we are going to use the same buttons Apple automatically creates when we add UIBarButtonItems to a UINavigationBar; the class we are interested in is called UINavigationButton and happens to be a subclass of UIButton. UINavigationButton has the following constructors:
With a little experimentation (this is an undocumented class after all) we came up with the following styles:-(id)initWithTitle:(NSString*)title style:(int)style; -(id)initWithImage:(UIImage*)image style:(int)style;
0 = Default Bordered Style
1 = Back Button Style
2 = Done Button Style (blue)
3 = Destructive Button Style (gray or red depending on the context)
We need two of these styles, earlier we said that this control should allow the user to navigate the filesystem; so the first button we need is a back button (style = 1) to place at the top-left corner of the alert. The convention is to display the title of the previous context within the button, so we are going to use the initWithTitle:style: constructor for this button. We also need a button for folder creation, we're going to use the default bordered button for this (style = 0). Now we have a small dilemma - we can't simply use the "+" string here as it would not look correct, so we need to use the initWithImage:style: constructor. But as we said earlier, using a custom image makes for horrible UI compatibility, we need to somehow find the same image UIKit uses for UIBarButtonSystemItemAdd. UIKit has access to this image, so by extension we also have access to it, if we know where to look:
// place this near the top of your implementation file
extern UIImage *_UIImageWithName(NSString* name);
UIImage* addImage = _UIImageWithName(@"UINavigationBarAddButton.png");
UINavigationButton* addButton = nil;
addButton = [[UINavigationButton alloc] initWithImage:addImage
style:0];
Now that we have our buttons, we just have to set their appropriate target/action pairs, position them correctly and we have a tiny, functional and portable replacement to a UINavigationBar.
extern UIImage *_UIImageWithName(NSString* name);
UIImage* addImage = _UIImageWithName(@"UINavigationBarAddButton.png");
UINavigationButton* addButton = nil;
addButton = [[UINavigationButton alloc] initWithImage:addImage
style:0];
The Table
From iPhone OS 3.0 onwards, UIAlertView creates a small table on its own if you add too many buttons to fit the screen in the current interface orientation. We are not going to use this table for a number of reasons. First, it will require significant hackery to cause it to display the table when we have only two or three items to display (and this is extremely prone to breaking if Apple modifies the underlying implementation). Second, in order to provide the same drill-down effect as UINavigationController (and UITransitionView before it) we need to insert a container UIView into the hierarchy, set its clipsToBounds property to YES, and then add our UITableView as a subview of this container view. We do this because as we animate the table from one set of data to another, it will appear to escape the edges of the alertView.
UIView* container = nil;
container = [[UIView alloc] initWithFrame:CGRectMake(11, 47, 261, 135)];
container.backgroundColor = [UIColor whiteColor];
container.clipsToBounds = YES;
[self addSubview:container];
// _tableView is an ivar
_tableView = [[UITableView alloc] initWithFrame:CGRectMake(0, 0, 261, 135)
style:UITableViewStylePlain];
_tableView.delegate = self;
_tableView.dataSource = self;
[container addSubview:_tableView];
(Note: put this in the -draw method)
container = [[UIView alloc] initWithFrame:CGRectMake(11, 47, 261, 135)];
container.backgroundColor = [UIColor whiteColor];
container.clipsToBounds = YES;
[self addSubview:container];
// _tableView is an ivar
_tableView = [[UITableView alloc] initWithFrame:CGRectMake(0, 0, 261, 135)
style:UITableViewStylePlain];
_tableView.delegate = self;
_tableView.dataSource = self;
[container addSubview:_tableView];
(Note: put this in the -draw method)
Notice how we set the datasource and delegate properties to the YFFileBrowser object. The next step is to make our browser conform to the UITableViewDatasource protocol:
- (NSInteger)tableView:(UITableView *)tableView
numberOfRowsInSection:(NSInteger)section {
// |_data| is an NSArray* ivar containing the directory contents at currentPath
return [_data count];
}
- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"listCell"];
if (cell == nil) {
cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault
reuseIdentifier:@"listCell"] autorelease];
cell.textLabel.font = [UIFont boldSystemFontOfSize:15];
}
NSString* item = [_data objectAtIndex:indexPath.row];
NSString* full = [currentPath stringByAppendingPathComponent:item];
cell.textLabel.text = item;
NSFileManager* fm = [NSFileManager defaultManager];
BOOL isDir = NO;
if ([fm fileExistsAtPath:full
isDirectory:&isDir] && isDir) {
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
cell.selectionStyle = UITableViewCellSelectionStyleBlue;
cell.textLabel.textColor = [UIColor blackColor];
cell.imageView.image = [Resources iconForFolder];
}
else {
cell.accessoryType = UITableViewCellAccessoryNone;
cell.selectionStyle = UITableViewCellSelectionStyleNone;
cell.textLabel.textColor = [UIColor grayColor];
cell.imageView.image = [Resources iconForExtension:[item pathExtension]];
}
return cell;
}
numberOfRowsInSection:(NSInteger)section {
// |_data| is an NSArray* ivar containing the directory contents at currentPath
return [_data count];
}
- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"listCell"];
if (cell == nil) {
cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault
reuseIdentifier:@"listCell"] autorelease];
cell.textLabel.font = [UIFont boldSystemFontOfSize:15];
}
NSString* item = [_data objectAtIndex:indexPath.row];
NSString* full = [currentPath stringByAppendingPathComponent:item];
cell.textLabel.text = item;
NSFileManager* fm = [NSFileManager defaultManager];
BOOL isDir = NO;
if ([fm fileExistsAtPath:full
isDirectory:&isDir] && isDir) {
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
cell.selectionStyle = UITableViewCellSelectionStyleBlue;
cell.textLabel.textColor = [UIColor blackColor];
cell.imageView.image = [Resources iconForFolder];
}
else {
cell.accessoryType = UITableViewCellAccessoryNone;
cell.selectionStyle = UITableViewCellSelectionStyleNone;
cell.textLabel.textColor = [UIColor grayColor];
cell.imageView.image = [Resources iconForExtension:[item pathExtension]];
}
return cell;
}
Now the fun part! How are we going to simulate the drill-down effect without using a UINavigationController? Simple, we use a tiny subset of the powerful CoreAnimation framework (QuartzCore) called CATransition. To do this we need to conform to the UITableViewDelegate protocol so we can handle row selections:
- (CGFloat)tableView:(UITableView *)tableView
heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return 38.0f;
}
- (NSIndexPath*)tableView:(UITableView *)tableView
willSelectRowAtIndexPath:(NSIndexPath *)indexPath {
NSString* item = [_data objectAtIndex:indexPath.row];
NSString* full = [currentPath stringByAppendingPathComponent:item];
NSFileManager* fm = [NSFileManager defaultManager];
BOOL isDir = NO;
if ([fm fileExistsAtPath:full
isDirectory:&isDir] && isDir) {
return indexPath;
}
return nil; // disallow selection
}
- (void)tableView:(UITableView *)tableView
didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tableView deselectRowAtIndexPath:indexPath animated:YES];
NSString* item = [_data objectAtIndex:indexPath.row];
CAMediaTimingFunction* timing;
timing = [CAMediaTimingFunction
functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
CATransition *animation = [CATransition animation];
[animation setTimingFunction:timing];
[animation setDelegate:self];
[animation setType:kCATransitionPush];
[animation setSubtype:kCATransitionFromRight];
[animation setDuration:0.3];
[animation setFillMode:kCAFillModeForwards];
[animation setRemovedOnCompletion:YES];
[[myTableView layer] addAnimation:animation
forKey:@"pushAnim"];
self.currentPath = [currentPath stringByAppendingPathComponent:item];
}
(Note: remember to import the QuartzCore header and link against the QuartzCore framework)
heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return 38.0f;
}
- (NSIndexPath*)tableView:(UITableView *)tableView
willSelectRowAtIndexPath:(NSIndexPath *)indexPath {
NSString* item = [_data objectAtIndex:indexPath.row];
NSString* full = [currentPath stringByAppendingPathComponent:item];
NSFileManager* fm = [NSFileManager defaultManager];
BOOL isDir = NO;
if ([fm fileExistsAtPath:full
isDirectory:&isDir] && isDir) {
return indexPath;
}
return nil; // disallow selection
}
- (void)tableView:(UITableView *)tableView
didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tableView deselectRowAtIndexPath:indexPath animated:YES];
NSString* item = [_data objectAtIndex:indexPath.row];
CAMediaTimingFunction* timing;
timing = [CAMediaTimingFunction
functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
CATransition *animation = [CATransition animation];
[animation setTimingFunction:timing];
[animation setDelegate:self];
[animation setType:kCATransitionPush];
[animation setSubtype:kCATransitionFromRight];
[animation setDuration:0.3];
[animation setFillMode:kCAFillModeForwards];
[animation setRemovedOnCompletion:YES];
[[myTableView layer] addAnimation:animation
forKey:@"pushAnim"];
self.currentPath = [currentPath stringByAppendingPathComponent:item];
}
Path Selection
Now that we have an interface to work with, we need to be able to store the current path and return it to our handler once it has been chosen. To do this we must first create a property to store the current path:
@property (nonatomic, copy) NSString* currentPath;
@synthesize currentPath;
@synthesize currentPath;
We synthesize the property because, while we are going to override the setter, we do not wish to create our own getter. Override the appropriate UITableViewDelegate Method for row selection, and use it to update currentPath. Next, override the setCurrentPath: setter, and use it to update the backButton title and the alert's title, update the table's contents and animate the transition from the old set of contents to the new one. We also need to do the same in the backButton's action, this time moving up the filesystem tree and using the reverse transition.
- (void)setCurrentPath:(NSString*)path {
[currentPath release];
currentPath = [path copy];
if (path == nil) {
return;
}
NSArray *contents = [[NSFileManager defaultManager]
directoryContentsAtPath:path];
self.title = [[currentPath lastPathComponent]
stringByAppendingString:@"\n\n\n\n"];
// |navButton| is the backButton we created earlier
[navButton setTitle:[[currentPath
stringByDeletingLastPathComponent]
lastPathComponent]]; // get parent directory path
if ([currentPath isEqualToString:HOME_DIR]) {
// mini sandbox to prevent navigating outside a particular directory
navButton.alpha = 0;
}
else {
navButton.alpha = 1;
}
self.data = contents;
}
- (void)back {
CATransition *animation = [CATransition animation];
[animation setTimingFunction:
[CAMediaTimingFunction
functionWithName:kCAMediaTimingFunctionEaseInEaseOut]];
[animation setDelegate:self];
[animation setType:kCATransitionPush];
[animation setSubtype:kCATransitionFromLeft];
[animation setDuration:0.3];
[animation setFillMode:kCAFillModeForwards];
[animation setRemovedOnCompletion:YES];
[[_tableView layer] addAnimation:animation forKey:@"pushAnim"];
self.currentPath = [currentPath stringByDeletingLastPathComponent];
}
[currentPath release];
currentPath = [path copy];
if (path == nil) {
return;
}
NSArray *contents = [[NSFileManager defaultManager]
directoryContentsAtPath:path];
self.title = [[currentPath lastPathComponent]
stringByAppendingString:@"\n\n\n\n"];
// |navButton| is the backButton we created earlier
[navButton setTitle:[[currentPath
stringByDeletingLastPathComponent]
lastPathComponent]]; // get parent directory path
if ([currentPath isEqualToString:HOME_DIR]) {
// mini sandbox to prevent navigating outside a particular directory
navButton.alpha = 0;
}
else {
navButton.alpha = 1;
}
self.data = contents;
}
- (void)back {
CATransition *animation = [CATransition animation];
[animation setTimingFunction:
[CAMediaTimingFunction
functionWithName:kCAMediaTimingFunctionEaseInEaseOut]];
[animation setDelegate:self];
[animation setType:kCATransitionPush];
[animation setSubtype:kCATransitionFromLeft];
[animation setDuration:0.3];
[animation setFillMode:kCAFillModeForwards];
[animation setRemovedOnCompletion:YES];
[[_tableView layer] addAnimation:animation forKey:@"pushAnim"];
self.currentPath = [currentPath stringByDeletingLastPathComponent];
}
(Note: notice how we appended four return characters to the title, that's because UIAlertView does not expose working resize methods, and its "setFrame:" results in a broken alertView. Appending the return characters causes the alertView to expand its height, without having to resort to CFAffine scaling transformations that could cause significant distortion to the shape of the alert). Now in order to pass the selected path (or the cancel event) to the handler, we need to set up a delegate protocol for our control:
@protocol YFFileBrowserDelegate
- (void)fileBrowser:(YFFileBrowser*)browser
didSelectPath:(NSString*)path
withContext:(id)context;
- (void)fileBrowserDidCancel:(YFFileBrowser*)browser;
@end
- (void)fileBrowser:(YFFileBrowser*)browser
didSelectPath:(NSString*)path
withContext:(id)context;
- (void)fileBrowserDidCancel:(YFFileBrowser*)browser;
@end
We make our handler conform to this protocol, and call the appropriate method on the handler once the user makes a selection (or chooses to cancel).
- (void)alertView:(UIAlertView *)alert
clickedButtonAtIndex:(NSInteger)buttonIndex {
if (buttonIndex != [alert cancelButtonIndex]) {
[_browserDelegate fileBrowser:self
didSelectPath:currentPath
withContext:context];
}
else {
[_browserDelegate fileBrowserDidCancel:self];
}
}
clickedButtonAtIndex:(NSInteger)buttonIndex {
if (buttonIndex != [alert cancelButtonIndex]) {
[_browserDelegate fileBrowser:self
didSelectPath:currentPath
withContext:context];
}
else {
[_browserDelegate fileBrowserDidCancel:self];
}
}
Folder Creation
The final matter we have to deal with is folder creation. The addButton we created earlier calls a method when tapped, inside this method we throw a new UIAlertView with a textField as a subview. This causes our original alert to be hidden, which is exactly the kind of behavior we desire. Read the chosen folder name from the textField and then use NSFileManager to create a directory with that name inside currentPath. If the directory creation is successful, set currentPath to the newly created folder.
@interface UIAlertView (PrivateTextFieldStuff)
- (id)addTextFieldWithValue:(id)value label:(id)label;
- (id)textFieldAtIndex:(int)index;
- (int)textFieldCount;
- (id)textField;
@end
@interface AlertPrompt : UIAlertView
- (id)initWithTitle:(NSString *)title
message:(NSString *)message
delegate:(id)delegate
cancelButtonTitle:(NSString *)cancelButtonTitle
okButtonTitle:(NSString *)okButtonTitle;
- (NSString *)enteredText;
@end
@implementation AlertPrompt
- (id)initWithTitle:(NSString *)title
message:(NSString *)message
delegate:(id)delegate
cancelButtonTitle:(NSString *)cancelButtonTitle
okButtonTitle:(NSString *)okayButtonTitle {
if (self = [super initWithTitle:title
message:message
delegate:delegate
cancelButtonTitle:cancelButtonTitle
otherButtonTitles:okayButtonTitle, nil]) {
[self addTextFieldWithValue:@"" label:@""];
}
return self;
}
- (NSString *)enteredText {
return [[self textFieldAtIndex:0] text];
}
@end
- (id)addTextFieldWithValue:(id)value label:(id)label;
- (id)textFieldAtIndex:(int)index;
- (int)textFieldCount;
- (id)textField;
@end
@interface AlertPrompt : UIAlertView
- (id)initWithTitle:(NSString *)title
message:(NSString *)message
delegate:(id)delegate
cancelButtonTitle:(NSString *)cancelButtonTitle
okButtonTitle:(NSString *)okButtonTitle;
- (NSString *)enteredText;
@end
@implementation AlertPrompt
- (id)initWithTitle:(NSString *)title
message:(NSString *)message
delegate:(id)delegate
cancelButtonTitle:(NSString *)cancelButtonTitle
okButtonTitle:(NSString *)okayButtonTitle {
if (self = [super initWithTitle:title
message:message
delegate:delegate
cancelButtonTitle:cancelButtonTitle
otherButtonTitles:okayButtonTitle, nil]) {
[self addTextFieldWithValue:@"" label:@""];
}
return self;
}
- (NSString *)enteredText {
return [[self textFieldAtIndex:0] text];
}
@end
(Thanks to Dustin Howett for AlertPrompt)
- (void)newFolder {
AlertPrompt *prompt = [AlertPrompt alloc];
prompt = [prompt initWithTitle:@"New Folder"
message:@"Enter a name for the new folder"
delegate:self
cancelButtonTitle:@"Cancel"
okButtonTitle:@"OK"];
prompt.tag = kNewFolderAlert;
[prompt show];
[prompt release];
}
AlertPrompt *prompt = [AlertPrompt alloc];
prompt = [prompt initWithTitle:@"New Folder"
message:@"Enter a name for the new folder"
delegate:self
cancelButtonTitle:@"Cancel"
okButtonTitle:@"OK"];
prompt.tag = kNewFolderAlert;
[prompt show];
[prompt release];
}
Why did we set a tag on the newFolder alert? Remember that we already have an alertView delegate set up in this class, in order to differentiate between the two alerts we need to tag them. The best way to do this is to define some random integer constant and use that to identify the alert within the delegate method:
- (void)alertView:(UIAlertView *)alert
clickedButtonAtIndex:(NSInteger)buttonIndex {
if (alert.tag == kNewFolderAlert) {
if (buttonIndex != [alert cancelButtonIndex])
{
NSString *entered = [(AlertPrompt *)alert enteredText];
NSFileManager* fm = [NSFileManager defaultManager];
BOOL success = [fm createDirectoryAtPath:
[currentPath stringByAppendingPathComponent:entered]
attributes:nil];
if (success) {
self.currentPath = [currentPath stringByAppendingPathComponent:entered];
}
}
}
else {
if (buttonIndex != [alert cancelButtonIndex]) {
[_browserDelegate fileBrowser:self
didSelectPath:currentPath
withContext:context];
}
else {
[_browserDelegate fileBrowserDidCancel:self];
}
}
}
clickedButtonAtIndex:(NSInteger)buttonIndex {
if (alert.tag == kNewFolderAlert) {
if (buttonIndex != [alert cancelButtonIndex])
{
NSString *entered = [(AlertPrompt *)alert enteredText];
NSFileManager* fm = [NSFileManager defaultManager];
BOOL success = [fm createDirectoryAtPath:
[currentPath stringByAppendingPathComponent:entered]
attributes:nil];
if (success) {
self.currentPath = [currentPath stringByAppendingPathComponent:entered];
}
}
}
else {
if (buttonIndex != [alert cancelButtonIndex]) {
[_browserDelegate fileBrowser:self
didSelectPath:currentPath
withContext:context];
}
else {
[_browserDelegate fileBrowserDidCancel:self];
}
}
}
A link to a complete, drop-in implementation of this control will be posted here later