• Preen and Prune

    Adding Drag support to an NSTreeController with Core Data
    Part 2 An Unordered Tree


    Introduction

    This article describes the trials and tribulations of using an NSOutlineView with an NSTreeController and core data and supporting drag operations.

    Currently I'm working on adding sub-categories to Laughing Man Factorial. I found adding drag support to a tree to be one of the more difficult things to do. I also found that most of the information I collected was scattered around in places so I figured it was time to give back by putting all this in one place.

    Unordered versus Ordered Trees

    An unordered tree allows the user to store elements in any chosen order.


    An unordered tree

    Users are quite accepting of groupings that are only in alphabetcial order, the filesystem, or most common mail clients (Apple's mail, and Microsoft Outlook) are common examples. Have you ever prefixed a name with "_" to get it to show up at the top?.


    An ordered tree

    In effect both trees are actually ordered, but to create a tree that is not ordered by the viewable name we add a hidden variable and do some acrobatics to keep the order the user chooses.

    In the first part of this article we explained how to hook up everything and create an ordered tree that supports dragging. We will now expand on this by adding support for an unordered tree. This article assumes that you have some familiarity with core data and bindings. Cocoa Dev Central has some good primers if you need to get up to speed.

    Follow the instructions below by adding to the project you built in part 1 or download a completed version of the project and poke at it.


    Download Project

    To create an unordered tree we add a hidden numeric position variable and we set our sort descriptors to it. Code acrobatics will come into play because we have to determine what this position variable should be after each drag and refresh the tree.

    Modify the Core Data Object (Some Fluff)

    Add a required position attribute to the group entity as a Integer 64, make the default value 0.



    Change the sort descriptor (More Fluff)

    Change the sort descriptor from name to position in the DragController awakeFromNib method.

    DragController.m
    NSSortDescriptor* sortDesc = [[NSSortDescriptor alloc] initWithKey:@"position" ascending:YES];


    The algorithm (Meaty)

    The new position attribute we added will now determine the sort order but we will also use an interval of 10 to make sorting them easier. For example let's say that we have 3 items:

    • cat 1 - pos = 0
    • cat 2 - pos = 10
    • cat 3 - pos = 20

    When category 3 is dragged in between category 1 and 2 we simply subtract 1 from the position above below it

    • cat 1 - pos = 0
    • cat 3 - pos = 9
    • cat 2 - pos = 10

    Now we do a pass on the list to reset the interval.

    • cat 1 - pos = 0
    • cat 3 - pos = 10
    • cat 2 - pos = 20

    Resorting implementation (Some Meat)

    Add the following methods to the DragController implementation file.DragController.m

    - (NSArray* ) getSubGroups:(NSManagedObjectContext*)objectContext forParent:(NSManagedObject*)parent { NSFetchRequest *request = [[[NSFetchRequest alloc] init] autorelease]; NSEntityDescription *entity = [NSEntityDescription entityForName:@"group" inManagedObjectContext:objectContext]; [request setEntity:entity]; NSSortDescriptor* aSortDesc = [[NSSortDescriptor alloc] initWithKey:@"position" ascending:YES]; [request setSortDescriptors:[NSArray arrayWithObject: aSortDesc] ]; [aSortDesc release]; NSPredicate* validationPredicate = [NSPredicate predicateWithFormat:@"parent == %@", parent ]; [ request setPredicate:validationPredicate ]; NSError *error = nil; // TODO - check the error bozo return [objectContext executeFetchRequest:request error:&error]; } - (void) resortGroups:(NSManagedObjectContext*)objectContext forParent:(NSManagedObject*)parent { NSArray *array = [ self getSubGroups:objectContext forParent:parent ]; // Reset the indexes... NSEnumerator *enumerator = [array objectEnumerator]; NSManagedObject* anObject; int index = 0; while (anObject = [enumerator nextObject]) { [anObject setValue:[ NSNumber numberWithInt:(index * INTERVAL ) ] forKey:@"position"]; index++; } }

    The first method will get all subgroups for a parent, the second will resort the subgroups for a parent. Keeners are probably wondering why we need to pass in a managedObjectContext and a parent since all managedObjects can access their managedObjectContext. We need to have two parameters to support the root group where the parent parameter is NULL. These methods should ideally go in a utility class because they're not really relevant to dragging, but I'm trying to keep this article short.

    Another private class hack (Fluff)

    Add the following to the header file, we're going to need it for our acrobatics. Same story as _NSArrayControllerTreeNode in part 1, an _NSControllerTreeProxy gets returned when arrangedObjecst is called on an NSTreeController.

    DragController.h
    @interface _NSControllerTreeProxy : NSObject { // opaque } // // Number of objects at the root level. // - (unsigned int)count; - (id)nodeAtIndexPath:(id)fp8; - (id)objectAtIndexPath:(id)fp8; @end

    What is even more comforting is the documentation for the arrangedObjects method of NSTreeController.

    "Returns an object containing the receiver's sorted content objects. This is an opaque root node representing all the currently displayed objects. This method should be used for binding, no assumption should be made about what methods this object supports."

    Jeepers!

    AcceptDrop gets a Facelift (Meat)

    The last and final thing to do is the acrobatics in the acceptDrop method.

    DragController.m
    - (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id <NSDraggingInfo>)info item:(id)item childIndex:(int)index { _NSArrayControllerTreeNode* parentNode = item; _NSArrayControllerTreeNode* siblingNode; _NSControllerTreeProxy* proxy = [ groupTreeControl arrangedObjects ]; NSManagedObject* draggedGroup = [ draggedNode observedObject ]; BOOL draggingDown = NO; BOOL isRootLevelDrag = NO; // ---------------------- // Setup comparison paths // ------------------------- NSIndexPath* draggedPath = [ draggedNode indexPath ]; NSIndexPath* siblingPath = [ NSIndexPath indexPathWithIndex: index ]; if ( parentNode == NULL ) { isRootLevelDrag = YES; } else { // A non-root drag - the index value is relative to this parent's children siblingPath = [ [ parentNode indexPath ] indexPathByAddingIndex: index ]; } // ---------------------- // Compare paths - modify sibling path for down drags, exit for redundant drags // ----------------------------------------------------------------------------- switch ( [ draggedPath compare:siblingPath] ) { case NSOrderedAscending: // reset path for down dragging if ( isRootLevelDrag ) { siblingPath = [ NSIndexPath indexPathWithIndex: index - 1]; } else { siblingPath = [ [ parentNode indexPath ] indexPathByAddingIndex: index - 1 ]; } draggingDown = YES; break; case NSOrderedSame: return NO; break; } siblingNode = [ proxy nodeAtIndexPath:siblingPath ]; // ------------------------------------------------------------ // SPECIAL CASE: Dragging to the bottom // ------------------------------------------------------------ // - K - K - C - C // - - U - - C OR - U - F // - - C ====> - - F - F ====> - K // - - F - U - K - U // ------------------------------------------------------------ if ( isRootLevelDrag && siblingNode == NULL ) { draggingDown = YES; siblingPath = [ NSIndexPath indexPathWithIndex: [ proxy count ] - 1 ]; siblingNode = [ proxy nodeAtIndexPath:siblingPath ] ; } // ------------------------------------------------------------ // Give the dragged item a postion relative to it's new sibling // ------------------------------------------------------------ NSManagedObject* sibling = [ siblingNode observedObject ]; NSNumber* bystanderPosition = [ sibling valueForKey:@"position"]; int newPos = ( draggingDown ? [ bystanderPosition intValue ] + 1 : [ bystanderPosition intValue ] - 1 ); [draggedGroup setValue:[ NSNumber numberWithInt:newPos ] forKey:@"position"]; // ----------------------------------------------------- // Set the new parent for the dragged item, // resort the position attributes and refresh the tree // ----------------------------------------------------- [ draggedGroup setValue:[ parentNode observedObject ] forKey:@"parent" ]; [ self resortGroups:[draggedGroup managedObjectContext] forParent:[ parentNode observedObject ] ]; [ groupTreeControl rearrangeObjects ]; return YES; }

    This implementation is as simple as I've been able to make it but still seems fairly acrobatic. The first point to note is that the item parameter always points to the parent for the drag. This causes some special cases in root drags, especially root drags to the bottom of the tree. We use indexPaths as much as possible, something that would not be possible if we weren't using the undocumented and unsupported _NSArrayControllerTreeNode class. We also use an indexPath comparison to determine whether the drag is upwards or downwards and add (-/+)1 respectively.

    Conclusion

    Hopefully this was helpful to others, and if there is an easier way to do this, by all means, send me a shout. You can still impove on this by customizing the add method and setting the position of newly created groups to something more reasonably (i.e. based on current selected item) .

    References