UIDocument From Scratch
Learn how to add document support to your app using UIDocument. By Lea Marolt Sonnenschein.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
UIDocument From Scratch
30 mins
There are a few ways to store data in the iOS ecosystem:
- UserDefaults for small amounts of data.
- Core Data for large amounts of data.
- UIDocuments when you base your app around the concept of individual documents the user can create, read, update and delete.
The iOS 11 additions of UIDocumentBrowserViewController
and the Files app have made life significantly simpler by providing easy access to manage files in apps. But what if you wanted more granular control?
In this tutorial, you’ll learn how to create, retrieve, edit and delete UIDocument
s to the iOS file system from scratch. This covers four topics:
- Creating data models.
- Subclassing
UIDocument
. - Creating and listing
UIDocument
s. - Updating and deleting
UIDocument
s.
Protocols and delegate patterns,
and Error handling in Swift. If you aren’t, review these tutorials before you get started.
Getting Started
In this tutorial, you’ll create an app called PhotoKeeper, which allows you to store and name your favorite photos. Use the Download Materials button at the top or bottom of this tutorial to download the starter project.
Open the starter project. Then, build and run.
You can add entries to a table view by tapping the + button on the right and edit them by tapping the Edit button on the left.
The app you’ll end up with will allow you to select and name your favorite photos. You’ll also be able to change the photo or title or delete it entirely.
Data Models
UIDocument
supports two different classes for input/output:
- Data: A simple data buffer. Use this when your document is a single file.
- FileWrapper: A directory of file packages which the OS treats as a single file. It’s great when your document consists of multiple files you want to load independently.
The data model for this tutorial is quite simple: It’s just a photo! So, it might seem that using Data would make the most sense.
However, you want to show a thumbnail of a photo in the master view controller before the user opens a file. If you used Data, you’d have to open and decode every single document from the disk to get the thumbnails. Since the images can be quite large, this could lead to slow performance and high memory overhead.
So, you’re going to use FileWrapper instead. You’ll store two documents inside the wrapper:
- PhotoData represents the full-size photo.
- PhotoMetadata represents the photo thumbnail. It’s a small amount of data that the app can load quickly.
First, define some constants. Open Document.swift and add this at the top of the document, right after import UIKit
:
extension String {
static let appExtension: String = "ptk"
static let versionKey: String = "Version"
static let photoKey: String = "Photo"
static let thumbnailKey: String = "Thumbnail"
}
Keep in mind:
- “ptk” is your app’s specific file extension, so you can identify the directory as a document your app knows how to handle.
- “Version” is the key to encode and decode the file’s version number so you can update the data structure if you want support older files in the future.
-
“Photo” and “Thumbnail” are keys for
NSCoding
.
Now open PhotoData.swift and implement the PhotoData
class:
class PhotoData: NSObject, NSCoding {
var image: UIImage?
init(image: UIImage? = nil) {
self.image = image
}
func encode(with aCoder: NSCoder) {
aCoder.encode(1, forKey: .versionKey)
guard let photoData = image?.pngData() else { return }
aCoder.encode(photoData, forKey: .photoKey)
}
required init?(coder aDecoder: NSCoder) {
aDecoder.decodeInteger(forKey: .versionKey)
guard let photoData = aDecoder.decodeObject(forKey: .photoKey) as? Data else {
return nil
}
self.image = UIImage(data: photoData)
}
}
PhotoData
is a simple NSObject
that holds the full-size image and its own version number. You implement the NSCoding
protocol to encode and decode these to a data buffer.
Next, open PhotoMetadata.swift and paste this after the imports:
class PhotoMetadata: NSObject, NSCoding {
var image: UIImage?
init(image: UIImage? = nil) {
self.image = image
}
func encode(with aCoder: NSCoder) {
aCoder.encode(1, forKey: .versionKey)
guard let photoData = image?.pngData() else { return }
aCoder.encode(photoData, forKey: .thumbnailKey)
}
required init?(coder aDecoder: NSCoder) {
aDecoder.decodeInteger(forKey: .versionKey)
guard let photoData = aDecoder.decodeObject(forKey: .thumbnailKey)
as? Data else {
return nil
}
image = UIImage(data: photoData)
}
}
PhotoMetadata
does the same as PhotoData
, except the image it stores will be much smaller. In a more fully-featured app, you could be storing other information about the photo in here (like notes or ratings), which is why it’s a separate type.
Congrats, you now have the model classes for PhotoKeeper!
Subclassing UIDocument
UIDocument
is an abstract base class. This means you must subclass it and implement certain required methods before it can be used. In particular, you have to override two methods:
-
load(fromContents:ofType:)
This is where you read the document and decode the model data. -
contents(forType:)
Use this to write the model into the document.
First, you’ll define some more constants. Open Document.swift and then add this right above the class definition for Document
:
private extension String {
static let dataKey: String = "Data"
static let metadataFilename: String = "photo.metadata"
static let dataFilename: String = "photo.data"
}
You’ll use these constants to encode and decode your UIDocument
files.
Next, add these properties to the Document
class:
// 1
override var description: String {
return fileURL.deletingPathExtension().lastPathComponent
}
// 2
var fileWrapper: FileWrapper?
// 3
lazy var photoData: PhotoData = {
// TODO: Implement initializer
return PhotoData()
}()
lazy var metadata: PhotoMetadata = {
// TODO: Implement initializer
return PhotoMetadata()
}()
// 4
var photo: PhotoEntry? {
get {
return PhotoEntry(mainImage: photoData.image, thumbnailImage: metadata.image)
}
set {
photoData.image = newValue?.mainImage
metadata.image = newValue?.thumbnailImage
}
}
Here’s what you did:
- You override
description
to return the title of the document by taking thefileURL
, removing the “ptk” extension and grabbing the last part of the path component. -
fileWrapper
is the OS file system node representing the directory that contains your photo and metadata. -
photoData
andphotoMetadata
are the data models used to interpret the photo.metadata and photo.data subfiles thefileWrapper
contains. These are lazy variables, and you’ll be adding code to pull them from files later on. -
photo
is the property used to access and update your main and thumbnail image when you make changes. It’s aliasedPhotoEntry
type simply contains your two images.
Next, it’s time to add the code to write the UIDocument
to disk.
First, add these methods below the properties you’ve just added:
private func encodeToWrapper(object: NSCoding) -> FileWrapper {
let archiver = NSKeyedArchiver(requiringSecureCoding: false)
archiver.encode(object, forKey: .dataKey)
archiver.finishEncoding()
return FileWrapper(regularFileWithContents: archiver.encodedData)
}
override func contents(forType typeName: String) throws -> Any {
let metaDataWrapper = encodeToWrapper(object: metadata)
let photoDataWrapper = encodeToWrapper(object: photoData)
let wrappers: [String: FileWrapper] = [.metadataFilename: metaDataWrapper,
.dataFilename: photoDataWrapper]
return FileWrapper(directoryWithFileWrappers: wrappers)
}
encodeToWrapper(object:)
uses NSKeyedArchiver
to convert the object that implements NSCoding
into a data buffer. Then it creates a FileWrapper
file with the buffer and adds it to the directory.
To write data to your document, you implement contents(forType:)
. You encode each model type into a FileWrapper
, then create a dictionary of wrappers with filenames as keys. Finally, you use this dictionary to create another FileWrapper
wrapping the directory.
Great! Now you can implement reading. Add the following methods:
override func load(fromContents contents: Any, ofType typeName: String?) throws {
guard let contents = contents as? FileWrapper else { return }
fileWrapper = contents
}
func decodeFromWrapper(for name: String) -> Any? {
guard
let allWrappers = fileWrapper,
let wrapper = allWrappers.fileWrappers?[name],
let data = wrapper.regularFileContents
else {
return nil
}
do {
let unarchiver = try NSKeyedUnarchiver.init(forReadingFrom: data)
unarchiver.requiresSecureCoding = false
return unarchiver.decodeObject(forKey: .dataKey)
} catch let error {
fatalError("Unarchiving failed. \(error.localizedDescription)")
}
}
You need load(fromContents:ofType:)
to implement reading. All you do is initialize the fileWrapper
with the contents.
decodeFromWrapper(for:)
does the opposite of encodeToWrapper(object:)
. It reads the appropriate FileWrapper
file from the directory FileWrapper
and converts the data contents back to an object via the NSCoding
protocol.
The last thing to do is implement the getters for photoData
and photoMetadata
.
First, replace the lazy initializer for photoData
with:
//1
guard
fileWrapper != nil,
let data = decodeFromWrapper(for: .dataFilename) as? PhotoData
else {
return PhotoData()
}
return data
Then, replace the lazy initializer for photoMetadata
with:
guard
fileWrapper != nil,
let data = decodeFromWrapper(for: .metadataFilename) as? PhotoMetadata
else {
return PhotoMetadata()
}
return data
Both lazy initializers do pretty much the same thing, but they look for fileWrapper
s with different names. You try to decode the appropriate file from the fileWrapper
directory as an instance of your data model class.