Nine Circles of Shell

NSOutlineView Part 1: Node

NSOutlineView Part 1: Node

25 January, 2021

This is the first in three articles I'll be writing explaining NSOutlineViews and the data structures needed to implement them. The guide is written in Objective-C, but the patterns and frameworks could easily be translated to Swift.

Github repo for this project.

I had a hard time finding a good guide for this, especially in Objective-C, so this will serve as the step-by-step guide I wish had been available to me. This guide will be split into three articles. This, being the first, will discuss how to create a tree of nodes to represent objects in a directory structure. Part 2 will discuss how to actually set up the NSOutlineView in code. Part 3 will show how to implement state restoration so that your app remembers the disclosure status of your outline view.

The goal will be to represent something like this...

|____Folder2
| |____file2.txt
|____Folder3
| |____file3.txt
|____Folder1
| |____file1.txt

Like this... Final outline view

Why am I writing this in Objective-C? I think it is important for anyone writing code on Apple's platforms to at least be familiar with Objective-C. Apple's platforms may be on a Swift/SwiftUI trajectory, but there is still a great deal of code written in Objective-C, UIKit and AppKit. My read is that AppKit is still the better option for new MacOS projects and there is an abundance of Swift tutorials. Hence, Objective-C.

The Application Support Directory

Before we start writing code for the node class, we need to take a look at some helper code. We are going to create a category of NSFileManager that will add two new methods to that class. The first will return the path to the Application Support directory within our app's container. The second will return an array of the visible contents of a given directory.

If your familiar with Swift, you can think of an ObjC category as similar to a Swift extension. With that said, there is a similar construct in ObjC called an extension just to muddy the waters more. Know that for our use case of simply adding a few public methods to an existing class, a category will work well.

Here is the header file and implementation for our category of NSFileManager. Much of the code for the first method, applicationSupportDirectory, is from this guide. You can also find more information on working with files in AppKit here.

NSFileManager+AppSupport.h

#import <AppKit/AppKit.h>
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface NSFileManager (NSFileManager_AppSupport)
- (NSString *)applicationSupportDirectory;
- (NSMutableArray *)visibleContentsOfDirAtPath:(NSString *) path error:(NSError * _Nullable) error;
@end

NS_ASSUME_NONNULL_END

NSFileManager+AppSupport.m

#import "NSFileManager+NSFileManager_AppSupport.h"

#import <AppKit/AppKit.h>


@implementation NSFileManager (NSFileManager_AppSupport)
- (NSString *)applicationSupportDirectory {
    NSString *appName = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleExecutable"];
    NSError *error;
    NSString *result = [self findOrCreateDirectory:NSApplicationSupportDirectory inDomain:NSUserDomainMask appendPathComponent:appName error:&error];

    if (error) {
        NSLog(@"Unable to find or create Application Support directory: %@\n", error);
    }
    return result;
}

- (NSString *)findOrCreateDirectory:(NSSearchPathDirectory)searchPathDirectory
                           inDomain:(NSSearchPathDomainMask)domainMask
                appendPathComponent:(NSString *)appendComponent
                              error:(NSError **)errorOut {
    // Search for path
    NSArray *paths = NSSearchPathForDirectoriesInDomains(searchPathDirectory, domainMask, YES);
    if (paths.count == 0) {
        // Return an error
        return nil;
    }

    // First path is probably what we want
    NSString *resolvedPath = [paths objectAtIndex:0];

    // Append component if it was provided to function
    if (appendComponent) {
        resolvedPath = [resolvedPath stringByAppendingPathComponent:appendComponent];
    }

    // Create path if it does not exist
    NSError *error;
    BOOL success = [self createDirectoryAtPath:resolvedPath withIntermediateDirectories:YES attributes:nil error:&error];

    if (!success) {
        if (errorOut) {
            *errorOut = error;
        }
        return nil;
    }

    // Success...?
    if (errorOut) {
        *errorOut = nil;
    }
    return resolvedPath;
}

