swiftmacosdrag-and-dropnstableview

Cancelled Drag in NSTableView Leads to Crash on Subsequent Drags


I have encountered a problem in implementing drag & drop in an NSTableView. My code only allows drop operations within the NSTableView from where the drag was originated. If the item is dragged outside of the boundaries of the NSTableView, the validateDrop function returns [] for DropOperation, meaning the drop is not valid.

On lifting the finger with the item outside of the NSTableView, the app animates the item returning to its original place and the table look fine.

However, if I then try to lift another item to drag, in my function tableView( dragginSession willBegin at) I have a line where I want to retrieve the row index of the cell being lifted, as follows:

public 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)
        let rows = self.tableView.numberOfRows

While on the first drag rowIndex is the correct index of the cell I lift, after a cancelled drag, when this function is called rowIndex is -1. The tableView is still of the same size and still has the same number of rows, but it seems that cancelling a drag messes up something so that the table is unable to return the correct row for a cell that is dragged after cancelling a prior drag operation.

Below is the full code for the view controller

#if os(macOS)
import Foundation
import MGBGraphicPackage
import AppKit


let rowIndexPasteBoardType = NSPasteboard.PasteboardType("com.MGBCapital.BareBoneTablePasteBoardIdentifier")

public final class BareBoneTestTableVC: NSViewController {
   
    var cellFont = NSFont(name: "AvenirNextCondensed-DemiBold", size: 14)
    
    var lineItems = [LineItem]()
    let columns: Int = 50
    let rows: Int = 1000
 
    var tableScrollView: NSScrollView!
    var tableView: NSTableView!
    var reusetableIdentifier = "reusetableIdentifier"
        
    public init() {
        super.init(nibName: nil, bundle: nil)
        
        for index in 0..<rows {
            
            let lineItem = LineItem(rowNumber: index, itemNumbers: [Int](0..<columns))
            self.lineItems.append(lineItem)
        }
    }
    
    required init?(coder: NSCoder) {  fatalError("init(coder:) has not been implemented") }
    
    public override func loadView() { self.view = NSView() }
    
    override public func viewWillAppear() {
        super.viewWillAppear()
        self.setup()
    }
    
    
    func setup()  {
        
        self.tableScrollView = NSScrollView()
        self.tableView = NSTableView()
        
        self.tableView.registerForDraggedTypes([.string])
   
        self.view.addSubview(self.tableScrollView)
        self.tableScrollView.translatesAutoresizingMaskIntoConstraints = false
        self.tableScrollView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor, constant: +10).isActive = true
        self.tableScrollView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor, constant: -10).isActive = true
        self.tableScrollView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: +10).isActive = true
        self.tableScrollView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -10).isActive = true
        
        self.tableScrollView.documentView = self.tableView
        self.tableScrollView.drawsBackground = false
        self.tableScrollView.contentView.drawsBackground = false
        
        self.tableScrollView.hasHorizontalScroller = true
        self.tableScrollView.hasVerticalScroller = true
        
        
        self.tableView.columnAutoresizingStyle = .noColumnAutoresizing
        self.tableView.backgroundColor = .clear
        self.tableView.allowsMultipleSelection = false
        self.tableView.intercellSpacing = CGSize(width: 0, height: 0)
 
        self.tableView.dataSource = self
        self.tableView.delegate = self

   
        for columns in self.tableView.tableColumns { self.tableView.removeTableColumn(columns) }
      
        for index in 0..<columns {
            let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "Column \(index)"))
            let headerCell = NSTableHeaderCell(textCell: "Col \(index)")
            headerCell.identifier = NSUserInterfaceItemIdentifier("Column \(index)")
            
            column.headerCell = headerCell
            column.resizingMask = .userResizingMask
            column.width = 60
            column.minWidth = 60
            column.maxWidth = 1000
            
            self.tableView.addTableColumn(column)
        }
    }
}



extension BareBoneTestTableVC: NSTableViewDataSource, NSTableViewDelegate {
    
    public func numberOfRows(in tableView: NSTableView) -> Int {  return  rows  }
    
    public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
        
        guard let myColumn = tableColumn
        else { return nil }
      
        guard let columnIndex = self.tableView.tableColumns.firstIndex(of: myColumn)
        else { print("ERROR: No colunIndex - q93847r") ; return nil }
        
