iCloud and UIDocument: Beyond the Basics, Part 2/4

Ray Wenderlich

This post is also available in: Korean

Learn how to make a complete UIDocument + iCloud app!

Learn how to make a complete UIDocument + iCloud app!

This is a blog post by site administrator Ray Wenderlich, an independent software developer and gamer.

Welcome back to our document-based iCloud app tutorial series!

In this tutorial series, we are making a complete document-based iCloud app called PhotoKeeper, with features that go beyond just the basics.

In the first part of the series, we created model classes for our app and a UIDocument wrapper. We also added logic to list and add local files.

In this second part of the series, we will make the app fully functional when it comes to local files. We will improve the look of our master view, implement the detail view, and add support for renaming and deleting files.

If you’re eager to get into iCloud, don’t worry – getting UIDocument support working like we’re doing here is a prerequisite! We’re laying a framework that will make switching to iCloud much easier.

This project continues where we left off last time, so if you don’t have it already grab the previous code sample and open up the project. Let’s begin!

A Better Table View

Right now our table view is the boring default style that the template made for us. Let’s fix this up so it’s more the way we want for PhotoKeeper.

Open MainStoryboard.storyboard and select the Table View inside the Master View Controller. Switch to the Size Inspector, and set the Row Height to 80:

Setting row height of a table view

Next select the Table View Cell and set the Style to Custom to remove the default controls:

Setting cell type to custom

Drag a UIImageView, UITextField, and UILabel into the cell so it looks roughly like the following:

Adding controls to a UITableViewCell

Note we are adding a UITextField instead of a UILabel for the document name so that later we can implement renaming the document.

Speaking of the UITextField – select it and go to the Attributes Inspector. Set the Border Style to None, Clear Button to Appears while editing, the Font to System 17.0, Capitalization to Words, and Correction to No:

Modifying UITextField settings

To nicely support landscape orientation, set the autosizing attributes of the text field and label as follows:

Setting autosizing attributes

To fill in our controls, we need a way to access them via code. We could get them from the cell by tag, but to keep our code cleaner and easier to read we will make a custom UITableView sublass instead.

Create a new file with the iOS\Cocoa Touch\Objective-C class template, name it PTKEntryCell, and make it a subclass of UITableViewCell. Then replace the contents of PTKEntryCell.h with the following:

#import <UIKit/UIKit.h>
 
@interface PTKEntryCell : UITableViewCell
 
@property (weak, nonatomic) IBOutlet UIImageView * photoImageView;
@property (weak, nonatomic) IBOutlet UITextField * titleTextField;
@property (weak, nonatomic) IBOutlet UILabel * subtitleLabel;
 
@end

Here we make outlets for the controls in our cell, and also add a delegate that we can notify when the user edits the text.

While you’re at it switch to PTKEntryCell.m and replace the contents with the following:

#import "PTKEntryCell.h"
 
@implementation PTKEntryCell
@synthesize photoImageView;
@synthesize titleTextField;
@synthesize subtitleLabel;
 
- (void)setEditing:(BOOL)editing animated:(BOOL)animated
{
    [super setEditing:editing animated:animated];
 
    [UIView animateWithDuration:0.1 animations:^{
        if(editing){
            titleTextField.enabled = YES;
            titleTextField.borderStyle = UITextBorderStyleRoundedRect;
        }else{
            titleTextField.enabled = NO;
            titleTextField.borderStyle = UITextBorderStyleNone;
        }
    }];
 
}
 
@end

Here we implement setEditing in order to switch our text field into editing mode or not based on whether the cell is in editing mode.

Now let’s configure our table view to use this custom cell class in the Storyboard editor. Open MainStoryboard.storyboard, select the cell, and in the identity inspector set it to PTKEntryCell:

Setting the cell class in the Identity Inspector

Now control-drag from the cell to the image view, text field, and label controls, connecting them to the photoImageView, titleTextField, and subtitleLabel outlets.

