NSCollectionView Part 1

Xcode 9b/Swift 4/macOS 10.12

(This tutorial has been updated and tested under Xcode 9 since the previous instructions no longer worked to create new projects, which is exactly the same problem I had going from 7 to 8/10.10 to 10.11. I currently have one project that will compile and run under Xcode 8, but crash badly under Xcode 9 – it’s exactly the same code, running on exactly the same system (10.12.5). I’d love to continue to support older versions, but my time is limited.)

The 10.11 version was using Bindings, but until I’ve figured out what the current approach to bindings is, I am going back to populating cells manually. This has the advantage that you can use structs as your data type, though I must add a warning here: elsewhere (in NSOutlineViewDataSource) I have found that casting from Any to MyProtocol fails when the item conforming to MyProtocol is a struct, and works nicely when it is a class. I don’t know the origin of that bug, but it’s worth flagging up that structs may cause unexpected problems when dealing with a collection/API that expects classes.

While most of the pre-10.11 APIs are still available, they are being softly deprecated: With ElCapitan, NSCollectionView has been given a thorough overhaul. It now allows sections (yay!) and custom layouts (defer yay //work out how to *actually* design one; and more importantly, how to bring the selected item to the front), but this means that NSCollectionView now uses index paths instead of Int indexes. Xcode had a long-running problem of creating a ‘prototype’ segue to a prototype item in the storyboard which had then to be deleted; this has now been fixed.

Setting up an NSCollectionView used to be really easy. You’d need a content array; you’d create an NSCollectionView, arrange the view of its NSCollectionViewItem to your satisfaction, and you could use bindings to connect the textfields and imageViews to properties on your data.

The ElCapitan onwards version is much more complicated than that. I am, on the whole, very unhappy that after many years of writing _less_ boilerplate code, we now seem to be writing *more*: the new style NSCollectionView (just like NSTableView with grouped data) needs a datasource to display data and a delegate to do anything with it (like select items). At the same time, Apple does not offer a clear instruction/guide for setting up NSCollectionView or a good example datasource model which allows easy access to/easy binding to an NSCollectionView.

(Bindings are a contentious topic. They’re a legacy ObjectiveC technology, so many Swift developers avoid them (they work only with NSObject subclasses). I haven’t looked at the WWDC 2017 mentioning (in one of the ‘here’s five tips’ sessions), but overall I got the impression that Apple was trying to phase out bindings. If the new KVO implementation leads to new bindings, I will be over the moon. For now, I have decided against them.)

NSCollectionView: Displaying data (with groups and headers; without interactivity)
1) Model setup: Create a class Animal which inherits from NSObject and which has a animalName property and an init(name: String) initialiser.
For the setup I am using – binding interface elements to the collectionView’s representedObject – this needs to be an NSObject Subclass. I have opted for this method because, although I am certain I have used different methods in the past, this seems to be the most stable (and least painful, not to mention minimal-code) solution.

2) IB setup
To set up an NSCollectionView, you need two things.
a) Drag an NSCollectionView from the library to your location.
b) in the Attributes Inspector, set the layout to ‘Grid’. Or ‘Flow’. Do not set it to ‘ContentArray (Legacy)’ because if you do, none of the other steps will matter, because your collection items Will Not Show Up. Set the item size to a reasonable number (eg, 150 x 100). I also like to set the section inset to 10px.

c) Go to File -> New File -> Cocoa Class and subclass NSCollectionViewItem, let’s call it ‘AnimalDisplay’. Check the ‘create XIB’ box.

To cut a very long story short: as of writing (Xcode 8, macOS 10.11), there seems to be absolutely no way of creating a 10.11 (not legacy) collectionView _entirely_ in storyboards. After looking at 20+ examples, and trying every possible permutation of attempting to register an NSCollectionViewItem subclass, I have come to the conclusion that it is not possible: register class then calls init?(nibName: bundle:), and although I did try to override loadView() as recommended by Apple’s documentation for instantiating NSViewController subclasses manually, I ended up with a situation where the collectionItem was created, but not returned by makeItemForIndexPath.
My most promising effort involved adding an NSCollectionViewItem to the Storyboard, giving it an ID of AnimalDisplay, but in the datasource, I got as far as creating an item which then fled into nil.
No, I have no idea how you can create an item which turns into nil the next moment. In Swift, this should not be possible: once a strong reference is created with ‘let’, it should remain. It doesn’t.

At this point, I’m giving up and creating a separate Xib file like everybody else.

d) Fluff the interface up a bit: definitely give it an animalName label; and play around with a way of making it visible. Give the xib the size you set for your collectionView items in step 2b. The easiest way is an NSBox. Create an outlet for your animalNameLabel.