        let cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: self.reusetableIdentifier), owner: nil) as? BareBoneCustomTableCell ?? BareBoneCustomTableCell(identifier: self.reusetableIdentifier, font: self.cellFont ?? .systemFont(ofSize: 12))
        
        var myString = ""
        if columnIndex == 0 {  myString = "Row \(row)"  }
        else { myString = "R\(row) C\(columnIndex)" }
        
        if columnIndex == 0 { cell.fill(text: myString, alignment: .left, textColor: .purple, backgroundColor: .yellow) }
        else { cell.fill(text: myString) }
        
        return cell
    }
    
    
    public func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {  return 20 }
    
    public func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool {   return false  }
    
}


extension BareBoneTestTableVC {
 
    public func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
        
        let lineItem = self.lineItems[row]
        let item = NSPasteboardItem()
        item.setString(lineItem.labeltemRow, forType: .string)
        
        let rowData = withUnsafeBytes(of: row) { Data($0) }
        item.setData(rowData, forType: rowIndexPasteBoardType)
        
        return item
    }
    
 
  
    
    public 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)
        let rows = self.tableView.numberOfRows
        
        print("DRAGGING WILL BEGIN AT tableView point: \(tableViewPoint) - rowIndexes: \(rowIndex)")
        print("tableView has \(rows) rows -> bounds: \(self.tableView.bounds)")
 
        // MARK: THIS IS THE ISSUE
        if rowIndex == -1 {
            
            print("ERROR: Row Index is -1")
            return
        }
        
          
        if columnIndex < 0 { return }
        guard let cellView =  tableView.view(atColumn: columnIndex, row: rowIndex, makeIfNecessary: true)  else { print("ERROR: No cellView - 98264r") ; return }

        let xOffset = cellView.frame.origin.x
        
        session.enumerateDraggingItems(options: NSDraggingItemEnumerationOptions.concurrent, for: nil, classes: [NSPasteboardItem.self], searchOptions: [:]) { (item, index, _) in
            
            let pbItem = session.draggingPasteboard.pasteboardItems?[index]
            
            guard let  rowData = pbItem?.data(forType: rowIndexPasteBoardType) else { print("ERROR: No rowData - 09238475") ; return }
            
            let row = rowData.withUnsafeBytes({ $0.load(as: Int.self) })
            
            // MARK: Restrict the dragging preview to the width of the scrollView
            guard let rowView = tableView.rowView(atRow: row, makeIfNecessary: true)  else { return }
            
            // MARK: This is where the clipView is visible in the scrollView, in tableView coordinates
            // MARK: The minX is where the first visible cell is and it should be the minX of the drad preview frame
            guard let visibleBounds = tableView.enclosingScrollView?.contentView.bounds else  { print("ERROR: No visibleBounds - 02938475t") ; return }
    
            let narrowerBounds = CGRect(x: rowView.bounds.minX, y: rowView.bounds.minY, width: visibleBounds.width, height: rowView.bounds.height)
            
            // MARK: The image is the row captured from the first column, regardless of the visible columns and the width is the width of the visible clipView
            guard let image = NSImage(data: rowView.dataWithPDF(inside: narrowerBounds))?.reverseTint(color: .red) else { print("ERROR: Could not create image - 90q82473r") ; return }
            
            var origin = item.draggingFrame.origin
            
            // MARK: -xOffset goes thww whole way to the first column --- adding back visibleBoundsMinx brings the origin to the first visible column
            origin.x -= xOffset - visibleBounds.minX
            let frame = NSRect(origin: origin, size: image.size)
            item.setDraggingFrame(frame, contents: image)
        }
        
    }
    
    
    public func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {
        
        guard dropOperation == .above, let tableView = info.draggingSource as? NSTableView, tableView == self.tableView else {
            
            return []
        }
        
        tableView.draggingDestinationFeedbackStyle = .gap
        return .move
    }
    
    
    public func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool {
        
        print("acceptDrop: row: \(row)")
        
        guard let items = info.draggingPasteboard.pasteboardItems,
              let pasteBoardItem = items.first,
              let pasteBoardItemName = pasteBoardItem.string(forType: .string),
              let index = self.lineItems.map({ $0.labeltemRow }).firstIndex(of: pasteBoardItemName) else {
            
            print("DROP ERROR")
            tableView.draggingDestinationFeedbackStyle = .regular
            return false
        }
        
        let indexset = IndexSet(integer: index)
        self.lineItems.move(fromOffsets: indexset, toOffset: row)
        
        tableView.beginUpdates()
        tableView.moveRow(at: index, to: (index < row ? row - 1 : row))
        tableView.endUpdates()
        
        
        // MARK: Need to reset this to .regular otherwise leaving it as defined in validateDrop() as .gap messes up the indexes
        tableView.draggingDestinationFeedbackStyle = .regular
        
        return true
    }
    
    
    
    public func tableView( _ tableView: NSTableView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint,operation: NSDragOperation)  {
        
       
        
        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)
  
        print("Drag Ended at row: \(rowIndex)")
        
        if !self.tableView.bounds.contains(tableViewPoint) {
            
            print("Dropped Outside")
            
        }
    }
    
    
}