Let’s set up the master view controller to use this cell now. Open PTKMasterViewController.h and mark the class as implementing UITextFieldDelegate:

#import <UIKit/UIKit.h>
 
@interface PTKMasterViewController : UITableViewController <UITextFieldDelegate>
 
@end

Then switch to PTKMasterViewController.m and import PTKEntryCell.h at the top of the file:

#import "PTKEntryCell.h"

And replace tableView:cellForRowAtIndexPath: with the following:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    PTKEntryCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"];
 
    PTKEntry *entry = [_objects objectAtIndex:indexPath.row];
 
    cell.titleTextField.text = entry.description;
    cell.titleTextField.delegate = self;
    if (entry.metadata && entry.metadata.thumbnail) {
        cell.photoImageView.image = entry.metadata.thumbnail;
    } else {
        cell.photoImageView.image = nil;
    }
    if (entry.version) {
        cell.subtitleLabel.text = [entry.version.modificationDate mediumString];
    } else {
        cell.subtitleLabel.text = @"";
    }
 
    return cell;
}

This is similar to what we had before, except we are setting the controls in our new PTKEntryCell. We’ll implement the delegate callback later so don’t worry about it for now.

That’s it – compile and run, and now the app’s looking a bit better!

PhotoKeeper displaying custom cells

It doesn’t show photos yet (since we had no way to set photos), but that’s what we’ll do next :]

Creating the Detail View

Right now our detail view doesn’t work – it’s just the default detail view created for us by the template. Let’s fix that up.

Open MainStoryboard.storyboard and find the Detail View Controller. Delete the label that’s in the middle, and add a big old UIImageView filling up the entire view:

Adding an image view to a view controller

Open up the Assistant Editor, make sure that PTKDetailViewController.h is visible in the assistant editor, and control-drag from the image view below the @interface. Enter imageView for the name, and click Connect.

Connecting the image view to an outlet

Next switch to PTKDetailViewController.h and modify the file so it looks like the following:

#import <UIKit/UIKit.h>
 
@class PTKDocument;
@class PTKDetailViewController;
 
@protocol PTKDetailViewControllerDelegate 
- (void)detailViewControllerDidClose:(PTKDetailViewController *)detailViewController;
@end
 
@interface PTKDetailViewController : UIViewController <UIImagePickerControllerDelegate, UINavigationControllerDelegate>
 
@property (strong, nonatomic) PTKDocument * doc;
@property (weak, nonatomic) IBOutlet UIImageView *imageView;
@property (weak) id <PTKDetailViewControllerDelegate> delegate;
 
@end

Here we added a protocol/delegate which we can notify when the user is done, added a property of the (opened) PTKDocument we should display, and marked our class as implementing two protocols we’ll need to use an image picker.

Note that we are passing an opened PTKDocument as the item to display (rather than the fileURL). This was a design decision. It can take time to open a document, especially if it is large or not fully synchronized with iCloud, and it depends where you want to spend that time – before transitioning to the detail view or after. We chose before for this.

Next switch to PTKDetailViewController.m and replace it with the following. Note I am giving a lot of code at once here, but this is pretty simple stuff so it should be OK.

#import "PTKDetailViewController.h"
#import "PTKDocument.h"
#import "PTKData.h"
#import "UIImageExtras.h"
 
@interface PTKDetailViewController ()
- (void)configureView;
@end
 
@implementation PTKDetailViewController {
    UIImagePickerController * _picker;
}
 
@synthesize doc = _doc;
@synthesize imageView = _imageView;
@synthesize delegate = _delegate;
 
#pragma mark - Managing the detail item
 
- (void)setDoc:(id)newDoc
{
    if (_doc != newDoc) {
        _doc = newDoc;
 
        // Update the view.
        [self configureView];
    }
}
 
- (void)configureView
{
    // Update the user interface for the detail item.
    self.title = [self.doc description];
    if (self.doc.photo) {
        self.imageView.image = self.doc.photo;
    } else {
        self.imageView.image = [UIImage imageNamed:@"defaultImage.png"];
    }
}
 