e)Add an instance of a CollectionViewItem to your new .xib file and change its class to AnimalDisplay.
(I’m not certain where this is documented, I got it from a number of tutorials – if you try to run without it it will crash because it unexpectedly found nil; I’m not certain how one would know if nobody else has worked it out first.) This works slightly differently than Controllers and their associated views work normally, especially in storyboards: here you basically instantiate an instance of the nib file (AnimalDisplay.xib) which in turn instantiates an instance of its controller class (AnimalDisplay: NSCollectionViewItem).

f) Connect the _object’s_ animalLabel outlet to the label in the view. For some reason – probably related to the timing of instantiation – if I simply connect the outlet in AnimalDisplay.swift to the label, it mostly tends to be nil and cause a crash. I wasted my time so you don’t have to.

g) In Xcode 8, you had to connect that AnimalDisplay’s ‘view’ outlet to the view in your xib to display items; in Xcode 9 this should happen automatically, but if your items don’t show up, that’s a good thing to check.

h) If you’re using bindings (and objects inheriting from NSObject), bind the label’s ‘value’ to your AnimalDisplay object with a model key path of representedObject.animalName. IB will give you a (justified) warning: representedObject could be anything, and does not necessarily have an animalName property.

In a previous version of this post, I used

override var representedObject: Any? {
didSet {
if let myAnimal = representedObject as? Animal {
animalLabel.stringValue = myAnimal.animalName
}
}
}

[an alternative is a specific variable – var representedAnimal: Animal, which does not need to be cast]

(and in the first instance set the label’s value directly when the datasource created the item), but in Xcode 8.1 when I create a new project, the outlets are still nil when the representedObject gets set, and thus the application will crash. I have legacy projects built with older versions of Xcode which have been converted to Swift 3 which also have a build target of 10.11 which use the above code and which do not crash. Go figure. In Xcode 9b, this seems to be mainly fixed, but I still ended up with a project where the datasource (see below) failed, even though an identical file works fine.

This is a last resort to fix things once you’ve checked all IB connections and gone over the connections with a fine-toothed comb. Doing a new NSCollectionViewItem/.xib combination with the same setup should not fix things, but did for me.

3) DataSource setup
As with NSTableView, the common thing is to have the same object function both as datasource and as delegate; I prefer to keep my data sources separate. I’ll start with a SingleSectionSource since I find it much easier to use separate files to work things out. This way, I can implement a source for a single section, then swap it for a source handling multiple sections, and I have the old working code in case I need to look at it again or in case I want to test features in the future where more sophistication = more complication = more potential errors.

In Xcode 9, if you add a protocol, you get not only the ‘does not conform’ message, but a Fixit offer to add method stubs. If you accept that, it will prefix methods with @available(OSX 10.11, *) – this can be safely deleted since this NSCollectionView would not work under 10.10 anyway.

a) Create
class SingleSectionSource: NSObject, NSCollectionViewDataSource

and add the following property:
var allAnimals: [Animal] = [Animal(name: “Cow”), Animal(name: “Dog”)]

and add an SingleSectionSource object to the ViewControllerScene in the Storyboard.

b) Implement the NSCollectionViewDataSource protocol:

func numberOfSections(in collectionView: NSCollectionView) -> Int {
return 1
}

This function turns out to be optional – it is not needed for the NSCollectionView to work correctly with one section. However, the moment you are looking to use grouped data, you’ll *really* miss it.

func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
return allAnimals.count
}

func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "AnimalDisplay"), for: indexPath) as? AnimalDisplay
item?.representedObject = allAnimals[indexPath.item] return item!
}

Yeah. I’m not happy about the force unwrap, but that seems somewhat inevitable, and I’m simply returning the position of the item in the allAnimals array.

NSUserInterfaceItemIdentifier(rawValue: “AnimalDisplay”) is the new, Swift 4 answer to stringly-typed items. This is the name of your NScollectionViewItem’s xib file. Note you can still easily mistype it.

c) connect the collectionView’s ‘dataSource’ outlet to the SingleDataSource object in the same scene.

Build and Run.

ScreenShot2016-08-06at23.49.45-2016-08-6-10-00.png

(This shows the AnimalDisplay.xib and the resulting window.)

4) Add fish!

a) create a FishDisplay NSCollectionViewItem with associated nib, add a FishDisplay instance to the nib and add a label and bind the label’s value to the representedObject/create a disSet observer for represented object as in 2h)

b) create a habitat enum:
enum Habitat {
case land
case water
}

