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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Advanced Collection Views in OS X Tutorial
30 mins
- Prerequisites
- Getting Started
- Add New Images to the Collection View
- The Add Button
- Specify Where to Insert New Items
- Insert the New Items
- Remove Items from the Collection View
- Enable Multi-Selection
- The Remove Button
- Enable Removal of Items
- Drag and Drop in Collection Views
- Define Your Drop Target
- Fix the UI
- More Fun With Selection and Highlighting
- Animation in Collection Views
- Sticky Headers
- Where To Go From Here
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:
- 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
- Removes selected items from the model
- 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:
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:
- Registered for the dropped object types SlidesPro accepts
- Enabled dragging items within and into the collection view
- 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.
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:
- 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 unconditionallytrue
. - 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'sNSURL
. Returningnil
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:
- 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 notnil
, it's an indication that the Drag Source is the collection view. - Implement the delegation methods related to drop. This method returns the type of operation to perform.
- In SlidesPro, the items aren't able to act as containers; this allows dropping between items but not dropping on them.
- When moving items inside the collection view, the operation is
Move
. When the Dragging Source is another app, the operation isCopy
.
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.
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:
- You set the
identifier
according to thekind
parameter received. When it isn't a header view, you set theidentifier
to the emptyString
. When you pass tomakeSupplementaryViewOfKind
anidentifier
that doesn't have a matching class or nib, it will returnnil
. When anil
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. - When it is a header view, you set up its labels as before.
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.
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:
- Call this method to update the model when items are moved
- Remove the dragged item from the model
- 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:
- This is invoked when the user releases the mouse to commit the drop operation.
- Then it falls here when it's a move operation.
- It updates the model.
- Then it notifies the collection view about the changes.
- It falls here to accept a drop from another app.
- Calls the same method in
ViewController
as Add with URLs obtained from theNSDraggingInfo
. - 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.