I recently tried to implement drag and drop with NSTableView
in a project and ran into a bunch of issues with finding resources that actually helped. Such is the life of a Cocoa developer. So here’s my attempt at the definitive guide to drag and drop with NSTableView
. (This should also apply to NSOutlineView
, since they work in a similar way.)
In this tutorial, you’ll make an app that has two table views, each containing a list of fruits. By the end of the tutorial, you’ll be able to:
- drag a fruit name to the other table view to copy it over
- drag and drop to reorder the items within a table view
- drag the fruit names into other apps
To get started, download the template Xcode project here. You can find the completed tutorial here.
Table of Contents
- 🍎 Introduction
- 🍑 Writing to the Pasteboard
- 🍊 Dropping on a Table View to Insert
- 🥭 Creating a Custom Pasteboard Type
- 🍓 Writing Multiple Types to the Pasteboard
- 🍇 Drag and Drop to Reorder Rows
- 🍍 Dropping Onto the Entire Table View
- 🥝 Dragging to Trash to Delete
🍎 Introduction
Drag and drop is implemented with a dragging pasteboard. When a drag starts, you write to the pasteboard. When a drag ends, you can read data from the pasteboard. NSTableView
and NSOutlineView
have delegate methods that make it (relatively) easy to deal with drag and drop for their rows.
🍑 Writing to the Pasteboard
The first delegate method to implement is tableView(_:pasteboardWriterForRow:)
in LeftTableViewController
. The function has a return type that conforms to the protocol NSPasteboardWriting
. NSString
does, so you can just cast the String
to NSString
and return that.
func tableView(
_ tableView: NSTableView,
pasteboardWriterForRow row: Int)
-> NSPasteboardWriting?
{
return FruitManager.leftFruits[row] as NSString
}
For more information on
NSPasteboardWriting
and what Cocoa classes conform to it, check out the documentation.
Returning a non-nil value from this function will make the cell draggable. But you can’t drop it on anything just yet. In order to be able to drag into other apps, the drag’s operation mask needs to be set to work outside the app.
Add a line to viewDidLoad()
which sets the default operation mask for the table view to copy
for destinations outside the app:
tableView.setDraggingSourceOperationMask(.copy, forLocal: false)
You can now drag from the left table view into other apps! Try dragging cells into TextEdit or Messages.
🍊 Dropping on a Table View to Insert
Now you need to set up the right table view to accept drops on it. First, add this line to viewDidLoad()
in RightTableViewController
:
tableView.registerForDraggedTypes([.string])
This registers the table view to accept drags containing string
types. Since you returned an NSString
in the pasteboard writer of the left table view, the pasteboard type is string
. Simple enough, right?
Validate the Drop
Next, implement tableView(_:validateDrop:proposedRow:proposedDropOperation:)
. This is called when a drag is hovering over the table view, before it has been dropped or canceled.
In this function, you can specify how to respond to a proposed drop operation, which can be either on or above a cell. In this table view, the user will be allowed to drop items between cells to insert them. That means we want to allow proposed drops above
but not respond to proposed drops on
.
func tableView(
_ tableView: NSTableView,
validateDrop info: NSDraggingInfo,
proposedRow row: Int,
proposedDropOperation dropOperation: NSTableView.DropOperation)
-> NSDragOperation
{
if dropOperation == .above {
return .move
}
return []
}
Accept the Drop
The next function to implement is tableView(_:acceptDrop:row:dropOperation:)
. This is where you’ll handle the data from the dragging pasteboard after it’s been dropped on the table view.
First, get the array of pasteboard items from the drop. Because table views support multiple selection, multiple rows could be dropped simultaneously. Each dragged row corresponds to a pasteboard item, with each item containing the string you set for the row.
For each pasteboard item, get the string stored in it with the method NSPasteboardItem.string(forType: NSPasteboard.PasteboardType)
.
func tableView(
_ tableView: NSTableView,
acceptDrop info: NSDraggingInfo,
row: Int,
dropOperation: NSTableView.DropOperation)
-> Bool
{
guard let items = info.draggingPasteboard.pasteboardItems
else { return false }
let fruits = items.compactMap{ $0.string(forType: .string) }
FruitManager.rightFruits.insert(contentsOf: fruits, at: row)
tableView.insertRows(at: IndexSet(row...row + newFruits.count - 1),
withAnimation: .slideDown)
return true
}
Now try dragging from the left table view to the right table view!
Bonus: since it’s registered for the
string
pasteboard type, you can highlight some text in another app and drag that into the right table view.
It's a drag fest!
🥭 Creating a Custom Pasteboard Type
Create an extension of NSPasteboard.PasteboardType
. This is a UTI string and should be a unique identifier.
extension NSPasteboard.PasteboardType {
static let tableViewIndex = NSPasteboard.PasteboardType("io.natethompson.tableViewIndex")
}
🍓 Writing Multiple Types to the Pasteboard
In PasteboardUtil.swift
there’s a class FruitPasteboardWriter
. Make the class conform to NSPasteboardWriting
and add the following protocol stubs:
func writableTypes(
for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType]
{ }
func pasteboardPropertyList(
forType type: NSPasteboard.PasteboardType) -> Any?
{ }
In writableTypes(for:)
, return [.string, .tableViewIndex]
. These are the pasteboard types that this class can write to the pasteboard.
Now, in pasteboardPropertyList(forType:)
, if the type is string
, return the fruit name. If the type is tableViewIndex
, return the row index. It should now look like this:
class FruitPasteboardWriter: NSObject, NSPasteboardWriting {
var fruit: String
var index: Int
init(fruit: String, at index: Int) {
self.fruit = fruit
self.index = index
}
func writableTypes(
for pasteboard: NSPasteboard)
-> [NSPasteboard.PasteboardType]
{
return [.string, .tableViewIndex]
}
func pasteboardPropertyList(
forType type: NSPasteboard.PasteboardType) -> Any?
{
switch type {
case .string:
return fruit
case .tableViewIndex:
return index
default:
return nil
}
}
}
When an instance of this object is written to the pasteboard, you can get the value for either (or both) type, depending on what you want to do. We’ll get to that next.
🍇 Drag and Drop to Reorder Rows
Currently, if you drag a cell from the right table view into the right table view, it will create a duplicate. Let’s change it so you can reorder the cells without duplicating. Plus, we’ll make it animate a gap between the rows so it’ll look really nice.
First, register the right table view for the dragged type you created. Modify the line you wrote in viewDidLoad()
earlier to:
tableView.registerForDraggedTypes([.string, .tableViewIndex])
Next, implement tableView(_:pasteboardWriterForRow:)
in RightTableViewController
using the FruitPasteboardWriter
you just made:
func tableView(
_ tableView: NSTableView,
pasteboardWriterForRow row: Int)
-> NSPasteboardWriting?
{
return FruitPasteboardWriter(fruit: FruitManager.rightFruits[row], at: row)
}
Destination Feedback Style
Then, you’ll want to rewrite tableView(_:validateDrop:proposedRow:proposedDropOperation:)
and set the draggingDestinationFeedbackStyle
based on the dragging source. The gap
style works well when reordering rows. For other dragging sources, we’ll keep using regular
, which draws the insertion line if the drop operation is above
.
func tableView(
_ tableView: NSTableView,
validateDrop info: NSDraggingInfo,
proposedRow row: Int,
proposedDropOperation dropOperation: NSTableView.DropOperation)
-> NSDragOperation
{
guard dropOperation == .above else { return [] }
if let source = info.draggingSource as? NSTableView,
source === tableView
{
tableView.draggingDestinationFeedbackStyle = .gap
} else {
tableView.draggingDestinationFeedbackStyle = .regular
}
return .move
}
There’s a bug in
NSTableView
that requires implementingtableView(_:heightOfRow:)
to get thegap
style to animate correctly. This has already been set up in the template project.
Move Rows
Now you just need to get the table view indexes when the drag is dropped. Modify tableView(_:acceptDrop:row:dropOperation)
so it looks like this:
func tableView(
_ tableView: NSTableView,
acceptDrop info: NSDraggingInfo,
row: Int,
dropOperation: NSTableView.DropOperation) -> Bool
{
guard let items = info.draggingPasteboard.pasteboardItems
else { return false }
let indexes = items.compactMap{ $0.integer(forType: .tableViewIndex)
if !indexes.isEmpty {
FruitManager.rightFruits.move(with: IndexSet(indexes), to: row)
// Reordering with multiple selection enabled is hard:
// https://stackoverflow.com/a/26855499/7471873
tableView.beginUpdates()
var oldIndexOffset = 0
var newIndexOffset = 0
for oldIndex in oldIndexes {
if oldIndex < row {
tableView.moveRow(at: oldIndex + oldIndexOffset, to: row - 1)
oldIndexOffset -= 1
} else {
tableView.moveRow(at: oldIndex, to: row + newIndexOffset)
newIndexOffset += 1
}
}
tableView.endUpdates()
return true
}
let fruits = items.compactMap{ $0.string(forType: .string) }
FruitManager.rightFruits.insert(contentsOf: fruits, at: row)
tableView.insertRows(at: IndexSet(row...row + newFruits.count - 1),
withAnimation: .slideDown)
return true
}
This implementation prioritizes tableViewIndex
over string
. As it’s set up now, if you drag within the right table view, there will be values for tableViewIndex
and string
. It uses the row number values for tableViewIndex
to rearrange the array instead of just inserting at a new index.
Note:
NSArray.move(with:to:)
andNSPasteboardItem.integer(forType:)
are implemented in extensions, so check for those before you copy/paste and wonder why it doesn’t work.
🍍 Dropping Onto the Entire Table View
Sometimes you might not want users to have as much control of where the data they’re dropping lands. Maybe the table view is sorted or you want cells displayed in the order they were added. There’s a way to make the whole table view highlight when items are dragged over it.
Implement tableView(_:validateDrop:proposedRow:proposedDropOperation:)
in LeftTableViewController
. The line that does the magic here is this:
tableView.setDropRow(-1, dropOperation: .on)
Passing -1
and on
will highlight the entire table view. You can then return copy
to show the green + icon next to the cursor when a drag is hovering over the table view.
There’s one thing left to do in this function. We don’t want to allow drags from the left table view onto itself since this table view shouldn’t be reordered by the user. Get the dragging source and return []
if the source is the left table view.
func tableView(
_ tableView: NSTableView,
validateDrop info: NSDraggingInfo,
proposedRow row: Int,
proposedDropOperation dropOperation: NSTableView.DropOperation)
-> NSDragOperation
{
if let source = info.draggingSource as? NSTableView,
source !== tableView
{
tableView.setDropRow(-1, dropOperation: .on)
return .copy
}
return []
}
info.draggingSource
isnil
when the source is in a different application. The implementation above disallows drags from other apps, sources within the same app that aren’tNSTableView
s, and drags from the left table view.
Lastly, implement tableView(_:acceptDrop:row:dropOperation:)
in LeftTableViewController
and append the dropped strings to the array.
func tableView(
_ tableView: NSTableView,
acceptDrop info: NSDraggingInfo,
row: Int,
dropOperation: NSTableView.DropOperation) -> Bool
{
guard let items = info.draggingPasteboard.pasteboardItems
else { return false }
let fruits = items.compactMap{ $0.string(forType: .string) }
FruitManager.leftFruits.append(contentsOf: fruits)
let oldCount = tableView.numberOfRows
tableView.insertRows(at: IndexSet(oldCount...oldCount + newFruits.count - 1),
withAnimation: .slideDown)
return true
}
🥝 Dragging to Trash to Delete
I think dragging to Trash is kind of a gimmick unless you’re writing a file manager, but it’s fun and easy so here’s how to do it.
In RightTableViewController
, add the following line to viewDidLoad()
:
tableView.setDraggingSourceOperationMask([.copy, .delete], forLocal: false)
This is the similar to what you did earlier on the left table view, with delete
in addition to copy
. Setting the delete
operation allows the drag to be dropped onto the Trash icon.
Now it’s just a matter of implementing tableView(_:draggingSession:endedAt:operation:)
and checking if the drag operation is delete
.
func tableView(
_ tableView: NSTableView,
draggingSession session: NSDraggingSession,
endedAt screenPoint: NSPoint,
operation: NSDragOperation)
{
if operation == .delete,
let items = session.draggingPasteboard.pasteboardItems
{
let indexes = items.compactMap {
$0.integer(forType: .tableViewIndex)
}
for index in indexes.reversed() {
FruitManager.rightFruits.remove(at: index)
}
tableView.removeRows(at: IndexSet(indexes), withAnimation: .slideUp)
}
}
Woohoo you made it to the end! Go take a break and eat some fruit or something. I’m hungry.