Swift Development with Cocoa (2015)

Chapter 13. Document-Based Applications

For the user, a computer and its applications are simply ways to access and work with their documents (e.g., their spreadsheets, images, music, etc.). The designers of OS X and iOS understand this, and provide a number of tools for making apps designed around letting the user create, edit, and work with documents.

The idea of a document-based application is simple: the application can create documents, and open previously created documents. The user edits the document and saves it to disk. The document can then be stored, sent to another user, duplicated, or anything else that a file can do.

While both OS X and iOS provide technologies that allow you to make document-based applications, the way in which documents are presented to the user differs.

On OS X, as with other desktop-based OSes, users manage their documents through the Finder, which is the dedicated file management application. The entire filesystem is exposed to the user through the Finder.

On iOS, the filesystem is still there, but the user rarely sees it. Instead, all documents are presented to the user and managed by the application. All the tasks involved in managing documents—creating new files, renaming files, deleting files, copying files, and so on—must be done by your application.

NOTE

The user has some access to the filesystem through document picker view controllers, which are discussed in Document Pickers.

More than one application may be able to open a document. For example, JPEG images can be opened by both the built-in Preview application and by Photoshop for different purposes. Both OS X and iOS provide ways for applications to specify that they are able to open certain kinds of documents.

In this chapter, you’ll learn how to create apps that work with documents on both iOS and OS X.

The NSDocument and UIDocument Classes

In iOS and OS X, documents are represented in your application with the UIDocument and NSDocument classes, respectively. These classes represent the document and store its information. Every time a new document is created, a new instance of your application’s NSDocument orUIDocument subclass is created.

Document Objects in MVC

Document objects participate in the model-view-controller paradigm. In your app, document objects are model objects—they handle the reading and writing of information to disk, and provide that information to other parts of the application.

All document objects, at their core, provide two important methods: one to save the information by writing it to disk, and one to load the information by reading it from disk. The document object, therefore, is in charge of converting the document information that’s held in memory (i.e., the objects that represent the user’s data) into a data representation that can be stored on disk.

For NSDocument, the methods are these:

func dataOfType(typeName: String?,

    error outError: NSErrorPointer) -> NSData?

func readFromData(data: NSData?,

    ofType typeName: String?, error outError: NSErrorPointer) -> Bool

And for UIDocument, the methods are these:

func contentsForType(typeName: String!,

    error outError: NSErrorPointer) -> AnyObject!

func loadFromContents(contents: AnyObject!,

    ofType typeName: String!, error outError: NSErrorPointer) -> Bool

The first set of methods is responsible for producing an object that can be written to disk, such as an NSData object. The second is the opposite—given an object that represents one or more files on the disk, the document object should prepare itself for use by the application.

Kinds of Documents

OS X and iOS support three different ways of representing a document on disk:

§  Flat files, such as JPEG images and text documents, which are loaded into memory wholesale.

§  File packages, which are folders that contain multiple files, but are presented to the user as a single file. Xcode project files are file packages.

§  Databases, which are single files that are partially loaded into memory as needed.

All three of these methods are used throughout OS X and iOS, and there’s no single “correct” way to represent files. Each one has strengths and weaknesses:

§  A flat file is easy to understand from a development point of view, where you simply work with a collection of bytes in an NSData object. It is also very easy to upload to the Web and send via email. However, a flat file must be read entirely into memory, which can lead to performance issues if the file is very large.

§  File packages are a convenient way to break up a large or complex document into multiple pieces. For example, Keynote presentations are file packages that contain a single file describing the presentation’s contents (its slides, text, layout, etc.), and include all images, movies, and other resources as separate files next to the description file. This reduces the amount of data that must be kept in memory, and allows your application to treat each piece of the document as a separate part.

The downside is that other operating systems besides OS X and iOS don’t have very good support for file packages. Additionally, you can’t upload a file package to a website without first converting it to a single file (such as by zipping it).

§  Databases combine the advantages of single-file simplicity with the random-access advantage of file packages. However, making your application work with databases requires writing more complex code. Some of this is mitigated by the existence of tools and frameworks like SQLite and Core Data, but your code will still be more complex.

