An Overview of the iOS 11 Document Browser View Controller
Learn SwiftUI and take your iOS Development to the Next Level |
Use of iCloud to store files requires a basic understanding of the UIDocument class. Introduced as part of the iOS 5 SDK, the UIDocument class is the recommended mechanism for working with iCloud-based file and document storage.
The objective of this chapter is to provide a brief overview of the UIDocument class before working through a simple example demonstrating the use of UIDocument to create and perform read and write operations on a document on the local device file system. Once these basics have been covered the next chapter will extend the example to store the document using the iCloud document storage service.
An Overview of the UIDocument Class
The iOS UIDocument class is designed to provide an easy to use interface for the creation and management of documents and content. While primarily intended to ease the process of storing files using iCloud, UIDocument also provides additional benefits in terms of file handling on the local file system such as reading and writing data asynchronously on a background queue, handling of version conflicts on a file (a more likely possibility when using iCloud) and automatic document saving.
Subclassing the UIDocument Class
UIDocument is an abstract class, in that it cannot be directly instantiated from within code. Instead applications must create a subclass of UIDocument and, at a minimum, override two methods:
- contents(forType:) - This method is called by the UIDocument subclass instance when data is to be written to the file or document. The method is responsible for gathering the data to be written and returning it in the form of a Data or FileWrapper object.
- load(fromContents:) - Called by the subclass instance when data is being read from the file or document. The method is passed the content that has been read from the file by the UIDocument subclass and is responsible for loading that data into the application's internal data model.
Conflict Resolution and Document States
Storage of documents using iCloud means that multiple instances of an application can potentially access the same stored document consecutively. This considerably increases the risk of a conflict occurring when application instances simultaneously make different changes to the same document. One option is to simply let the most recent save operation overwrite any changes made by the other application instances. A more user friendly alternative, however, is to implement conflict detection code in the application and present the user with the option to resolve the conflict. Such resolution options will be application specific but might include presenting the file differences and letting the user choose which one to save, or allowing the user to merge the conflicting file versions.
The current state of a UIDocument subclass object may be identified by accessing the object's documentState property. At any given time this property will be set to one of the following constants:
- UIDocumentState.normal - The document is open and enabled for user editing.
- UIDocumentState.closed - The document is currently closed. This state can also indicate an error in reading a document.
- UIDocumentState.inConflict - Conflicts have been detected for the document.
- UIDocumentState.savingError - An error occurred when an attempt was made to save the document.
- UIDocumentState.editingDisabled - The document is busy and is not currently safe for editing.
- UIDocumentState.progressAvailable - The current progress of the document download is available via the progress property of the document object.
Clearly one option for detecting conflicts is to periodically check the documentState property for a UIDocumentState.inConflict value. That said, it only really makes sense to check for this state when changes have actually been made to the document. This can be achieved by registering an observer on the UIDocumentStateChangedNotification notification. When the notification is received that the document state has changed, the code will need to check the documentState property for the presence of a conflict and act accordingly.
The UIDocument Example Application
The remainder of this chapter will focus on the creation of an application designed to demonstrate the use of the UIDocument class to read and write a document locally on an iOS 11 based device or simulator. To create the project, begin by launching Xcode and creating a new product named iCloudStore using the Single View Application template and the Swift programming language.
Creating a UIDocument Subclass
As previously discussed, UIDocument is an abstract class that cannot be directly instantiated. It is necessary, therefore, to create a subclass and to implement some methods in that subclass before using the features that UIDocument provides. The first step in this project is to create the source file for the subclass so select the Xcode File -> New -> File… menu option and in the resulting panel select iOS in the tab bar and the Cocoa Touch Class template before clicking on Next. On the options panel, set the Subclass of menu to UIDocument, name the class MyDocument and click Next to create the new class. With the basic outline of the subclass created the next step is to begin implementing the user interface and the corresponding outlets and actions.
Designing the User Interface
The finished application is going to consist of a user interface comprising a UITextView and UIButton. The user will enter text into the text view and initiate the saving of that text to a file by touching the button. Select the Main.storyboard file and display the Interface Builder Object Library (View -> Utilities -> Show Object Library). Drag and drop the Text View and Button objects into the view canvas, resizing the text view so that it occupies only the upper area of the view. Double-click on the button object and change the title text to "Save":
Figure 41-1
Click on the Text View so that it is selected and use the Auto Layout Add New Constraints menu to add Spacing to nearest neighbor constraints on the top, left and right-hand edges of the view with the Constrain to margins option switched on. Before adding the nearest neighbor constraints, also enable the Height constraint so that the height of the view is preserved at runtime.
Having configured the constraints for the Text View, select the Button view and use the Auto Layout Align menu to configure a Horizontal Center in Container constraint. With the Button view still selected, display the Add New Constraints menu and add a Spacing to nearest neighbor constraint on the top edge of the view using the current value and with the Constrain to margins option switched off.
Remove the example Latin text from the text view object by selecting it in the view canvas and deleting the value from the Text property in the Attributes Inspector panel. With the user interface designed it is now time to connect the action and outlet. Select the Text View object in the view canvas, display the Assistant Editor panel and verify that the editor is displaying the contents of the ViewController.swift file. Ctrl-click on the Text View object and drag to a position just below the "class ViewController" declaration line in the Assistant Editor. Release the line and in the resulting connection dialog establish an outlet connection named textView.
Finally, Ctrl-click on the button object and drag the line to the area immediately beneath the viewDidLoad method declaration in the Assistant Editor panel. Release the line and, within the resulting connection dialog, establish an Action method on the Touch Up Inside event configured to call a method named saveDocument.
Implementing the Application Data Structure
So far we have created and partially implemented a UIDocument subclass named MyDocument and designed the user interface of the application together with corresponding actions and outlets. As previously discussed, the MyDocument class will require two methods that are responsible for interfacing between the MyDocument object instances and the application's data structures. Before we can implement these methods, however, we first need to implement the application data structure. In the context of this application the data simply consists of the string entered by the user into the text view object. Given the simplicity of this example we will declare the data structure, such as it is, within the MyDocument class where it can be easily accessed by the contents(forType:) and load(fromContents:) methods. To implement the data structure, albeit a single data value, select the MyDocument.swift file and add a declaration for a String object:
import UIKit class MyDocument: UIDocument { var userText: String? = "Some Sample Text" }
Now that the data model is defined it is time to complete the MyDocument class implementation.
Learn SwiftUI and take your iOS Development to the Next Level |
Implementing the contents(forType:) Method
The MyDocument class is a subclass of UIDocument. When an instance of MyDocument is created and the appropriate method is called on that instance to save the application's data to a file, the class makes a call to its contents(forType:) instance method. It is the job of this method to collect the data to be stored in the document and to pass it back to the MyDocument object instance in the form of a Data object. The content of the Data object will then be written to the document. While this may sound complicated most of the work is done for us by the parent UIDocument class. All the method needs to do, in fact, is get the current value of the userText String object, put it into a Data object and return it.
Select the MyDocument.swift file and add the contents(forType:) method as follows:
override func contents(forType typeName: String) throws -> Any { if let content = userText { let length = content.lengthOfBytes(using: String.Encoding.utf8) return NSData(bytes:content, length: length) } else { return Data() } }
Implementing the load(fromContents:) Method
The load(fromContents:) instance method is called by an instance of MyDocument when the object is instructed to read the contents of a file. This method is passed a Data object containing the content of the document and is responsible for updating the application's internal data structure accordingly. All this method needs to do, therefore, is convert the Data object contents to a string and assign it to the userText object:
override func load(fromContents contents: Any, ofType typeName: String?) throws { if let userContent = contents as? Data { userText = NSString(bytes: (contents as AnyObject).bytes, length: userContent.count, encoding: String.Encoding.utf8.rawValue) as String? } }
The implementation of the MyDocument class is now complete and it is time to begin implementing the application functionality.
= Loading the Document at App Launch
The ultimate goal of the application is to save any text in the text view to a document on the local file system of the device. When the application is launched it needs to check if the document exists and, if so, load the contents into the text view object. If, on the other hand, the document does not yet exist it will need to be created. As is usually the case, the best place to perform these tasks is the viewDidLoad method of the view controller.
Before implementing the code for the viewDidLoad method we first need to perform some preparatory work. First, both the viewDidLoad and saveDocument methods will need access to a URL object containing a reference to the document and also an instance of the MyDocument class, so these need to be declared in the view controller implementation file. With the ViewController.swift file selected in the project navigator, modify the file as follows:
import UIKit class ViewController: UIViewController { @IBOutlet weak var textView: UITextView! var document: MyDocument? var documentURL: URL? . . . }
The first task for the viewDidLoad method is to identify the path to the application's Documents directory (a task outlined in Working with Directories in Swift on iOS 11) and construct a full path to the document which will be named savefile.txt. The method will then need to use the document URL to create an instance of the MyDocument class. The code to perform these tasks can be implemented as outlined in the following code fragment:
let filemgr = FileManager.default let dirPaths = filemgr.urls(for: .documentDirectory, in: .userDomainMask) documentURL = dirPaths[0].appendingPathComponent("savefile.txt") if let url = documentURL { document = MyDocument(fileURL: url) document?.userText = ""
The next task for the method is to identify whether the save file exists. In the event that it does, the open(completionHandler:) method of the MyDocument instance object is called to open the document and load the contents (thereby automatically triggering a call to the load(fromContents:) method created earlier in the chapter).
The open(completionHandler:) method allows for a code block to be written to which is passed a Boolean value indicating the success or otherwise of the file opening and reading process. On a successful read operation this handler code simply needs to assign the value of the userText property of the MyDocument instance (which has been updated with the document contents by the load(fromContents:) method) to the text property of the textView object, thereby making it visible to the user.
In the event that the document does not yet exist, the save(to:) method of the MyDocument class will be called using the argument to create a new file:
if filemgr.fileExists(atPath: (url.path)!) { document?.open(completionHandler: {(success: Bool) -> Void in if success { print("File open OK") self.textView.text = self.document?.userText } else { print("Failed to open file") } }) } else { document?.save(to: url, for: .forCreating, completionHandler: {(success: Bool) -> Void in if success { print("File created OK") } else { print("Failed to create file ") } }) }
Note that for the purposes of debugging, print calls have been made at key points in the process. These can be removed once the application is verified to be working correctly.
Bringing the above code fragments together results in the following, fully implemented loadFile method which will need to be called from the viewDidLoad method:
Learn SwiftUI and take your iOS Development to the Next Level |
override func viewDidLoad() { super.viewDidLoad() loadFile() } . . func loadFile() { let filemgr = FileManager.default let dirPaths = filemgr.urls(for: .documentDirectory, in: .userDomainMask) documentURL = dirPaths[0].appendingPathComponent("savefile.txt") if let url = documentURL { document = MyDocument(fileURL: url) document?.userText = "" if filemgr.fileExists(atPath: (url.path)) { document?.open(completionHandler: {(success: Bool) -> Void in if success { print("File open OK") self.textView.text = self.document?.userText } else { print("Failed to open file") } }) } else { document?.save(to: url, for: .forCreating, completionHandler: {(success: Bool) -> Void in if success { print("File created OK") } else { print("Failed to create file ") } }) } } }
Saving Content to the Document
When the user touches the application's save button the content of the text view object needs to be saved to the document. An action method has already been connected to the user interface object for this purpose and it is now time to write the code for this method.
Since the viewDidLoad method has already identified the path to the document and initialized the document object, all that needs to be done is to call that object's save(to:) method using the .saveForOverwriting option. The save(to:) method will automatically call the contents(forType:) method implemented previously in this chapter. Prior to calling the method, therefore, it is important that the userText property of the document object be set to the current text of the textView object.
Bringing this all together results in the following implementation of the saveDocument method:
@IBAction func saveDocument(_ sender: Any) { document?.userText = textView.text if let url = documentURL { document?.save(to: url, for: .forOverwriting, completionHandler: {(success: Bool) -> Void in if success { print("File overwrite OK") } else { print("File overwrite failed") } }) } }
Testing the Application
All that remains is to test that the application works by clicking on the Xcode run button. Upon execution, any text entered into the text view object should be saved to the savefile.txt file when the Save button is touched. Once some text has been saved, click on the stop button located in the Xcode toolbar. On subsequently restarting the application the text view should be populated with the previously saved text.
Summary
While the UIDocument class is the cornerstone of document storage using the iCloud service it is also of considerable use and advantage in terms of using the local file system storage of an iOS device. As an abstract class, UIDocument must be subclassed and two mandatory methods implemented within the subclass in order to operate. This chapter worked through an example of using UIDocument to save and load content using a locally stored document. The next chapter will look at using UIDocument to perform cloud-based document storage and retrieval.
Learn SwiftUI and take your iOS Development to the Next Level |