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

This is a blog post by site administrator Ray Wenderlich, an independent software developer and gamer. Welcome back to the final part of 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 and […] By Ray Wenderlich.

Leave a rating/review
Save for later
Share
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 the final part of 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 and second parts of the series, we made a fully functional UIDocument-based app that works with local files with full CRUD support.

In this third part of the series, we got iCloud mostly working. All CRUD operations are functional, and you can even rename and delete files.

That brings us to this fourth and final part of the series! Here we’ll show you how you can resolve conflicts, and move/copy files to/from iCloud.

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 wrap this project up!

iCloud and Conflicts: Overview

Open your project and open the same document on two different devices. Change the picture on both at the same time, tap Done, and see what happens.

Basically iCloud will automatically choose the “lastest change” as the current document by default. But since this is dependent on network connections, this might not be what the user actually wants.

The good news is when the same document is modified at the same time like this, iCloud will detect this as a conflict and store copies of any conflicting documents. It’s up to you how you deal with the conflict – you could try to merge changes or let the user pick one (or more) of the versions to keep.

In this tutorial, when there’s a conflict we’re going to display a view controller to the user that shows the conflicts and allows the user to pick one of them to keep. You might want to use a slightly different strategy for your app, but the same core idea remains.

Displaying Conflicts

The first thing we need to do is display and detect conflicts. UIDocument has a property called documentState that is a bitfield of different flags that can be on a document, including a “conflict” state.

We’ve actually been passing this along in our PTKEntry all along, now’s our chance to actually use it!

Let’s start by just logging out what the document state is when we read documents. Make the following changes to PTKMasterViewController.m:

// Add to top of "Helpers" section
- (NSString *)stringForState:(UIDocumentState)state {
    NSMutableArray * states = [NSMutableArray array];
    if (state == 0) {
        [states addObject:@"Normal"];
    }
    if (state & UIDocumentStateClosed) {
        [states addObject:@"Closed"];
    }
    if (state & UIDocumentStateInConflict) {
        [states addObject:@"In Conflict"];
    }
    if (state & UIDocumentStateSavingError) {
        [states addObject:@"Saving error"];
    }
    if (state & UIDocumentStateEditingDisabled) {
        [states addObject:@"Editing disabled"];
    }
    return [states componentsJoinedByString:@", "];
}

// Replace "Loaded File URL" log line in loadDocURL with the following
NSLog(@"Loaded File URL: %@, State: %@, Last Modified: %@", [doc.fileURL lastPathComponent], [self stringForState:state], version.modificationDate.mediumString);

Compile and run your app, and if you have any documents in the conflict state you should see something like this:

Loaded File URL: Photo 4.ptk, State: In Conflict, Last Modified: Today 10:01:17 AM

Let’s modify our app to have a visual representation of when a document is in the conflict state. Open MainStoryboard.storyboard and add a 32×32 image view to the bottom left of the thumbnail image view, and set the image to warning.png:

Adding warning image to table view cell

Then switch to PTKEntryCell.h and add a property for this:

@property (weak, nonatomic) IBOutlet UIImageView * warningImageView;

And synthesize it in PTKEntryCell.m:

@synthesize warningImageView;

Switch back to MainStoryboard.storyboard, control-drag from the cell to the warning image view, and connect it to the warningImageView outlet.

Finally, open PTKMasterViewController.m and add this to the end of tableView:cellForRowAtIndexPath (before the call to return cell):

if (entry.state & UIDocumentStateInConflict) {
    cell.warningImageView.hidden = NO;
} else {
    cell.warningImageView.hidden = YES;
}

Compile and run, and now if there is a conflict you should see a warning flag next to the document in question!

Seeing when a UIDocument is in conflict in the table view

Creating a Conflict View Controller

Now that we know what documents are in conflict, we have to do something about it. We’ll create a view controller that lists all the versions that are available and let the user choose one to keep.

Create a new file with the iOS\Cocoa Touch\Objective-C class template, name it PTKConflictViewController, and make it a subclass of UITableViewController. Make sure the checkboxes are NOT checked, and finish creating the file.

We’ll add the code for this later, first let’s design the UI. Open MainStoryboard.storyboard, and drag a new UITableViewController into the storyboard below the detail view controller.