The current trend in OS X and iOS is toward flat files and databases, because these are easier to archive and upload to iCloud.

NOTE

In this book, we’ll be covering flat files, because they’re simpler to work with. The same overall techniques apply to file packages and databases, however, and if you want to learn more about using them, check out the Document-Based Programming Guide in the Xcode documentation.

The Role of Documents

A document object (i.e., a subclass of NSDocument or UIDocument) is both a model and a model-controller in the model-view-controller paradigm. For simpler applications, the document object is simply a model—it loads and saves data, and provides methods to let controller objects access that information.

For more complex applications, a document object may operate as a model-controller (i.e., it would be responsible for loading information from disk and creating a number of subobjects that represent different aspects of the document). For example, a drawing and painting application’s documents would include layers, color profiles, vector shapes, and so on.

Document-Based Applications on OS X

OS X was designed around document manipulation, and there is correspondingly strong support for building document-based applications in Cocoa and Xcode.

When creating a document-based application, you specify the name of the NSDocument class used by your application. You also create a nib file that contains the user interface for your document, including the window, controls, toolbars, and other views that allow the user to manipulate the contents of the document.

Both the document class and the document nib file are used by the NSDocumentController to manage the document-related features of your app:

§  When you create a new document, a new instance of your document class is created, and copies of the view objects in the document nib file are instantiated. The new document object is placed in charge of the view.

§  When the user instructs the application to save the current document, the document controller displays a dialog box that asks the user where she wants to save her work. When the user selects a location, the document controller asks the frontmost document object to store its contents in either an NSData or NSFileWrapper object (for flat files and file packages, respectively; if the document is a database, it saves its contents via its own mechanisms). The document controller then takes this returned object and writes it to disk.

§  When the application is asked to open a document, the document controller determines which class is responsible for handling the document’s contents. An instance of the document class is instantiated and asked to load its data from disk; the controls are also instantiated from the nib as previously discussed, and the user starts working on the document.

Autosaving and Versions

Starting with OS X 10.7 Lion and iOS 5, the system autosaves users’ work as they go, even if they haven’t previously saved it. This feature is built into the NSDocumentController class (and on iOS, the UIDocument class), which means that no additional work needs to be done by your application.

Autosaving occurs whenever the user switches to another application, when the user quits your application, and also periodically. From your code’s perspective, it’s the same behavior as the user manually saving the document; however, the system keeps all previous versions of the document.

The user can ask to see all the previous versions, which the system handles for you automatically. The user is then able to compare two versions of the document, and copy and paste content from past versions.

Representing Documents with NSDocument

To demonstrate how to make a document-based application in OS X, we’ll make an application that works with its own custom document format. This application will start out as a simple text editor, and we’ll move on to more sophisticated data manipulation from there.

The first thing to do is create a new Cocoa app for OS X. Name it CocoaDocuments, and make sure that Use Core Data is off.

Turn Create a Document-Based Application on, and set the document extension to sampleDocument. When you create the application, it will load and save files named along the lines of MyFile.sampleDocument.

When you create a document-based application in Xcode, the structure of the application is different from non-document-based applications. For example, Xcode assumes that the majority of your application’s work will be done in the document class, and therefore doesn’t bother to create or set up an application delegate class.

It does, however, create a Document class, which is a subclass of NSDocument. This is used as the document class for the “sampleDocument” type. By default, the Document class does nothing except indicate to the application that the interface for manipulating it should be loaded from theDocument nib file (see the windowNibName property in Document.swift), which Xcode has also already created when setting up the application.

Stubs of dataOfType(typeName:error:) and readFromData(data:ofType:error:) are also provided, although they do nothing except create an NSError object, which lets the document system gracefully display an alert box that lets the user know that there was a problem working with the data.

If you open Document.xib, you’ll find the window that contains the interface that will represent each document that the user has open. If you select the file’s owner in the outline and go to the Identity Inspector (the third button from the left at the top of the Utilities pane), you’ll note that the object that owns the file is a Document object (Figure 13-1). This means you can create actions and outlets between your Document class and the interface.

