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.

Animated Set (CS193p fall of 2017, assignment IV solution)

This project is comprised of two parts: A Set game and a Concentration game, both presented by a UITabBarViewController. The Set game written in this app is a game based on the Set one, which is a popular card game (I’ve used this video to get its business logic). The Concentration is a simple game based on matches of card pairs, which are then removed from the table. All these projects were developed based on the previous assignments (I, II and III).

The fourth assignment required the student to add animations to the following events of the Set game:

  • All cards are smoothly rearranged in the grid, this is done using UIViewPropertyAnimator to animate each card’s frame.
  • All cards are dealt one by one at the beginning of the game and after the discovery of a match. Each card moves from the deck to its appropriate position in the cards grid. As per the requirements, two cards mustn’t be dealt at the same time. The animation uses UIViewPropertyAnimator combined with the UIDynamicAnimator class and a UISnapBehavior.
  • In the case of a match, cards are removed from the grid and put into the matched deck. This removal animation was also written using the UIViewPropertyAnimator, UIDynamicAnimator and UISnapBehavior classes.
  • The flipping over of cards. This animation was written with the transition API in the UIView class.
The usage of a UITabBarController to embed the Set and Concentration controllers was also required, and the Concentration tab also had to use a UISplitViewController, with a theme chooser as the master controller and the game’s controller as the detail one.
 Assignment IV project storyboard

Main challenges

I had some troubles before getting the deal animation working fine, it conflicted with the rearrangement of cards, which uses a UIViewPropertyAnimator to animate each card’s frame to its correct position in the grid. This happened mainly because of both animations modifying the same properties at the same time:

override func layoutSubviews() {
  super.layoutSubviews()
    
  // Only updates the buttons frames if the centered rect has changed,
  // This will occur when orientation changes.
  // This check will prevent frame changes while
  // the dynamic animator is doing it's job.
  if grid.frame != gridRect {
    updateViewsFrames()
  }
}

The deal animation can be triggered by the addition of new cards to the container and can also be independently called by using the dealCardsWithAnimation method in the container view:

/// Adds new buttons to the UI.
/// - Parameter byAmount: The number of buttons to be added.
/// - Parameter animated: Bool indicating if the addition should be animated.
func addButtons(byAmount numberOfButtons: Int = 3, animated: Bool = false) {
  guard isPerformingDealAnimation == false else { return }
    
  let cardButtons = makeButtons(byAmount: numberOfButtons)
    
  for button in cardButtons {
    // Each button is hidden and face down by default.
    button.alpha = 0
    button.isFaceUp = false
      
    addSubview(button)
    buttons.append(button)
  }
    
  grid.cellCount += cardButtons.count
  grid.frame = gridRect
    
  if animated {
    dealCardsWithAnimation()
  }
}
I also had troubles with the device orientation changes while the cards were being dealt. In order to solve this issue, I had to cancel any running animations and rearrange the cards after the transition was done. In the controllers I had the following code:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
  // In case the controller is still being presented and the
  // views haven't been instantiated.
  guard containerView != nil else { return }
    
  coordinator.animate(alongsideTransition: { _ in
    self.containerView.prepareForRotation()
  }) { _ in
    self.containerView.updateViewsFrames(withAnimation: true)
  }
}
 And in the container view:
/// Prepares the container for the device's rotation event.
/// Stops any running deal animations and respositions all the views.
func prepareForRotation() {
  animator.removeAllBehaviors()
    
  // Invalidates all scheduled deal animations.
  scheduledDealAnimations?.forEach { timer in
    if timer.isValid {
      timer.invalidate()
    }
  }
    
  positioningAnimator?.stopAnimation(true)
    
  for button in buttons {
    button.transform = .identity
    button.setNeedsDisplay()
  }
    
  isPerformingDealAnimation = false
}

Improvements

There’s some code that still needs refactoring. Also, none of the extra credit requirements regarding the Set game were executed, these are going to be my next steps.

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