UIDocument From Scratch

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

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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() {
  loadLocal()
  tableView.reloadData()
}

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:

refresh()

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)
  
  showDetailVC()
  
  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
showDetailVC()

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 {
  showImagePicker()
}
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 { 
    return 
}

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

if let imageAsset = info[UIImagePickerController.InfoKey.phAsset] as? PHAsset {
  let imageManager = PHImageManager.default()
  
  imageManager.requestImage(
                 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 {
  delegate?.detailViewControllerDidFinish(
             self, 
             with: photoEntry, 
             title: titleTextField.text
             )
  dismiss(animated: true, completion: nil)
  return
}

// 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.") }
    
  self.delegate?.detailViewControllerDidFinish(
                   self, 
                   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.