Advanced Collection Views in OS X Tutorial

Gabriel Miro

Advanced Collection Views in OS X Tutorial

If you want to learn about the advanced capabilities of NSCollectionView, you’ve come to the right place. This is the second part of a tutorial that covered the basics, and in this Advanced Collection Views in OS X Tutorial, you step deeper into the encompassing world of collection views.

In this OS X tutorial, you’ll learn how:

  • To add, remove, move and reorder items
  • To implement drag and drop with collection views
  • To fine-tune selection and highlighting
  • To use animation in collection views
  • To implement sticky section headers

Prerequisites

You need basic knowledge of NSCollectionView, and you’ll need to know your way around the project from the Collection Views tutorial.

Getting Started

SlidesPro is the app you’re going to build, and it picks up where the previous tutorial left off.

Download the SlidesPro starter project here.

Build and run.

SlidesProStarterScreen

Add New Images to the Collection View

In this section, you’ll walk through the steps needed to add new items to the collection.

The Add Button

You’re not going to be able to add anything to that collection view until you make a way to do it. Good thing you’re a developer! What’s needed here is a button that displays a standard open panel from which you can choose images.

Open Main.storyboard and drag a Push Button into the bottom of the collection view. In the Attributes Inspector, set its Title to Add, and uncheck Enabled.

Add Slide Button

Select the Editor \ Resolve Auto Layout Issues \ Add Missing Constraints menu item to set the button’s Auto Layout constraints.

Build and run and check if you’ve got a button.

Add Button Added

Specify Where to Insert New Items

SlidesPro should be set up so that when you select an item, the new item is inserted starting at the index path of whatever image you’ve selected. Then this item and the rest of the section are pushed below the new items.

Accordingly, the add button can only be enabled when an item is selected.

In ViewController, add an IBOutlet for the button:

  @IBOutlet weak var addSlideButton: NSButton!

Next, open Main.storyboard and connect the outlet to the button.

You’ll need to track selection changes that will ultimately enable and disable the button inside of highlightItems(_: atIndexPaths:), a ViewController method. When items are selected or deselected it’s called by the two NSCollectionViewDelegate methods.

To do this, you just need to append one line to highlightItems(_: atIndexPaths:):

  func highlightItems(selected: Bool, atIndexPaths: Set<NSIndexPath>) {
    .......
    .......
    addSlideButton.enabled = collectionView.selectionIndexPaths.count == 1
  }

With this line you enable the button only when one item is selected.
Build and run. Verify that the button is enabled only when an item is selected.
First_Run

Insert the New Items

Adding new items to a collection view is a two-stage process. First, you add the items to the model then notify the collection view about the changes.

Note: When editing operations, such as add, remove and move, you must always update the model before you notify the collection view to tell it to update its layout.

To update your model, you’ll need to append the following to the ImageDirectoryLoader class:

  func insertImage(image: ImageFile, atIndexPath: NSIndexPath) {
    let imageIndexInImageFiles = sectionsAttributesArray[atIndexPath.section].sectionOffset + atIndexPath.item
    imageFiles.insert(image, atIndex: imageIndexInImageFiles)
    let sectionToUpdate = atIndexPath.section
    sectionsAttributesArray[sectionToUpdate].sectionLength += 1
    sectionLengthArray[sectionToUpdate] += 1
    if sectionToUpdate < numberOfSections-1 {
      for i in sectionToUpdate+1...numberOfSections-1 {
        sectionsAttributesArray[i].sectionOffset += 1
      }
    }
  }

This method inserts the new image to your data model and updates everything so that your model stays in a consistent state.

Add the following methods to ViewController. The first method is called from the IBAction method that’s triggered by clicking the add button, and then it’s followed by the action method:

   private func insertAtIndexPathFromURLs(urls: [NSURL], atIndexPath: NSIndexPath) {
    var indexPaths: Set<NSIndexPath> = []
    let section = atIndexPath.section
    var currentItem = atIndexPath.item
 
    // 1
    for url in urls {
      // 2
      let imageFile = ImageFile(url: url)
      let currentIndexPath = NSIndexPath(forItem: currentItem, inSection: section)
      imageDirectoryLoader.insertImage(imageFile, atIndexPath: currentIndexPath)
      indexPaths.insert(currentIndexPath)
      currentItem += 1
    }
 
    // 3
    collectionView.insertItemsAtIndexPaths(indexPaths)
  }
 
  @IBAction func addSlide(sender: NSButton) {
    // 4
    let insertAtIndexPath = collectionView.selectionIndexPaths.first!
    //5
    let openPanel = NSOpenPanel()
    openPanel.canChooseDirectories = false
    openPanel.canChooseFiles = true
    openPanel.allowsMultipleSelection = true;
    openPanel.allowedFileTypes = ["public.image"]
    openPanel.beginSheetModalForWindow(self.view.window!) { (response) -> Void in
      guard response == NSFileHandlingPanelOKButton else {return}
      self.insertAtIndexPathFromURLs(openPanel.URLs, atIndexPath: insertAtIndexPath)
    }
  }
  1. This iterates over the URLs chosen in the Open panel.
  2. For each URL, an ImageFile instance is created and added to the model.
  3. This notifies the collection view.
  4. The NSIndexPath of the selected item defines where the insertion starts.
  5. This creates an NSOpenPanel and configures it to only allow the selection of image files and shows it.

