Kit Cross

Reordering table views with drag and drop

macOS has excellent support for drag and drop. Here’s how to allow a user to reorder an NSTableView.

One of the joys of using macOS is the polished interface. That extends to table views, one of the most common and versatile controls in AppKit. Usually, if you think you could drag it, in macOS you can. That’s thanks to excellent system wide support for drag and dropping. If you can’t, it’s a sure sign the app is non–native or a janky Electron app.

Here’s the table view we’re going to create.

The rows are NSTextFields to keep this example simple. I’ve provided the full source code to this playground at the end.

Meet the pasteboard

macOS runs a pasteboard server, pbs in the background that holds multiple pasteboards. When you hit Command-C or Command-V, you read and write from the general pasteboard. But there are other pasteboards, including the drag pasteboard.

The drag pasteboard is the one we need. As soon as a user starts dragging, we write a data type of our choice to this pasteboard, and when the drop happens we can read the pasteboard and understand what was dropped and where.

Lifecycle of a drag and drop

It helps to understand what happens when you drag a row across the table view.

First you start the drag (mouse down on the table view). The table view writes the row info into the drag pasteboard. As you move over the table view, AppKit calls the validateDrop method on the table view delegate to check what the insertion animation should be.

When the row is dropped, an acceptDrop method is called. We read the row info, rearrange the data source and animate the changes to the table view.

First we’ll need a data source. I’m going to use a simple array of strings. And it’s important that the array is a var so we can reorganise it later on.

Registering for dragged types

In our viewDidLoad method we make sure to register the table view as accepting dragged pasteboard items which are strings. This method inherits from NSView and without it, our table view will not accept the dragged row.

tableView.registerForDraggedTypes([.string])

Writing to the drag pasteboard

AppKit writes to the drag pasteboard when we start dragging. We’ve already enabled this view for string drag types, and our data source is an array of strings, so all we need to do is return the string in our array. The protocol is NSPasteboardWriting and NSString implements this protocol already. As we’re using Swift, we’ll need to cast it to NSString.

func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
  return accounts[row] as NSString
}

Validating the drop

This method is called every time the dragged row is moved across the table. The dropOperation can either be above the row or on top of the row. Because we’re moving a row, we don’t want to drop it on top of another row, so we return early unless the drop operation is above another row.

We also only want to allow drags from this table view, we could change this if we needed to allow drags from other windows, views or controls.

Finally, the draggingDestinationFeedbackStyle allows a choice of the drag style of the table view. The gap style looks good for this use case, but the regular style can also be used.

func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {

  guard dropOperation == .above,
        let tableView = info.draggingSource as? NSTableView else { return [] }

  tableView.draggingDestinationFeedbackStyle = .gap
  return .move
}

Accepting the drop request

As soon as the new row is dropped in place, we need to read the pasteboard, search our data source and find the index of the matching string.

Using guard statements allows us to return early if we can’t get the right values and prevents lots of unwrapping of optionals.

func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool {

  /* Read the pasteboard items and ensure there is at least one item,
   find the string of the first pasteboard item and search the datasource
   for the index of the matching string */
  guard let items = info.draggingPasteboard.pasteboardItems,
        let pasteBoardItem = items.first,
        let pasteBoardItemName = pasteBoardItem.string(forType: .string),
        let index = accounts.firstIndex(of: pasteBoardItemName) else { return false }

  let indexset = IndexSet(integer: index)
  accounts.move(fromOffsets: indexset, toOffset: row)

  /* Animate the move to the rows in the table view. The ternary operator
   is needed because dragging a row downwards means the row number is 1 less */
  tableView.beginUpdates()
  tableView.moveRow(at: index, to: (index < row ? row - 1 : row))
  tableView.endUpdates()

  return true
}

Remember that the table view doesn’t explicitly know about the data source. So we also need to update the array backing the table with the moved record. The Swift method array.move(fromOffsets:, toOffset:) can achieve this.

Finally, we animate the changes to the table view. The ternary operator is required because by default, dropping rows happens above the row, hence dragging downwards requires we reduce the row index by one.

Download the example

I’ve created a gist that showcases this example in its entirety, including the boilerplate code that sets up the view controller and programatically constructs the table view.

macos cocoa swift