Drag & Drop Reorder Rows on NSTableView
Asked Answered
B

10

21

I was just wondering if there was an easy way to set an NSTableView to allow it to reorder its rows without writing any pasteboard code. I only need it to be able to do this internally, within one table. I have no issue writing the pboard code, except that I'm fairly sure that I saw Interface Builder have a toggle for this somewhere / saw it working by default. It certainly seems like a common enough task.

Thanks

Baseless answered 23/1, 2010 at 3:5 Comment(0)
V
16

If you take a look at the tool tip in IB you'll see that the option you refer to

- (BOOL)allowsColumnReordering

controls, well, column reordering. I do not believe there is any other way to do this other than the standard drag-and-drop API for table views.

EDIT: ( 2012-11-25 )

The answer refers to drag-and-drop reordering of NSTableViewColumns; and while it was the accepted answer at the time. It does not appear, now nearly 3 years on, to be correct. In service of making the information useful to searchers, I'll attempt to give the more correct answer.

There is no setting that allows drag and drop reordering of NSTableView rows in Interface Builder. You need to implement certain NSTableViewDataSource methods, including:

- tableView:acceptDrop:row:dropOperation:

- (NSDragOperation)tableView:(NSTableView *)aTableView validateDrop:(id < NSDraggingInfo >)info proposedRow:(NSInteger)row proposedDropOperation:(NSTableViewDropOperation)operation

- (BOOL)tableView:(NSTableView *)aTableView writeRowsWithIndexes:(NSIndexSet *)rowIndexes toPasteboard:(NSPasteboard *)pboard

There are other SO question that address this reasonably thoroughly, including this one

Apple link to Drag and Drop APIs.

Valuer answered 25/1, 2010 at 21:59 Comment(3)
Why would that affect reordering rows?Hydrophilous
It looks like a case of a misread question. Although the question is almost 3 years old, I've attempted to fix it for future searchers. Thanks for pointing out the error.Valuer
That's one of the great things about SO ;) Thanks for adding more info.Hydrophilous
D
32

Set your table view's datasource to be a class that conforms to NSTableViewDataSource.

Put this in an appropriate place (-applicationWillFinishLaunching, -awakeFromNib, -viewDidLoad or something similar):

tableView.registerForDraggedTypes(["public.data"])

Then implement these three NSTableViewDataSource methods:

tableView:pasteboardWriterForRow:
tableView:validateDrop:proposedRow:proposedDropOperation:
tableView:acceptDrop:row:dropOperation:

Here is fully-working code that supports drag-and-drop reordering multiple rows:

func tableView(tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
  let item = NSPasteboardItem()
  item.setString(String(row), forType: "public.data")
  return item
}

func tableView(tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableViewDropOperation) -> NSDragOperation {
  if dropOperation == .Above {
    return .Move
  }
  return .None
}

func tableView(tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool {
  var oldIndexes = [Int]()
  info.enumerateDraggingItemsWithOptions([], forView: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) {
    if let str = ($0.0.item as! NSPasteboardItem).stringForType("public.data"), index = Int(str) {
            oldIndexes.append(index)
    }
  }

  var oldIndexOffset = 0
  var newIndexOffset = 0

  // For simplicity, the code below uses `tableView.moveRowAtIndex` to move rows around directly.
  // You may want to move rows in your content array and then call `tableView.reloadData()` instead.
  tableView.beginUpdates()
  for oldIndex in oldIndexes {
    if oldIndex < row {
      tableView.moveRowAtIndex(oldIndex + oldIndexOffset, toIndex: row - 1)
      --oldIndexOffset
    } else {
      tableView.moveRowAtIndex(oldIndex, toIndex: row + newIndexOffset)
      ++newIndexOffset
    }
  }
  tableView.endUpdates()

  return true
}

Swift 3 version:

func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
    let item = NSPasteboardItem()
    item.setString(String(row), forType: "private.table-row")
    return item
}

func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableViewDropOperation) -> NSDragOperation {
    if dropOperation == .above {
        return .move
    }
    return []
}