The class of the file’s owner object can be set using the Identity Inspector

Figure 13-1. The class of the file’s owner object can be set using the Identity Inspector

Saving Simple Data

The first version of this application will be a plain text editor. We’ll now modify the interface for the document to display a text field, and make the Document class save and load plain text:

1.    Open Document.xib and delete the label in the window.

By default, the interface contains a window that has a label inside it. We’ll keep the window, but lose the label.

2.    Add a wrapping text field to the window.

Open the objects library and scroll until you find “Wrapping text field.” Alternatively, search for “wrapping” at the bottom of the library.

Drag the text field into the window and resize it to make it fill the entire window.

When you’re done, the interface should look like Figure 13-2.

3.    Open the assistant and connect the text field to the class. Once the interface has been built, you need to connect the interface to the document class. Open the assistant and Document.swift should open. If it doesn’t, use the jump bar at the top of the assistant editor to select it.

Control-drag from the text field into the Document class, and create a new outlet called textField.

In addition to having a variable that connects the document class to the text field, we need a variable that contains the document’s text. This is because the document loading and interface setup take place at different times. When your readFromData(data:ofType:error:)method is called, the textField won’t yet exist, so you must store the information in memory. This is also a better design as far as model-view-controller goes, because it means that your data and your views are kept separate.

4.    Add a string property called text by adding the following code to Document:

    var text = ""

The final UI for the document window

Figure 13-2. The final UI for the document window

5.    Now we’ll update the loading and saving methods, and make them load and save the text. We’ll also update the windowControllerDidLoadNib method, which is called when the interface for this document has been loaded and is your code’s opportunity to prepare the interface with your loaded data.

Replace methods dataOfType(type:, error:), readFromData(data:, ofType:, error:), and windowControllerDidLoadNib with the following code:

override func windowControllerDidLoadNib(aController: NSWindowController) {

    // The window has loaded, and is ready to display.

    // Take the text that we loaded earlier and display

    // it in the text field

    super.windowControllerDidLoadNib(aController)

    self.textField.stringValue = self.text

}

override func dataOfType(typeName: String?,

    error outError: NSErrorPointer) -> NSData? {

    // Convert the contents of the text field into data,

    // and return it

    self.text = self.textField.stringValue

    return self.text.dataUsingEncoding(NSUTF8StringEncoding,

        allowLossyConversion: false)

}

override func readFromData(data: NSData?, ofType typeName: String?,

    error outError: NSErrorPointer) -> Bool {

    // Attempt to load a string from the data; if it works, store it

    // in self.text

    if data?.length > 0 {

        let string = NSString(data: data, encoding: NSUTF8StringEncoding)

        self.text = string

    } else {

        self.text = ""

    }

    return true

}

Now run the application and try creating, saving, and opening documents. You can also use Versions to look at previous versions of the documents you work with. If you quit the app and relaunch it, all open documents will reopen.

Saving More Complex Data

Simple text is easy to read and write, but more complex applications need more structured information. While you could write your own methods for serializing and deserializing your model objects, it’s often the case that the data you want to save is no more complex than a dictionary or an array of strings and numbers.

JavaScript Object Notation (JSON) is an ideal method for representing data like this. JSON is a simple, human-readable, lightweight way to represent arrays, dictionaries, numbers, and strings, and both OS X and iOS provide tools for converting certain useful objects into JSON and back.

The NSJSONSerialization class allows you to provide a property list class, and get back an NSData object that contains the JSON data that describes that class. A property list class is one of these classes:

§  Strings

§  Numbers

§  NSDate

§  NSURL

§  NSData

§  Arrays and dictionaries, as long as they only contain objects in this list

In the case of the container classes (dictionaries and arrays), these objects are only allowed to contain other property list classes.

To get JSON data for an object, you do this:

let dictionary = ["One": 1, "Two":2]

var error : NSError? = nil

let serializedData = NSJSONSerialization.dataWithJSONObject(dictionary,

    options: NSJSONWritingOptions(), error: &error)

// After this call, 'serializedData' is either nil or full of JSON data.

