NSOutlineView Part 2: Outline Implementation
25 January, 2021The complete code for this project can be found here.
This is the second part in three-part series on implementing NSOutlineViews in Objective-C. Part 1 discussed how to create a Node class to help us represent objects in the file system. Part 3 will discuss how to add state restoration to what we'll implement today. This article will show how to use that Node structure to populate an NSOutlineView to visually represent file system objects in a hierarchical manner. The final article will discuss how to implement state restoration to make the outline view behave nicely. As a reminder, this is what we're trying to accomplish.
If you are at all familiar with UI/AppKit, you won't be surprised to hear that we will mainly be looking at a ViewController subclass that implements delegate and datasource methods for NSOutlineView. There is one other setup class, TreeManager
, we'll need, in addition to our Node and NSFileManager category we created in the last article.
The Tree Manager
This class will have one method and one property. The purpose of this class is to create an object that will manage each tree of nodes on behalf of the OutlineView. The TreeManager will store an array of references to the root node of each tree of file system objects. The initializer for this class will recursively initialize all valid children for each of the root nodes.
One important concept for NSOutlineViews is the idea of the nil
parent. An object whose parent is nil
will be represented at the top of the hierarchy. When TreeManager
is initialized, it will get the contents of our App Support directory, and create a node for each object and store those in an array. Each node in that array will be a top-level node, and have a nil
parent. These nodes will all be represented at the top level in the NSOutlineView. That is to say, if you collapsed all directories in the outline view, the only visible items would be the objects represented by nodes in TreeManager
's nodes
array.
nil virtual root |--- nil ---|
| | |
Nodes sharing nil parent n1 n2 n3
represent top level of |
outline hierarchy |
|
Subsequent nodes represent n4
sub folders and files
Here is the header and implementation files for TreeManager
...
TreeManager.h
#import <Foundation/Foundation.h>
#import "Node.h"
#ifndef TreeManager_h
#define TreeManager_h
@interface TreeManager : NSObject
// Properties
@property NSMutableArray *nodes;
- (id)init;
@end
#endif /* TreeManager_h */
TreeManager.m
#import <Foundation/Foundation.h>
#import "TreeManager.h"
#import "NSFileManager+AppSupport.h"
@implementation TreeManager
- (id)init {
self = [super init];
// 1.
// Loop through the contents of the directory and create a new root node for each dir in there
NSFileManager *fm = [NSFileManager defaultManager];
NSString *appSup = [fm applicationSupportDirectory];
// 2.
NSArray *contents = [fm visibleContentsOfDirAtPath:appSup error:nil];
_nodes = [NSMutableArray array];
for (NSString *item in contents) {
NSString *fullDirPath = [appSup stringByAppendingPathComponent:item];
Node *node = [[Node alloc] initWithPath:fullDirPath parent:nil];
[_nodes addObject:node];
}
// 3.
// Sort nodes
NSSortDescriptor *order = [NSSortDescriptor sortDescriptorWithKey:@"relativePath" ascending:YES];
[_nodes sortUsingDescriptors:[NSArray arrayWithObject:order]];
return self;
}
@end
Let's break this down real quick...
- We get the sandboxed App Support folder and get its contents.
- Iterate over the App Support contents and create a node for each object within. Add each node to the
TreeManager
'snodes
array. - Sort the nodes.
OutlineView
Time for the meat and potatoes of this set up: the NSOutlineView. I'm not going to get screenshots of how to set this up main.storyboard. Here are the rough steps though.
- Drag an OutlineView into a view controller in main.storyboard.
- Using the assistant editor, drag an outlet to a view controller class (drag into the interface in the header file, not the implementation).
- In the header file, make sure the class conforms to
NSOutlineViewDataSource
andNSOutlineViewDelegate
. - Drag from the OutlineView in main.storyboard to the view controller in the navigation hierarchy. Set it to be the datasource and delegate for the OutlineView (you can also do this in code, I'll show this later).
- Add an instance of
TreeManager
as a property.
The header file for your ViewController might look something like this...
#import <Cocoa/Cocoa.h>
#import "TreeManager.h"
@interface ViewController : NSViewController <NSOutlineViewDataSource, NSOutlineViewDelegate>
@property (strong) IBOutlet NSOutlineView *outlineView;
@property TreeManager *treeManager;
@end
To get an OutlineView working, your view controller needs to implement the correct DataSource and Delegate methods. DataSource methods tell your OutlineView how to get its data. Delegate methods tell the OutlineView how to behave in different circumstances (setting when things are expandable, what should be displayed in a view, etc...). We're going to implement the following data source methods...
outlineView: numberOfChildrenOfItem:
outlineView: isItemExpandable:
outlineView: child: ofItem:
outlineView: objectValueForTableColumn: byItem:
And the following delegate method...
outlineView: numberOfChildrenOfItem:
This method tells the outline view, given the current item, how many children it should expect for that item.
- (NSInteger)outlineView:(NSOutlineView *)outlineView numberOfChildrenOfItem:(id)item {
if (item == nil) {
if (_treeManager == nil) {
_treeManager = [[TreeManager alloc] init];
}
return _treeManager.nodes.count;
}
Node *node = (Node *)item;
return [node numberOfChildren];
}
If the item is nil and there is no TreeManager
instance yet, the code will create one and then return the number of nodes in the nodes
array. Remember, nil
acts as the virtual parent of the top level of the OutlineView. If the item is not nil
, we are somewhere further down in the hierarchy. If that is the case, to get the number of children, we cast the current item to a Node
and then call numberOfChildren
.
outlineView: isItemExpandable
This method tells the outline view whether or not an item is expandable. On the user's end, an expandable item will get a disclosure arrow to indicate that it can be expanded or collapsed to expose and hide items within.
- (BOOL)outlineView:(NSOutlineView *)outlineView isItemExpandable:(id)item {
if (item == nil) {
return YES;
} else {
return [item numberOfChildren] > 0;
}
}
If the current item is nil
(the virtual root of our entire tree), we return YES
because this item must be expanded for us to see the top level of the tree. For the rest, the method returns whether or not the number of children for that node is greater than 0. If it is, it has children and needs to be expandable. Otherwise, it is not expandable. This method doesn't check if the item is a directory or file. If a directory has no children, it is still not expandable.
outlineView: child: ofItem
This method returns the child of an item at the given index.
- (id)outlineView:(NSOutlineView *)outlineView child:(NSInteger)index ofItem:(id)item {
return (item == nil) ? _treeManager.nodes[index] : [(Node *) item childAtIndex:index];
}
I'm using the ternary operator here because there isn't much to it. If the current item is nil, return the node at the given index in the array of nodes in TreeManager
. Otherwise, return the node at the index in the current item's array of children.
outlineView: viewForTableColumn: item:
This is the only delegate method we'll implement. It's also the method that will actually tell our outline view how to look, since we aren't implementing a custom tableViewCell for this project.
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item {
// 1.
NSString *itemText = [[item relativePath] lastPathComponent];
// 2.
NSTableCellView *cell = [outlineView makeViewWithIdentifier:@"DataCell" owner:self];
cell.textField.stringValue = itemText;
// 3.
NSImage *img = [[NSWorkspace sharedWorkspace] iconForFile:[item fullPath]]; // Get default icons for file type at path
cell.imageView.image = img;
return cell;
}
Let's break this one down...
1. First we get the last path component of the relative path for our item. We'll use this to label the item in the outline view.
2. We create the actual cell and set its textField.stringValue
to that last path component.
3. The next line calls iconForFile
from NSWorkspace
to get the default system icon for the item at that file path. That way, our outline view will show the little folder icon for directories, and the appropriate icon for any other file types populated within it, and we don't have to explicitly tell it what icons to use for everything! It then sets the imageView
with the result of that method call and returns the cell.
viewDidLoad
and writeFilesInAppSupport
We don't need much from viewDidLoad
, but it does need to do a few important things.
- (void)viewDidLoad {
[super viewDidLoad];
[_outlineVIew setDelegate:self];
[_outlineVIew setDataSource:self];
[self writeFilesInAppSupport];
}
First, it sets the delegate and dataSource for the outlineView
outlet to itself, this ViewController. This is what lets us control outlineView
from this file. If we did not set this, our app wouldn't reflect any of our delegate or dataSource implementations.
Next we call a little method named writeFilesInAppSupport
. The only thing this method needs to do is write a few files to our sandboxed App Support folder so that when we launch the app we actually see something in the outline view. Here's that method for good measure.
- (void)writeFilesInAppSupport {
NSFileManager *fm = [NSFileManager defaultManager];
NSString *appSupportPath = [fm applicationSupportDirectory];
// Folder names
NSString *folder1 = [appSupportPath stringByAppendingPathComponent:@"Folder1"];
NSString *folder2 = [appSupportPath stringByAppendingPathComponent:@"Folder2"];
NSString *folder3 = [appSupportPath stringByAppendingPathComponent:@"Folder3"];
// Write some folders
[fm createDirectoryAtPath:folder1 withIntermediateDirectories:NO attributes:nil error:nil];
[fm createDirectoryAtPath:folder2 withIntermediateDirectories:NO attributes:nil error:nil];
[fm createDirectoryAtPath:folder3 withIntermediateDirectories:NO attributes:nil error:nil];
// File names
NSString *file1 = [folder1 stringByAppendingPathComponent:@"file1.txt"];
NSString *file2 = [folder2 stringByAppendingPathComponent:@"file2.txt"];
NSString *file3 = [folder3 stringByAppendingPathComponent:@"file3.txt"];
// Write some files
[fm createFileAtPath:file1 contents:nil attributes:nil];
[fm createFileAtPath:file2 contents:nil attributes:nil];
[fm createFileAtPath:file3 contents:nil attributes:nil];
}
Technically this method only ever needs to be called the first time you run the app since the files it writes are persistent and exist for real on your file system. It doesn't hurt for the purposes of our demo that it happens each time though. If the user ever needed to write to any of these files within the app, we'd have to implement something different since this would be constantly overwriting their data each time they launched it.
ViewController.m
Here is the entire ViewController.m implementation.
#import "ViewController.h"
#import "Node.h"
#import "TreeManager.h"
#import "NSFileManager+AppSupport.h"
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[_outlineView setDelegate:self];
[_outlineView setDataSource:self];
[self writeFilesInAppSupport];
}
- (void)setRepresentedObject:(id)representedObject {
[super setRepresentedObject:representedObject];
// Update the view, if already loaded.
}
#pragma mark - DataSource implementations
- (NSInteger)outlineView:(NSOutlineView *)outlineView numberOfChildrenOfItem:(id)item {
if (item == nil) {
if (_treeManager == nil) {
_treeManager = [[TreeManager alloc] init];
}
return _treeManager.nodes.count;
}
Node *node = (Node *)item;
return [node numberOfChildren];
}
- (BOOL)outlineView:(NSOutlineView *)outlineView isItemExpandable:(id)item {
if (item == nil) {
return YES;
} else {
return [item numberOfChildren] > 0;
}
}
- (id)outlineView:(NSOutlineView *)outlineView child:(NSInteger)index ofItem:(id)item {
return (item == nil) ? _treeManager.nodes[index] : [(Node *) item childAtIndex:index];
}
#pragma mark - Delegate implementations
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item {
NSString *itemText = [[item relativePath] lastPathComponent];
NSTableCellView *cell = [outlineView makeViewWithIdentifier:@"DataCell" owner:self];
cell.textField.stringValue = itemText;
NSImage *img = [[NSWorkspace sharedWorkspace] iconForFile:[item fullPath]]; // Get default icons for file type at path
cell.imageView.image = img;
return cell;
}
- (void)outlineViewSelectionDidChange:(NSNotification *)notification {
Node *node = [_outlineView itemAtRow:_outlineView.selectedRow];
[self writeSelectedItem:node];
}
#pragma mark - Restoration
/**
Called to save state
*/
- (id)outlineView:(NSOutlineView *)outlineView persistentObjectForItem:(id)item {
Node *node = (Node *)item;
return [node fullPath];
}
/**
Called to get items and restore state
*/
- (id)outlineView:(NSOutlineView *)outlineView itemForPersistentObject:(id)object {
NSString *nodePath = (NSString *)object;
Node *node = [_treeManager getNodeWithRelativePath:nodePath];
if (node)
return node;
return nil;
}
/**
Write selected item in the outlineView to NSUserDefault. Will be called whenever the selection changes.
*/
- (void)writeSelectedItem:(Node *)node {
NSLog(@"Writing state for node: %@", node.relativePath);
[[NSUserDefaults standardUserDefaults] setObject:[node fullPath] forKey:@"selectedNode"];
[[NSUserDefaults standardUserDefaults] synchronize];
}
/**
Reads the last selected item from NSUserDefaults and selects it in outlineView. Will be called when view loads.
*/
- (void)restoreSelectedNode {
// Get selected node
NSString *fullPath = [[NSUserDefaults standardUserDefaults] objectForKey:@"selectedNode"];
Node *node = [_treeManager getNodeWithRelativePath:fullPath];
// Select the item
[self selectItem:node inOutlineView:_outlineView];
}
/**
https://stackoverflow.com/questions/1096768/how-to-select-items-in-nsoutlineview-without-nstreecontroller
*/
- (void)selectItem:(id)item inOutlineView:(NSOutlineView *)ov {
NSInteger itemIndex = [ov rowForItem:item];
[ov selectRowIndexes: [NSIndexSet indexSetWithIndex: itemIndex] byExtendingSelection: NO];
}
#pragma mark - Directory writer
/**
Will be called in viewDidLoad to write some dirs and files.
*/
- (void)writeFilesInAppSupport {
NSFileManager *fm = [NSFileManager defaultManager];
NSString *appSupportPath = [fm applicationSupportDirectory];
// Folder names
NSString *folder1 = [appSupportPath stringByAppendingPathComponent:@"Folder1"];
NSString *folder2 = [appSupportPath stringByAppendingPathComponent:@"Folder2"];
NSString *folder3 = [appSupportPath stringByAppendingPathComponent:@"Folder3"];
// Write some folders
[fm createDirectoryAtPath:folder1 withIntermediateDirectories:NO attributes:nil error:nil];
[fm createDirectoryAtPath:folder2 withIntermediateDirectories:NO attributes:nil error:nil];
[fm createDirectoryAtPath:folder3 withIntermediateDirectories:NO attributes:nil error:nil];
// File names
NSString *file1 = [folder1 stringByAppendingPathComponent:@"file1.txt"];
NSString *file2 = [folder2 stringByAppendingPathComponent:@"file2.txt"];
NSString *file3 = [folder3 stringByAppendingPathComponent:@"file3.txt"];
// Write some files
[fm createFileAtPath:file1 contents:nil attributes:nil];
[fm createFileAtPath:file2 contents:nil attributes:nil];
[fm createFileAtPath:file3 contents:nil attributes:nil];
}
@end
Wrap up and Additional Resources
That should get us to a working outline view, as pictured above. Part three is going to go over state preservation, which will be a big quality of life improvement for users.
- Apple's documentation on outline views.
- Apple's documentation on file management and NSFileManager