Nine Circles of Shell

NSOutlineView Part 3: State Restoration

NSOutlineView Part 3: State Restoration

25 January, 2021

The complete code for this project can be found here.

In part 1 of this series I showed you how to create the Node class for representing items in the file system hierarchy. In part 2 I showed how to use the Node class in conjunction with NSOutlineView to display an outline view representing items in the file system that the user can collapse and expand. Finally we are going to go over adding state restoration to what we coded in part 2. State restoration means that when the user closes our app and then relaunches it, the outline view will remember how its items were expanded. We can even have it remember what the user's last selection was. Some of this behavior is baked into NSOutlineView's, but we'll still have to do some coding to get it to work. Going the extra mile to implement this is really important for over all user experience. The user won't notice when you do implement this, but they will notice and definitely be annoyed when you don't.

State Restoration in Cocoa

I had a hard time making heads or tails of Apple's documentation on this (big surprise). This Stack Overflow answer helped me out a lot though. Here's the general gist of what happens in the state restoration process.

  1. Whenever the expansion state of your outline view changes, the Data Source method outlineView: persistentObjectForItem: is called to save some kind of identifier for that object. In our case, we're just going to write the full file path of the object in the file system as a unique identifier.
  2. When your application is launched, its compatriot method outlineView: itemForPersistentObject: is called to recreate the item that was saved. This code will be used to rebuild the outline view's items and their state, including whether or not they are collapsed.
  3. We need to write an identifier to User Defaults whenever the selection in the outlineView changes.
  4. When the app is launched, before the outline view is loaded, we need to read in the last selected item and then programmatically select that.
  5. We need to write code to find Nodes within a tree with a string identifier so we can actually find the items whose state we are saving.

Searching for a Node

Last things first! We need to write a method that will find a Node in an array of trees. I'm going to tack this onto the TreeManager class for now, since it will have to search all of the trees for the item. This is also going to take two methods, and some recursion. So yeah, hope that isn't too off putting.

Here is the first method!

- (Node *)getNodeWithPath:(NSString *)path {
    Node *node;
    for (Node *child in _nodes) {
        node = [self checkNode:child ForPath:path];
        if (node != nil)
            break;
    }
    return node;
}

Not too bad so far. It looks at the TreeManager's array of nodes and for each one of them, it "checks" if its full file path matches the path we gave the method. For each iteration, if the node isn't nil, that means we found it and should stop looking.

When I say "checks", I of course mean it calls the method checkNode: ForPath: on each Node it sees until it finds a match. Let's take a look at that method.

- (Node *)checkNode:(Node *)node ForPath:(NSString *)path {
// 1. 
    if ([[node fullPath] isEqualToString:path]) {
        return node;
// 2. 
    } else if ([node numberOfChildren] > 0) {
        for (Node *child in [node children]) {
            node = [self checkNode:child ForPath:path];
// 3. 
            if (node != nil) {
                return node;
            }
        }
    }
    return nil;
}

Let's break this one down a little more. 1. First it checks if this Node's fullPath matches the given string. If it does, we found our Node and it returns triumphantly up the recursion tree. 2. If this Node doesn't match, we start iterating through the children and call this function recursively on each one. 3. On each iteration, we check if node is not nil, if it is we return that node up the recursion chain. Finally if nothing is found, the method just returns nil.

So with these two methods, we can now find any item in our tree as long as its full file path matches the string we are looking for.

New Delegate Methods

We only need to add one new delegate method to our View Controller: outlineViewSelectionDidChange:. This method is called whenever a new item in the outline is selected. Here is our implementation of this method.

- (void)outlineViewSelectionDidChange:(NSNotification *)notification {
    Node *node = [_outlineView itemAtRow:_outlineView.selectedRow];
    [self writeSelectedItem:node];
}

This is pretty simple. It grabs the node representing the selected row, and then calls a method called writeSelectedItem: for the selected node. Let's take a look at that now...

- (void)writeSelectedItem:(Node *)node {
    [[NSUserDefaults standardUserDefaults] setObject:[node fullPath] forKey:@"selectedNode"];
    [[NSUserDefaults standardUserDefaults] synchronize];
}

This method simply writes the fullpath for the given node to User Defaults. User Defaults is a way to save data that is used to determine how an application behaves based on user preferences. It isn't where you would save data such as a document or video file, but it is a great place to save things such as a font they selected, or whether they use dark or light mode. You know. User Defaults.

Anyway. User Defaults uses key-value pairs to store data. When you write something to User Defaults, you provide what you're writing, and a key to help you find it later. In this case, we're writing the fullpath of the node, and we'll use the key "selectedNode" to help us find it later.

New Data Source Methods

AppKit's state restoration API requires us to implement a couple of new data source methods: outlineView: persistentObjectForItem: and outlineView: itemForPersistentObject:. The first is called so our app knows what to save for our state, and the second is called so our app knows what to load to restore our state. Here is the implementation we'll use for the first...

- (id)outlineView:(NSOutlineView *)outlineView persistentObjectForItem:(id)item {
    Node *node = (Node *)item;
    return [node fullPath];
}

This casts the item to a Node, then simply returns the string representing the absolute file path for the node. That filepath is what the system will use to identify each node. When the app is launched again, itemForPersistentObject will be called. It takes the absolute file path we saved and calls getnodeWithpath to find the appropriate node representing that object in the outline view.