Adding a new table view controller to the Storyboard editor

We want the master view controller to display it, so control-drag from the master view controller to the new table view controller, and choose Push from the popup. Then click the segue and name it “showConflicts”:

Adding a new segue to display the conflicts view controller

Next zoom in to 100% size and let’s set up the table view cell. Set the row height to 80, and add an image view and two labels roughly like the following:

Laying out a table view cell for conflicts

We could create a custom cell sublass like we did before for the master table view, but I’m feeling lazy so we’ll look up the subviews by tag instead. So set the tag of the image view to 1, the first label to 2, and the second label to 3.

Setting the tag on a view in the Storyboard editor

To finish configuring the cell we need to set a cell reuse identifier for our cell so we can dequeue it. Select the Table View Cell and set the Identifeir to MyCell.

Setting the conflict cell's identifier

Finally, we need to set the class of our view controller. Select the table View Controller and in the identity inspector set the cass to PTKConflictViewController.

Setting the view controller class in the Identity inspector

Before we implement the PTKConflictViewController, we should create a class to keep track of everything we need to know for each cell (similar to PTKEntry).

For a conflicted file, you can get a list of all of the different versions of the file that are in conflict. These versions are referenced via the NSFileVersion class.

So we need to keep track of the NSFileVersion for each row, open that version and get its metadata as well (so we can display the thumbnail to the user).

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

#import <Foundation/Foundation.h>

@class PTKMetadata;

@interface PTKConflictEntry : NSObject

@property (strong) NSFileVersion * version;
@property (strong) PTKMetadata * metadata;

- (id)initWithFileVersion:(NSFileVersion *)version metadata:(PTKMetadata *)metadata;

@end

And replace PTKConflictEntry.m with the following:

#import "PTKConflictEntry.h"

@implementation PTKConflictEntry
@synthesize version = _version;
@synthesize metadata = _metadata;

- (id)initWithFileVersion:(NSFileVersion *)version metadata:(PTKMetadata *)metadata {
    if ((self = [super init])) {
        self.version = version;
        self.metadata = metadata;
    }
    return self;
}

@end

Finally onwards to implementing PTKConflictViewController! When displaying the view controller, we’ll pass in the fileURL to display conflicts for, so modify PTKConflictViewController.h to the following:

#import <UIKit/UIKit.h>

@interface PTKConflictViewController : UITableViewController

@property (strong) NSURL * fileURL;

@end

Then make the following changes to PTKConflictViewController.m:

// Add imports to the top of the file
#import "PTKConflictEntry.h"
#import "PTKDocument.h"
#import "PTKMetadata.h"
#import "NSDate+FormattedStrings.h"

// Modify @implementation to add a private instance variable for the entries and synthesize fileURL
@implementation PTKConflictViewController {
    NSMutableArray * _entries;
}
@synthesize fileURL;

// Add to end of viewDidLoad
_entries = [NSMutableArray array];

// Replace numberOfSectionsInTableView, numberOfRowsInSection, and cellForRowAtIndexPath with the following
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
    return 1;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    NSLog(@"%d rows", _entries.count);
    return _entries.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString *CellIdentifier = @"MyCell";
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    
    // Configure the cell...
    UIImageView * imageView = (UIImageView *) [cell viewWithTag:1];
    UILabel * titleLabel = (UILabel *) [cell viewWithTag:2];
    UILabel * subtitleLabel = (UILabel *) [cell viewWithTag:3];
    PTKConflictEntry * entry = [_entries objectAtIndex:indexPath.row];
    
    if (entry.metadata) {
        imageView.image = entry.metadata.thumbnail;
    }
    titleLabel.text = [NSString stringWithFormat:@"Modified on %@", entry.version.localizedNameOfSavingComputer];
    subtitleLabel.text = [entry.version.modificationDate mediumString];
    
    return cell;
}

OK so that’s pretty straightforward – we’re just displaying any PTKConflictEntry classes in the _entries array.

But what about creating these PTKConflictEntry classes in the first place from the fileURL? We’ll do that in viewWillAppear. Add this right after viewDidUnload:

