23

I've got a simple, single-column, view-based NSTableView with items in it that can be dragged to reorder them. During drag and drop, I'd like to make it so that a gap for the item-to-be-dropped opens up at the location under the mouse. GarageBand does something like this when you drag to reorder tracks (video here: http://www.screencast.com/t/OmUVHcCNSl). As far as I can tell, there's no built in support for this in NSTableView.

Has anyone else tried to add this behavior to NSTableView and found a good solution? I've thought of and tried a couple approaches without much success. My first thought was to double the height of the row under the mouse during a drag by sending -noteHeightOfRowsWithIndexesChanged: in my data source's -tableView:validateDrop:... method, then returning twice the normal height in -tableView:heightOfRow:. Unfortunately, best I can tell, NSTableView doesn't update its layout during drag and drop, so despite calling noteHeightOfRowsWithIndexesChanged:, the row height isn't actually updated.

Note that I'm using a view-based NSTableView, but my rows are not so complex that I couldn't move to a cell-based table view if doing so helped accomplish this. I'm aware of the easy, built-in ability to animate a gap for the dropped item after a drag is complete. I'm looking for a way to open a gap while the drag is in progress. Also, this is for an app to be sold in the Mac App Store, so it must not use private API.

EDIT: I've just filed an enhancement request with Apple requesting built in support for this behavior: http://openradar.appspot.com/12662624. Dupe if you'd like to see it too. Update: The enhancement I requested was implemented in OS X 10.9 Mavericks, and this behavior is now available using NSTableView API. See NSTableViewDraggingDestinationFeedbackStyleGap.

Andrew Madsen
  • 21,309
  • 5
  • 56
  • 97
  • I would like to know how to do this too! – Vervious Apr 13 '12 at 17:40
  • I'm not sure if this would work or not, but what about inserting a blank row as it moves? – lnafziger Apr 16 '12 at 06:11
  • @Inafziger I thought of that too but I think "Unfortunately, best I can tell, NSTableView doesn't update its layout during drag and drop" makes that not work. – N_A Apr 16 '12 at 15:06
  • Yeah, the trouble is that NSTableView stops updating layout while a drag is active. Perhaps that behavior can be overcome by subclassing, but I have no idea how. – Andrew Madsen Apr 16 '12 at 15:28
  • Can you drag the item and have the drag operation not be part of the NSTableView? – N_A Apr 16 '12 at 17:15
  • @mydogisbox, that's an interesting idea. I'll see if I can find a decent way to do that and will report back. – Andrew Madsen Apr 17 '12 at 14:53
  • @AndrewMadsen For the purpose of clarity, was thinking that you can drag to other controls and I'm assuming that action is not part of the nstableview, so if you dragged to a "different" control (which happens to also be the nstableview), then maybe the nstableview would start updating its layout again. – N_A Apr 17 '12 at 15:11

4 Answers4

9

I feel bizarre for doing this, but there's an extremely thorough answer in the queue here that appears to have been deleted by its author. In it, they provided the correct links to a working solution, which I feel need to be presented as an answer for someone else to take and run with, inclusive of them if they desire to do so.

From the documentation for NSTableView, the following caveats are tucked away for row animation effects:

Row Animation Effects

Optional constant that specifies that the tableview will use a fade for row or column removal. The effect can be combined with any NSTableViewAnimationOptions constant.

enum {
    NSTableViewAnimationEffectFade = 0x1,
    NSTableViewAnimationEffectGap = 0x2,
};

Constants:

...

NSTableViewAnimationEffectGap

Creates a gap for newly inserted rows. This is useful for drag and drop animations that animate to a newly opened gap and should be used in the delegate method tableView:acceptDrop:row:dropOperation:.

Going through the example code from Apple, I find this:

- (void)_performInsertWithDragInfo:(id <NSDraggingInfo>)info parentNode:(NSTreeNode *)parentNode childIndex:(NSInteger)childIndex {
    // NSOutlineView's root is nil
    id outlineParentItem = parentNode == _rootTreeNode ? nil : parentNode;
    NSMutableArray *childNodeArray = [parentNode mutableChildNodes];
    NSInteger outlineColumnIndex = [[_outlineView tableColumns] indexOfObject:[_outlineView outlineTableColumn]];

    // Enumerate all items dropped on us and create new model objects for them    
    NSArray *classes = [NSArray arrayWithObject:[SimpleNodeData class]];
    __block NSInteger insertionIndex = childIndex;
    [info enumerateDraggingItemsWithOptions:0 forView:_outlineView classes:classes searchOptions:nil usingBlock:^(NSDraggingItem *draggingItem, NSInteger index, BOOL *stop) {
        SimpleNodeData *newNodeData = (SimpleNodeData *)draggingItem.item;
        // Wrap the model object in a tree node
        NSTreeNode *treeNode = [NSTreeNode treeNodeWithRepresentedObject:newNodeData];
        // Add it to the model
        [childNodeArray insertObject:treeNode atIndex:insertionIndex];
        [_outlineView insertItemsAtIndexes:[NSIndexSet indexSetWithIndex:insertionIndex] inParent:outlineParentItem withAnimation:NSTableViewAnimationEffectGap];
        // Update the final frame of the dragging item
        NSInteger row = [_outlineView rowForItem:treeNode];
        draggingItem.draggingFrame = [_outlineView frameOfCellAtColumn:outlineColumnIndex row:row];

        // Insert all children one after another
        insertionIndex++;
    }];

}

I'm unsure if it's really this simple, but it's at least worth inspection and outright refutal if it doesn't meet your needs.

Edit: see this answer's comments for the steps followed to the right solution. The OP has posted a more complete answer, which should be referred to by anyone looking for solutions to the same problem.

Community
  • 1
  • 1
MrGomez
  • 23,788
  • 45
  • 72
  • 1
    Thanks for this answer MrGomez. My guess at the reason for the answerer removing this answer is that I mentioned in my question that I'm aware of this and that it's not what I'm looking for. This is for animating a gap *after* the item is dropped rather than a (moving) gap during the drag. I'm actually already using this solution as a stopgap/compromise if I can't figure out how to do what I really want. – Andrew Madsen Apr 17 '12 at 04:24
  • @AndrewMadsen Thank you for your kind reply. I had assumed this to be the case, but I wanted to make sure that this was clarified for anyone seeking out this question directly. I'll look into better options to solve this problem more elegantly, and for now, leave this as a sign post for other potential answerers to follow. After all: knowing what you've tried and refuted, explicitly, should lead to better answers. :) – MrGomez Apr 17 '12 at 05:18
  • 1
    @AndrewMadsen Since no one's answered your question so far, I've gone ahead and dug a bit further to see if I could answer it myself. I discovered in [this documentation node](https://developer.apple.com/library/mac/#documentation/Cocoa/Conceptual/DragandDrop/Tasks/acceptingdrags.html) that you can implement [`draggingUpdated:`](https://developer.apple.com/library/mac/#documentation/Cocoa/Reference/ApplicationKit/Protocols/NSDraggingDestination_Protocol/Reference/Reference.html) in your destination to get the drag operation sender and, from there, the current pointer location. – MrGomez Apr 20 '12 at 20:45
  • 1
    @AndrewMadsen The idea here is to then use this position to perform your animation effect, capturing `draggingEnded:` and `draggingExited:` to perform any necessary cleanup. This should allow you to perform the operations you seek during your drag operation. Do let me know if this works so I can update my answer accordingly and, if plausible, provide some example code. :) – MrGomez Apr 20 '12 at 20:48
  • Thanks for the further thoughts, MrGomez. I'll give this a try tomorrow and post results. – Andrew Madsen Apr 21 '12 at 03:37
  • It looks like this will work. I've been busy and haven't had time to sit down and implement it completely, but a quick test by calling `[self noteHeightOfRowsWithIndexesChanged:]` with the row that is the target of the drag operation (and the previously targeted row) in the table view's `-draggingUpdated:` reveals that the table will animate the row height even while the drag is in progress. I haven't tried inserting a blank row, but that may work too. Either way, I should be able to make this work. I can edit your answer with my code, or send it to you if you'd like to edit it. – Andrew Madsen Apr 22 '12 at 14:28
  • @AndrewMadsen Sure, that'd be fine. I think the easiest collaboration point, without throwing this into community wiki mode, is to have you throw the code into this or your own answer. I'll then update the prose of my answer, either pointing to yours or pointing out the source of the code (giving credit where it's due). I'm glad, in any event, that we've gotten this working for you. :) – MrGomez Apr 22 '12 at 17:54
  • 1
    I'm glad too, thanks! For the benefit of others in the future, I'll add an answer of my own with details of my implementation including sample code. I've awarded your answer the bounty, but it should be edited later to clarify that the (current/original) answer doesn't provide a solution to the question. – Andrew Madsen Apr 22 '12 at 19:14
  • @AndrewMadsen I agree. When it comes in, I'll edit my answer. Thanks! – MrGomez Apr 22 '12 at 19:59
  • I finally got the time to implement this. I've added and accepted my own answer with an explanation of my solution and some sample code. It's not completely polished yet, but it works decently. Thanks again! – Andrew Madsen Apr 28 '12 at 21:14
  • @AndrewMadsen Thanks! Sorry I took so long to get back to you; I'd been horrifically busy all last week through this weekend. I've updated my answer to point to yours and given it a well-deserved +1. As before, I'm glad you managed to get this working, and I hope it continues to do so. :) – MrGomez Apr 30 '12 at 21:37
8

Note: The behavior this question and answer describes are now available using built in API in NSTableView on OS X 10.9 Mavericks and later. See NSTableViewDraggingDestinationFeedbackStyleGap.

This answer may still be useful if this behavior is needed in an app targeting OS X 10.8 or earlier. Original answer below:

I've implemented this now. My basic approach looks like this:

@interface ORSGapOpeningTableView : NSTableView

@property (nonatomic) NSInteger dropTargetRow;
@property (nonatomic) CGFloat heightOfDraggedRows;

@end

@implementation ORSGapOpeningTableView

#pragma mark - Dragging

- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender
{
    NSInteger oldDropTargetRow = self.dropTargetRow;
    NSDragOperation result = [super draggingUpdated:sender];
    CGFloat imageHeight = [[sender draggedImage] size].height;
    self.heightOfDraggedRows = imageHeight;

    NSMutableIndexSet *changedRows = [NSMutableIndexSet indexSet];
    if (oldDropTargetRow > 0) [changedRows addIndex:oldDropTargetRow-1];
    if (self.dropTargetRow > 0) [changedRows addIndex:self.dropTargetRow-1];
    [self noteHeightOfRowsWithIndexesChanged:changedRows];

    return result;
}

- (void)draggingExited:(id<NSDraggingInfo>)sender
{
    self.dropTargetRow = -1;
    [self noteHeightOfRowsWithIndexesChanged:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, [self numberOfRows])]];

    [super draggingExited:sender];
}

