Wed, 10 Sep 2008 14:42:56 GMT

Outline view, tree controller and itemForPersistentObject

This is the scenario: your user interface comprises an outline view and a hierarchical data model. You want the outline view to display the hierarchy and remember the expansion state automatically in preferences. Hence when the user re-runs the application, the items that were expanded are still expanded, and vice versa: what was collapsed remains so. Outline view, tree controller, hierarchical model, bindings. That’s the recipe.

According to some, storing expansion state of an outline view when used with a tree controller is not just difficult: it’s impossible! But is it? No, is the simple answer. It’s actually quite easy. In this article I introduce a new helper class called RROutlineViewExpandedItemsAutosaver! Original aren’t I? It does not involve sub-classing or access to private methods. The solution presented uses only documented interfaces and only requires a small stateless class instance for handling all outline view auto-saving technicalities. It does assume Core Data use for modelling. But you can easily adapt the technique for other data-model implementations.

It’s impossible?

At first it seems so. For a start, if you read the documentation for setAutosaveExpandedItems: you can see that you also need to implement two data-source methods: outlineView:itemForPersistentObject: and outlineView:persistentObjectForItem:. Goes without saying, you also need a data source implementing those methods. Otherwise it does not work. In fact, it’s not exactly clear what these two necessary methods are supposed to do. The document talks about translating to and from an “archived object.” What does that mean exactly?

You can find a number of posts at CocoaBuilder on this issue. Keith Blount started a thread in 2005; Nick Briggs asked about it again in 2006. But no answers forthcoming! Some discussion exists and some solutions have been posted at CocoaDev (OutlineViewCoreDataBindings and NSOutlineViewStateSavingWithNSTreeController). But there is nothing definitive. Plus, the solution supplied relies on private methods and sub-classing. Messy!

Start with a test

In the spirit of Agile, let’s start with a test that fails.

Apple’s Developer Connection provides sample code. Their AbstractTree demonstrates Core Data, use of bindings along with an NSTreeController. Download the sample here as a ZIP file. It contains two subdirectories: AbstractTree and Tutorial. The tutorial is pretty good. It should help you get up-to-speed if you need some familiarity with the basic technology. Build and run the project living under the AbstractTree folder. It presents you with a window comprising an empty outline view and two buttons.

Empty outline view with two buttons

Click Add a few times. You get a series of nested nodes named “untitled node”. Quit the application, re-run and you find that, although data saved, the expansion state of the outline view resets. You have to manually expand each item. Of course, you can Option-click the disclosure triangles to auto-expand an item and all its sub-items. But that’s not really what we want. We want the expand or collapse state of each element displayed in the outline view to persist! That’s the goal.

So far so good. We have a test case that fails. Just what we wanted.

Outline view’s auto-save name

Open the sample’s MainMenu.xib. Select the NSOutlineView. Set its Autosave name to (say) Nodes. Exact value does not matter. This is just a little piece of text for constructing the preferences key. The application’s defaults will contain an array containing the expanded items. The key will become “NSOutlineView Items Nodes” where Nodes corresponds the the specified Autosave name.

Autosave name of Nodes

Leave the Autosave Expanded Items check box unchecked! Strange but important. If you check this box, the framework resolves the expand-collapse question straightaway. It needs to wait until bindings have been fully established and the data store has been added. The application will instead enable this option programmatically when the application finishes launching. Not before.

Autosave Expanded Items check box unchecked

Add the new class

Add the interface header, RROutlineViewExpandedItemsAutosaver.h, to the project. Contents as follows. Download header and module sources here.

#import <AppKit/AppKit.h>

//------------------------------------------------------------------------------
// RROutlineViewExpandedItemsAutosaver
//------------------------------------------------------------------------------
// This class acts as a "dummy" outline-view data source. Dummy because it does
// not source any data. But you wire it up as though it did. In reality, its
// sole purpose is to enable automatic saving of an outline view's expanded
// items. It sounds counter-intuitive, but you can apply bindings through a tree
// controller and at the very same time hook up a data source (such as one of
// these) in order to provide the necessary persistence translations.
//
// Connect an outline view to an instance of this class. Note, you can connect
// multiple outlines views to the same instance! The instance carries no
// state. Message interactions with outline views pass the outline view
// instance. So no state needed.
//
// The implementation makes a number of assumptions. It assumes you connect
// using bindings to a Core Data model. Hence for every item, it sends [[item
// representedObject] objectID] which assumes that the represented object
// responds to -objectID. Core Data's managed objects respond with a unique
// object identifier.