- (void)viewWillAppear:(BOOL)animated {
    
    [_entries removeAllObjects];    
    NSMutableArray * fileVersions = [NSMutableArray array];
    
    NSFileVersion * currentVersion = [NSFileVersion currentVersionOfItemAtURL:self.fileURL];
    [fileVersions addObject:currentVersion];
    
    NSArray * otherVersions = [NSFileVersion otherVersionsOfItemAtURL:self.fileURL];
    [fileVersions addObjectsFromArray:otherVersions];
    
    for (NSFileVersion * fileVersion in fileVersions) {
        
        // Create a resolve entry and add to entries
        PTKConflictEntry * entry = [[PTKConflictEntry alloc] initWithFileVersion:fileVersion metadata:nil];
        [_entries addObject:entry];
        NSIndexPath * indexPath = [NSIndexPath indexPathForRow:_entries.count-1 inSection:0];
        
        // Open doc and get metadata - when done, reload row so we can get thumbnail
        PTKDocument * doc = [[PTKDocument alloc] initWithFileURL:fileVersion.URL];
        NSLog(@"Opening URL: %@", fileVersion.URL);
        [doc openWithCompletionHandler:^(BOOL success) {
            if (success) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    entry.metadata = doc.metadata;
                    [self.tableView reloadRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationNone];
                });
                [doc closeWithCompletionHandler:nil];
            }
        }];
    }
    
    [self.tableView reloadData];
}

To get the different versions of the file that are in conflict, you use two different APIs in NSFileVersion – currentVersionOfItemAtURL (the current “winning” version), and otherVersionsOFItemAtURL (the other ones that iCloud isn’t sure about). We open up all of these versions and add them to our array, along with their metadata (thumbnail).

Finally, when the user taps a row we want to choose that file version and resolve the conflict. So replace didSelectRowAtIndexPath at the end of the file with this:

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    
    PTKConflictEntry * entry = [_entries objectAtIndex:indexPath.row];
    
    if (![entry.version isEqual:[NSFileVersion currentVersionOfItemAtURL:self.fileURL]]) {
        [entry.version replaceItemAtURL:self.fileURL options:0 error:nil];    
    }
    [NSFileVersion removeOtherVersionsOfItemAtURL:self.fileURL error:nil];
    NSArray* conflictVersions = [NSFileVersion unresolvedConflictVersionsOfItemAtURL:self.fileURL];
    for (NSFileVersion* fileVersion in conflictVersions) {
        fileVersion.resolved = YES;
    }
    
    [self.navigationController popViewControllerAnimated:YES];    
    
}

If the user chose one of the “other” versions of the file, we replace the current version of the file with what they chose.

We then remove all the other versions, and mark everything as resolved. Not too hard, eh?

Almost ready to try this out! Just make the following changes to PTKMasterViewController.m:

// Add new private variable
NSURL * _selURL;

// Modify didSelectRowAtIndexPath to check documenet state first
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    
    PTKEntry * entry = [_objects objectAtIndex:indexPath.row];
    [self.tableView deselectRowAtIndexPath:indexPath animated:YES];
    
    if (entry.state & UIDocumentStateInConflict) {
        
        _selURL = entry.fileURL;
        [self performSegueWithIdentifier:@"showConflicts" sender:self];
        
    } else {
        
        _selDocument = [[PTKDocument alloc] initWithFileURL:entry.fileURL];    
        [_selDocument openWithCompletionHandler:^(BOOL success) {
            NSLog(@"Selected doc with state: %@", [self stringForState:_selDocument.documentState]);
            
            dispatch_async(dispatch_get_main_queue(), ^{
                [self performSegueWithIdentifier:@"showDetail" sender:self];
            });
        }];
        
    }
} 

// Modify prepareForSegue to add a case for the showConflicts segue
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    if ([[segue identifier] isEqualToString:@"showDetail"]) {
        [[segue destinationViewController] setDelegate:self];
        [[segue destinationViewController] setDoc:_selDocument];
    } else if ([[segue identifier] isEqualToString:@"showConflicts"]) {
        [[segue destinationViewController] setFileURL:_selURL];
    }
}

And that’s it! Compile and run, and now when you tap an entry with a conflict you will now see the list of versions in conflict:

The conflict view controller in PhotoKeeper

And you can tap a file to restore to that verison and resolve the conflict:

A resolved conflict in PhotoKeeper

Moving Files To iCloud

At this point our app is almost done, except for one more bit – dealing with the user transitioning between iCloud off and iCloud on, and vice versa. There are two cases:

  • local => iCloud. If the user isn’t using iCloud (and has local files) and starts using iCloud, the app should automatically move all the local files to iCloud (renaming files if necesary to avoid name conflicts).
  • iCloud => local. If the user is using iCloud and wants to turn iCloud off and use local files, we should ask the user what he wants to do. The options are “copy the iCloud files to local”, “keep files on iCloud”, or “switch back to using iCloud.”

Let’s start with the first case. We already have the stub for localToiCloud called in the proper situation (we covered this in part 3). So make the following changes to PTKMasterViewController.m:

// Add a new private instance variable
BOOL _moveLocalToiCloud;

// Replace localToiCloud with the following (and add a new method)
- (void)localToiCloudImpl {
    // TODO
}

- (void)localToiCloud {
    NSLog(@"local => iCloud");
    
    // If we have a valid list of iCloud files, proceed
    if (_iCloudURLsReady) {
        [self localToiCloudImpl];
    } 
    // Have to wait for list of iCloud files to refresh
    else {
        _moveLocalToiCloud = YES;         
    }
}

// Add to end of processiCloudFiles, right before enableUpdates
if (_moveLocalToiCloud) {            
    _moveLocalToiCloud = NO;
    [self localToiCloudImpl];            
} 

OK, what’s going on here? Well, to move the files to iCloud we need to make sure there are no name conflicts. But we can’t know what files are in iCloud until the list of files returns from our NSMetadataQuery. So if the list of iCloud URLs isn’t ready, we set a flag to YES. When the metadata query results come in, we check this flag and call localToiCloudImpl (which will do the actual work) now that we have a valid list of filenames.

Next we need to make a slight tweak to getDocFilename so it can search in the list of iCloudURLs (instead of the list of PTKEntries). Make the following changes:

// Add right above getDocFilename:uniqueInObjects
- (BOOL)docNameExistsIniCloudURLs:(NSString *)docName {
    BOOL nameExists = NO;
    for (NSURL * fileURL in _iCloudURLs) {
        if ([[fileURL lastPathComponent] isEqualToString:docName]) {
            nameExists = YES;
            break;
        }
    }
    return nameExists;
}

// Replace TODO in docFilename:uniqueInObjects with this
nameExists = [self docNameExistsIniCloudURLs:newDocName];

So by passing in NO to docNameExists:uniqueInObjects we can have it make sure the name is unique in the iCloud URLs (even if iCloud if off).

Next replace localToiCloudImpl with the following:

- (void)localToiCloudImpl {
    
    NSLog(@"local => iCloud impl");
    
    NSArray * localDocuments = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:self.localRoot includingPropertiesForKeys:nil options:0 error:nil];
    for (int i=0; i < localDocuments.count; i++) {
        
        NSURL * fileURL = [localDocuments objectAtIndex:i];
        if ([[fileURL pathExtension] isEqualToString:PTK_EXTENSION]) {
            
            NSString * fileName = [[fileURL lastPathComponent] stringByDeletingPathExtension];
            NSURL *destURL = [self getDocURL:[self getDocFilename:fileName uniqueInObjects:NO]];
            
            // Perform actual move in background thread
            dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
                NSError * error;
                BOOL success = [[NSFileManager defaultManager] setUbiquitous:self.iCloudOn itemAtURL:fileURL destinationURL:destURL error:&error];
                if (success) {
                    NSLog(@"Moved %@ to %@", fileURL, destURL);
                    [self loadDocAtURL:destURL];
                } else {
                    NSLog(@"Failed to move %@ to %@: %@", fileURL, destURL, error.localizedDescription); 
                }
            });
            
        }
    }
    
}

This is the meat of the move logic. We loop through all of our local files, and get a unique filename for the local filename in iCloud. We then move the document to iCloud, which you can do by calling the setUbiquitous:itemAtURL:destinationItem method.

Compile and run the app, and make sure iCloud is off. Create a local document, and turn iCloud back on. Your local document should move to iCloud!

A local document moved to iCloud in PhotoKeeper