func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool {
    var oldIndexes = [Int]()
    info.enumerateDraggingItems(options: [], for: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) {
        if let str = ($0.0.item as! NSPasteboardItem).string(forType: "private.table-row"), let index = Int(str) {
            oldIndexes.append(index)
        }
    }

    var oldIndexOffset = 0
    var newIndexOffset = 0

    // For simplicity, the code below uses `tableView.moveRowAtIndex` to move rows around directly.
    // You may want to move rows in your content array and then call `tableView.reloadData()` instead.
    tableView.beginUpdates()
    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
}
Declinometer answered 11/11, 2014 at 0:27 Comment(4)
Excellent & useful answer. Note that as of writing (Dec 2015) it's no longer quite right, as enumerateDraggingItemsWithOptions will no longer take nil (I used [.Concurrent] instead), and .toInt() is no longer valid (use Int() instead).Aubreir
You probably don't want to use .Concurrent since we want to append index to oldIndexes in order.Declinometer
Do you have any hints on how to restructure / swap the model array?Rocha
When following the Swift 3 example don't forget to use private.table-row instead of public.data in tableView.registerForDraggedTypes.Katykatya
V
16

If you take a look at the tool tip in IB you'll see that the option you refer to

- (BOOL)allowsColumnReordering

controls, well, column reordering. I do not believe there is any other way to do this other than the standard drag-and-drop API for table views.

EDIT: ( 2012-11-25 )

The answer refers to drag-and-drop reordering of NSTableViewColumns; and while it was the accepted answer at the time. It does not appear, now nearly 3 years on, to be correct. In service of making the information useful to searchers, I'll attempt to give the more correct answer.

There is no setting that allows drag and drop reordering of NSTableView rows in Interface Builder. You need to implement certain NSTableViewDataSource methods, including:

- tableView:acceptDrop:row:dropOperation:

- (NSDragOperation)tableView:(NSTableView *)aTableView validateDrop:(id < NSDraggingInfo >)info proposedRow:(NSInteger)row proposedDropOperation:(NSTableViewDropOperation)operation

- (BOOL)tableView:(NSTableView *)aTableView writeRowsWithIndexes:(NSIndexSet *)rowIndexes toPasteboard:(NSPasteboard *)pboard

There are other SO question that address this reasonably thoroughly, including this one

Apple link to Drag and Drop APIs.

Valuer answered 25/1, 2010 at 21:59 Comment(3)
Why would that affect reordering rows?Hydrophilous
It looks like a case of a misread question. Although the question is almost 3 years old, I've attempted to fix it for future searchers. Thanks for pointing out the error.Valuer
That's one of the great things about SO ;) Thanks for adding more info.Hydrophilous
E
16

@Ethan's solution - Update Swift 4

in viewDidLoad :

private var dragDropType = NSPasteboard.PasteboardType(rawValue: "private.table-row")

override func viewDidLoad() {
    super.viewDidLoad()

    myTableView.delegate = self
    myTableView.dataSource = self
    myTableView.registerForDraggedTypes([dragDropType])
}

Later on delegate extension :

extension MyViewController: NSTableViewDelegate, NSTableViewDataSource {

    // numerbOfRow and viewForTableColumn methods

    func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {

        let item = NSPasteboardItem()
        item.setString(String(row), forType: self.dragDropType)
        return item
    }

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

        if dropOperation == .above {
            return .move
        }
        return []
    }

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

        var oldIndexes = [Int]()
        info.enumerateDraggingItems(options: [], for: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) { dragItem, _, _ in
            if let str = (dragItem.item as! NSPasteboardItem).string(forType: self.dragDropType), let index = Int(str) {
                oldIndexes.append(index)
            }
        }

        var oldIndexOffset = 0
        var newIndexOffset = 0

        // For simplicity, the code below uses `tableView.moveRowAtIndex` to move rows around directly.
        // You may want to move rows in your content array and then call `tableView.reloadData()` instead.
        tableView.beginUpdates()
        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
    }

}

Plus, for those it may concerne:

  1. If you want to disable certain cells from being dragable, return nil in pasteboardWriterForRows method

  2. If you want to prevent drop a certain locations ( too far for instance ) just use return [] in validateDrop's method

  3. Do not call tableView.reloadData() synchronously inside func tableView(_ tableView:, acceptDrop info:, row:, dropOperation:). This will disturb Drag and Drop animation, and can be very confusing. Find a way to wait until animation finishes, and async it's reloading

