Do you need help on a specific subject? Use the contact form (Request a blog entry) on the right hand side.

2015-06-17

Using Drag & Drop with custom types from Swift

I wanted to use Drag & Drop from within my own application for my own custom types. I already have implemented drag & drop for sources outside my application as I have blogged about in this post. In another post I have documented the use of the pasteboard from within Swift.

So adding drag & drop when the source and destination are my own app should be easy right?

Well, no. I did not find this easy at all. But I did get it to work :)

First things first: The apple documentation (that I could find) is of little to no help at all. It is either outdated or it laks guidance. Besides, I must be the only one having trouble with this because there is almost nothing to find on the inet ... well, there is, but it is also outdated...

Ok, here is the deal:

First decide which type to drag & drop. For that type the NSPasteboardWriting and NSPasteboardReading protocols have to be adopted. Oh, and that type (class) must inherit from NSObject as well.

Like this:

class MyData: NSObject, NSPasteboardWriting, NSPasteboardReading {
    
    var eData: String!
    var oData: String!    
    
    init(e: String, o: String) {
        super.init() // Necessary for NSObject
        oData = o
        eData = e
    }
        
    private func jsonString() -> String {
        let top = SwifterJSON.createJSONHierarchy()
        top["eData"].stringValue = eData
        top["oData"].stringValue = oData
        return top.description
    }
        
    
    // MARK: - Pasteboard Writing Protocol
    
    @objc func writableTypesForPasteboard(pasteboard: NSPasteboard!) -> [AnyObject]! {
        return ["com.mycompany.MyApp.MyData"]
    }
    
    @objc func pasteboardPropertyListForType(type: String!) -> AnyObject! {
        return jsonString()
    }
    
    
    // MARK: - Pasteboard Reading Protocol
    
    init!(pasteboardPropertyList propertyList: AnyObject!, ofType type: String!) {
        
        super.init()

        if let jsonString = propertyList asString {
            
            let (topOrNil, errorOrNil) = SwifterJSON.createJsonHierarchyFromString(jsonString)
            
            if let top = topOrNil {
                oData = top["oData"].stringValue ?? "Error"
                eData = top["eData"].stringValue ?? "Error"
            } else {
                return nil
            }
            
        } else {
            return nil
        }
    }

    static func readableTypesForPasteboard(pasteboard: NSPasteboard!) -> [AnyObject]! {
        return ["com.mycompany.MyApp.MyData"]
    }
    
    static func readingOptionsForType(type: String!, pasteboard: NSPasteboard!) -> NSPasteboardReadingOptions {
        return NSPasteboardReadingOptions.AsString
    }

}

In the above code I used my own SwifterJSON framework to encode and decode the data of MyData. If you would like to take a look at that, find the link on the left hand side of this blog.
Normally the propertyList in the pasteboard reading protocol will deliver NSData objects, but since I know that there will be a JSON string in there, I have also supplied the optional readingOptionsForType method so that the propertyList will be readable as a String when the init is called.
PS I have ignored error handling in init for brevity.

With the above code in place, we can now create MyData objects from the pasteboard like this:

    func performDragOperation(sender: NSDraggingInfo) -> Bool {
        
        
        // Get the pasteboard
        
        if let pboard = sender.draggingPasteboard() {
        
        
            // Get the Y coordinate for the drop
            
            let dropCoordinate = sender.draggingLocation()
            
            
            // Check if the drop position is within any of our subviews.
            
            if !pointIsInADocumentView(dropCoordinate) { return false }
            
            
            // Offset the Y coordinate
            
            let dropPoint = theView.convertPoint(dropCoordinate, toView: aSubView)
            
            
            // Get the index for the drop
            
            let dropIndex = lineIndexFromYCoordinate(dropPoint.y)
            
            
            // Make sure that there are filenames in the pasteboard
            
            if pboard.availableTypeFromArray([NSFilenamesPboardType]) == NSFilenamesPboardType {
                
                
                // Get the filenames from the pasteboard
                
                let files = filesFromPboard(pboard)
                
                
                // Add the filenames to the datasource
                
                insertLines(files, atIndex: dropIndex)
                
                
                // Update the view
                
                theView.needsDisplay = true
                
                
                // The drop was accepted
                
                return true
            
            } else if pboard.availableTypeFromArray(["com.mycompany.MyApp.MyData"]) == "com.mycompany.MyApp.MyData" {
                
             
                if var specs = pboard.readObjectsForClasses([MyData.self], options: nil) as? Array<MyData> {

                    dataModel.insertLines(fileSpecs, atIndex: dropIndex)
                        
                } else {
                    log.atLevelError(id: 0, source: "performDragOperation", message: "Could not read MyData from pasteboard")
                }

                // The drop was accepted
                
                return true
            }
        }
        
        // Still here, then something went wrong. The drop is not accepted.
        
        return false

    }