- (id)outlineView:(NSOutlineView *)outlineView itemForPersistentObject:(id)object {
    NSString *nodePath = (NSString *)object;
    Node *node = [_treeManager getNodeWithPath:nodePath];
    if (node)
        return node;

    return nil;
}

The last step in setting up state restoration is to make sure that the view controller knows it should expect to call these methods. By default, it won't even try. To do this well just add the following lines to viewDidLoad.

[_outlineView setAutosaveName:@"outlineDemo"];
[_outlineView setAutosaveExpandedItems:YES];

The first gives our view an identifier so the app knows what data to look for when working through the state restoration process. The second line tells the view that it should save all expanded items in the outline.

Restoring the selection

If you implemented persistentObjectForItem and itemForPersistentObject correctly and set up viewDidLoad, you'll see the expanded state of the outline view persist across app launches. What you won't see persist is the selection state. We're going to need to do that ourselves. We already did the first half of this process within our implementation of the outline view delegate method outlineViewSelectionDidChange and its helper method writeSelectedItem. We just need a way to load the last selected item that we saved in User Defaults. We're going to do this in two methods. The first load the saved item, and the second to actually select the time. Let's look at how to select an item programmatically first.

- (void)selectItem:(id)item inOutlineView:(NSOutlineView *)outline {
    NSInteger itemIndex = [outline rowForItem:item];

    [outline selectRowIndexes: [NSIndexSet indexSetWithIndex: itemIndex] byExtendingSelection: NO];
}

This method takes an outline view and an item of any type, calls rowForItem with the passed item to determine the item's index, and then calls selectRowIndexes: byExtendingSelection to programmatically select the item. Within that method call, it converts the NSInteger returned from rowForItem to an NSIndexSet.

So basically, the first line of this method find the row in the outline we'll need to select based on the item based to the method. The second calls the methods needed to actually select the item given the index we just determined. Hopefully that makes sense.

Moving on to the method that loads the item we need to restore...

- (void)restoreSelectedNode {
    // 1. 
    NSString *fullPath = [[NSUserDefaults standardUserDefaults] objectForKey:@"selectedNode"];

    // 2. 
    Node *node = [_treeManager getNodeWithPath:fullPath];

    // 3. 
    [self selectItem:node inOutlineView:_outlineView];
}

In order, this code does the following...

  1. Load the absolute file path from User Defaults using the key we set earlier "selectedNode".
  2. Finde the node object whose full path matches the file path we loaded in from User Defaults.
  3. Select the item represented by the found node in the outline view, using our selectItem: inOutlineView method.

Implementing these two methods should give us what we need to have the app save the selected item in the outline each time it changes, and then load that selection when we launch the app. All we have to do is add a call to restoreSelectedNode in viewDidLoad.

- (void)viewDidLoad {
    [super viewDidLoad];

    [_outlineView setDelegate:self];
    [_outlineView setDataSource:self];

    [_outlineView setAutosaveName:@"outlineDemo"];
    [_outlineView setAutosaveExpandedItems:YES];

    [self writeFilesInAppSupport];

    [self restoreSelectedNode];
}

Full Code

Here is the full code for each of the files we changed.

TreeManager.h

#import <Foundation/Foundation.h>
#import "Node.h"

#ifndef TreeManager_h
#define TreeManager_h

@interface TreeManager : NSObject

// Properties
@property NSMutableArray *nodes;

- (id)init;
- (Node *)getNodeWithPath:(NSString *)path;

@end


#endif /* TreeManager_h */

TreeManager.m

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

@implementation TreeManager

- (id)init {
    self = [super init];
    // 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];
    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];

    }

    // Sort nodes
    NSSortDescriptor *order = [NSSortDescriptor sortDescriptorWithKey:@"relativePath" ascending:YES];
    [_nodes sortUsingDescriptors:[NSArray arrayWithObject:order]];
    return self;
}

- (Node *)getNodeWithPath:(NSString *)path {
    Node *node;
    for (Node *child in _nodes) {
        node = [self checkNode:child ForPath:path];
        if (node != nil)
            break;
    }
    return node;
}

- (Node *)checkNode:(Node *)node ForPath:(NSString *)path {
    if ([[node fullPath] isEqualToString:path]) {
        return node;
    } else if ([node numberOfChildren] > 0) {
        for (Node *child in [node children]) {
            node = [self checkNode:child ForPath:path];
            if (node != nil) {
                return node;
            }
        }
    }
    return nil;
}

@end

ViewController.h

#import <Cocoa/Cocoa.h>
#import "TreeManager.h"

@interface ViewController : NSViewController <NSOutlineViewDataSource, NSOutlineViewDelegate>
@property (strong) IBOutlet NSOutlineView *outlineView;
@property TreeManager *treeManager;


@end

ViewController.m

#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];

    [_outlineView setAutosaveName:@"outlineDemo"];
    [_outlineView setAutosaveExpandedItems:YES];

    [self writeFilesInAppSupport];

    [self restoreSelectedNode];
}


- (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 getNodeWithPath: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 getNodeWithPath: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 *)outline {
    NSInteger itemIndex = [outline rowForItem:item];

    [outline 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