Image Gallery (CS193p fall of 2017, assignment V solution)

The assignment V asked the student to create an iPad app which let users drag images from other apps (using multitasking) and create an image gallery from them. Each dragged image needed to be fetched from its URL in order to be presented in the gallery’s collection view. The image galleries can be removed, edited or added from a master controller, which presents each gallery document in a table view. Each image can be displayed in a separate detail controller, which uses a scroll view and an image view to display the selected image.

The main features to be learned were:

  • Table views (to display each gallery as a document)
  • Collection views (to display each image)
  • multithreading (involved in requesting the image’s data)
  • Scroll views (used to display the images in detail, using an Image view as the subview)
  • Text fields (used to rename a gallery from its table view cell)
  • Drag and drop API

Main challenges

The most difficult feature to be added was to support the drop interactions in the gallery’s collection view. I had some problems to correctly add the image to the data source while dismissing the placeholder collection view cell. The drop interactions also involved fetching the image from the loaded URL.

...
// Loads the URL.
_ = item.dragItem.itemProvider.loadObject(ofClass: URL.self) { (provider, error) in
  if let url = provider?.imageURL {
    draggedImage.imagePath = url

    // Downloads the image from the fetched url.
    URLSession(configuration: .default).dataTask(with: url) { (data, response, error) in
      DispatchQueue.main.async {
        if let data = data, let _ = UIImage(data: data) {
          // Adds the image to the data source.
          placeholderContext.commitInsertion { indexPath in
            draggedImage.imageData = data
            self.insertImage(draggedImage, at: indexPath)
          }
        } else {
          // There was an error. Remove the placeholder.
          placeholderContext.deletePlaceholder()
        }
      }
    }.resume()
  }
}

I also had some difficulties in handling the drop from within the app (reordering of images). Hopefully, I had the demo code, which had a similar situation and treatment:

func collectionView(_ collectionView: UICollectionView, canHandle session: UIDropSession) -> Bool {
  if collectionView.hasActiveDrag {
    // if the drag is from this collection view, the image isn't needed.
    return session.canLoadObjects(ofClass: URL.self)
  } else {
    return session.canLoadObjects(ofClass: URL.self) && session.canLoadObjects(ofClass: UIImage.self)
  }
}
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
  guard gallery != nil else {
    return UICollectionViewDropProposal(operation: .forbidden)
  }

  // Determines if the drag was initiated from this app, in case of reordering.
  let isDragFromThisApp = (session.localDragSession?.localContext as? UICollectionView) == collectionView
  return UICollectionViewDropProposal(operation: isDragFromThisApp ? .move : .copy, intent: .insertAtDestinationIndexPath)
}

And in the perform drop method:

func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
  let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0)

  for item in coordinator.items {
    if let sourceIndexPath = item.sourceIndexPath {

      // The drag was initiated from this collection view.
      if let galleryImage = item.dragItem.localObject as? ImageGallery.Image {

        collectionView.performBatchUpdates({
          self.gallery.images.remove(at: sourceIndexPath.item)
          self.gallery.images.insert(galleryImage, at: destinationIndexPath.item)
          collectionView.deleteItems(at: [sourceIndexPath])
          collectionView.insertItems(at: [destinationIndexPath])
        })

        coordinator.drop(item.dragItem, toItemAt: destinationIndexPath)
      }   
    } else {
      // The drag was initiated from outside of the app.
      ...
    }
  }
}

I’ve completed all the extra credit tasks for this assignment. I’ve added a new class called ImageGalleryStore, which was in charge of persisting the gallery models using the UserDefaults API. The storage mechanism used the NotificationCenter API to communicate the model changes back to the controllers. To make each gallery model become able to be stored I’ve simply added conformance to the Codable protocol.

I’ve also added support to the deletion of images using a drop interaction in a UIBarButtonItem. Since UIBarButtonItems aren’t views (and can’t handle drop interactions because of that), I had to use the custom view initializer, adding a button with the drop interaction attached to it:

override func viewDidLoad() {
  super.viewDidLoad()
  // In order to implement the drop interaction in the navigation bar button,
  // a custom view had to be added, since a UIBarButtonItem is not a
  // view and doesn't handle interactions.

  let trashButton = UIButton()
  trashButton.setImage(UIImage(named: "icon_trash"), for: .normal)  

  let dropInteraction = UIDropInteraction(delegate: self)
  trashButton.addInteraction(dropInteraction)

  let barItem = UIBarButtonItem(customView: trashButton)
  navigationItem.rightBarButtonItem = barItem  

  barItem.customView!.widthAnchor.constraint(equalToConstant: 25).isActive = true
  barItem.customView!.heightAnchor.constraint(equalToConstant: 25).isActive = true
  
  ...
}

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

Leave a comment