// Returns a mutable array of the visible contents (no .DS_Stores) of a directory
- (NSMutableArray *)visibleContentsOfDirAtPath:(NSString *)path error:(NSError *)error {
    NSArray *allContents = [self contentsOfDirectoryAtPath:path error:nil];
    NSMutableArray *visibleContents = [NSMutableArray array];

    for (NSString *item in allContents) {
        if (![item hasPrefix:@"."]) {
            [visibleContents addObject:item];
        }
    }
    return visibleContents;
}

@end

So what applicationSupportDirectory ends up returning is a string that looks something like so...

 ~/Library/Containers/your-org-name.your-app-name/Data/Library/Application\ Support/your-app-name

As you will soon see, accessing our app's App Support directory is a really common operation in this project, so it's really handy to have this method available to us. To briefly mention visibleContentsOfDirAtPath:error, this simply returns an array of any item in the given directory path that does not begin with a period. In [Unix systems][unix], such files are not generally visible. This is to keep things like .ds_store files from showing up in our outline view.

File System Node

Now that our category is out of the way, the actual first step in this project is to write a node class that will represent an object in the file system. This node needs to have references to its parent (the directory containing it) and its children (files and subdirectories within it). It will also need a method to return a single child from its array of children with a given index, and a method to return the node's full file path. My Node design was inspired by this example from Apple's documentation.

Here's some more ASCII art to show what we're trying to accomplish.

         Parent
           |
  |------Node-------|
  |        |        |
child1   child2   child3

The Node could be one of many children for its parent, and likewise each of the three children could have any number of children themselves.

Now for some code. Here is the header file with public properties and methods to start.

#ifndef Node_h
#define Node_h
#import <Cocoa/Cocoa.h>

#endif /* Node_h */

@interface Node : NSObject

#pragma mark - Properties
@property (nonatomic) NSString *relativePath;
@property Node *parent;
@property (nonatomic) NSMutableArray *children;

#pragma mark - Initializers
- (id)init;
- (id)initWithPath:(NSString*)value parent:(Node *) parent;
- (id)initWithRootItem:(NSString*)rootPath;

#pragma mark - Public Methods
- (NSString *)fullPath;
- (Node *)childAtIndex:(NSInteger)index;
- (NSInteger)numberOfChildren;

@end

Properties and Methods

Let's look at each property and method and examine what they are actually doing.

  • NSStrirng *relativePath: this will return the path relative to the application support folder. If the parent of the item in question is the app support folder itself, it returns the full app support path with the item itself appended as the last path component.
  • Node *parent: A reference to the node above the current item in the tree.
  • NSMutableArray *children: An array that holds the children for the current item. Each child will, in turn, have a reference to the current node as a parent.

init and initWithPath:parent

There are two Initializers for this class. The default initializer calls [super init] and then allocates memory for the array of children. This is called when we want to allocate a Node, but we don't really know what we're going to do with it yet.

- (instancetype)init {
    self = [super init];
    if (self) {
        leafNodes = [[NSMutableArray alloc] init];
    }
    return self;
}

The second initializer, initWithPath:parent does a little bit more...

- (id)initWithPath:(NSString *)path parent:(Node *)parent {
    self = [super  init];
    if (self) {
        _relativePath = path;
        _parent = parent;
        _children = [self setupChildren];
    }
    return self;
}

It takes a filepath, and Node as its arguments. It assigns them to _relativePath and _parent, respectively. Then it calls [self setupChildren]. This is a helper method that is not exposed in Node's API. Here is the code for that...

- (NSMutableArray *)setupChildren {
    if (_children == nil) {
        NSFileManager *fm = [NSFileManager defaultManager];
        NSString *fullPath = [self fullPath];
        BOOL isDir, fileExists;

        // Check if the child is valid and if it is a directory
        fileExists = [fm fileExistsAtPath:fullPath isDirectory:&isDir];
        if (fileExists && isDir) {
            NSArray *contents = [fm visibleContentsOfDirAtPath:fullPath error:nil];
            _children = [[NSMutableArray alloc] initWithCapacity:contents.count];

            for (NSString *item in contents) {
                Node *newChild = [[Node alloc] initWithPath:item parent:self];
                [_children addObject:newChild];
            }
        }
    }

    // Sort children...
    if (_children != nil) {
        NSSortDescriptor *order = [NSSortDescriptor sortDescriptorWithKey:@"relativePath" ascending:YES];
        [_children sortUsingDescriptors:[NSArray arrayWithObject:order]];
    }

    return _children;
}