Eastertide answered 17/9, 2018 at 13:6 Comment(0)
R
6

This answer covers Swift 3, View-based NSTableViews and single/multiple rows drag&drop reorder.

There are 2 main steps which must be performed in order to achieve this:

  • Register table view to a specifically allowed type of object which can be dragged.

    tableView.register(forDraggedTypes: ["SomeType"])

  • Implement 3 NSTableViewDataSource methods: writeRowsWith, validateDrop and acceptDrop.

Before drag operation has started, store IndexSet with indexes of rows which will be dragged, in the pasteboard.

func tableView(_ tableView: NSTableView, writeRowsWith rowIndexes: IndexSet, to pboard: NSPasteboard) -> Bool {

        let data = NSKeyedArchiver.archivedData(withRootObject: rowIndexes)
        pboard.declareTypes(["SomeType"], owner: self)
        pboard.setData(data, forType: "SomeType")

        return true
    }

Validate drop only if dragging operation is above of specified row. This ensures when dragging is performed other rows won't be highlighted when dragged row will float above them. Also, this fixes an AutoLayout issue.

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

    if dropOperation == .above {
        return .move
    } else {
        return []
    }
}

When accepting drop just retrieve IndexSet that previously was saved in the pasteboard, iterate through it and move rows using calculated indexes. Note: Part with iteration and row moving I've copied from @Ethan's answer.

    func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool {
        let pasteboard = info.draggingPasteboard()
        let pasteboardData = pasteboard.data(forType: "SomeType")

        if let pasteboardData = pasteboardData {

            if let rowIndexes = NSKeyedUnarchiver.unarchiveObject(with: pasteboardData) as? IndexSet {
                var oldIndexOffset = 0
                var newIndexOffset = 0

                for oldIndex in rowIndexes {

                    if oldIndex < row {
                        // Dont' forget to update model

                        tableView.moveRow(at: oldIndex + oldIndexOffset, to: row - 1)
                        oldIndexOffset -= 1
                    } else {
                        // Dont' forget to update model

                        tableView.moveRow(at: oldIndex, to: row + newIndexOffset)
                        newIndexOffset += 1
                    }
                }
            }
        }

        return true
    }

View-based NSTableViews update themselfs when moveRow is called, there is no need to use beginUpdates() and endUpdates() block.

Ratsbane answered 28/12, 2016 at 12:12 Comment(0)
S
5

Unfortunately you do have to write the Paste board code. The Drag and Drop API is fairly generic which makes it very flexible. However, if you just need reordering it's a bit over-the-top IMHO. But anyway, I have created a small sample project which has an NSOutlineView where you can add and remove items as well as reorder them.

This is not an NSTableView but the implementation of the Drag & Drop protocol is basically identical.

I implemented drag and Drop in one go so it's best to look at this commit.

screenshot

Stoop answered 24/10, 2013 at 8:3 Comment(0)
V
3

If your are moving only one row at the time you can use the following code:

    func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool {
        let pasteboard = info.draggingPasteboard()
        guard let pasteboardData = pasteboard.data(forType: basicTableViewDragAndDropDataType) else { return false }
        guard let rowIndexes = NSKeyedUnarchiver.unarchiveObject(with: pasteboardData) as? IndexSet else { return false }
        guard let oldIndex = rowIndexes.first else { return false }

        let newIndex = oldIndex < row ? row - 1 : row
        tableView.moveRow(at: oldIndex, to: newIndex)

        // Dont' forget to update model

        return true
    }
Veedis answered 6/1, 2017 at 15:59 Comment(1)
Any idea how to do the unarchiveObject part with Swift 5? That method was deprecated.Glottology
S
1

This is an update to @Ethan's answer for Swift 3:

let dragDropTypeId = "public.data" // or any other UTI you want/need

func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
    let item = NSPasteboardItem()
    item.setString(String(row), forType: dragDropTypeId)
    return item
}

func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableViewDropOperation) -> NSDragOperation {
    if dropOperation == .above {
        return .move
    }
    return []
}

