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.
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.