- (void)draggingEnded:(id<NSDraggingInfo>)sender
{
    self.dropTargetRow = -1;
    self.heightOfDraggedRows = 0.0;
    self.draggedRows = nil;
    [self noteHeightOfRowsWithIndexesChanged:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, [self numberOfRows])]];
}

- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender
{
    self.dropTargetRow = -1;
    self.heightOfDraggedRows = 0.0;
    self.draggedRows = nil;
    [self noteHeightOfRowsWithIndexesChanged:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, [self numberOfRows])]];

    return [super performDragOperation:sender];
}

// In my delegate and data source:

- (NSDragOperation)tableView:(NSTableView *)tableView validateDrop:(id<NSDraggingInfo>)info proposedRow:(NSInteger)row proposedDropOperation:(NSTableViewDropOperation)dropOperation
{
    if (dropOperation == NSTableViewDropOn) 
    {
        dropOperation = NSTableViewDropAbove;
        [self.tableView setDropRow:++row dropOperation:dropOperation];
    }

    NSDragOperation result = [self.realDataSource tableView:tableView validateDrop:info proposedRow:row proposedDropOperation:dropOperation];
    if (result != NSDragOperationNone) 
    {
        self.tableView.dropTargetRow = row;
    } 
    else 
    {
        self.tableView.dropTargetRow = -1; // Don't open a gap
    }
    return result;
}