func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool {
    var oldIndexes = [Int]()
    info.enumerateDraggingItems(options: [], for: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) {
        if let str = ($0.0.item as! NSPasteboardItem).string(forType: self.dragDropTypeId), let index = Int(str) {
            oldIndexes.append(index)
        }
    }

    var oldIndexOffset = 0
    var newIndexOffset = 0

    // For simplicity, the code below uses `tableView.moveRowAtIndex` to move rows around directly.
    // You may want to move rows in your content array and then call `tableView.reloadData()` instead.
    tableView.beginUpdates()
    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()

    self.reloadDataIntoArrayController()

    return true
}
Subnormal answered 6/10, 2016 at 19:56 Comment(4)
Please explain why you provided this update, i.e. what it improvesSaladin
@ThomasTempelmann on October 6, his answer had no Swift 3 version. As per the Meta, I posted a new answer rather than editing an already-existing one.Subnormal
Also don' tforget to place tableView.registerForDraggedTypes([dragDropTypeId]) in viewDidLoad for instanceEastertide
Fnally allowed me to drag MULTIPLE rows at once. Thanks!Lowry
F
1

Swift 5 solution. I had to add 'registerForDraggedTypes' method in viewDidLoad for this to work.

 override func viewDidLoad() {
    super.viewDidLoad()
    
    // Do any additional setup after loading the view.

    tableView.dataSource = self
    tableView.delegate = self

    // you must register the type you want to drag-n-drop! in this case 'strings'
    tableView.registerForDraggedTypes([.string])
    

    self.mapView.fitAll(in: teamManager.group.teams(), andShow: true)

}

extension ViewController : NSTableViewDataSource {
    func numberOfRows(in tableView: NSTableView) -> Int {
        return dataModel.count // or whatever
    }
    
    func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
        let pasteboard = NSPasteboardItem()
            
        // in this example I'm dragging the row index. Once dropped i'll look up the value that is moving by using this.
        // remember in viewdidload I registered strings so I must set strings to pasteboard
        pasteboard.setString("\(row)", forType: .string)
        return pasteboard
    }
    
    
    func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {
        
        let canDrop = (row > 2) // in this example you cannot drop on top two rows
        print("valid drop \(row)? \(canDrop)")
        if (canDrop) {
            return .move //yes, you can drop on this row
        }
        else {
            return [] // an empty array is the equivalent of nil or 'cannot drop'
        }
    }
    
    
    func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool {
        let pastboard = info.draggingPasteboard
        if let sourceRowString = pastboard.string(forType: .string) {
            print("from \(sourceRowString). dropping row \(row)")
            return true
        }
        
        return false
    }
}
Fiasco answered 26/1, 2021 at 22:51 Comment(0)
S
1

Here is the fully working code in swift 5. Lets you move multiple items at once!

override func viewDidLoad() {
        super.viewDidLoad()
        myTableView.delegate = self
        myTableView.dataSource = self
        myTableView.registerForDraggedTypes([.string])

    }       

func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
        let pasteboard = NSPasteboardItem()
        pasteboard.setString("\(row)", forType: .string)
        return pasteboard
    }

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

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

        var oldIndexes = [Int]()
         info.enumerateDraggingItems(options: [], for: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) { dragItem, _, _ in
            if let str = (dragItem.item as? NSPasteboardItem)?.string(forType: .string), let index = Int(str) {
                 oldIndexes.append(index)
             }
         }

         var oldIndexOffset = 0
         var newIndexOffset = 0

         // For simplicity, the code below uses `tableView.moveRowAtIndex` to move rows around directly.
         // You may want to move rows in your content array and then call `tableView.reloadData()` instead.
         tableView.beginUpdates()
         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
    }
Seyler answered 14/4, 2021 at 13:40 Comment(0)
P
0

Hope it's not too late... I work with VisualStudio for Mac in C# and don't have Swift skills... Can you give me a transcription in C# of this part of you'r sample? Thank tou for helping if possible

         info.enumerateDraggingItems(options: [], for: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) { dragItem, _, _ in
        if let str = (dragItem.item as? NSPasteboardItem)?.string(forType: .string), let index = Int(str) {
             oldIndexes.append(index)
         }
     }
Panpipe answered 22/8, 2022 at 6:34 Comment(1)
I think you should ask question in the comment section, and probably go to chatroom, instead of posting an answer hereIcarus

© 2022 - 2024 — McMap. All rights reserved.