Advanced Collection Views in OS X Tutorial

Become an OS X collection view boss! In this tutorial, you’ll learn how to implement drag and drop with collection views, fine-tune selection and highlighting, implement sticky section headers, and more. By Gabriel Miro.

3.4 (5) · 1 Review

Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Enable Removal of Items

Now you'll add the code that removes items from the collection. As it is with adding, removing is a two-stage process where you must remove images from the model before notifying the collection view about the changes.

To update the model, add the following method at the end of the ImageDirectoryLoader class:

  func removeImageAtIndexPath(indexPath: NSIndexPath) -> ImageFile {
    let imageIndexInImageFiles = sectionsAttributesArray[indexPath.section].sectionOffset + indexPath.item
    let imageFileRemoved = imageFiles.removeAtIndex(imageIndexInImageFiles)
    let sectionToUpdate = indexPath.section
    sectionsAttributesArray[sectionToUpdate].sectionLength -= 1
    if sectionToUpdate < numberOfSections-1 {
      for i in sectionToUpdate+1...numberOfSections-1 {
        sectionsAttributesArray[i].sectionOffset -= 1
      }
    }
    return imageFileRemoved
  }

In ViewController, add the IBAction method that's triggered when you click the Remove button:

  @IBAction func removeSlide(sender: NSButton) {
    
    let selectionIndexPaths = collectionView.selectionIndexPaths
    if selectionIndexPaths.isEmpty {
      return
    }
    
    // 1
    var selectionArray = Array(selectionIndexPaths)
    selectionArray.sortInPlace({path1, path2 in return path1.compare(path2) == .OrderedDescending})
    for itemIndexPath in selectionArray {
      // 2
      imageDirectoryLoader.removeImageAtIndexPath(itemIndexPath)
    }
    
    // 3
    collectionView.deleteItemsAtIndexPaths(selectionIndexPaths)
  }

Here's what happens in there:

  1. Creates an array to iterate over the selection in descending order regarding index paths, so you don’t need to adjust index path values during the iteration
  2. Removes selected items from the model
  3. Notifies the collection view that items have been removed

Now open Main.storyboard and connect the removeSlide(_:) IBAction to the button.

This is how the Connections Inspector of View Controller should look after adding the outlets and actions:

SlidesProActionsOutlets

Build and run.

Select one or more images and click the Remove button to verify that it successfully removes the items.

Drag and Drop in Collection Views

One of the best things about OS X is that you can drag and drop items to move or copy them to different apps. Users expect this behavior, so you'd be wise to add it to anything you decide to put out there.

With SlidesPro, you'll use drag-and-drop to implement the following capabilities:

  • Move items inside the collection view
  • Drag image files from other apps into the collection view
  • Drag items from the collection view into other apps

To support drag-and-drop, you'll need to implement the relevant NSCollectionViewDelegate methods, but you have to register the kind of drag-and-drop operations SlidesPro supports.

Add the following method to ViewController:

  func registerForDragAndDrop() {
    // 1
    collectionView.registerForDraggedTypes([NSURLPboardType])
    // 2
    collectionView.setDraggingSourceOperationMask(NSDragOperation.Every, forLocal: true)
    // 3
    collectionView.setDraggingSourceOperationMask(NSDragOperation.Every, forLocal: false)
  }

In here, you've:

  1. Registered for the dropped object types SlidesPro accepts
  2. Enabled dragging items within and into the collection view
  3. Enabled dragging items from the collection view to other applications

At the end of viewDidLoad(), add:

    registerForDragAndDrop()

Build and run.

Try to drag an item -- the item will not move. Drag an image file from Finder and try to drop it on the collection view…nada.

What about left-clicking for 5 seconds...while kissing my elbow?

I asked you to perform this test so you can see that items aren't responding to dragging, and nothing related to drag-and-drop works. Why is that? You'll soon discover.

The first issue is that there needs to be some additional logic to handle the action, so append the following methods to the NSCollectionViewDelegate extension of ViewController:

  // 1
  func collectionView(collectionView: NSCollectionView, canDragItemsAtIndexes indexes: NSIndexSet, withEvent event: NSEvent) -> Bool {
    return true
  }
  
  // 2
  func collectionView(collectionView: NSCollectionView, pasteboardWriterForItemAtIndexPath indexPath: NSIndexPath) -> NSPasteboardWriting? {
    let imageFile = imageDirectoryLoader.imageFileForIndexPath(indexPath)
    return imageFile.url.absoluteURL
  }