- (void)viewDidLoad
{
    [super viewDidLoad];
	// Do any additional setup after loading the view, typically from a nib.
 
    UIBarButtonItem *backButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(doneTapped:)];
    self.navigationItem.leftBarButtonItem = backButtonItem;
    [self.navigationItem setHidesBackButton:YES animated:YES];
 
    UITapGestureRecognizer * tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(imageTapped:)];
    [self.view addGestureRecognizer:tapRecognizer];
 
    [self configureView];
}
 
- (void)viewDidUnload
{
    [self setImageView:nil];
    [super viewDidUnload];
}
 
- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation
{
    return (interfaceOrientation != UIInterfaceOrientationPortraitUpsideDown);
}
 
#pragma mark Callbacks
 
- (void)imageTapped:(UITapGestureRecognizer *)recognizer {
    if (!_picker) {
        _picker = [[UIImagePickerController alloc] init];
        _picker.delegate = self;
        _picker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
        _picker.allowsEditing = NO;
    }
 
    [self presentModalViewController:_picker animated:YES];
}
 
- (void)doneTapped:(id)sender {
 
    NSLog(@"Closing %@...", self.doc.fileURL);
 
    [self.doc saveToURL:self.doc.fileURL forSaveOperation:UIDocumentSaveForOverwriting completionHandler:^(BOOL success) {
        [self.doc closeWithCompletionHandler:^(BOOL success) {
            dispatch_async(dispatch_get_main_queue(), ^{                        
                if (!success) {
                    NSLog(@"Failed to close %@", self.doc.fileURL);
                    // Continue anyway...
                }
 
                [self.delegate detailViewControllerDidClose:self];
            });
        }];
    }];
 
}
 
#pragma mark UIImagePickeerControllerDelegate
 
- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker {
 
    [self dismissViewControllerAnimated:YES completion:nil];
 
}
 
- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info {    
 
    UIImage *image = (UIImage *) [info objectForKey:UIImagePickerControllerOriginalImage];
 
    CGSize mainSize = self.imageView.bounds.size;
    UIImage *sImage = [image imageByBestFitForSize:mainSize]; //[image scaleToFitSize:mainSize];
 
    self.doc.photo = sImage;
    self.imageView.image = sImage;
 
    [self dismissViewControllerAnimated:YES completion:nil];
 
}
 
@end

Read through this and make sure you understand what’s going on. There are just a few things to point out here:

  • When the user changes the image, we change it in the document immediately via the setter we wrote earlier. Since the setter uses the undo manager, UIDocument knows about the change so will auto-save the document at some point in the future.
  • When the done button is tapped, we close the document. This triggers a save if it hasn’t been auto-saved already. We notify the delegate when we’re done so they can dismiss us.

Almost done – just need to make the master view controller use this. First, right now the template has made is so when a row is tapped a segue is automatically performed (bypassing tableView:didSelectRowAtIndexPath). We first need to modify this so tableView:didSelectRowAtIndexPath is called first, so we have a chance to open the document before performing the segue.

To fix this, open MainStoryboard.storyboard and delete the segue between the master view controller and the detail view controller. Then control-drag from the master view controller (NOT the table view cell) to the detail view controller, and choose Push from the segue popup. Then select the segue and name it showDetail:

Fixing the Segue

Next open PTKMasterViewController.h and mark the class as implementing the detail view controller delegate:

#import <UIKit/UIKit.h>
#import "PTKEntryCell.h"
#import "PTKDetailViewController.h"
 
@interface PTKMasterViewController : UITableViewController <UITextFieldDelegate, PTKDetailViewControllerDelegate>
 
@end

Then switch to PTKMasterViewController.m and make the following changes:

// 1) Add right above the "View lifecycle" section with awakeFromNib
#pragma mark PTKDetailViewControllerDelegate
 