The yellowish underlaid code is the important bit. I have left some of the older code in so that you see how this could all work together. Refer to the blogs I mentioned at the top to see more on that. You can find a link to the logging framework I use to the left of this blog. (Note: I always accept the drop, this is my own data type, so I better make sure all errors are fixed before shipping!)

Oh, lest I forget, we also need to register ourselves to receive the new type of drop: in awakeFromNb I added the following:

        // Add drag and drop support
        

        registerForDraggedTypes([NSFilenamesPboardType, "com.mycompany.MyApp.MyData"])

With this code in place we can accept drops for our own custom data on a pasteboard. All that is left is to provide for a source that generates the pasteboard information. I had to do this in a view, but it can also be done in a window (so I believe...). In my custom NSView child I added the dragging source protocol:

class MyView: NSView, NSDraggingSource {...

The only method that I needed was:

    func draggingSession(session: NSDraggingSession, sourceOperationMaskForDraggingContext context: NSDraggingContext) -> NSDragOperation {
                
        switch context {
        case .OutsideApplication: return .None
        case .WithinApplication: return .Move
        }

    }

In this method I make clear that the drag & drop for the session that this view will begin is only available for internal drag & drop's. The data cannot be exported (dropped) outside my app.

To begin the drag & drop I added the following code to the mouseDown method:

    override func mouseDown(theEvent: NSEvent) {        
        
        // Get the mouse position from the event position
        
        let mp = convertPoint(theEvent.locationInWindow, fromView: nil)
        
        if (mp.x >= 0.0) && (mp.y >= 0.0) && (mp.x <= frame.width) && (mp.y <= frame.height) {
            
            let mousePosition = CGPointMake(mp.x - MARGIN_BEFORE_LINE, mp.y)

            // If the option key is down as well, this will be a drag & drop
                
            if theEvent.modifierFlags.isSet(.AlternateKeyMask) {
                
                // Find out if the mouse is down in a selection
                
                let lineIndex = lineIndexFromYCoordinate(mousePosition.y)
                
                if lineIndex >= dataSource.nofLines { return } // Ignore if the mouse down position is outside any of the lines
                
                let line = dataSource.lineAtIndex(lineIndex)
                
                let charIndex = line.indexForOffset(mousePosition.x)
                
                let lines = dataSource.linesInBlokSelect(lineIndex, charIndex: charIndex, lineSource: dataSource.lineAtIndex)
                
                var dragItems = Array<NSDraggingItem>()
                for l in lines { dragItems.append(NSDraggingItem(pasteboardWriter: l)) }
                
                beginDraggingSessionWithItems(dragItems, event: theEvent, source: self)
                
                // Remove the original data from the datamodel
                
                for l in lines { dataSource.removeLine(l) }
                
            } else {
                
                // Normal mouse down
                
                mouseDownPosition = mousePosition
            }
                
        } else {
            
            return
        }

    }

The salient point is the code with the yellowish background. All the other code is only necessary for my application. beginDraggingSessionWithItems is the code that starts the drag operation. But it needs an array of NSDraggingItem(s). Thus the MyData objects are wrapped in a NSDraggingItem  The NSDraggingItem can only wrap objects that implement the NSPasteboardWriting protocol.

While the above should get you up and running, it is not complete. You will probably want to add visual indicators of your own making to the drag & drop. This can be done (so I understand) through the NSDraggingItem

Happy coding...

Did this help?, then please help out a small independent.
If you decide that you want to make a small donation, you can do so by clicking this
link: a cup of coffee ($2) or use the popup on the right hand side for different amounts.
Payments will be processed by PayPal, receiver will be sales at balancingrock dot nl
Bitcoins will be gladly accepted at: 1GacSREBxPy1yskLMc9de2nofNv2SNdwqH

We don't get the world we wish for... we get the world we pay for.

No comments:

Post a Comment