I want to go into this method in depth, because this is actually probably the most important method in the entire class. This is what recursively builds out our tree of nodes as it sets up each nodes children.

The first thing the method does is create a NSFileManager object, which gets the full file path for the current node (I'll show you how later), and initializes two boolean flags that we'll use to check if a file system item is valid, and if it is a directory or not.

The next block of code is the meat of this method. It uses the NSFileManager object to check if a file exists with this node's full path. If it does, and if the file is a directory, the code does the following.

  1. Create an array out of the visible contents of the directory for this node.
  2. Allocates the _children array of this Node with size matching the contents of the file.
  3. Loop through each item in contents and create a new Node using the initWithPath:parent. This will eventually call setupChildren for each child, and then those children's children and so on recursively until the entire tree is built.
  4. Last but not least, the method sorts the children for this Node and then returns the fully initialized Node.

fullPath

This is another recursive function. All it does is construct the full path for the Node, extending to the root node of the entire tree. The method recursively looks at the relativePath of the Nodes parent and appends the relative path of the current node as it constructs the fullPath.

- (NSString *)fullPath {
    // If no parent, return own relative path
    if (_parent == nil) {
        return _relativePath;
    }

    // Recursively move up heirarchy prepending parent paths
    return [[_parent fullPath] stringByAppendingPathComponent:_relativePath];
}

This method is useful as it gives us a way to uniquely identify each Node, and the corresponding file system item, with a string.

childAtIndex

This method will be used in the outline view data source method outlineView:child:ofItem: to return the child at a given index path.

- (Node *)childAtIndex:(NSInteger)index {
    return [[self children] objectAtIndex:index];
}

numberOfChildren

This one is pretty self explanatory. If the Node has no children, it returns 0. Otherwise it returns the count of the array containing the children.

- (NSInteger)numberOfChildren {
    return (_children == nil) ? 0 : [[self children] count];
}

Full implementation File

#import <Foundation/Foundation.h>
#import "Node.h"
#import "NSFileManager+AppSupport.h"

@implementation Node

static Node *rootItem = nil;
static NSMutableArray *leafNodes = nil;

- (instancetype)init {
    self = [super init];
    if (self) {
        leafNodes = [[NSMutableArray alloc] init];
    }
    return self;
}

- (id)initWithPath:(NSString *)path parent:(Node *)parent {
    self = [super  init];
    if (self) {
        _relativePath = path;
        _parent = parent;
        _children = [self setupChildren];
    }
    return self;
}

- (NSMutableArray *)setupChildren {
    if (_children == nil) {
        NSFileManager *fm = [NSFileManager defaultManager];
        NSString *fullPath = [self fullPath];
        BOOL isDir, fileExists;

        // Check if the child is valid and if it is a directory
        fileExists = [fm fileExistsAtPath:fullPath isDirectory:&isDir];
        if (fileExists && isDir) {
            NSArray *contents = [fm visibleContentsOfDirAtPath:fullPath error:nil];
            _children = [[NSMutableArray alloc] initWithCapacity:contents.count];

            for (NSString *item in contents) {
                Node *newChild = [[Node alloc] initWithPath:item parent:self];
                [_children addObject:newChild];
            }
        }
    }

    // Sort children...
    if (_children != nil) {
        NSSortDescriptor *order = [NSSortDescriptor sortDescriptorWithKey:@"relativePath" ascending:YES];
        [_children sortUsingDescriptors:[NSArray arrayWithObject:order]];
    }

    return _children;
}

- (NSString *)fullPath {
    // If no parent, return own relative path
    if (_parent == nil) {
        return _relativePath;
    }

    // Recursively move up heirarchy prepending parent paths
    return [[_parent fullPath] stringByAppendingPathComponent:_relativePath];
}

- (Node *)childAtIndex:(NSInteger)index {
    return [[self children] objectAtIndex:index];
}

- (NSInteger)numberOfChildren {
    return (_children == nil) ? 0 : [[self children] count];
}

@end