- (void)detailViewControllerDidClose:(PTKDetailViewController *)detailViewController {
    [self.navigationController popViewControllerAnimated:YES];
    NSFileVersion * version = [NSFileVersion currentVersionOfItemAtURL:detailViewController.doc.fileURL];
    [self addOrUpdateEntryWithURL:detailViewController.doc.fileURL metadata:detailViewController.doc.metadata state:detailViewController.doc.documentState version:version];
}
 
// 2) Add right above prepareForSegue
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
 
    PTKEntry * entry = [_objects objectAtIndex:indexPath.row];
    [self.tableView deselectRowAtIndexPath:indexPath animated:YES];
 
    _selDocument = [[PTKDocument alloc] initWithFileURL:entry.fileURL];    
    [_selDocument openWithCompletionHandler:^(BOOL success) {
        dispatch_async(dispatch_get_main_queue(), ^{
            [self performSegueWithIdentifier:@"showDetail" sender:self];
        });
    }];            
 
}
 
// 3) Replace prepareForSegue with the following
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    if ([[segue identifier] isEqualToString:@"showDetail"]) {
        [[segue destinationViewController] setDelegate:self];
        [[segue destinationViewController] setDoc:_selDocument];
    } 
}

Let’s go through these methods one by one:

  1. When the detail view is done, we pop the view controller and refresh our table view entry.
  2. When a row is selected, we open the document and perform the segue once it’s loaded.
  3. When a segue is performed, we pass the document to be edited to the detail view, and also set ourselves as its delegate.

If you’re still rusty with the concept of segues, you might want to check out our storyboard tutorial.

That’s it! Compile and run, and now you can set photos on your documents and they will persist even if you shut down/restart the app!

Setting the photos in PhotoKeeper

Deleting Documents

Hey, we’re getting pretty close to a functional app here! But right now although we can create files, we can’t delete flies. Let’s add that real quick.

Make the following changes to PTKMasterViewController.m:

// Add to end of "Entry management methods" section
- (void)removeEntryWithURL:(NSURL *)fileURL {
    int index = [self indexOfEntryWithFileURL:fileURL];
    [_objects removeObjectAtIndex:index];
    [self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:index inSection:0]] withRowAnimation:UITableViewRowAnimationLeft];
}
 
// Add at end of "File management methods" section
- (void)deleteEntry:(PTKEntry *)entry {
 
    // Simple delete to start
    NSFileManager* fileManager = [[NSFileManager alloc] init];
    [fileManager removeItemAtURL:entry.fileURL error:nil];
 
    // Fixup view
    [self removeEntryWithURL:entry.fileURL];
 
}
 
// Replace tableView:commitEditingStyle:forRowAtIndexPath
- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath
{
    if (editingStyle == UITableViewCellEditingStyleDelete) {                
        PTKEntry * entry = [_objects objectAtIndex:indexPath.row];
        [self deleteEntry:entry];        
    } 
}

The only part worth mentioning here is the delete itself is performed by NSFileManager’s removeItemAtURL method. This will cause problems with iCloud as-is but will work fine for local docs, so we’ll keep it this way for now.

Compile and run, and now you can swipe rows to permanently delete them! Be sure to shut down and restart the app to verify they are gone for good.

Renaming Documents

Next we need to add a way for users to rename their files rather than always having “Photo”, “Photo 1″, etc.

First let’s add a method to rename an entry to a given filename. Add this method to PTKMasterViewController.m to the “Entry management methods” section, right below addOrUpdateEntryWithURL:metadata:state:version:

- (BOOL)renameEntry:(PTKEntry *)entry to:(NSString *)filename {
 
    // Bail if not actually renaming
    if ([entry.description isEqualToString:filename]) {
        return YES;
    }
 
    // Check if can rename file
    NSString * newDocFilename = [NSString stringWithFormat:@"%@.%@",
                                 filename, PTK_EXTENSION];
    if ([self docNameExistsInObjects:newDocFilename]) {
        NSString * message = [NSString stringWithFormat:@"\"%@\" is already taken.  Please choose a different name.", filename];
        UIAlertView * alertView = [[UIAlertView alloc] initWithTitle:nil message:message delegate:nil cancelButtonTitle:nil otherButtonTitles:@"OK", nil];
        [alertView show];
        return NO;
    }
 
    NSURL * newDocURL = [self getDocURL:newDocFilename];
    NSLog(@"Moving %@ to %@", entry.fileURL, newDocURL);
 
    // Simple renaming to start
    NSFileManager* fileManager = [[NSFileManager alloc] init];
    NSError * error;
    BOOL success = [fileManager moveItemAtURL:entry.fileURL toURL:newDocURL error:&error];
    if (!success) {
        NSLog(@"Failed to move file: %@", error.localizedDescription);
        return NO;
    }    
 
    // Fix up entry
    entry.fileURL = newDocURL;
    int index = [self indexOfEntryWithFileURL:entry.fileURL];
    [self.tableView reloadRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:index inSection:0]] withRowAnimation:UITableViewRowAnimationNone];
 
    return YES;
 
}

This checks if the name is available (and warns the user if not), and otherwise moves the file using NSFileManager’s moveItemAtURL API. This particular API has problems when it comes to iCloud (which we’ll discuss later) but works quite fine with local files so we’ll keep it that way.

Now let’s call this method when the user renames a file by modify the text field of a cell while in edit mode. Remember how we added a delegate to the UITableViewCell to notify us when the user changes the text for the filename, but never implemented it? Now’s the time!

Add the following code right above the “View lifecycle” section with awakeFromNib:

#pragma mark Text Views
 
- (void)textChanged:(UITextField *)textField {
    UIView * view = textField.superview;
    while( ![view isKindOfClass: [PTKEntryCell class]]){
        view = view.superview;
    }
    PTKEntryCell *cell = (PTKEntryCell *) view;
    NSIndexPath * indexPath = [self.tableView indexPathForCell:cell];
    PTKEntry * entry = [_objects objectAtIndex:indexPath.row];
    NSLog(@"Want to rename %@ to %@", entry.description, textField.text);
    [self renameEntry:entry to:textField.text];
}
 
- (BOOL)textFieldShouldReturn:(UITextField *)textField {
	[textField resignFirstResponder];
    [self textChanged:textField];
	return YES;
}

Pretty simple, eh? Compile and run, and now you can rename files!

Bonus: Scrolling Fix

If you have a lot of photos, you might notice that if you tap on a text field toward the bottom of the table view, the keybaord overlaps it so you can’t see what you’re editing. That’s kind of annoying, eh?

Here’s a quick fix courtesy of ZeLogolas on StackOverflow. Make the following changes to PTKMasterViewController.m:

// Add private instance variable
UITextField * _activeTextField;
 
// Add inside "Text Views" section, above textChanged
-(void) keyboardWillShow:(NSNotification *)note
{
    // Get the keyboard size
    CGRect keyboardBounds;
    [[note.userInfo valueForKey:UIKeyboardFrameBeginUserInfoKey] getValue: &keyboardBounds];
 
    // Detect orientation
    UIInterfaceOrientation orientation = [[UIApplication sharedApplication] statusBarOrientation];
    CGRect frame = self.tableView.frame;
 
    // Start animation
    [UIView beginAnimations:nil context:NULL];
    [UIView setAnimationBeginsFromCurrentState:YES];
    [UIView setAnimationDuration:0.3f];
 
    // Reduce size of the Table view 
    if (orientation == UIInterfaceOrientationPortrait || orientation == UIInterfaceOrientationPortraitUpsideDown)
        frame.size.height -= keyboardBounds.size.height;
    else 
        frame.size.height -= keyboardBounds.size.width;
 
    // Apply new size of table view
    self.tableView.frame = frame;
 
    // Scroll the table view to see the TextField just above the keyboard
    if (_activeTextField)
    {
        CGRect textFieldRect = [self.tableView convertRect:_activeTextField.superview.bounds fromView:_activeTextField.superview];
        [self.tableView scrollRectToVisible:textFieldRect animated:NO];
    }
 
    [UIView commitAnimations];
}
 