Open Main.storyboard and connect the addSlide(_:) IBAction to the button.

Build and run.

Select the last image in Section 1 — on my system it’s the Desert.jpg slide.

Click the Add button. In the Open panel navigate to the My Private Zoo folder inside the project’s root directory and select all files.

MyPrivateZoo

Click Open. The app will insert the new images in Section 1, starting at item 2, where Desert.jpg was before the insertion.

ZooBeforeAfter

Remove Items from the Collection View

To remove items in SlidesPro you’ll need a remove button, and it should sit next to the add button. The most logical implementation is that it should remove all selected items, hence, this button should be enabled only when one or more items are selected.

And then there’s this detail: multi-selection must be enabled to allow you to work with more than one image at a time.

This section will walk you through adding the button and enabling multi-select.

Enable Multi-Selection

Open Main.storyboard and select the Collection View. In the Attributes Inspector, check Allows Multiple Selection.

MultipleSelection

Build and run and verify that multi-selection works.

To expand or reduce a collection’s selection, press and hold the shift or command key while you click on various items. Multi-selections can reach across sections.

The Remove Button

Open Main.storyboard, and then drag a Push Button from the Object Library and place it to the left of the Add button.

In the Attributes Inspector, set its Title to Remove, and uncheck Enabled.
RemoveButton
Set the button’s Auto Layout constraints by selecting the Editor \ Resolve Auto Layout Issues \ Add Missing Constraints menu item.

Build and run.

RemoveBtnAdded

Add an IBOutlet in ViewController:

  @IBOutlet weak var removeSlideButton: NSButton!

Next, open Main.storyboard and connect the outlet to the button.

In ViewController, at the end of highlightItems(_: atIndexPaths:), add the line to enable/disable the remove button.

  func highlightItems(selected: Bool, atIndexPaths: Set<NSIndexPath>) {
    .......
    .......
    removeSlideButton.enabled = !collectionView.selectionIndexPaths.isEmpty
  }

Build and run, then select an item. Both the add and remove buttons should become enabled. Add more items to the selection; the add button should become disabled while the remove button stays enabled.

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.

Fix the UI

The current implementation of drag-and-drop in SlidesPro doesn’t support drop across sections. Also, multi-selection is supported only for a drop outside SlidesPro. To disable in UI, these unsupported capabilities change the else part of the second if statement to:

  func collectionView(collectionView: NSCollectionView, validateDrop draggingInfo: NSDraggingInfo, proposedIndexPath
    proposedDropIndexPath: AutoreleasingUnsafeMutablePointer<NSIndexPath?>, dropOperation proposedDropOperation: UnsafeMutablePointer<NSCollectionViewDropOperation>) -> NSDragOperation {
    if proposedDropOperation.memory == NSCollectionViewDropOperation.On {
      proposedDropOperation.memory = NSCollectionViewDropOperation.Before
    }
    if indexPathsOfItemsBeingDragged == nil {
      return NSDragOperation.Copy
    } else {
      let sectionOfItemBeingDragged = indexPathsOfItemsBeingDragged.first!.section
      // 1
      if let proposedDropsection = proposedDropIndexPath.memory?.section where sectionOfItemBeingDragged == proposedDropsection && indexPathsOfItemsBeingDragged.count == 1 {
        return NSDragOperation.Move
      } else {
        // 2
        return NSDragOperation.None
      }
    }
  }
  1. The drop is enabled only when the source and target sections match and exactly one item is selected.
  2. Otherwise, it prevents the drop by returning .None

Build and run. Try dragging an item from one section to another. The drop indicator does not present itself, meaning a drop is impossible.

Now drag a multi-selection. While inside the bounds of the collection view there is no drop indicator; however, drag it to Finder, and you’ll see that a drop is allowed.

Note: If you tried to drag the selection outside the collection view, you might have noticed a highlight issue. You’ll come back to this in the upcoming section, “More Fun With Selection and Highlighting”.

More Fun With Selection and Highlighting

In the previous section, you noticed an issue with highlighting.

For the sake of sanity in this discussion, the item being moved will be Item-1. After Item-1 lands at a new position it stays highlighted, and the Add and Remove buttons are enabled, but the selection is empty.