// If there was a problem, the 'error' variable is set to point to an

// NSError object that describes the problem.

You can pass other values for the options parameter as well—check the documentation for NSJSONSerialization. If you don’t want to pass an option in, just provide an empty NSJSONWritingOptions().

To load JSON data in and get back an object, you do this:

let loadedDictionary =

    NSJSONSerialization.JSONObjectWithData(serializedData,

        options: NSJSONReadingOptions(), error: &error) as? [String:Int]

// loadedDictionary is now either a dictionary that maps

// strings to ints, or is nil

We’ll now modify the application to store both a block of text and a Boolean value in a JSON-formatted document. To do this, we’ll include a checkbox control in the application’s UI, and a Bool property in the Document:

1.    Open Document.xib.

2.    Resize the text field to make some room at the bottom of the window.

3.    Drag a checkbox into the window. The interface should now look something like Figure 13-3.

The updated interface, with the checkbox at the bottom of the window

Figure 13-3. The updated interface, with the checkbox at the bottom of the window

4.    Open Document.swift in the assistant.

5.    Control-drag from the checkbox into the Document class, and create a new outlet called checkbox.

6.    Add a Bool property called checked to Document.

7.    Replace the methods dataOfType(type:error:), readFromData(data:ofType:error:), and windowControllerDidLoadNib with the following code:

8.  override func windowControllerDidLoadNib(aController: NSWindowController) {

9.   

10.    // The window has loaded, and is ready to display.

11.    // Take the text that we loaded earlier and display

12.    // it in the text field

13.    super.windowControllerDidLoadNib(aController)

14. 

15.    self.textField.stringValue = self.text

16.    self.checkbox.integerValue = Int(self.checked)

17.}

18. 

19.override func dataOfType(typeName: String?,

20.    error outError: NSErrorPointer) -> NSData? {

21. 

22.    self.text = self.textField.stringValue

23.    self.checked = Bool(self.checkbox.integerValue)

24. 

25.    let dictionary = ["checked": self.checked,

26.                        "text": self.text]

27. 

28.    var error : NSError? = nil

29. 

30.    let serializedData = NSJSONSerialization.dataWithJSONObject(dictionary,

31.        options: NSJSONWritingOptions.PrettyPrinted, error: &error)

32. 

33.    if serializedData == nil || error != nil {

34. 

35.        outError.memory = error

36. 

37.        return nil;

38.    } else {

39.        return serializedData

40.    }

41. 

42.}

43. 

44.override func readFromData(data: NSData, ofType typeName: String?,

45.    error outError: NSErrorPointer) -> Bool {

46. 

47.    var error : NSError? = nil

48. 

49.    let data = NSJSONSerialization.JSONObjectWithData(data,

50.        options: NSJSONReadingOptions(), error: &error) as? NSDictionary

51. 

52.    if data == nil || error != nil {

53.        outError.memory = error

54.        return false

55.    }

56. 

57.    if let text = data!["text"] as? String {

58.        self.text = text

59.    }

60. 

61.    if let checked = data!["checked"] as? Bool {

62.        self.checked = checked

63.    }

64. 

65.    return true

}

This new code stores the document information in an NSDictionary, and returns the JSON in the NSData.

NOTE

If you’re curious, the JSON representation of this dictionary looks like this:

{

  "checked" : true,

  "text" : "Hello!!"

}

The loading code does the same in reverse—it takes the NSData that contains the JSON, and converts it to an NSDictionary. The loaded dictionary then has the data copied out of it.

Now run the application and create, load, and save some new documents!

Document-Based Applications on iOS

In contrast to apps on OS X, apps on iOS generally only have one document open at a time. This means that the document API is simpler, as an NSDocumentController is not needed—the concept of “frontmost document” doesn’t apply.

In iOS, instead of using NSDocument, you use UIDocument. However, instead of users selecting which document to open via the Finder, you instead present a list of the user’s documents and allow the user to select a file. When she chooses a file, you create an instance of your document class and instruct the document object to load from the appropriate URL.