public class BareBoneCustomTableCell: NSTableCellView {
    
    public var textLabel: NSTextField!
    public var attributes:  [NSAttributedString.Key : NSObject]!
    
    public init(identifier: String, font: NSFont) {
        
        super.init(frame: .zero)
        self.identifier = NSUserInterfaceItemIdentifier(rawValue: identifier)
        
        self.wantsLayer = true
        
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = .center

        self.attributes = [
            NSAttributedString.Key.paragraphStyle: paragraphStyle,
            NSAttributedString.Key.font: font,
            NSAttributedString.Key.foregroundColor: NSColor.systemPink,
        ]

        let attributedString = NSAttributedString(string: "", attributes: self.attributes)

        self.textLabel = NSTextField(labelWithAttributedString: attributedString)
  
        self.addSubview(self.textLabel)
        
        self.textLabel.translatesAutoresizingMaskIntoConstraints = false
        self.textLabel.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
        self.textLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true
        self.textLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor).isActive = true
        self.textLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor).isActive = true
    }
    
    required init?(coder: NSCoder) {  fatalError("init(coder:) has not been implemented")  }

    
    public func fill(text: String?) {
        
        let attributedString = NSAttributedString(string: text ?? "n/a", attributes: self.attributes)
      
        self.textLabel.attributedStringValue = attributedString
    }
    
    public func fill(text: String?, alignment: NSTextAlignment, textColor: UNIColor, backgroundColor: UNIColor?)  {
        
        self.attributes[NSAttributedString.Key.foregroundColor] = textColor
        self.layer?.backgroundColor = backgroundColor?.cgColor
        
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = alignment
        
        self.attributes[NSAttributedString.Key.paragraphStyle] = paragraphStyle
  
        let attributedString = NSAttributedString(string: text ?? "n/a", attributes: self.attributes)
      
        self.textLabel.attributedStringValue = attributedString
  
        
    }
}


public class LineItem: Codable {
    
    public var rowNumber: Int?
    public var itemNumbers: [Int]?
    
    public var isDummy: Bool = false { didSet { print("isDummy Changed for \(rowNumber ?? -9999)") } }

    public init() { self.isDummy = true }
    
    public init(rowNumber: Int, itemNumbers: [Int]) {
        
        self.rowNumber = rowNumber
        self.itemNumbers = itemNumbers
        self.isDummy = false
    }
    
    public func labelItem(item: Int) -> String? {
     
        if item > (itemNumbers?.count ?? 0) { return nil }
        if  let haveRow = self.rowNumber, let haveItemNumber = self.itemNumbers?[item] {
            let myString = "R\(haveRow) - C\(haveItemNumber)"
            return myString
        }
        else { return nil }
    }
    
    var labeltemRow: String {  return("Row: \(self.rowNumber ?? -999)")   }
}
#endif

I would appreciate any hint.


Solution

  • In acceptDrop you do:

    // MARK: Need to reset this to .regular otherwise leaving it as defined in validateDrop() as .gap messes up the indexes
    tableView.draggingDestinationFeedbackStyle = .regular
    

    This is missing in tableView(_:draggingSession:endedAt:operation:) which is called instead of acceptDrop when the drag ends outside the table view.