Persistent Image Gallery (CS193p fall of 2017, assignment VI solution)

The assignment VI  asked the student to convert the ImageGallery app from the previous assignment (V) and make it support the new Files API, using a UIDocumentBrowserViewController instance to present the documents.

I enjoyed the new Apple’s API and solution to deal with documents in iOS. Now an app can add integration to the new Files app, the documents you interact with now provide autosave, asynchronous open and close operations and they also take care of storage for you (filesystem, iCloud). Not to mention the new UIDocumentBrowserViewController class, which handles all common document operations just like the Files app does, and provides a similar interface. You can also export your own type, marking your app as the owner and editor.

This assignment also asked the student to provide caching for the image fetching requests. I’ve done so using the URLCache class and providing it to my own URLSessionConfiguration.

Main challenges

To deal with documents I had to create my own instance of the UIDocument class, which handles an instance of my ImageGallery struct. I had already written the code necessary to convert the model to JSON, pretty simple with the new Codable API.

class ImageGalleryDocument: UIDocument {
  
  // MARK: - Properties
  
  /// The document thumbnail.
  var thumbnail: UIImage?
  
  /// The gallery stored by this document.
  var gallery: ImageGallery?
  
  // MARK: - Life cycle
  
  override func contents(forType typeName: String) throws -> Any {
    return gallery?.json ?? Data()
  }
  
  override func load(fromContents contents: Any, ofType typeName: String?) throws {
    if let data = contents as? Data {
      gallery = ImageGallery(json: data)
    }
  }
  
  override func fileAttributesToWrite(to url: URL, for saveOperation: UIDocumentSaveOperation) throws -> [AnyHashable : Any] {
    var attributes = try super.fileAttributesToWrite(to: url, for: saveOperation)
    if let thumbnail = thumbnail {
      attributes[URLResourceKey.thumbnailDictionaryKey] = [URLThumbnailDictionaryItem.NSThumbnail1024x1024SizeKey : thumbnail]
    }
    
    return attributes
  }
}

In the gallery display controller, I’ve added properties to hold the new document. All I had to do was to open, change and save it.

  // ...
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    
    galleryDocument?.open { success in
      if success {
        if self.gallery == nil {
          self.gallery = self.galleryDocument!.gallery
          self.gallery.title = self.galleryDocument!.localizedName
        }
      } else {
        self.presentWarningWith(title: "Error", message: "Document can't be viewed.") {
          self.dismiss(animated: true)
        }
      }
    }
  }

  // ...

  @IBAction func didTapDone(_ sender: UIBarButtonItem) {
    galleryDocument?.gallery = gallery
    
    if !cachedImages.isEmpty {
      galleryDocument?.thumbnail = cachedImages.first?.value
    }
    
    galleryDocument?.updateChangeCount(.done)
    galleryDocument?.close() { success in
      if !success {
        self.presentWarningWith(title: "Error", message: "The document can't be saved.") {
          self.dismiss(animated: true)
        }
      } else {
        self.dismiss(animated: true)
      }
    }
  }

To provide support for caching, I’ve created a class handling all image fetching requests. Inside this class, I’ve configured a specific URLSession.

/// The session used to make each data task.
private(set) lazy var session: URLSession = {
  let cache = URLCache(memoryCapacity: 4 * 1024 * 1024, diskCapacity: 80 * 1024 * 1024, diskPath: nil)
    
  let configuration = URLSessionConfiguration.default
  configuration.urlCache = cache
  configuration.requestCachePolicy = .returnCacheDataElseLoad
    
  return URLSession(configuration: configuration, delegate: nil, delegateQueue: nil)
}()
  
// MARK: - Imperatives
  
/// Requests an image at the provided URL.
func request(
  at url: URL,
  withCompletionHandler completion: @escaping (Data) -> (),
  andErrorHandler onError: @escaping (Error?, URLResponse?) -> ()
) {
  let task = session.dataTask(with: url) { (data, response, transportError) in
    guard transportError == nil, let data = data else {
      onError(transportError, nil)
      return
    }
    
    guard let httpResponse = response as? HTTPURLResponse,
      (200...299).contains(httpResponse.statusCode),
      ["image/jpeg", "image/png"].contains(httpResponse.mimeType) else {
        onError(nil, response)
        return
    }
    completion(data)
  }
  task.resume()
}

The source code for this solution can be found in my Github repository.

Leave a comment