You also provide the interface for letting the user create a new document; when she does so, you again create an instance of your document class and immediately save the new document. Generally, you then immediately open the newly created file.

We’re going to create an iPhone application that acts as a simple text editor. Creating document-based applications on iOS is less automated than on OS X, but is still fairly straightforward.

This application will present its interface with two view controllers: a master view controller that lists all available documents, and a detail view controller that displays the contents of the currently open document and allows the user to edit it.

The built-in master-detail application template for iOS is ideal for this, and we’ll use that. We’ll also have to create our UIDocument subclass manually:

1.    Create a new master-detail app for iOS. Make this application designed for iPhone and name it iOSDocuments.

2.    We’ll start by creating the interface. Open Main.storyboard and locate the master view controller.

3.    Update the segues. We need to replace the default segues that come with the application template that Xcode provides.

Delete the segue that connects the master view controller.

Then, hold down the Control key, and drag from the master view controller to the detail view controller, and choose Push from the menu that appears.

Finally, select the newly created segue, and set its identifier to showDetail.

4.    Add a bar button item to the navigation bar.

NOTE

This button will be the “create new document” button. Select it and set its identifier to Add to make it display a + symbol.

5.    Open MasterViewController.swift in the assistant.

6.    Connect the button. Control-drag from the button to the MasterViewController class, and create an action named createDocument.

7.    Update the prototype cell. Select the prototype cell that now appears in the table view. Set its style to Basic and its identifier to FileCell. Set its accessory to Disclosure Indicator.

8.    Open the detail view controller and delete the label in the middle of the view.

9.    Add a text view. Drag a UITextView into the view controller’s view. Make the text view fill the entire screen.

10.Open DetailViewController.swift in the assistant.

11.Connect the text view to the detail view controller. Control-drag from the text view into the DetailViewController class. Create a new outlet called textView.

12.Add a Done button to the navigation bar in the detail view controller. Drag a bar button item into the lefthand side of the navigation bar and set its identifier to Done. (We’ll make the code not display the Back button.)

When you’re done, the interface should look like Figure 13-4.

13.Make the view controller the delegate for the text view. We want the detail view controller to be notified when the user makes changes. Control-drag from the text view to the view controller, and select “delegate” from the menu that pops up.

We’ll now make the code for our UIDocument subclass, called SampleDocument. This document class will manage its data in a flat file, which means that it will work by loading and saving its content in an NSData:

The application’s interface

Figure 13-4. The application’s interface

1.    Create a new Cocoa Touch class. Name the new class SampleDocument and make it a subclass of UIDocument.

2.    Update SampleDocument.swift so that it reads as follows:

3.  import UIKit

4.   

5.  class SampleDocument: UIDocument {

6.   

7.      var text = ""

8.   

9.      // Called when a document is opened.

10.    override func loadFromContents(contents: AnyObject,

11.        ofType typeName: String, error outError: NSErrorPointer) -> Bool {

12. 

13.        self.text = ""

14. 

15.        if let data = contents as? NSData {

16. 

17.            if data.length > 0 {

18.                // Attempt to decode the data into text; if it's successful

19.                // store it in self.text

20.                if let theText =

21.                    NSString(data: data, encoding: NSUTF8StringEncoding) {

22.                        self.text = theText

23.                }

24.            }

25. 

26.        }

27. 

28.        return true

29. 

30.    }

31. 

32.    // Called when the system needs a snapshot of the current state of

33.    // the document, for autosaving.

34.    override func contentsForType(typeName: String,

35.        error outError: NSErrorPointer) -> AnyObject? {

36. 

37.        return self.text.dataUsingEncoding(NSUTF8StringEncoding)

38. 

39.    }

40. 

}

We’ll now update the code for MasterViewController.swift to display a list of files:

import UIKit

class MasterViewController: UITableViewController {

    var documentURLs : [NSURL] = []

    func URLForDocuments() -> NSURL {

        return NSFileManager.defaultManager()

            .URLsForDirectory(NSSearchPathDirectory.DocumentDirectory,

                inDomains: NSSearchPathDomainMask.UserDomainMask).last as NSURL

    }

