UIDocument From Scratch

Learn how to add document support to your app using UIDocument. By Lea Marolt Sonnenschein.

4.4 (7) ·

Download materials
Save for later
Update note: Lea Marolt Sonnenschein updated this tutorial for iOS 13, Xcode 11 and Swift 5. Ray Wenderlich wrote the original.

There are a few ways to store data in the iOS ecosystem:

  1. UserDefaults for small amounts of data.
  2. Core Data for large amounts of data.
  3. 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 UIDocuments to the iOS file system from scratch. This covers four topics:

  1. Creating data models.
  2. Subclassing UIDocument.
  3. Creating and listing UIDocuments.
  4. Updating and deleting UIDocuments.
Note: This tutorial assumes you’re already familiar with NSCoding,
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:

  1. PhotoData represents the full-size photo.
  2. 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:

  1. You override description to return the title of the document by taking the fileURL, removing the “ptk” extension and grabbing the last part of the path component.
  2. fileWrapper is the OS file system node representing the directory that contains your photo and metadata.
  3. photoData and photoMetadata are the data models used to interpret the photo.metadata and photo.data subfiles the fileWrapper contains. These are lazy variables, and you’ll be adding code to pull them from files later on.
  4. photo is the property used to access and update your main and thumbnail image when you make changes. It’s aliased PhotoEntry 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)
  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? {
    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:

  fileWrapper != nil,
  let data = decodeFromWrapper(for: .dataFilename) as? PhotoData 
  else {
    return PhotoData()

return data

Then, replace the lazy initializer for photoMetadata with:

  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 fileWrappers with different names. You try to decode the appropriate file from the fileWrapper directory as an instance of your data model class.

Creating Documents

Before you can display a list of documents, you need to be able to add at least one so that you have something to look at. There are three things you need to do to create a new document in this app:

  1. Store entries.
  2. Find an available URL.
  3. Create the document.

Storing Entries

If you create an entry in the app, you’ll see the creation date in the cell. Instead of displaying dates, you want to display information about your documents, like the thumbnail or your own text.

All of this information is held in another class called Entry. Each Entry is represented by a cell in the table view.

First, open Entry.swift and replace the class implementation — but not the Comparable extension! — with:

class Entry: NSObject {
  var fileURL: URL
  var metadata: PhotoMetadata?
  var version: NSFileVersion
  private var editDate: Date {
    return version.modificationDate ?? .distantPast
  override var description: String {
    return fileURL.deletingPathExtension().lastPathComponent
  init(fileURL: URL, metadata: PhotoMetadata?, version: NSFileVersion) {
    self.fileURL = fileURL
    self.metadata = metadata
    self.version = version

Entry simply keeps track of all the items discussed above. Make sure you don’t delete the Comparable!

At this point, you’ll see a compiler error, so you have to clean the code up a bit.

Now, go to ViewController.swift and remove this piece of code. You’ll replace it later:

private func addOrUpdateEntry() {
  let entry = Entry()

Since you just removed addOrUpdateEntry, you’ll see another compiler error:

Delete the line calling addOrUpdateEntry() in addEntry(_:).

Finding an Available URL

The next step is to find a URL where you want to create the document. This isn’t as easy as it sounds because you need to auto-generate a filename that isn’t already taken. First, you’ll check if a file exists.

Go to ViewController.swift. At the top, you’ll see two properties:

private var selectedEntry: Entry?
private var entries: [Entry] = []
  • selectedEntry will help you keep track of the entry the user is interacting with.
  • entries is an array that contains all the entries on the disk.

To check if a file exists, look through entries to see if the name is already taken.

Now, add two more properties below:

private lazy var localRoot: URL? = FileManager.default.urls(
                                     for: .documentDirectory, 
                                     in: .userDomainMask).first
private var selectedDocument: Document?

The localRoot instance variable keeps track of the document’s directory. The selectedDocument will be used for passing data between the master and detail view controllers.

Now, under viewDidLoad() add this method to return the full path for a file for a specific filename:

private func getDocumentURL(for filename: String) -> URL? {
  return localRoot?.appendingPathComponent(filename, isDirectory: false)

Then under that, add a method that checks whether the filename already exists:

private func docNameExists(for docName: String) -> Bool {
  return !entries.filter{ $0.fileURL.lastPathComponent == docName }.isEmpty

If the filename does already exist, you want to find a new one.

So, add a method to find a non-taken name:

private func getDocFilename(for prefix: String) -> String {
  var newDocName = String(format: "%@.%@", prefix, String.appExtension)
  var docCount = 1
  while docNameExists(for: newDocName) {
    newDocName = String(format: "%@ %d.%@", prefix, docCount, String.appExtension)
    docCount += 1
  return newDocName

getDocFilename(for:) starts with the document name passed in and checks if it is available. If not, it adds 1 to the end of the name and tries again until it finds an available name.

Creating a Document

There are two steps to create a Document. First, initialize the Document with the URL to save the file to. Then, call saveToURL to save the files.

After you create the document, you need to update the objects array to store the document and display the detail view controller.

Now add this code below indexOfEntry(for:) to find the index of an entry for a specific fileURL:

private func indexOfEntry(for fileURL: URL) -> Int? {
  return entries.firstIndex(where: { $0.fileURL == fileURL }) 

Next, add a method to add or update an entry below:

private func addOrUpdateEntry(
  for fileURL: URL,
  metadata: PhotoMetadata?,
  version: NSFileVersion
) {
  if let index = indexOfEntry(for: fileURL) {
    let entry = entries[index]
    entry.metadata = metadata
    entry.version = version
  } else {
    let entry = Entry(fileURL: fileURL, metadata: metadata, version: version)

  entries = entries.sorted(by: >)

addOrUpdateEntry(for:metadata:version:) finds the index of an entry for a specific fileURL. If it exists, it updates its properties. If it doesn’t, it creates a new Entry.

Finally, add a method to insert a new document:

private func insertNewDocument(
  with photoEntry: PhotoEntry? = nil, 
  title: String? = nil) {
  // 1
  guard let fileURL = getDocumentURL(
    for: getDocFilename(for: title ?? .photoKey)
  ) else { return }
  // 2
  let doc = Document(fileURL: fileURL)
  doc.photo = photoEntry

  // 3
  doc.save(to: fileURL, for: .forCreating) { 
    [weak self] success in
    guard success else {
      fatalError("Failed to create file.")

    print("File created at: \(fileURL)")
    let metadata = doc.metadata
    let URL = doc.fileURL
    if let version = NSFileVersion.currentVersionOfItem(at: fileURL) {
      // 4
      self?.addOrUpdateEntry(for: URL, metadata: metadata, version: version)

You’re finally putting all the helper methods you wrote to good use. Here, the code you added:

  1. Finds an available file URL in the local directory.
  2. Initializes a Document.
  3. Saves the document right away.
  4. Adds an entry to the table.

Now, add the following to addEntry(_:) to call your new code:


Final Changes

You’re almost ready to test this out!

Find tableView(_:cellForRowAt:) and replace the cell configuration with:

cell.photoImageView?.image = entry.metadata?.image
cell.titleTextField?.text = entry.description
cell.subtitleLabel?.text = entry.version.modificationDate?.mediumString

Build and run your project. You should now be able to tap the + button to create new documents that get stored on the file system:

If you look at the console output, you should see messages showing the full paths of where you’re saving the documents, like this:

File created at: file:///Users/leamaroltsonnenschein/Library/Developer/CoreSimulator/Devices/C1176DC2-9AF9-48AB-A488-A1AB76EEE8E7/data/Containers/Data/Application/B9D5780E-28CA-4CE9-A823-0808F8091E02/Documents/Photo.PTK

However, this app has a big problem. If you build and run the app again, nothing will show up in the list!

That’s because there is no code to list documents yet. You’ll add that now.

Listing Local Documents

To list local documents, you’re going to get the URLs for all the documents in the local Documents directory and open each one. You’ll read the metadata out to get the thumbnail but not the data, so things stay efficient. Then, you’ll close it again and add it to the table view.

In ViewController.swift, you need to add the method that loads a document given a file URL. Add this right underneath the viewDidLoad():

private func loadDoc(at fileURL: URL) {
  let doc = Document(fileURL: fileURL)
  doc.open { [weak self] success in
    guard success else {
      fatalError("Failed to open doc.")
    let metadata = doc.metadata
    let fileURL = doc.fileURL
    let version = NSFileVersion.currentVersionOfItem(at: fileURL)
    doc.close() { success in
      guard success else {
        fatalError("Failed to close doc.")

      if let version = version {
        self?.addOrUpdateEntry(for: fileURL, metadata: metadata, version: version)

Here you open the document, get the information you need to create an Entry and display the thumbnails. You then close it again rather than keeping it open. This is important for two reasons:

  1. It avoids the overhead of keeping an entire UIDocument in memory when you only need one part.
  2. UIDocuments can only be opened and closed once. If you want to open the same fileURL again, you have to create a new UIDocument instance.

Add these methods to perform the refresh underneath the method you just added:

private func loadLocal() {
  guard let root = localRoot else { return }
  do {
    let localDocs = try FileManager.default.contentsOfDirectory(
                          at: root, 
                          includingPropertiesForKeys: nil, 
                          options: [])
    for localDoc in localDocs where localDoc.pathExtension == .appExtension {
      loadDoc(at: localDoc)
  } catch let error {
    fatalError("Couldn't load local content. \(error.localizedDescription)")

private func refresh() {

This code iterates through all the files in the Documents directory and loads every document with your app’s file extension.

Now, you need to add the following to the bottom of viewDidLoad() to load the document list when the app starts:


Build and run. Now your app should correctly pick up the list of documents since last time you ran it.

Creating Actual Entries

It’s finally time to create real entries for PhotoKeeper. There are two cases to cover for adding photos:

  1. Adding a new entry.
  2. Editing an old entry.

Both these cases will present the DetailViewController. However, when the user wants to edit an entry, you’ll pass that document through from the selectedDocument property on ViewController to the document property on DetailViewController.

Still in ViewController.swift, add a method that presents the detail view controller below insertNewDocument(with:title:):

private func showDetailVC() {
  guard let detailVC = detailVC else { return }
  detailVC.delegate = self
  detailVC.document = selectedDocument
  mode = .viewing
  present(detailVC.navigationController!, animated: true, completion: nil)

Here you access the computed property detailVC, if possible, and pass the selectedDocument if it exists. If it’s nil, then you know you’re creating a new document. mode = .viewing lets the view controller know that it’s in viewing rather than editing mode.

Now go to the UITableViewDelegate extension and implement tableView(_:didSelectRowAt):

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  let entry = entries[indexPath.row]
  selectedEntry = entry
  selectedDocument = Document(fileURL: entry.fileURL)
  tableView.deselectRow(at: indexPath, animated: false)

Here, you grab the entry the user selected, populate the selectedEntry and selectedDocument properties and show the detail view controller.

Now replace the addEntry(_:) implementation with:

selectedEntry = nil
selectedDocument = nil

Here you empty selectedEntry and selectedDocument before showing the detail view controller to indicate that you want to create a new document.

Build and run. Now try adding a new entry.

Looking good, but nothing happens when you tap on Done. Time to fix that!

An entry consists of a title and two images. The user can type in a title in the text field and select a photo by interacting with the UIImagePickerController after tapping the Add/Edit Photo button.

Go to DetailViewController.swift.

First, you need to implement openDocument(). It gets called at the end of viewDidLoad() to finally open the document and access the full-sized image. Add this code to openDocument():

if document == nil {
else {
  document?.open() { [weak self] _ in
    self?.fullImageView.image = self?.document?.photo?.mainImage
    self?.titleTextField.text = self?.document?.description

After opening the document, you assign the stored image to your fullImageView and the document's description as the title.

Store and Crop

When the user selects their image, UIImagePickerController returns the info in imagePickerController(_:didFinishPickingMediaWithInfo:).

At that point, you want to assign the selected image to fullImageView, create a thumbnail and save the full and thumbnail images in their respective local variables, newImage and newThumbnailImage.

Replace the code in imagePickerController(_:didFinishPickingMediaWithInfo:) with:

guard let image = info[UIImagePickerController.InfoKey.originalImage] 
  as? UIImage else { 

let options = PHImageRequestOptions()
options.resizeMode = .exact
options.isSynchronous = true

if let imageAsset = info[UIImagePickerController.InfoKey.phAsset] as? PHAsset {
  let imageManager = PHImageManager.default()
                 for: imageAsset, 
                 targetSize: CGSize(width: 150, height: 150), 
                 contentMode: .aspectFill, 
                 options: options
               ) { (result, _) in
      self.newThumbnailImage = result

fullImageView.image = image
let mainSize = fullImageView.bounds.size
newImage = image.imageByBestFit(for: mainSize)

picker.dismiss(animated: true, completion: nil)

After ensuring the user picked an image, you use the Photos and AssetsLibrary frameworks to create a thumbnail. Instead of having to figure out what the most relevant rectangle of the image to crop to is yourself, these two frameworks do it for you!

In fact, the thumbnails look exactly the same as they do in your Photos library:

Compare and Save

Finally, you need to implement what happens when the user taps the Done button.

So, update donePressed(_:) with:

var photoEntry: PhotoEntry?

if let newImage = newImage, let newThumb = newThumbnailImage {
  photoEntry = PhotoEntry(mainImage: newImage, thumbnailImage: newThumb)

// 1
let hasDifferentPhoto = !newImage.isSame(photo: document?.photo?.mainImage)
let hasDifferentTitle = document?.description != titleTextField.text
hasChanges = hasDifferentPhoto || hasDifferentTitle

// 2
guard let doc = document, hasChanges else {
             with: photoEntry, 
             title: titleTextField.text
  dismiss(animated: true, completion: nil)

// 3
doc.photo = photoEntry
doc.save(to: doc.fileURL, for: .forOverwriting) { [weak self] (success) in
  guard let self = self else { return }
  if !success { fatalError("Failed to close doc.") }
                   with: photoEntry, 
                   title: self.titleTextField.text
  self.dismiss(animated: true, completion: nil)

After making sure that the appropriate images exist you:

  1. Check whether there have been changes to either the images or title by comparing the new images to the document's.
  2. If you didn't pass an existing document, you relinquish control to the delegate, the master view controller.
  3. If you did pass a document, you first save and overwrite it and then let the delegate do its magic.

Insert or Update

The last piece of the puzzle is to either insert or update this new data on the master view controller.

Go to ViewController.swift and find the DetailViewControllerDelegate extension and implement the empty delegate method detailViewControllerDidFinish(_:with:title:):

// 1
  let doc = viewController.document,
  let version = NSFileVersion.currentVersionOfItem(at: doc.fileURL) 
  else {
    if let docData = photoEntry {
      insertNewDocument(with: docData, title: title)

// 2
if let docData = photoEntry {
  doc.photo = docData

addOrUpdateEntry(for: doc.fileURL, metadata: doc.metadata, version: version)

Here's what you added:

  1. If the detail view controller doesn't have a document, you insert a new one.
  2. If the document exists, you simply update the old entry.

Now, build and run to see this in action:

Success! You can finally create proper entries and even edit the photos! But if you try to change the title or delete an entry, the changes will only be temporary and come back with a vengeance when you quit and open the app.

That won't do.

Deleting and Renaming

For both deleting and renaming documents, you'll use the FileManager, which gives you access to a shared file manager object that allows you to interface with the contents of the file system and make changes to it.

First, go back to ViewController.swift and change the implementation of delete(entry:) to:

let fileURL = entry.fileURL
guard let entryIndex = indexOfEntry(for: fileURL) else { return }

do {
  try FileManager.default.removeItem(at: fileURL)
  entries.remove(at: entryIndex)
} catch {
  fatalError("Couldn't remove file.")

For deleting, you use FileManager's removeItem(at:) method. When you build and run, you'll see you can now swipe rows to permanently delete them. Be sure to shut down and restart the app to verify they are gone for good.

Next, you'll add the ability to rename documents.

First, add the following code to rename(_:with:):

guard entry.description != name else { return }

let newDocFilename = "\(name).\(String.appExtension)"

if docNameExists(for: newDocFilename) {
  fatalError("Name already taken.")

guard let newDocURL = getDocumentURL(for: newDocFilename) else { return }

do {
  try FileManager.default.moveItem(at: entry.fileURL, to: newDocURL)
} catch {
  fatalError("Couldn't move to new URL.")

entry.fileURL = newDocURL
entry.version = NSFileVersion.currentVersionOfItem(at: entry.fileURL) ?? entry.version


For renaming, you use FileManager's moveItem(at:to:) method. Everything else in the method above is your run-of-the-mill table view management. Pretty simple, eh?

The last thing to do is to check whether the user has changed the title of the document in detailViewControllerDidFinish(_:with:title:).

Go back to that delegate method and add this code at the end:

if let title = title, let entry = selectedEntry, title != entry.description {
  rename(entry, with: title)

Finally, build and run to try out this awesome new way to store photos!

Where to Go From Here?

Now that you've gone through all the steps of creating a UIDocument from scratch, I recommend you take a look at our tutorial on using UIDocumentBrowserViewController which makes it significantly easier to handle documents.

If you're interested in diving deeper into creating your own documents and managing files, check out Apple's documentation on UIDcocument and FileManager

If you're after a deep dive into NSCoding, take a look at this tutorial.

Finally, to learn more about managing data for your apps, check out our video course on Saving Data in iOS.

If you have any questions or comments, please join the forum discussion below.