c) add a habitat property to your animal and create an appropriate initializer

d) create different collectionViewItems according to habitat:

func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
let current = allAnimals[indexPath.item]

switch current.habitat {
case .land:
let item = collectionView.makeItem(withIdentifier: "AnimalDisplay", for: indexPath) as? AnimalDisplay
item?.representedObject = allAnimals[indexPath.item] return item!

case .water:
let item = collectionView.makeItem(withIdentifier: "FishDisplay", for: indexPath) as? FishDisplay
item?.representedObject = allAnimals[indexPath.item] return item!
}
}

Build and Run. The collectionView is happy to use different nibs to create items.

ScreenShot2016-08-07at12.59.33-2016-08-6-10-00.png

6) Let’s make this a bit more obvious by widening the land animals and adding extra depth to fish. (In Xcode 8b4/MacOS 10.11.5 I have had a bit of a bug with the item width – in the ‘Flow Layout’ section of IB, I had to set it to much wider than the item for all of the item to show up; once all of it had been displayed, I could reduce the width again.

ScreenShot2016-08-07at13.10.23-2016-08-6-10-00.png

If you don’t set autolayout properly and simply resize the window at will, you can get some fun error messages:

2016-08-07 13:22:17.814 Simple CollectionView[2300:182496] the behavior of the UICollectionViewFlowLayout is not defined because:
2016-08-07 13:22:17.814 Simple CollectionView[2300:182496] the item height must be less than the height of the UICollectionView minus the section insets top and bottom values.

Note that I am using NSCollectionView here…

7) The whole point of using the new API and the datasource is that we want sections in our collectionView. Let’s implement that.

This code has two basic assumptions:

– all animals will have tags (animals without tags are not displayed)
– animals should be displayed in all of the categories they are tagged with (rather than just the first)

a) Create a Tag struct

struct Tag {
let name: String
let animals: [Animal] let sectionNumber: Int
}

b) Add a tags property to the animal model and update the initializer.

c) Create a MultipleSections.swift file that inherits from NSObject and conforms to NSCollectionViewDataSource protocol. Add an instance of it to the ViewController scene in the storyboard; set it to be the collectionView’s datasource.

var allAnimals: [Animal] = [Animal(name: "Cow", habitat: .land, tags: ["One", "Two"]), Animal(name: "Dog", habitat: .land, tags: ["One", "Three"]), Animal(name: "Starfish", habitat: .water, tags: ["One", "Two", "Three", "Four"]), Animal(name: "Shark", habitat: .water, tags: [])]

allAnimals remains the backing data for the collection view; new elements will (eventually) be inserted into this array.

d) Create two computed properties; sections (an array of Tags) and allTagNames (a set of strings):

var sections: [Tag] {
var tagIndex: Int = 0
var mySections: [Tag] = [] for tagName in allTags {
let resultArray = allAnimals.filter({$0.tags.contains(tagName)})
let resultTag = Tag(name: tagName, animals: resultArray, sectionNumber: tagIndex)
mySections.append(resultTag)
tagIndex += 1
}
return mySections
}

var allTags: Set{
let tagArray = allAnimals.map({$0.tags})
let flattenedTags = tagArray.flatten()
return Set(flattenedTags)
}

What I like about this solution is that adding new tags and adding new elements is trivial. This is not optimised code, and for large datasets, creating all these Tags and throwing them away again could slow things down – but as a proof of concept it feels very transparent.

Right now, the code is very transparent in what it does, but does a lot of unnecessary computing. I’m leaving it here because my main concern is ‘a data model that works with index paths’ rather than ‘efficient tagging of data’.
Before I switch to a different model altogether I’d want to prove that this is a performance bottleneck. The beauty of this model is that it’s expandable – I can group my data on the fly by adding further properties and instead of accessing allTags directly (this is bad form anyway), accessing a function that gives me the number of sections accordingly to a choice. I can group my collection alphabetically (a new section for every letter; a new section for A-C, D-F…) or anything that takes my fancy. All I need to do is create a ‘Sectionable’ protocol and define a new container that worked in the same manner as my Tag struct.

e) Implement the datasource methods:
func numberOfSections(in collectionView: NSCollectionView) -> Int {
return allTags.count
}

func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
if let mySection = sections.first(where: {$0.sectionNumber == section}) {
return mySection.animals.count
} else {
return 0
}
}

