Using 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 creating and managing 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 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 that cannot be directly instantiated from within code. Instead, apps 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 app’s internal data model.
Conflict Resolution and Document States
Storing documents using iCloud means that multiple instances of an app can potentially access the same stored document consecutively. This considerably increases the risk of a conflict occurring when app instances simultaneously make different changes to the same document. One option is to let the most recent save operation overwrite any changes made by the other app instances. A more user-friendly alternative, however, is to implement conflict detection code in the app and present the user with the option to resolve the conflict. Such resolution options will be app 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 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 App
The remainder of this chapter will focus on creating an app designed to demonstrate using the UIDocument class to read and write a document locally on an iOS 17-based device or simulator.
To create the project, launch Xcode and create a new product named iCloudStore using the iOS App template and the Swift programming language.
Creating a UIDocument Subclass
As previously discussed, UIDocument is an abstract class that cannot be directly instantiated. Therefore, it is necessary to create a subclass and 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. 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 implementing the user interface and the corresponding outlets and actions.
Designing the User Interface
The finished app will have a user interface comprising a UITextView and UIButton. The user will enter text into the text view and save it to a file by touching the button.
Select the Main.storyboard file and display the Library dialog. 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”:
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 view’s height 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 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 it 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 App Data Structure
So far, we have created and partially implemented a UIDocument subclass named MyDocument and designed the user interface of the app together with corresponding actions and outlets. As previously discussed, the MyDocument class will require two methods for interfacing between the MyDocument object instances and the app’s data structures. Before implementing these methods, we first need to implement the app data structure. In this app, 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"
}
Code language: Swift (swift)
Now that the data model is defined, it is time to complete the MyDocument class implementation.
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 app’s data to a file, the class makes a call to its contents(forType:) instance method. This method’s job is to collect the data stored in the document and pass it back to the MyDocument object instance as a Data object. The content of the Data object will then be written into 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 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()
}
}
Code language: Swift (swift)
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 document’s content and is responsible for updating the app’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?
}
}
Code language: Swift (swift)
The implementation of the MyDocument class is now complete, and it is time to begin implementing the app functionality.
Loading the Document at App Launch
The app’s ultimate goal is to save any text in the text view to a document on the device’s local file system. When the app 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 an instance of the MyDocument class, so these need to be declared in the view controller implementation file. Then, 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?
.
.
}
Code language: Swift (swift)
The first task for the viewDidLoad method is to identify the path to the app’s Documents directory (a task outlined in iOS 17 Directory Handling and File I/O in Swift – A Worked Example) and construct a full path to the document 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 = ""
Code language: JavaScript (javascript)
The next task for the method is identifying whether the saved file exists. If 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.
If 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 ")
}
})
}
Code language: Swift (swift)
Note that print calls have been made at key points in the process for debugging purposes. These can be removed once the app 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:
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 ")
}
})
}
}
}
Code language: Swift (swift)
Saving Content to the Document
When the user touches the app’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. Before calling the method, therefore, the userText property of the document object must 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")
}
})
}
}
Code language: Swift (swift)
Testing the App
All that remains is to test that the app 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. After restarting the app, 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 using the local file system storage of an iOS device. UIDocument must be subclassed as an abstract class, and two mandatory methods must be implemented within the subclass 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.