Copying Files From iCloud

Now let's try the other way around. Make the following changes to PTKMasterViewController.m:

// Add new instance variable
BOOL _copyiCloudToLocal;

// Replace iCloudToLocal and add a stub
- (void)iCloudToLocalImpl {
    
    NSLog(@"iCloud => local impl");
    // TODO
}

- (void)iCloudToLocal {
    NSLog(@"iCloud => local");
    
    // Wait to find out what user wants first
    UIAlertView * alertView = [[UIAlertView alloc] initWithTitle:@"You're Not Using iCloud" message:@"What would you like to do with the documents currently on this iPad?" delegate:self cancelButtonTitle:@"Continue Using iCloud" otherButtonTitles:@"Keep a Local Copy", @"Keep on iCloud Only", nil];
    alertView.tag = 2;
    [alertView show];
    
}

// Add second case in alertView:didDismissWithButtonIndex
// @"What would you like to do with the documents currently on this iPad?" 
// Cancel: @"Continue Using iCloud" 
// Other 1: @"Keep a Local Copy"
// Other 2: @"Keep on iCloud Only"
else if (alertView.tag == 2) {
    
    if (buttonIndex == alertView.cancelButtonIndex) {
        
        [self setiCloudOn:YES];
        [self refresh];
        
    } else if (buttonIndex == alertView.firstOtherButtonIndex) {
        
        if (_iCloudURLsReady) {
            [self iCloudToLocalImpl];
        } else {
            _copyiCloudToLocal = YES;
        }
        
    } else if (buttonIndex == alertView.firstOtherButtonIndex + 1) {            
        
        // Do nothing
        
    } 
    
}

// Add to end of processiCloudFiles, right before call to enableUpdates
else if (_copyiCloudToLocal) {
    _copyiCloudToLocal = NO;
    [self iCloudToLocalImpl];
}

This follows a similar strategy to what we did before, except we give the user a chance to choose what to do first.

Next replace iCloudToLocalImpl with the following:

- (void)iCloudToLocalImpl {
    
    NSLog(@"iCloud => local impl");
    
    for (NSURL * fileURL in _iCloudURLs) {
    
        NSString * fileName = [[fileURL lastPathComponent] stringByDeletingPathExtension];
        NSURL *destURL = [self getDocURL:[self getDocFilename:fileName uniqueInObjects:YES]];
        
        // Perform copy on background thread
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {            
            NSFileCoordinator* fileCoordinator = [[NSFileCoordinator alloc] initWithFilePresenter:nil];
            [fileCoordinator coordinateReadingItemAtURL:fileURL options:NSFileCoordinatorReadingWithoutChanges error:nil byAccessor:^(NSURL *newURL) {
                NSFileManager * fileManager = [[NSFileManager alloc] init];
                NSError * error;
                BOOL success = [fileManager copyItemAtURL:fileURL toURL:destURL error:&error];                     
                if (success) {
                    NSLog(@"Copied %@ to %@ (%d)", fileURL, destURL, self.iCloudOn);
                    [self loadDocAtURL:destURL];
                } else {
                    NSLog(@"Failed to copy %@ to %@: %@", fileURL, destURL, error.localizedDescription); 
                }
            }];
        });
    }
    
}

Here we use NSFileManager's copyItemAtURL to copy the file, making sure to wrap it in an NSFileCoordinator for safety.

Compile and run the app, and make sure iCloud is enabled. Then switch to Settings and disable iCloud, and this will appear:

Prompt when iCloud switched off

If you tap "Keep a Local Copy", you will see the items all copied locally for you! :]

Where To Go From Here?

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

Congratulations, you have made a semi-real world document-based iCloud app! It can do all the basic CRUD operations, as well as the more tricky bits like handling conflicts, handling both local and iCloud documents, and having the capability to switch documents between the two.

I don't claim to have done everything in the best possible manner in this tutorial or made no mistakes. As such, I have put this project on GitHub in case anyone wants to take it from here and add any fixes/improvements moving forward.

I hope this tutorial has been useful to anyone trying to get iCloud working with a document-based app. If you have any questions, comments, or suggestions for improvement, please join the forum discussion below! :]


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

Contributors

Over 300 content creators. Join our team.