read

To support reordering of rows in a NSTableView, you need to understand how a table works with drag and drop operation.

In a drag and drop operation, there is a source and a destination. They can be the same table view, or can be different views (any kind of view)! In the example code below, we use the same table view, but I have added comments on which is for source/destination.

There are 3 methods of NSTableViewDataSource to implement.

I will use an example of a table view of accounts, with an array of accounts as the data model. This data model could be a fetchedResultsController.fetchedObjects for Core Data. To persist the new ordering, you have to save it yourself.

1. Register the pasteboard type to accept drops

Drag and drop is versatile and items can be dropped across apps and views. The “communication” is via pasteboard, kind of like a temporary holding area for the dragged item.

In your destination view where items will be dropped, you need to register the type of items that you can handle.

// Let's say our app can accept this "mymoney.account" type
let accountPasteboardType = NSPasteboard.PasteboardType(rawValue: "mymoney.account")

override func viewDidLoad() {
    super.viewDidLoad()
    tableView.registerForDraggedTypes([accountPasteboardType])
}

2. Write to pasteboard when dragged

When a row is being dragged, you write your model’s data to pasteboard.

extension AccountsView: NSTableViewDataSource {
    // For the source table view
    func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
        let account = accounts![row]
        let pasteboardItem = NSPasteboardItem()
        pasteboardItem.setString(account.uuid, forType: accountPasteboardType)
        return pasteboardItem
    }
}

The method returns a concrete NSPasteboardItem, which implements the protocol NSPasteboardWriting. The right way is to implement the protocol for your model and return the model.

We use account.uuid, which is a String representation. If you use Core Data NSManagedObject, you can use objectID.uriRepresentation().absoluteString.

3. Handle when dropped

Not surprisingly, the other 2 methods are for the destination table view when dropped.

The first method is to return .move for only .above drag operation (there is a .on drag operation which is more like swapping items, not reordering, so we don’t want it to move).

The second method is to handle acceptDrop, persist the new ordering, then animate the rows.

extension AccountsView: NSTableViewDataSource {
    // For the destination table view
    func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {
        if dropOperation == .above {
            return .move
        } else {
            return []
        }
    }

    // For the destination table view
    func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool {
        guard
            let item = info.draggingPasteboard.pasteboardItems?.first,
            let theString = item.string(forType: accountPasteboardType),
            let account = accounts?.first(where: { $0.uuid == theString }),
            let originalRow = accounts?.firstIndex(of: account)
            else { return false }

        var newRow = row
        // When you drag an item downwards, the "new row" index is actually --1. Remember dragging operation is `.above`.
        if originalRow < newRow {
            newRow = row - 1
        }

        // Animate the rows
        tableView.beginUpdates()
        tableView.moveRow(at: originalRow, to: newRow)
        tableView.endUpdates()

        // Persist the ordering by saving your data model
        saveAccountsReordered(at: originalRow, to: newRow)

        return true
    }
}

You retrieve the NSPasteboardItem from the info object, and with that string representation you retrieve the account model and the original row (the index in the data model array).

The persisting of the new order and the animation or the rows are 2 separate operations.

I will not give the complete code for saveAccountsReordered (obviously you need to update the orders), but the following code is pretty important:

// Disable the delegate temporarily as we already animating on our own
fetchedResultsController.delegate = nil
try context.save()
fetchedResultsController.delegate = self

// Need to re-fetch
try fetchedResultsController.performFetch()

Multiple items

The example we have use a single item for drag and drop.

If you support multiple items, it get more complicated during the acceptDrop phase. You can refer to this stackoverflow answer on how to move all of the rows.


Image

@samwize

¯\_(ツ)_/¯

Back to Home