Here's what's happening in here:

  1. When the collection view is about to start a drag operation, it sends this message to its delegate. The return value indicates whether the collection view is allowed to initiate the drag for the specified index paths. You need to be able to drag any item, so you return unconditionally true.
  2. Implementing this method is essential so the collection view can be a Drag Source. If the method in section one allows the drag to begin, the collection view invokes this method one time per item to be dragged. It requests a pasteboard writer for the item's underlying model object. The method returns a custom object that implements NSPasteboardWriting; in your case it's NSURL. Returning nil prevents the drag.

Build and run.

Try to drag an item, the item moves… Hallelujah!

Perhaps I spoke too soon? When you try to drop the item in a different location in the collection view, it just bounces back. Why? Because you did not define the collection view as a Drop Target.

Now try to drag an item and drop it in Finder; a new image file is created matching the source URL. You have made progress because it works to drag-and-drop from SlidesPro to another app!

Define Your Drop Target

Add the following property to ViewController:

  var indexPathsOfItemsBeingDragged: Set<NSIndexPath>!

Add the following methods to the NSCollectionViewDelegate extension of ViewController:

  // 1
  func collectionView(collectionView: NSCollectionView, draggingSession session: NSDraggingSession, willBeginAtPoint screenPoint: NSPoint, forItemsAtIndexPaths indexPaths: Set<NSIndexPath>) {
    indexPathsOfItemsBeingDragged = indexPaths
  }

  // 2
  func collectionView(collectionView: NSCollectionView, validateDrop draggingInfo: NSDraggingInfo, proposedIndexPath
    proposedDropIndexPath: AutoreleasingUnsafeMutablePointer<NSIndexPath?>, dropOperation proposedDropOperation: UnsafeMutablePointer<NSCollectionViewDropOperation>) -> NSDragOperation {
    // 3
    if proposedDropOperation.memory == NSCollectionViewDropOperation.On {
      proposedDropOperation.memory = NSCollectionViewDropOperation.Before
    }
    // 4
    if indexPathsOfItemsBeingDragged == nil {
      return NSDragOperation.Copy
    } else {
        return NSDragOperation.Move
    }
  }

Here's a section-by-section breakdown of this code:

  1. An optional method is invoked when the dragging session is imminent. You'll use this method to save the index paths of the items that are dragged. When this property is not nil, it's an indication that the Drag Source is the collection view.
  2. Implement the delegation methods related to drop. This method returns the type of operation to perform.
  3. In SlidesPro, the items aren't able to act as containers; this allows dropping between items but not dropping on them.
  4. When moving items inside the collection view, the operation is Move. When the Dragging Source is another app, the operation is Copy.

Build and run.

Drag an item. After you move it, you'll see some weird gray rectangle with white text. As you keep moving the item over the other items, the same rectangle appears in the gap between the items.

HeaderAsInterGapIndicator

What is happening?

Inside of ViewController, look at the DataSource method that's invoked when the collection view asks for a supplementary view:

  func collectionView(collectionView: NSCollectionView, viewForSupplementaryElementOfKind kind: String, atIndexPath indexPath: NSIndexPath) -> NSView {
    let view = collectionView.makeSupplementaryViewOfKind(NSCollectionElementKindSectionHeader, withIdentifier: "HeaderView", forIndexPath: indexPath) as! HeaderView
    view.sectionTitle.stringValue = "Section \(indexPath.section)"
    let numberOfItemsInSection = imageDirectoryLoader.numberOfItemsInSection(indexPath.section)
    view.imageCount.stringValue = "\(numberOfItemsInSection) image files"
    return view
  }

When you start dragging an item, the collection view’s layout asks for the interim gap indicator’s supplementary view. The above DataSource method unconditionally assumes that this is a request for a header view. Accordingly, a header view is returned and displayed for the inter-item gap indicator.

None of this is going to work for you so replace the content of this method with:

  func collectionView(collectionView: NSCollectionView, viewForSupplementaryElementOfKind kind: String, atIndexPath indexPath: NSIndexPath) -> NSView {
    // 1
    let identifier: String = kind == NSCollectionElementKindSectionHeader ? "HeaderView" : ""
    let view = collectionView.makeSupplementaryViewOfKind(kind, withIdentifier: identifier, forIndexPath: indexPath)
    // 2
    if kind == NSCollectionElementKindSectionHeader {
      let headerView = view as! HeaderView
      headerView.sectionTitle.stringValue = "Section \(indexPath.section)"
      let numberOfItemsInSection = imageDirectoryLoader.numberOfItemsInSection(indexPath.section)
      headerView.imageCount.stringValue = "\(numberOfItemsInSection) image files"
    }
    return view
  }