To confirm this is true, select any item — Item-2. It highlights as expected, but Item-1 stays highlighted. It should have been deselected and the highlight removed when you selected Item-2.

Click anywhere between the items to deselect everything. Item-2’s highlight goes away, the Add and Remove buttons are disabled, as they should be for no selection, but Item-1 is still highlighted.

Note: The collection view tracks its selection in its selectionIndexPaths property. To debug, you can insert print statements to show the value of this property.

So what’s going wrong here?

Apparently, the collection view successfully deselects Item-1, but the collectionView(_:didDeselectItemsAtIndexPaths: ) delegate method is not called to remove the highlight and disable the buttons.

In NSCollectionView.h, the comments for the above method and its companion for the select action say, “Sent at the end of interactive selection…”. Hence, these notifications are sent only when you select/deselect via UI.

Here’s your answer, Sherlock: The deselection behavior that should occur when you’re moving an item is performed programmatically via the deselectItemsAtIndexPaths(_:) method of NSCollectionView.

You’ll need to override this method.

Go to File \ New \ File… and create a new Cocoa Class by the name CollectionView make it a subclass of NSCollectionView and put it in the Views group.

The template may add a drawRect(_:) — make sure to delete it.

EmptyCollectionView

Add the following method to CollectionView:

  override func deselectItemsAtIndexPaths(indexPaths: Set<NSIndexPath>) {
    super.deselectItemsAtIndexPaths(indexPaths)
    let viewController = delegate as! ViewController
    viewController.highlightItems(false, atIndexPaths: indexPaths)
  }

The method calls its super implementation followed by a call to highlightItems(_:atIndexPaths:) of its delegate, allowing ViewController to highlight/unhighlight items and enable/disable buttons respectively.

Open Main.storyboard and select the Collection View. In the Identity Inspector, change Class to CollectionView.

CollectionViewIB

Build and run.

Move an item inside the collection to a different location. Nothing shows as highlighted and buttons disable as expected. Case closed.

Animation in Collection Views

NSCollectionView, as a subclass of NSView, can perform animations via the animator proxy. It’s as easy as adding a single word in your code before an operation such as removal of items.

At the end of the removeSlide(_:) method in ViewController, replace this:

    collectionView.deleteItemsAtIndexPaths(selectionIndexPaths)

With this:

    collectionView.animator().deleteItemsAtIndexPaths(selectionIndexPaths)

Build and run.

Select several items and click the Remove button. Watch as the items glide to take up their new positions on the screen.

The default duration is a quarter of a second. To experience a really cool and beautiful effect, add a setting for the duration of the animation at a higher value. Place it above the line you just added:

    NSAnimationContext.currentContext().duration = 1.0
    collectionView.animator().deleteItemsAtIndexPaths(selectionIndexPaths)

Build and run, and then remove some items. Cool effect, isn’t it?

You can do the same for insertItemsAtIndexPaths when you’re adding items, as well as for moveItemAtIndexPath when moving an item.

Sticky Headers

When you scroll a collection view with section headers, the first element of a given section that vanishes at the top of the screen is its header.

In this section, you’ll implement Sticky Headers, so the top-most section header will pin itself to the top of the collection view. It will hold its position until the next section header bumps it out of the way.

StickyHeadersScreen

To make this effect reality, you’ll subclass NSCollectionViewFlowLayout.

Go to File \ New \ File… and create a new Cocoa Class named StickyHeadersLayout as a subclass of NSCollectionViewFlowLayout, and put it in the Layout group.

StickyHeaderSkeleton

In ViewController, change the first line of configureCollectionView() to:

    let flowLayout = StickyHeadersLayout()

Now implement sticky headers by adding the following method to the empty body of the StickyHeadersLayout class:

  override func layoutAttributesForElementsInRect(rect: NSRect) -> [NSCollectionViewLayoutAttributes] {
 
    // 1
    var layoutAttributes = super.layoutAttributesForElementsInRect(rect)
 
    // 2
    let sectionsToMoveHeaders = NSMutableIndexSet()
    for attributes in layoutAttributes {
      if attributes.representedElementCategory == .Item {
        sectionsToMoveHeaders.addIndex(attributes.indexPath!.section)
      }
    }
 
    // 3
    for attributes in layoutAttributes {
      if let elementKind = attributes.representedElementKind where elementKind == NSCollectionElementKindSectionHeader {
        sectionsToMoveHeaders.removeIndex(attributes.indexPath!.section)
      }
    }
 
    // 4
    sectionsToMoveHeaders.enumerateIndexesUsingBlock { (index, stop) -> Void in
      let indexPath = NSIndexPath(forItem: 0, inSection: index)
      let attributes = self.layoutAttributesForSupplementaryViewOfKind(NSCollectionElementKindSectionHeader, atIndexPath: indexPath)
      if attributes != nil {
        layoutAttributes.append(attributes!)
      }
    }
 
    for attributes in layoutAttributes {
      // 5
      if let elementKind = attributes.representedElementKind where elementKind == NSCollectionElementKindSectionHeader {
        let section = attributes.indexPath!.section
        let attributesForFirstItemInSection = layoutAttributesForItemAtIndexPath(NSIndexPath(forItem: 0, inSection: section))
        let attributesForLastItemInSection = layoutAttributesForItemAtIndexPath(NSIndexPath(forItem: collectionView!.numberOfItemsInSection(section) - 1, inSection: section))
        var frame = attributes.frame
 
        // 6
        let offset = collectionView!.enclosingScrollView?.documentVisibleRect.origin.y
 
        // 7
        let minY = CGRectGetMinY(attributesForFirstItemInSection!.frame) - frame.height
 
        // 8
        let maxY = CGRectGetMaxY(attributesForLastItemInSection!.frame) - frame.height
 
        // 9
        let y = min(max(offset!, minY), maxY)
 
        // 10
        frame.origin.y = y
        attributes.frame = frame
 
        // 11
        attributes.zIndex = 99
      }
    }
 
    // 12
    return layoutAttributes
  }

