(This is not the promised Part III of my previous series. Which is not fully functional and needs an overhaul, but right now, I am lacking time.)
In most cases, users expect to find outline views as they left them.
Apple's Autoexpand explained
Apple provides a mechanism for saving the expansion status of an NSOutlineView; it is detailed in
this answer on Stackoverflow The steps are
– set AutosaveExpandedItems to true
– provide an Autosave name
– implement itemForPersistentObject (a unique identifier that can be used to identify your object; my objects have UUIDs, so I’m using the uuidString)
– implement persistentObjectForItem (you have a uuidString, you trawl your datasource to retrieve an item)
The identifiers of ‘objects that should be expanded’ are written to NSUserDefaults (hence the need to provide an autosave name) and read when the outlineView loads.
The documentation for NSOutlineView says
the outline view saves the state of its expanded items and restores that state the next time the user launches the app.
which shows up one of the major problems with this concept: I have a document-based app, and all of the documents will dump their expanded items in the same pool. If each app has a sidebar with 30-50 expandable items, that adds up very quickly.
And worse, I now have to take care that if I copy and paste from one document to another, I have to provide a new UUID, otherwise I create weird behaviour where some objects don’t behave as expected (because an object with the same ID was expanded/collapsed in another document) which will make it harder to merge documents at a later date.
Finally, if I move that document to a different computer or as a different user, the expansion state will not be saved.
Even if I could get the outline view to check the userDefaults after my data is actually loaded (in a document-based app where the data is loaded from file, it always gets called before the outlineView is actually populated), this workflow would not work well for my app since it does not scale.
So I needed a different solution. Whether an item is expanded or not is, strictly speaking, a function of the view – the data does not change – but trying to preserve the expansion status is a conceptual challenge.
Implementing expansion
One possible strategy is to follow Apple’s lead and to preserve an array of identifiers (ordered from the top level down, so they get expanded in the correct order) as a separate item in my document. When my data has been loaded, I would then call a function that compares each item in the outlineView with the expandableID, and if the ID is present, the item gets expanded.
The other strategy is to add an ‘isExpanded’ property to each item, and after they are loaded, parse the tree to create an array of expandable items, and expand all of them. This is the strategy I’ve used here.