@interface RROutlineViewExpandedItemsAutosaver : NSObject
@end

Add the source module RROutlineViewExpandedItemsAutosaver.m. Contents follow.

#import "RROutlineViewExpandedItemsAutosaver.h"

@implementation RROutlineViewExpandedItemsAutosaver

//------------------------------------------------------------------------------
#pragma mark Outline View Data Source
//------------------------------------------------------------------------------

// Although linked to the core-data context via bindings and a tree controller,
// the outline view also has an instance of this object connected to the outline
// view's data source.

- (id)outlineView:(NSOutlineView *)outlineView itemForPersistentObject:(id)object
{
    // Iterate all the items. This is not straightforward because the outline
    // view items are nested. So you cannot just iterate the rows. Rows
    // correspond to root nodes only. The outline view interface does not
    // provide any means to query the hidden children within each collapsed row
    // either. However, the root nodes do respond to -childNodes. That makes it
    // possible to walk the tree.
    NSMutableArray *items = [NSMutableArray array];
    NSInteger i, rows = [outlineView numberOfRows];
    for (i = 0; i < rows; i++)
    {
        [items addObject:[outlineView itemAtRow:i]];
    }
    for (i = 0; i < [items count] && ![object isEqualToString:[[[[[items objectAtIndex:i] representedObject] objectID] URIRepresentation] absoluteString]]; i++)
    {
        [items addObjectsFromArray:[[items objectAtIndex:i] childNodes]];
    }
    return i < [items count] ? [items objectAtIndex:i] : nil;
}

- (id)outlineView:(NSOutlineView *)outlineView persistentObjectForItem:(id)item
{
    // "Persistent object" means a unique representation of the item's object,
    // representing the objects identity, not its state. Outline view writes
    // this to user defaults as soon as the item expands. That's when it asks
    // for the persistent object, sending -outlineView:persistentObjectForItem:
    // and execution arrives here. A minor problem arises when adding new
    // items. The new item represents a new unsaved managed object. The managed
    // object only has a temporary object identifier. It will receive a
    // permanent one when saved. So, if the objectID answers a temporary one,
    // ask the context to save and re-request the objectID. The second request
    // gives a permanent identifier, assuming saving succeeds. Don't worry about
    // committing unsaved edits at this point.
    NSManagedObject *object = [item representedObject];
    NSManagedObjectID *objectID = [object objectID];
    if ([objectID isTemporaryID])
    {
        if (![[object managedObjectContext] save:NULL])
        {
            return nil;
        }
        objectID = [object objectID];
    }
    return [[objectID URIRepresentation] absoluteString];
}

@end

Add this header and module to the project, under the Classes group. An instance of this class will become the new data source for the outline view.

Add the new data-source

Add a new instance of RROutlineViewExpandedItemsAutosaver to the nib and connect the new instance to the NSOutlineView as the outline view’s dataSource. Use Interface Builder. Drag an Object from the palette to the nib. Change its class in the Identity Inspector panel (Command-6). Then connect the NSOutlineView to it as the new data source. It replaces the pre-existing connection between outline view and app delegate.

After this, the nib is ready.

App delegate

Apple have used the standard Core Data application delegate. It builds a managed object model, persistent store co-ordinator and managed object context. The classic Core Data stack! Our changes need to add a message-send for enabling auto-save expanded items to the outline view. But it’s not the only change required.

There is a subtle problem. It concerns bindings and Core Data. The important requirement is that when the outline view tries to restore auto-saved expanded items, the outline view has its items already loaded. Otherwise how can it translate “persistent objects” to items. Persistent objects, in this context, refers to keys used in the application defaults to identify the set of expanded items; everything not expanded defaults to collapsed.

Moving where the application adds its persistent store

When the application loads, it automatically loads MainMenu nib. All the object instances therein become instantiated. Indirectly, the managed object context and its other Core Data stack components come to life at this time. Therein lies the subtle problem. The Cocoa framework will not immediately load the Core Data objects if the context already has its store when nib-loading establishes the bindings. That’s a mouthful. Basically, the order must go: 1. Establish bindings. Do this wholesale. 2. Add persistent store. At this point, the outline view grabs its contents through bindings to the tree controller. 3. Enable auto-save expanded items.