func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {

let sectionTag = sections.filter({$0.sectionNumber == indexPath.section})[0]

let current = sectionTag.animals[indexPath.item]

switch current.habitat {
case .land:
let item = collectionView.makeItem(withIdentifier: "AnimalDisplay", for: indexPath) as? AnimalDisplay
item?.representedObject = sectionTag.animals[indexPath.item] return item!
case .water:
let item = collectionView.makeItem(withIdentifier: "FishDisplay", for: indexPath) as? FishDisplay
item?.representedObject = sectionTag.animals[indexPath.item] return item!
}
}

Build and Run. The animals are grouped neatly.

8) Supplementary Views (in this case, a header with the group name)

For these, you don’t need a view controller, just a nib and an NSView subclass.

a) Create your header: HeaderView.xib (NewFile ->Source-> UserInterface -> View) with a label, set its view to be of the ‘HeaderView’ class and create an NSView subclass ‘HeaderView’ with an outlet to that label. Make your headerView stand out – here I’ve just added
NSColor.green.set()
NSBezierPath.fill(self.bounds)

to draw(_ dirty Rect: NSRect).

b) set the properties – width and height – for the NSCollectionView’s HeaderSize in the main storyboard. If you leave it at the standard 0, 0 your header will not show up.
(You can tweak this further by implementing methods from the NSCollectionViewDelegateFlowLayout protocol, if you want.)

c) decide what you want your header to display (the tag you’re sorting by, the number of items in each section, etc)

d) In your data source, implement

func collectionView(_ collectionView: NSCollectionView, viewForSupplementaryElementOfKind kind: NSCollectionView.SupplementaryElementKind, at indexPath: IndexPath) -> NSView {
let headerView = collectionView.makeSupplementaryView(ofKind: .sectionHeader, withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "HeaderView"), for: indexPath) as! HeaderView

//populate your headerView with the desired information

return headerView
}

Build and Run. Done!

e) if you want to make the headers (or footers) stick to the top (or bottom) of a section, you need to do this via NSCollectionViewFlowLayout. The simplest way – and I have no idea why this is not exposed in InterfaceBuilder, since the stickiness of table headers _is_, is to create an outlet for your flow layout in the CollectionViewDelegate (drill down in IB from your collectionView)

ScreenShot2017-07-12at21.52.35-2016-08-6-10-00.png

@IBOutlet weak var flowLayout: NSCollectionViewFlowLayout!

and add

flowLayout.sectionHeadersPinToVisibleBounds = true

to viewDidLoad.

9) Refactor
The problem with the current code is that sections and allTags are accessed (and recalculated) for every item in the collectionView. This, obviously, is a bad idea.

a) In the dataSource, create a sections array, and move the calculation of the section content into new functions:

var sections: [Tag] = []

func createSections (tagnames: [String]) -> [Tag] {
var tagIndex: Int = 0
var mySections: [Tag] = [] for tagName in tagnames {
let resultArray = allAnimals.filter({$0.tags.contains(tagName)})
let resultTag = Tag(name: tagName, animals: resultArray, sectionNumber: tagIndex)
mySections.append(resultTag)
tagIndex += 1
}
return mySections
}

func extractTags() -> [String]{
let tagArray = allAnimals.map({$0.tags})
let flattenedTags = tagArray.flatten()
let tagSet = Set(flattenedTags)

var uniqueTagsArray = Array(tagSet)
uniqueTagsArray.sort(by: Int {
return sections.count
}

d) Run the ‘refreshTags’ function before you start. In order to do this, you need to create an outlet for the dataSource object in your viewController and add dataSource.refreshTags()to viewDidLoad.

Build and run. It should work as before (only the sections will be alphabetical)

In the second part, I will deal with selection, and double-clicking items. I may or may not add drag-and-drop to it; or I may make that a separate post.

And more…

Usage Example

Displaying data that is not tabular (= where sorting it by columns makes sense) but where you want to keep each individual ‘record’ together.

Alternatives

NSStackView for small collections that don’t need more than one axis; NSTableView for tabular data.

Extensions

Firstly, adding All The Functionality: selection, reordering, drag-and-drop. Secondly, all of this has been using the standard flow layout – it’s possible to subclass NSCollectionViewFlowLayout and to use its delegate to customise the appearance much further.

useful sources

What’s new in CollectionViews (WWDC 2015) – sadly the accompanying app, CocoaSlideCollection, is ObjectiveC only. I borrowed the idea of Tags from this.
Collection Views in OSX Tutorial (Wenderlich) – this makes use of a fairly complicated setup, much of which is not explained; it also fudges the sections by using an array of arbitrary numbers for how many items are in each section and relies on keeping an array with lengths and an array with attributes in sync. While this is not impossible – people used to program like this all the time – I cannot say that I like the idea; I always worry that at some point the synchronisation will break.