0) Build your outlineView and populate it with data. Some of the data should be expandable. Here, I have three items: a MapCluster (the expandable item), a LayerItem (controlling Location, Icon, and Character layers – this can never have children), and the maps themselves (they cannot have children either).
I’m actually using a protocol to collate items from three properties so that the LayerItem is always first, maps are in the middle, and further mapClusters are at the end of listing. During this I found out that if I have protocol B inheriting from protocol A, I can do let x: A = B(), but, at least under 10.13, let x: [A] = [B()] will fail, so I need to use arrayOfB.compactMap {$0 as? A } – the compiler tells me it will always succeed and I should delete it, but this is the only way of adding an array of B to an array of A.
1) The parent class has a property called children; this is how the tree is built. (Since I use subarrays to build the childrenArray, I don’t need to filter for potentially expandable items, I simply access my array of subClusters. Your data model may vary.) I also added an isExpanded Bool to the mapCluster class.
2a) In my controller class (which is the outlineViewDelegate), I implemented
func outlineViewItemDidExpand(_ notification: Notification) {
if let userInfo = notification.userInfo as? [AnyHashable: MapCluster]{
setMapHasChangedTrue()
for cluster in userInfo.values{
cluster.isExpanded = true
}
}
}
I decided that since I’m saving expansion state with the model, I want it to dirty the document. The function ‘setMapHasChangedTrue()
‘ does that (I’m not going into the details here); this means that you will get prompted to save if you close the document, respectively the autosaved document will contain the last expansion status.
The itemDidCollapse
function is identical and sets isExpanded to false.
2b) I save isExpanded with the model and read it from disk. My architecture allows me to save the settings separately from the image; so if all that has changed it the expansion status of one or more items, that is all that will get saved.
3) I implement a function that returns only items I want to expand:
func clustersToExpand(_ clusterArray: [MapCluster]) -> [MapCluster]{
var expandableClusters: [MapCluster] = []
for cluster in clusterArray{
if cluster.isExpanded == true {
expandableClusters.append(cluster)
}
let expandableChildClusters = clustersToExpand(cluster.allSubClusters)
expandableClusters.append(contentsOf: expandableChildClusters)
}
return expandableClusters
}
4) In the function where I inject my content, I then retrieve the items that should be expanded, and tell the NSOutlineView to expand them.
func injectAllMapClusters(_ allMapClusters: [MapCluster]){
self.allMapClusters = allMapClusters
mapOutlineView.reloadData()
let expandableClusters = clustersToExpand(allMapClusters)
for item in (expandableClusters){
mapOutlineView.expandItem(item)
}
}
This array works from the top down, so items at the root level are expanded first, and so on. Earlier I had experimented with a set, but sets mean random access, and if you try to expand a child while the parent is collapsed, nothing happens, so the results were unpredictable.
While I was working this out I also learnt that I had previously misunderstood reloadData(): it does not reload all of the data, just the visible data. Trying to be economical by inserting only a single item into the table played havoc with my different row heights, so don’t be afraid of reloadData().
And more…
Usage Example
Quite honestly every time you have an outlineView, you should save its state.
Alternatives
See above: Apple’s implementation is not, in my opinion, suitable for a document-based App; I’ve described an alternative architecture, but at this point my desire to write clean code and preserve MVC distinctions took a nosedive. If I rephrase ‘item is expanded’ as ‘item is of high interest to the user’ I can easily justify the inclusion, and I would rather save a property per item than a separate list of expandableItemIdentifiers.
Extensions
Time will tell how useful this pattern is for use with a long and complex sourceList or whether I might need to tweak this.
Aug 7 2020
NSOutlineView: Saving expansion status
(This is not the promised Part III of my previous series. Which is not fully functional and needs an overhaul, but right now, I am lacking time.)
In most cases, users expect to find outline views as they left them.
Apple's Autoexpand explained
– set AutosaveExpandedItems to true
– provide an Autosave name
– implement itemForPersistentObject (a unique identifier that can be used to identify your object; my objects have UUIDs, so I’m using the uuidString)
– implement persistentObjectForItem (you have a uuidString, you trawl your datasource to retrieve an item)
The identifiers of ‘objects that should be expanded’ are written to NSUserDefaults (hence the need to provide an autosave name) and read when the outlineView loads.
The documentation for NSOutlineView says
the outline view saves the state of its expanded items and restores that state the next time the user launches the app.
which shows up one of the major problems with this concept: I have a document-based app, and all of the documents will dump their expanded items in the same pool. If each app has a sidebar with 30-50 expandable items, that adds up very quickly.
And worse, I now have to take care that if I copy and paste from one document to another, I have to provide a new UUID, otherwise I create weird behaviour where some objects don’t behave as expected (because an object with the same ID was expanded/collapsed in another document) which will make it harder to merge documents at a later date.
Finally, if I move that document to a different computer or as a different user, the expansion state will not be saved.
Even if I could get the outline view to check the userDefaults after my data is actually loaded (in a document-based app where the data is loaded from file, it always gets called before the outlineView is actually populated), this workflow would not work well for my app since it does not scale.
So I needed a different solution. Whether an item is expanded or not is, strictly speaking, a function of the view – the data does not change – but trying to preserve the expansion status is a conceptual challenge.
Implementing expansion
The other strategy is to add an ‘isExpanded’ property to each item, and after they are loaded, parse the tree to create an array of expandable items, and expand all of them. This is the strategy I’ve used here.
0) Build your outlineView and populate it with data. Some of the data should be expandable. Here, I have three items: a MapCluster (the expandable item), a LayerItem (controlling Location, Icon, and Character layers – this can never have children), and the maps themselves (they cannot have children either).
I’m actually using a protocol to collate items from three properties so that the LayerItem is always first, maps are in the middle, and further mapClusters are at the end of listing. During this I found out that if I have protocol B inheriting from protocol A, I can do let x: A = B(), but, at least under 10.13, let x: [A] = [B()] will fail, so I need to use arrayOfB.compactMap {$0 as? A } – the compiler tells me it will always succeed and I should delete it, but this is the only way of adding an array of B to an array of A.
1) The parent class has a property called children; this is how the tree is built. (Since I use subarrays to build the childrenArray, I don’t need to filter for potentially expandable items, I simply access my array of subClusters. Your data model may vary.) I also added an isExpanded Bool to the mapCluster class.
2a) In my controller class (which is the outlineViewDelegate), I implemented
func outlineViewItemDidExpand(_ notification: Notification) {
if let userInfo = notification.userInfo as? [AnyHashable: MapCluster]{
setMapHasChangedTrue()
for cluster in userInfo.values{
cluster.isExpanded = true
}
}
}
I decided that since I’m saving expansion state with the model, I want it to dirty the document. The function ‘
setMapHasChangedTrue()
‘ does that (I’m not going into the details here); this means that you will get prompted to save if you close the document, respectively the autosaved document will contain the last expansion status.The
itemDidCollapse
function is identical and sets isExpanded to false.2b) I save isExpanded with the model and read it from disk. My architecture allows me to save the settings separately from the image; so if all that has changed it the expansion status of one or more items, that is all that will get saved.
3) I implement a function that returns only items I want to expand:
func clustersToExpand(_ clusterArray: [MapCluster]) -> [MapCluster]{
var expandableClusters: [MapCluster] = [] for cluster in clusterArray{
if cluster.isExpanded == true {
expandableClusters.append(cluster)
}
let expandableChildClusters = clustersToExpand(cluster.allSubClusters)
expandableClusters.append(contentsOf: expandableChildClusters)
}
return expandableClusters
}
4) In the function where I inject my content, I then retrieve the items that should be expanded, and tell the NSOutlineView to expand them.
func injectAllMapClusters(_ allMapClusters: [MapCluster]){
self.allMapClusters = allMapClusters
mapOutlineView.reloadData()
let expandableClusters = clustersToExpand(allMapClusters)
for item in (expandableClusters){
mapOutlineView.expandItem(item)
}
}
This array works from the top down, so items at the root level are expanded first, and so on. Earlier I had experimented with a set, but sets mean random access, and if you try to expand a child while the parent is collapsed, nothing happens, so the results were unpredictable.
While I was working this out I also learnt that I had previously misunderstood reloadData(): it does not reload all of the data, just the visible data. Trying to be economical by inserting only a single item into the table played havoc with my different row heights, so don’t be afraid of reloadData().
And more…
Usage Example
Quite honestly every time you have an outlineView, you should save its state.
Alternatives
See above: Apple’s implementation is not, in my opinion, suitable for a document-based App; I’ve described an alternative architecture, but at this point my desire to write clean code and preserve MVC distinctions took a nosedive. If I rephrase ‘item is expanded’ as ‘item is of high interest to the user’ I can easily justify the inclusion, and I would rather save a property per item than a separate list of expandableItemIdentifiers.
Extensions
Time will tell how useful this pattern is for use with a long and complex sourceList or whether I might need to tweak this.
By Extelligent Cocoa • Application Design, Interface • • Tags: NSOutlineView