-(void) keyboardWillHide:(NSNotification *)note
{
    // Get the keyboard size
    CGRect keyboardBounds;
    [[note.userInfo valueForKey:UIKeyboardFrameBeginUserInfoKey] getValue: &keyboardBounds];
 
    // Detect orientation
    UIInterfaceOrientation orientation = [[UIApplication sharedApplication] statusBarOrientation];
    CGRect frame = self.tableView.frame;
 
    [UIView beginAnimations:nil context:NULL];
    [UIView setAnimationBeginsFromCurrentState:YES];
    [UIView setAnimationDuration:0.3f];
 
    // Reduce size of the Table view 
    if (orientation == UIInterfaceOrientationPortrait || orientation == UIInterfaceOrientationPortraitUpsideDown)
        frame.size.height += keyboardBounds.size.height;
    else 
        frame.size.height += keyboardBounds.size.width;
 
    // Apply new size of table view
    self.tableView.frame = frame;
 
    [UIView commitAnimations];
}
 
- (IBAction)textFieldDidBeginEditing:(UITextField *)textField
{
    _activeTextField = textField;
}
 
- (IBAction)textFieldDidEndEditing:(UITextField *)textField
{
    _activeTextField = nil;
}
 
// Add at bottom of viewDidLoad
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillShow:) name:UIKeyboardWillShowNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillHide:) name:UIKeyboardWillHideNotification object:nil];
 
// Add at bottom of viewDidUnload
[[NSNotificationCenter defaultCenter] removeObserver:self];

Compile and run, and it will now auto-scroll if you select an item at the bottom!

Where To Go From Here?

Here is a download with the state of the code sample so far.

At this point, you have a fully-functional UIDocument-based app that stores documents locally. You have now laid the groundwork that will make adding iCloud support much easier!

In the next part of the series, we’ll finally get to iCloud itself! We’ll get the basics working, and then in the final part of the series, we’ll cover more advanced aspects like moving files to/from iCloud, handling conflicts, and more.

If you have any questions, comments, or suggestions about better ways to do things, please join the forum discussion below! :]


This is a blog post by site administrator Ray Wenderlich, an independent software developer and gamer.

Ray Wenderlich

Ray is an indie software developer currently focusing on iPhone and iPad development, and the administrator of this site. He’s the founder of a small iPhone development studio called Razeware, and is passionate both about making apps and teaching others the techniques to make them.

When Ray’s not programming, he’s probably playing video games, role playing games, or board games.

User Comments

16 Comments

[ 1 , 2 ]
  • It is a very good tutorial so far. I am reaching the end of part two.

    But it seems like there is no PhotoKeeper2.zip downloadable.
    2 x PhotoKeeper1.zip instead.

    Probably a typo :)

    Bye.
    zaxonus
[ 1 , 2 ]

Other Items of Interest

Ray's Monthly Newsletter

Sign up to receive a monthly newsletter with my favorite dev links, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

Hang Out With Us!

Every month, we have a free live Tech Talk - come hang out with us!


Coming up in May: Procedural Level Generation in Games with Kim Pedersen.

Sign Up - May

Coming up in June: WWDC Keynote - Podcasters React! with the podcasting team.

Sign Up - June

Vote For Our Next Book!

Help us choose the topic for our next book we write! (Choose up to three topics.)

    Loading ... Loading ...

Our Books

Our Team

Tutorial Team

... 55 total!

Editorial Team

... 21 total!

Code Team

  • Orta Therox

... 1 total!

Translation Team

  • Cosmin Pupaza

... 38 total!

Subject Matter Experts

  • Richard Casey

... 4 total!