    func updateFileList() {

        documentURLs = NSFileManager.defaultManager()

            .contentsOfDirectoryAtURL(self.URLForDocuments(),

                includingPropertiesForKeys: nil,

                options: NSDirectoryEnumerationOptions(), error: nil) as [NSURL]

        self.tableView.reloadData()

    }

    override func viewWillAppear(animated: Bool) {

        self.updateFileList()

    }

    override func numberOfSectionsInTableView(tableView: UITableView) -> Int {

        return 1

    }

    override func tableView(tableView: UITableView,

        numberOfRowsInSection section: Int) -> Int {

        return documentURLs.count

    }

    override func tableView(tableView: UITableView,

        cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

        let cell = tableView

            .dequeueReusableCellWithIdentifier("FileCell") as UITableViewCell

        let URL = documentURLs[indexPath.row]

        cell.textLabel.text = URL.lastPathComponent

        return cell

    }

    override func tableView(tableView: UITableView,

        didSelectRowAtIndexPath indexPath: NSIndexPath) {

        let URL = documentURLs[indexPath.row]

        let documentToOpen = SampleDocument(fileURL: URL)

        documentToOpen.openWithCompletionHandler() {

            (success) in

            if success == true {

                self.performSegueWithIdentifier("showDetail",

                                                sender: documentToOpen)

            }

        }

    }

    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject!) {

        if segue.identifier == "showDetail" {

            let detailViewController =

                segue.destinationViewController as DetailViewController

            let document = sender as? SampleDocument

            detailViewController.detailItem = document

        }

    }

    @IBAction func createDocument(sender: AnyObject) {

        let dateFormatter = NSDateFormatter()

        dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ssZZZ"

        let dateString = dateFormatter.stringFromDate(NSDate())

        let fileName = "Document \(dateString).sampleDocument"

        let url = self.URLForDocuments().URLByAppendingPathComponent(fileName)

        let documentToCreate = SampleDocument(fileURL: url)

        documentToCreate.saveToURL(url,

            forSaveOperation: UIDocumentSaveOperation.ForCreating) {

            (success) in

            if success == true {

                self.performSegueWithIdentifier("showDetail",

                                                sender: documentToCreate)

            }

        }

    }

}

Finally, we’ll update the code for DetailViewController to make it display the content from the loaded SampleDocument object and send the user’s changes to the document. The DetailViewController will also notice when the user taps the Done button that was added earlier, and signal to the document that it should be saved and closed.

We also want to make the class conform to the UITextViewDelegate protocol, so that we receive changes from the user as she types them.

To make DetailViewController conform to UITextController, go to DetailViewController.swift and update it with the following code:

import UIKit

class DetailViewController: UIViewController, UITextViewDelegate {

    @IBOutlet weak var textView: UITextView!

    @IBAction func done(sender: AnyObject) {

        if let document : SampleDocument = self.detailItem {

            document.saveToURL(document.fileURL,

                forSaveOperation: UIDocumentSaveOperation.ForOverwriting)

            {

                (success) in

                self.navigationController?.popViewControllerAnimated(true)

                return

            }

        }

    }

    var detailItem: SampleDocument? {

        didSet {

            self.configureView()

        }

    }

    func configureView() {

        if let document: SampleDocument = self.detailItem {

            self.textView?.text = document.text

        }

    }

    func textViewDidChange(textView: UITextView!) {

        if let document : SampleDocument = self.detailItem {

            document.text = self.textView.text

            document.updateChangeCount(UIDocumentChangeKind.Done)

        }

    }

    override func viewDidLoad() {

        super.viewDidLoad()

        self.configureView()

        self.navigationItem.hidesBackButton = true

    }

}

In this code, the DetailViewController object has received the SampleDocument object loaded by the MasterViewController, and makes the text view display the text that it contains. Every time the user makes a change to the text field, the text in the SampleDocument is updated; the SampleDocument will automatically save the document’s contents in order to prevent data loss if something bad happens (like a crash or the device running out of battery).

When the Done button is tapped, the document is told to close, which saves any unsaved changes. Once this process is complete, the view controller dismisses itself.