swiftmacosnstableview

NSTableView multi-column drag reorder rows and include all columns


We have an app where the user can drag-reorder rows in a single column tableView OR with multi-column tableViews. It works but the drag only shows the string from the clicked on column - it essentially looks like this when dragging.

enter image description here

We want to enhance the UI so when the drag occurs, the rows animate (move) but also include the strings from all columns in the drag.

With our new code, the issue is when when the drag occurs, the origin.x is not correct - the image is either too far to the left or right. We were having difficuly even having the drag image appear in the list (it 'zooomed' in from outside the window in our first attempts) so by adding a temporary coordinate in for X, it worked better, but still isn't right.

Here's what it looks like when clicking and dragging in column 0, 1 and then 2

Col_0_drag

Col_1_drag

Col_2_drag

The tableView delegate code

//get the row index to be dragged and store it on the pasteboard as a string
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
    let pasteboardItem = NSPasteboardItem()
    pasteboardItem.setString( String(row), forType: .tableViewIndex)
    return pasteboardItem
}

//handle the drag
func tableView(_ tableView: NSTableView, draggingSession session: NSDraggingSession, willBeginAt screenPoint: NSPoint, forRowIndexes rowIndexes: IndexSet) {
    let draggedRow = rowIndexes.first!
    let rowView = tableView.rowView(atRow: draggedRow, makeIfNecessary: true)! //get the view of the row
    let draggingRect = tableView.rect(ofRow: rowIndexes.first!)

    let localMouseDownPoint = self.view.window?.convertPoint(fromScreen: screenPoint) //where did we mousedown in the window

    session.enumerateDraggingItems(options: [], for: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) { dragItem, _, _ in
        print("drag origin", dragItem.draggingFrame.origin)
        let snap = rowView.snapshot()
        let size = NSSize(width: draggingRect.width, height: dragItem.draggingFrame.height)
        let p = NSPoint(x: -500.0, y: dragItem.draggingFrame.origin.y) //inserted -500 here to at least get the drag in the window
        let frame = NSRect(origin: p, size: size)
        dragItem.setDraggingFrame(frame, contents: snap)
    }
}

//allow the drop for the .move operation
func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {
    guard dropOperation == .above else { return [] }
    //will add code to block drags from other sources
    tableView.draggingDestinationFeedbackStyle = .regular

    return .move
}

func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool {
    guard let pbItems = info.draggingPasteboard.pasteboardItems else { return false }

    let pbItem = pbItems.first!
    guard let oldIndexString = pbItem.string(forType: .tableViewIndex) else { return false }
    guard let oldIndex = Int(oldIndexString) else { return false }
    let oldIndexSet = IndexSet(integer: oldIndex)

    ItemManager.itemArray.move(with: oldIndexSet, to: row)

    tableView.beginUpdates()

    if oldIndex < row {
        tableView.moveRow(at: oldIndex, to: row - 1)
    } else {
        tableView.moveRow(at: oldIndex, to: row)
    }

    tableView.endUpdates()

    return true
}

Conceptually, instead of using the String to show as the item to be dragged, we capture an image of the entire row and use that instead - which works.

However, the placement is where the issue is - if the user clicks on the text in any column, the image should 'pick up the row' where that click is, not off to the left or right.

One thing we cannot determine how to correctly use is the

session.enumerateDraggingItems

The documentation is weak and what dragItem is actually populated with is unclear to us - it says the coordinates are in relation to the view it's contained in but this

print("drag origin", dragItem.draggingFrame.origin)

Revels an origin that's all negative numbers. Possibly flipped? And it appears the initial properties reflect the cell view that was clicked?


Solution

  • dragItem.draggingFrame is the frame of the cell in a mysterious coordinate space. To calculate the frame of the row in this coordinate space, adjust dragItem.draggingFrame by the difference between the frames of the row view and cell view. In my test app:

    func tableView(_ tableView: NSTableView, draggingSession session: NSDraggingSession,
        willBeginAt screenPoint: NSPoint, forRowIndexes rowIndexes: IndexSet) {
        guard let windowPoint = tableView.window?.convertPoint(fromScreen: screenPoint) else { return }
        let tableViewPoint = tableView.convert(windowPoint, from: nil)
        let columnIndex = tableView.column(at: tableViewPoint)
        let rowIndex = tableView.row(at: tableViewPoint)
        if columnIndex >= 0,
            let cellView = tableView.view(atColumn: columnIndex, row: rowIndex, makeIfNecessary: true) {
            let xOffset = cellView.frame.origin.x
            session.enumerateDraggingItems(options: [], for: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) { dragItem, _, _ in
                if let pbItem = dragItem.item as? NSPasteboardItem,
                    let row = pbItem.propertyList(forType: .tableViewIndex) as? Int,
                    let rowView = tableView.rowView(atRow: row, makeIfNecessary: true),
                    let image = NSImage(data: rowView.dataWithPDF(inside: rowView.bounds)) {
    
                    // adjust draggingFrame
                    var origin = dragItem.draggingFrame.origin
                    origin.x -= xOffset
                    let frame = NSRect(origin: origin, size: image.size)
                    dragItem.setDraggingFrame(frame, contents: image)
                }
            }
        }
    }