Okay, there’s a lot happening in there, but it makes sense when you take it section by section:

  1. The super method returns an array of attributes for the visible elements.
  2. The NSMutableIndexSet first aggregates all the sections that have at least one visible item.
  3. Remove all sections from the set where the header is already in layoutAttributes, leaving only the sections with “Missing Headers” in the set.
  4. Request the attributes for the missing headers and add them to layoutAttributes.
  5. Iterate over layoutAttributes and process only the headers.
  6. Set the coordinate for the top of the visible area, aka scroll offset.
  7. Make it so the header never goes further up than one-header-height above the upper bounds of the first item in the section.
  8. Make it so the header never goes further down than one-header-height above the lower bounds of the last item in the section.
  9. Let’s break this into 2 statements:
    1. maybeY = max(offset!, minY): When the top of the section is above the visible area this pins (or pushes down) the header to the top of the visible area.
    2. y = min(maybeY, maxY): When the space between the bottom of the section to the top of the visible area is less than header height, it shows only the part of the header’s bottom that fits this space.
  10. Update the vertical position of the header.
  11. Make the items “go” under the header.
  12. Return the updated attributes.

Add the following method to StickyHeadersLayout:

  override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
    return true
  }

You always return true because you want the to invalidate the layout as the user scrolls.

Build and run.

Scroll the collection to see your sticky headers in action.

Where To Go From Here

Download the final version of SlidesPro here.

In this Advanced Collection Views in OS X Tutorial you covered a lot of ground! You took the collection view from a rudimentary app to one that features the kinds of bells and whistles any Mac user would expect.

After all your hard work, you’re able to add and remove items, reorder them, and troubleshoot and correct highlighting/selection issues. You took it to the next level by adding in animations and implemented sticky headers to give SlidesPro a very polished look.

Most impressively, you now know how to build a functional, elegant collection view in OS X. Considering that the documentation for these is fairly limited, it’s a great skill to have.

Some of the topics that were not covered neither here nor in the basic tutorial are:

  • Creating custom layouts by subclassing directly NSCollectionViewLayout
  • “Data Source-less” collection views using Cocoa Bindings

One of the top resources recommended at the end of the basic tutorial is this excellent video tutorial series Custom Collection View Layout from Mic Pringle. Although it’s an iOS series, you can find lots of useful information that’s relevant to collection views in OS X as well.

I hope you found this tutorial most helpful! Let’s talk about it in the forums. I look forward to your questions, comments and discoveries!

Gabriel Miro

There is a long list of Operating Systems, languages, technologies, companies (large and small) I have worked with. The short version is as follows. Today I am an indie iOS an OS X developer. Started back in the seventies as a system programmer for main frames. I am programming for the Mac since 1984 when the Mac was born. Between 1984-1998 I was the co-owner of a Software House, specialising in multi-lingual word processors and desktop publishing applications for the Mac. Was a freelancer for a few years, then joined IBM as a Senior Engineer in development. After leaving IBM, I returned to my old love, i.e. development for the Apple platforms.

I love cats, photography, cooking, reading, music (listening and playing).

Other Items of Interest

Save time.
Learn more with our video courses.

raywenderlich.com Weekly

Sign up to receive the latest tutorials from raywenderlich.com each week, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

PragmaConf 2016 Come check out Alt U

Our Books

Our Team

Video Team

... 20 total!

Swift Team

... 16 total!

iOS Team

... 28 total!

Android Team

... 15 total!

macOS Team

... 10 total!

Apple Game Frameworks Team

... 11 total!

Unity Team

... 11 total!

Articles Team

... 11 total!

Resident Authors Team

... 15 total!