swiftcocoansoutlineviewnscodingnspasteboard

How to write to NSPasteboard and get it with the same Pointer


I have a custom class that conforms NSCoding protocol. Below is how I implement the protocol's methods.

User.swift

required init?(coder aDecoder: NSCoder) {
    self.name = aDecoder.decodeObject(forKey: #keyPath(name)) as! String
    self.age = aDecoder.decodeInteger(forKey: #keyPath(age))
}

func encode(with aCoder: NSCoder) {
    aCoder.encode(name, forKey: #keyPath(name))
    aCoder.encode(age, forKey: #keyPath(age))
}

I set the reading options to:

static func readingOptions(forType type: String, pasteboard: NSPasteboard) -> NSPasteboardReadingOptions {
    return .asKeyedArchive
}

This object will be put inside a NSDragPboard whenever the user drag a cell row on NSOutlineView. You can see the implementation below:

UserViewController.h

var users: User // Store the users to be used in the outlineView

.
.
.

func outlineView(_ outlineView: NSOutlineView, writeItems items: [Any], to pasteboard: NSPasteboard) -> Bool {
    if let user = items.first as? User {
        pasteboard.clearContents()
        pasteboard.writeObjects([user])
        return true
    }
    return false
}

After the user finish the dragging by releasing the mouse button, the app will get the pasteboard content, by doing:

func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool {

    let pasteboard = info.draggingPasteboard()

    if let userInPasteboard = pasteboard.readObjects(forClasses: [User.self], options: nil)?.first as? User {
        // We can access the user in pasteboard here
    }
}

But the problem is, the data from the pasteboard have different memory address than the data in the outlineView.

If we try to find the user in the outlineView using the user from the pasteBoard, we can't find it.

for user in self.users {
    if user == userInPasteboard {
        print("We found the user that was placed on pasteboard") // Never executed
    }
}

I can implement the Equatable protocol for the User class. But I think if somehow we can make the Object that's read from the pasteboard to have the same pointer address as Object that's written to the pasteboard, it would work.

Below is what I'm trying to achieve:

NSPasteboard writing and reading illustration

Is it possible to achieve it? How?


Solution

  • There are a number of ways to handle this. Since you said (in a comment) that this is just for reordering in a single outline view, I'll explain how I've done that.

    First, I added a private variable to my data source to track the items being dragged:

    private var itemsBeingDragged = [MyItem]()
    

    Since I'll use that at drop time to get the dragged items, it doesn't really matter what's on the pasteboard. I implemented outlineView(_:pasteboardWriterForItem:) like this:

    func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? {
        guard let node = item as? MyNode else { return nil }
        let pasteboardItem = NSPasteboardItem()
        pasteboardItem.setString(String(describing: node), forType: dragType)
        return pasteboardItem
    }
    

    To set and clear itemsBeingDragged, I implemented these methods:

    func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, willBeginAt screenPoint: NSPoint, forItems draggedItems: [Any]) {
        itemsBeingDragged = draggedItems.flatMap({ $0 as? MyItem })
    }
    
    func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
        itemsBeingDragged = []
    }
    

    Finally, I implemented outlineView(_:acceptDrop:item:childIndex:) like this:

    func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool {
        guard
            (info.draggingSource() as? NSOutlineView) === outlineView,
            let dropTarget = item as? MyItem
            else { return false }
    
        // Update model using `dropTarget`, `index`, and `itemsBeingDragged`.
        // Update `outlineView` to match.
        return true
    }
    

    The info.draggingSource() is nil if the drag is from another app. If the drag is from the same app, it's the object providing the dragged items. So the first thing I do is ensure that the drag is from the same outline view that the drop is on.

    UPDATE

    If the drag source is in another application, there's no point in getting a pointer to the original dragged item, because pointers are not valid across process boundaries—the original dragged item doesn't exist in your process. The only meaningful way to drag an item across process boundaries is to actually provide a serialized representation of the dragged item.

    If the drag source is also in your application, then you could stick a pointer on the pasteboard, but doing so is still not memory-safe. Better to do something like define a protocol for getting the dragged items from the source:

    protocol MyDragSource {
        var itemsBeingDragged: [Any]?
    }
    

    Then, in acceptDrop:

    guard
        let sameAppSource = info.draggingSource(),
        let source = (sameAppSource as? MyDragSource) ?? (sameAppSource as? NSTableView)?.dataSource as? MyDragSource,
        let draggedItems = source.itemsBeingDragged?.flatMap({ $0 as? AcceptableItem }),
        draggedItems.count > 0
        else { return false }
    

    …where AcceptableItem is whatever type you use for your outline view items.

    There's no shame in using info.draggingSource(). It's there for a reason.