- (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row
{
    CGFloat result = [tableView rowHeight];

    if (row == self.tableView.dropTargetRow - 1 && row > -1)
    {
        result += self.tableView.heightOfDraggedRows;
    }

    return result;
}

Note that this is simplified code, not a verbatim copy/paste from my program. I actually ended up making this all contained within an NSTableView subclass that uses proxy delegate and data source objects so the code in data source/delegate methods above is actually inside the proxies' intercept of the calls to the real delegate and data source. That way, the real data source and delegate don't have to do anything special to get the gap opening behavior. Also, there's sometimes a little flakiness with the table view animations, and this doesn't work for drags above the first row (no gap is opened since there's no row to make taller). All in all, despite the room for further improvement, this approach works reasonably well.

I'd still like to try a similar approach, but insert a blank row (as Caleb suggested) instead of changing the row height.

Andrew Madsen
  • 21,309
  • 5
  • 56
  • 97
  • 3
    Hey Andrew, any way you could throw up a simple example project on github showing this? – coneybeare Nov 15 '12 at 01:54
  • There are "proxy" data source and delegate objects in ORSGapOpeningTableView.m. These are used to intercept messages that NSTableView sends to its own data source and delegate so that the results can be modified regardless of the (external) data source/delegate. self.realDataSource is the real (ie. externally set) data source, rather than the proxy. – Andrew Madsen Jul 26 '13 at 03:08
7

As of Mac OS X 10.9 (Mavericks), there's a much easier solution to animating drag & drop in a NSTableView:

[aTableView setDraggingDestinationFeedbackStyle:NSTableViewDraggingDestinationFeedbackStyleGap];

The table view will automatically insert gaps with animation as a row is dragged which is much nicer than the old blue line insertion point method.

Roberto
  • 391
  • 5
  • 8
3

One way to accomplish what you're asking is to insert an empty row at the proposed drop point (that is, between the two nearest rows). It sounds like you've been looking at using NSTableViewAnimationEffectGap, which as you note is really meant for animating the insertion when the drop is accepted in -tableView:acceptDrop:row:dropOperation:.

Since you want to open up the gap before the user releases the mouse button to actually do the drop, you could instead insert a blank row using -insertRowsAtIndexes:withAnimation: from your table's -draggingUpdate: method and at the same time delete any blank row you previously inserted for this drag using -removeRowsAtIndexes:withAnimation:. Use NSTableViewAnimationSlideUp and NSTableViewAnimationSlideDown as the animations for these operations, as appropriate.

Caleb
  • 124,013
  • 19
  • 183
  • 272
  • 1
    Caleb, thanks a lot for the answer. I have not in fact focused on NSTableViewAnimationEffectGap, I only mentioned that I was aware of it and that it doesn't do what I want (see my first comment on MrGomez's answer). Based on MrGomez comments, I've tried an approach similar to what you describe, and it will work. The key in all of it is to do this in an override of `-draggingUpdated:` in an NSTableView subclass. If you try to do something similar in the data source's `-tableView:validateDrop:...` method (which is indirectly called by `-[NSTableView draggingUpdated:]`), it won't work. – Andrew Madsen Apr 22 '12 at 19:14