Simplify the -persistentStoreCoordinator implementation to:

- (NSPersistentStoreCoordinator *)persistentStoreCoordinator {
    if (persistentStoreCoordinator == nil) {
        persistentStoreCoordinator = [[NSPersistentStoreCoordinator alloc]
            initWithManagedObjectModel:[self managedObjectModel]];
    }
    return persistentStoreCoordinator;
}

And second, add a new method:

- (void)applicationDidFinishLaunching:(NSNotification *)notification
{
    NSFileManager *fileManager;
    NSString *applicationSupportFolder = nil;
    NSURL *url;
    NSError *error;

    fileManager = [NSFileManager defaultManager];
    applicationSupportFolder = [self applicationSupportFolder];
    if (![fileManager fileExistsAtPath:applicationSupportFolder isDirectory:NULL]) {
        [fileManager createDirectoryAtPath:applicationSupportFolder attributes:nil];
    }

    url = [NSURL fileURLWithPath:[applicationSupportFolder
        stringByAppendingPathComponent:@"AbstractTree.xml"]];
    if (![[self persistentStoreCoordinator]
        addPersistentStoreWithType:NSXMLStoreType
                     configuration:nil
                               URL:url
                           options:nil
                             error:&error]) {
        [[NSApplication sharedApplication] presentError:error];
    }    
    // Watch out for this! Make sure that the data is available before enabling
    // auto-save for expanded items. You can switch it on within the nib. Problem
    // though is that the data store must be available at nib awaking time!
    // Otherwise, how can the auto-saving of expanded items compare against
    // existing items in order to determine whether or not they should be restored
    // as expanded or not.
    //
    // There's another caveat. You need to let the bindings make the necessary
    // connections first, before connecting the Core Data context and persistent
    // store coordinator to the store. In other words, the store must be added
    // last. Here is a good place. Application-did-finish-launching occurs after
    // all nib awaking methods, after bindings have been established. Hence, when
    // the store gets added, the outline view immediately sees the contents
    // through its bindings. Otherwise the outline-view expanded items auto-saver
    // cannot see the items at all.
    [outlineView setAutosaveExpandedItems:YES];
}

That’s it!

Successful test

Build and go. This time, when you expand nodes, quit then re-run, the sample correctly expands the previously-expanded items. Great. You can view the sample application’s user defaults by typing, in Terminal:

defaults read com.apple.dts.AbstractTreeApp

It will list the saved expanded items array, resembling something along these lines:

{
    "NSOutlineView Items Nodes" =     (
        "x-coredata://68A35129-6EA4-45E7-B3FB-F1C15FDC02D9/Node/p104",
        "x-coredata://68A35129-6EA4-45E7-B3FB-F1C15FDC02D9/Node/p102"
    );
}

Download the complete sample project here.


Trackbacks

Use the following link to trackback from your own site:
http://blog.pioneeringsoftware.co.uk/trackbacks?article_id=13

Comments

  • Nolan Waite says

    Thanks for the good tips here; got me most of the way to victory. A couple of implementation notes I ran into (OS X 10.5, Xcode 3.1):

    • The outline view’s data source has a few required methods that aren’t mentioned here, but need to be a part of RROutlineViewExpandedItemsAutosaver. Scott Stevenson mentions them in a blog post..
    • If your outline view is not in your application’s main nib, applicationWillFinishLaunching gets called too early, causing your data source to deal with a zero-row outline view. I worked around this by observing selectedObjects on the tree controller (which I was already doing), and sending setAutosaveExpandedItems:YES in the response when the selectedObjects array is nonempty (instead of sending it in applicationWillFinishLaunching). This worked because the outline view and tree controller had bound selection paths, and both were set to disallow empty selection. Consider observing the tree controller’s contents if you don’t have these settings.

    Hopefully this saves someone a bit of messing around. Also, black on dark grey? Really?

  • Brendan Duddridge says

    Instead of using two loops in outlineView:persistentObjectForItem:, you could just do it in one loop like this:

    NSInteger i, rows = [outlineView numberOfRows];

    id returnItem = nil;

    for (i = 0; i < rows; i++) {

    id anItem = [outlineView itemAtRow:i];
    if ([object isEqualToString:
    

    [[[anItem representedObject] objectID] URIRepresentation] absoluteString]) {

    returnItem = anItem;
    break;
    

    }

    return returnItem;

Leave a comment

(never displayed)

Markdown enabled