Here's what you did in here:

  1. You set the identifier according to the kind parameter received. When it isn't a header view, you set the identifier to the empty String. When you pass to makeSupplementaryViewOfKind an identifier that doesn't have a matching class or nib, it will return nil. When a nil is returned, the collection view uses its default inter-item gap indicator. When you need to use a custom indicator, you define a nib (as you did for the header) and pass its identifier instead of the empty string.
  2. When it is a header view, you set up its labels as before.
Note: There is a bug in the Swift API regarding the method above, and the makeItemWithIdentifier and makeSupplementaryViewOfKind methods. The return value specified is NSView, but these methods may return nil so the return value should be NSView? -- the question mark is part of the value.

Build and run.

Now you see an unmistakable aqua vertical line when you drag an item, indicating the drop target between the items. It's a sign that the collection view is ready to accept the drop.

InterItemAnimation

Well…it's sort of ready. When you try to drop the item, it still bounces back because the delegate methods to handle the drop are not in place yet.

Append the following method to ImageDirectoryLoader:

  // 1
  func moveImageFromIndexPath(indexPath: NSIndexPath, toIndexPath: NSIndexPath) {

    // 2
    let itemBeingDragged = removeImageAtIndexPath(indexPath)

    let destinationIsLower = indexPath.compare(toIndexPath) == .OrderedDescending
    var indexPathOfDestination: NSIndexPath
    if destinationIsLower {
      indexPathOfDestination = toIndexPath
    } else {
      indexPathOfDestination = NSIndexPath(forItem: toIndexPath.item-1, inSection: toIndexPath.section)
    }
    // 3
    insertImage(itemBeingDragged, atIndexPath: indexPathOfDestination)
  }

Here's what's going on in there:

  1. Call this method to update the model when items are moved
  2. Remove the dragged item from the model
  3. Reinsert at its new position in the model

Finish things off here by adding the following methods to the NSCollectionViewDelegate extension in ViewController:

  // 1
  func collectionView(collectionView: NSCollectionView, acceptDrop draggingInfo: NSDraggingInfo, indexPath: NSIndexPath, dropOperation: NSCollectionViewDropOperation) -> Bool {
    if indexPathsOfItemsBeingDragged != nil {
      // 2
      let indexPathOfFirstItemBeingDragged = indexPathsOfItemsBeingDragged.first!
      var toIndexPath: NSIndexPath
      if indexPathOfFirstItemBeingDragged.compare(indexPath) == .OrderedAscending {
        toIndexPath = NSIndexPath(forItem: indexPath.item-1, inSection: indexPath.section)
      } else {
        toIndexPath = NSIndexPath(forItem: indexPath.item, inSection: indexPath.section)
      }
      // 3
      imageDirectoryLoader.moveImageFromIndexPath(indexPathOfFirstItemBeingDragged, toIndexPath: toIndexPath)
      // 4
      collectionView.moveItemAtIndexPath(indexPathOfFirstItemBeingDragged, toIndexPath: toIndexPath)
    } else {
      // 5
      var droppedObjects = Array<NSURL>()
      draggingInfo.enumerateDraggingItemsWithOptions(NSDraggingItemEnumerationOptions.Concurrent, forView: collectionView, classes: [NSURL.self], searchOptions: [NSPasteboardURLReadingFileURLsOnlyKey : NSNumber(bool: true)]) { (draggingItem, idx, stop) in
        if let url = draggingItem.item as? NSURL {
          droppedObjects.append(url)
        }
      }
      // 6
      insertAtIndexPathFromURLs(droppedObjects, atIndexPath: indexPath)
    }
    return true
  }
  
  // 7
  func collectionView(collectionView: NSCollectionView, draggingSession session: NSDraggingSession, endedAtPoint screenPoint: NSPoint, dragOperation operation: NSDragOperation) {
    indexPathsOfItemsBeingDragged = nil
  }

Here's what happens with these methods:

  1. This is invoked when the user releases the mouse to commit the drop operation.
  2. Then it falls here when it's a move operation.
  3. It updates the model.
  4. Then it notifies the collection view about the changes.
  5. It falls here to accept a drop from another app.
  6. Calls the same method in ViewController as Add with URLs obtained from the NSDraggingInfo.
  7. Invoked to conclude the drag session. Clears the value of indexPathsOfItemsBeingDragged.

Build and run.

Now it's possible to move a single item to a different location in the same section. Dragging one or more items from another app should work too.

Gabriel Miro

Contributors

Gabriel Miro

Author

Over 300 content creators. Join our team.